diff --git a/.github/locales.py b/.github/locales.py index 6127d9d80..a74d72588 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -1,13 +1,14 @@ import re import glob import requests +import os import lxml.etree as ET # builtin library doesn't preserve comments SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt" START_MARKER = "/* begin language list */" END_MARKER = "/* end language list */" -XML_NAME = "app/src/main/res/values-b+" +XML_NAME = "app/src/main/res/values-" ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" INDENT = " "*4 @@ -20,29 +21,29 @@ rest, after_src = rest.split(END_MARKER) # Load already added langs languages = {} -for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): - name, iso = lang.groups() - languages[iso] = name +for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest): + flag, name, iso = lang.groups() + languages[iso] = (flag, name) # Add not yet added langs for folder in glob.glob(f"{XML_NAME}*"): - iso = folder[len(XML_NAME):].replace("+", "-") + iso = folder[len(XML_NAME):] if iso not in languages.keys(): - entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found - languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple + entry = iso_map.get(iso.lower(),{'nativeName':iso}) + languages[iso] = ("", entry['nativeName'].split(',')[0]) -# Create pairs -pairs = [] -for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name - name = languages[iso] - pairs.append(f'{INDENT}Pair("{name}", "{iso}"),') +# Create triples +triples = [] +for iso in sorted(languages.keys()): + flag, name = languages[iso] + triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),') # Update settings file open(SETTINGS_PATH, "w+",encoding='utf-8').write( before_src + START_MARKER + "\n" + - "\n".join(pairs) + + "\n".join(triples) + "\n" + END_MARKER + after_src @@ -61,5 +62,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"): with open(file, 'wb') as fp: fp.write(b'\n') tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) + # Remove trailing new line to be consistent with weblate + fp.seek(-1, os.SEEK_END) + fp.truncate() except ET.ParseError as ex: print(f"[{file}] {ex}") diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index 056022d22..e84bb08b0 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -1,95 +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 }} - MAL_KEY: ${{ secrets.MAL_KEY }} - ANILIST_KEY: ${{ secrets.ANILIST_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..666e2ba10 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -1,18 +1,19 @@ name: Dokka +# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency +concurrency: + group: "dokka" + cancel-in-progress: true + on: push: - branches: [ master ] + branches: + # choose your default branch + - master + - main paths-ignore: - '*.md' -permissions: - contents: read - -concurrency: - group: "dokka" - cancel-in-progress: true - jobs: build: runs-on: ubuntu-latest @@ -24,35 +25,32 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/dokka" - - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@master with: path: "src" - name: Checkout dokka - uses: actions/checkout@v6 + uses: actions/checkout@master with: repository: "recloudstream/dokka" path: "dokka" token: ${{ steps.generate_token.outputs.token }} - + - name: Clean old builds run: | cd $GITHUB_WORKSPACE/dokka/ rm -rf "./app" rm -rf "./library" - - name: Set up JDK 17 - uses: actions/setup-java@v5 + - name: Setup JDK 17 + uses: actions/setup-java@v4 with: - distribution: temurin java-version: 17 + distribution: 'adopt' - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Setup Android SDK + uses: android-actions/setup-android@v3 - name: Generate Dokka run: | @@ -61,7 +59,8 @@ jobs: ./gradlew docs:dokkaGeneratePublicationHtml - name: Copy Dokka - run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ + run: | + cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ - name: Push builds run: | diff --git a/.github/workflows/instrumented-tests.yml b/.github/workflows/instrumented-tests.yml deleted file mode 100644 index 34c6eaf0b..000000000 --- a/.github/workflows/instrumented-tests.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Instrumented Tests - -on: - issue_comment: - types: [created] - -permissions: - contents: read - pull-requests: write - -jobs: - instrumented-tests: - runs-on: ubuntu-latest - if: | - github.event.issue.pull_request && - github.event.comment.body == '/run-tests' - steps: - - name: Check permission - uses: actions/github-script@v9 - with: - script: | - const login = context.payload.comment.user.login; - const association = context.payload.comment.author_association; - const allowed = ['OWNER', 'MEMBER']; - - const isAllowed = - login === 'Luna712' || - allowed.includes(association); - - if (!isAllowed) { - core.setFailed(`User ${login} is not permitted to trigger this workflow.`); - } - - - uses: actions/checkout@v6 - with: - ref: refs/pull/${{ github.event.issue.number }}/head - - - name: Post started comment - uses: actions/github-script@v9 - with: - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: 'Instrumented tests are running. [View live progress](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})' - }) - - - 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: Setup Gradle - uses: gradle/actions/setup-gradle@v5 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - cache-read-only: false - - - name: Get target SDK - id: sdk - run: | - TARGET_SDK=$(grep 'targetSdk' gradle/libs.versions.toml | grep -o '[0-9]\+' | head -1) - echo "version=$TARGET_SDK" >> $GITHUB_OUTPUT - - - name: Enable KVM - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: Run Instrumented Tests - id: tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ steps.sdk.outputs.version }} - arch: x86_64 - profile: Nexus 6 - script: ./gradlew connectedPrereleaseDebugAndroidTest - - - name: Upload Test Results - if: always() - uses: actions/upload-artifact@v7 - with: - name: instrumented-test-results - path: '**/build/reports/androidTests/' - - - name: Post finished comment - if: always() - uses: actions/github-script@v9 - with: - script: | - const success = '${{ steps.tests.outcome }}' === 'success'; - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: success - ? 'Instrumented tests passed. [View results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})' - : 'Instrumented tests failed. [View results](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})' - }) diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml new file mode 100644 index 000000000..88ab3656c --- /dev/null +++ b/.github/workflows/issue_action.yml @@ -0,0 +1,88 @@ +name: Issue automatic actions + +on: + issues: + types: [opened] + +jobs: + issue-moderator: + runs-on: ubuntu-latest + steps: + - name: Generate access token + id: generate_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.GH_APP_ID }} + private_key: ${{ secrets.GH_APP_KEY }} + - name: Similarity analysis + id: similarity + uses: actions-cool/issues-similarity-analysis@v1 + with: + token: ${{ steps.generate_token.outputs.token }} + filter-threshold: 0.60 + title-excludes: '' + comment-title: | + ### Your issue looks similar to these issues: + Please close if duplicate. + comment-body: '${index}. ${similarity} #${number}' + - name: Label if possible duplicate + if: steps.similarity.outputs.similar-issues-found =='true' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ["possible duplicate"] + }) + - uses: actions/checkout@v4 + - name: Automatically close issues that dont follow the issue template + uses: lucasbento/auto-close-issues@v1.0.2 + with: + github-token: ${{ steps.generate_token.outputs.token }} + issue-close-message: | + @${issue.user.login}: hello! :wave: + This issue is being automatically closed because it does not follow the issue template." + closed-issues-label: "invalid" + - name: Check if issue mentions a provider + id: provider_check + env: + GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}" + run: | + wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py" + pip3 install httpx + RES="$(python3 ./check_issue.py)" + echo "name=${RES}" >> $GITHUB_OUTPUT + - name: Comment if issue mentions a provider + if: steps.provider_check.outputs.name != 'none' + uses: actions-cool/issues-helper@v3 + with: + actions: 'create-comment' + token: ${{ steps.generate_token.outputs.token }} + body: | + Hello ${{ github.event.issue.user.login }}. + Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM). + + Found provider name: `${{ steps.provider_check.outputs.name }}` + - name: Label if mentions provider + if: steps.provider_check.outputs.name != 'none' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate_token.outputs.token }} + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ["possible provider issue"] + }) + - name: Add eyes reaction to all issues + uses: actions-cool/emoji-helper@v1.0.0 + with: + type: 'issue' + token: ${{ steps.generate_token.outputs.token }} + emoji: 'eyes' + + diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index f089afa8f..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,27 +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 }} - MAL_KEY: ${{ secrets.MAL_KEY }} - ANILIST_KEY: ${{ secrets.ANILIST_KEY }} - - name: Create pre-release - uses: marvinpinto/action-automatic-releases@latest + uses: "marvinpinto/action-automatic-releases@latest" with: repo_token: "${{ secrets.GITHUB_TOKEN }}" automatic_release_tag: "pre-release" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 433816e0f..7f6dd4123 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,40 +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: Ensure binary compatibility - # This is to ensure that your code is backwards compatible. - # If this fails you need to add a @Prerelease annotation to new code. - run: ./gradlew library:checkKotlinAbi - - 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..b86273d94 --- /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/COMPOSE.md b/COMPOSE.md deleted file mode 100644 index 8d83a50ae..000000000 --- a/COMPOSE.md +++ /dev/null @@ -1,21 +0,0 @@ -# Migration guide to Compose - -### 1. MVI instead of MVVM - -The current design of CloudStream loosely uses the MVVM architecture. - -This means that the UI invokes the viewmodel with function calls, and it responds with LiveData fields that are observed. While this has worked, it generates a lot of boilerplate and has created some friction. - -To make it easier to work with Compose, the new architecture will be based on MVI. In short this means that the viewmodel exposes a singular immutable class that is observed, and receives all UI events with a singular event that is a sealed class. All the UI should be able to be recreated based on this singular state class, and all interactions should be able to be replayed using only the event callback. - -For a more detailed overview, see: https://www.youtube.com/watch?v=b2z1jvD4VMQ - -This is part of the effort to make CloudStream cross platform, as it allows us to decouple UI and logic. - -### 2. KMP-compatible libraries - -We plan to leverage Kotlin's KMP project to compile our code to different architectures. However, this requires us to only use KMP-compatible libraries, no Java. Therefore any pull requests must ensure that they use KMP-compatible libraries only. - -### 3. UI Changes - -While migrating to the new compose UI, you also have the opportunity to change the UI. However, this should only be to freshen up the UI, not completely redesign it. It is also important to stress that this process should not lose any features of the old UI, and be very conservative with adding new features. \ No newline at end of file diff --git a/README.md b/README.md index c2492c5d8..3980b1096 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ + [Bugs Reports:](#bug_report) + [Enhancement:](#enhancment) + [Extension Development:](#extensions) -+ [Language Support:](#languages) ++ [Languauge Support:](#languages) + [Further Sources](#contact_and_sources) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ad8451a3..47e6a3606 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,96 +1,53 @@ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier -import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.dokka) - alias(libs.plugins.kotlin.serialization) + id("com.android.application") + id("kotlin-android") + id("org.jetbrains.dokka") } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) +val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" +val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() -abstract class GenerateGitHashTask : DefaultTask() { +fun getGitCommitHash(): String { + return try { + val headFile = file("${project.rootDir}/.git/HEAD") - @get:InputFile - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val headFile: RegularFileProperty - - @get:InputDirectory - @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val headsDir: DirectoryProperty - - @get:OutputDirectory - abstract val outputDir: DirectoryProperty - - @TaskAction - fun generate() { - val head = headFile.get().asFile - - val hash = try { - if (head.exists()) { - // Read the commit hash from .git/HEAD - val headContent = head.readText().trim() - if (headContent.startsWith("ref:")) { - val refPath = headContent.substring(5) // e.g., refs/heads/main - val commitFile = File(head.parentFile, refPath) - if (commitFile.exists()) commitFile.readText().trim() else "" - } else headContent // If it's a detached HEAD (commit hash directly) - } else "" // If .git/HEAD doesn't exist - } catch (_: Throwable) { - "" // Just set to an empty string if any exception occurs - }.take(7) // Get the short commit hash - - val outFile = outputDir.file("git-hash.txt").get().asFile - outFile.parentFile.mkdirs() - outFile.writeText(hash) + // Read the commit hash from .git/HEAD + if (headFile.exists()) { + val headContent = headFile.readText().trim() + if (headContent.startsWith("ref:")) { + val refPath = headContent.substring(5) // e.g., refs/heads/main + val commitFile = file("${project.rootDir}/.git/$refPath") + if (commitFile.exists()) commitFile.readText().trim() else "" + } else headContent // If it's a detached HEAD (commit hash directly) + } else { + "" // If .git/HEAD doesn't exist + }.take(7) // Return the short commit hash + } catch (_: Throwable) { + "" // Just return an empty string if any exception occurs } } -val generateGitHash = tasks.register("generateGitHash") { - val gitDir = layout.projectDirectory.dir("../.git") - - headFile.set(gitDir.file("HEAD")) - headsDir.set(gitDir.dir("refs/heads")) - - outputDir.set(layout.buildDirectory.dir("generated/git")) -} - android { @Suppress("UnstableApiUsage") testOptions { unitTests.isReturnDefaultValues = true } - // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491 - dependenciesInfo { - // Disables dependency metadata when building APKs. - includeInApk = false - // Disables dependency metadata when building Android App Bundles. - includeInBundle = false - } - - androidComponents { - onVariants { variant -> - variant.sources.assets?.addGeneratedSourceDirectory( - generateGitHash, - GenerateGitHashTask::outputDir - ) - } + viewBinding { + enable = true } signingConfigs { - // We just use SIGNING_KEY_ALIAS here since it won't change - // so won't kill the configuration cache. - if (System.getenv("SIGNING_KEY_ALIAS") != null) { + if (prereleaseStoreFile != null) { create("prerelease") { - val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" - val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() - - storeFile = prereleaseStoreFile?.let { file(it) } + storeFile = file(prereleaseStoreFile) storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") @@ -104,10 +61,12 @@ android { applicationId = "com.lagradost.cloudstream3" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = libs.versions.versionCode.get().toInt() - versionName = libs.versions.versionName.get() + versionCode = 65 + versionName = "4.5.1" - manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() + resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") + resValue("string", "commit_hash", getGitCommitHash()) + resValue("bool", "is_prerelease", "false") // Reads local.properties val localProperties = gradleLocalProperties(rootDir, project.providers) @@ -127,16 +86,6 @@ android { "SIMKL_CLIENT_SECRET", "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) - buildConfigField( - "String", - "MAL_KEY", - "\"" + (System.getenv("MAL_KEY") ?: localProperties["mal.key"]) + "\"" - ) - buildConfigField( - "String", - "ANILIST_KEY", - "\"" + (System.getenv("ANILIST_KEY") ?: localProperties["anilist.key"]) + "\"" - ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -164,9 +113,12 @@ android { productFlavors { create("stable") { dimension = "state" + resValue("bool", "is_prerelease", "false") } create("prerelease") { dimension = "state" + resValue("bool", "is_prerelease", "true") + buildConfigField("boolean", "BETA", "true") applicationIdSuffix = ".prerelease" if (signingConfigs.names.contains("prerelease")) { signingConfig = signingConfigs.getByName("prerelease") @@ -184,29 +136,13 @@ android { targetCompatibility = JavaVersion.toVersion(javaTarget.target) } - java { - // Use Java 17 toolchain even if a higher JDK runs the build. - // We still use Java 8 for now which higher JDKs have deprecated. - toolchain { - languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get())) - } - } - lint { + abortOnError = false checkReleaseBuilds = false } buildFeatures { buildConfig = true - viewBinding = true - } - - packaging { - jniLibs { - // Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23). - // Note: This may increase app startup time slightly. - useLegacyPackaging = true - } } namespace = "com.lagradost.cloudstream3" @@ -217,46 +153,44 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.json) androidTestImplementation(libs.core) - androidTestImplementation(libs.espresso.core) + implementation(libs.junit.ktx) androidTestImplementation(libs.ext.junit) - androidTestImplementation(libs.instancio.core) - androidTestImplementation(libs.junit.ktx) - androidTestImplementation(libs.kotlin.test) + androidTestImplementation(libs.espresso.core) // Android Core & Lifecycle implementation(libs.core.ktx) - implementation(libs.activity.ktx) - implementation(libs.annotation) implementation(libs.appcompat) - implementation(libs.fragment.ktx) - implementation(libs.bundles.lifecycle) - implementation(libs.bundles.navigation) - implementation(libs.kotlinx.collections.immutable) - implementation(libs.kotlinx.serialization.json) // JSON Parser + implementation(libs.navigation.ui.ktx) + implementation(libs.lifecycle.livedata.ktx) + implementation(libs.lifecycle.viewmodel.ktx) + implementation(libs.navigation.fragment.ktx) // Design & UI implementation(libs.preference.ktx) implementation(libs.material) implementation(libs.constraintlayout) + implementation(libs.swiperefreshlayout) // Coil Image Loading - implementation(libs.bundles.coil) + implementation(libs.coil) + implementation(libs.coil.network.okhttp) // Media 3 (ExoPlayer) implementation(libs.bundles.media3) implementation(libs.video) - // FFmpeg Decoding - implementation(libs.bundles.nextlib) - - // Anime-db for filler - implementation(libs.anime.db) - // PlayBack implementation(libs.colorpicker) // Subtitle Color Picker implementation(libs.newpipeextractor) // For Trailers implementation(libs.juniversalchardet) // Subtitle Decoding + // FFmpeg Decoding + implementation(libs.bundles.nextlibMedia3) + + // Crash Reports (AcraApplication.kt) + implementation(libs.acra.core) + implementation(libs.acra.toast) + // UI Stuff implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton) implementation(libs.palette.ktx) // Palette for Images -> Colors @@ -267,37 +201,50 @@ dependencies { implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV // Extensions & Other Libs - implementation(libs.jsoup) // HTML Parser - implementation(libs.ksoup) // HTML Parser implementation(libs.rhino) // Run JavaScript + implementation(libs.quickjs) + implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance implementation(libs.safefile) // To Prevent the URI File Fu*kery coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor - implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 - implementation(libs.jackson.module.kotlin) // JSON Parser - implementation(libs.zipline) - - // Temp/deprecated; will be removed once extensions have time to migrate from using it - implementation("com.google.code.gson:gson:2.11.0") - // Deprecated; will be removed once extensions have time to migrate from using it - implementation("me.xdrop:fuzzywuzzy:1.4.0") + implementation(libs.conscrypt.android) { + version { + strictly("2.5.2") + } + because("2.5.3 crashes everything for everyone.") + } // To Fix SSL Fu*kery on Android 9 + implementation(libs.jackson.module.kotlin) { + version { + strictly("2.13.1") + } + because("Don't Bump Jackson above 2.13.1, Crashes on Android TV's and FireSticks that have Min API Level 25 or Less.") + } // JSON Parser // Torrent Support - implementation(libs.torrentserver) + // implementation(libs.torrentserver) // Downloading & Networking + implementation(libs.work.runtime) implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib - implementation(project(":library")) + implementation(project(":library") { + // There does not seem to be a good way of getting the android flavor. + val isDebug = gradle.startParameter.taskRequests.any { task -> + task.args.any { arg -> + arg.contains("debug", true) + } + } + + this.extra.set("isDebug", isDebug) + }) } tasks.register("androidSourcesJar") { archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.directories) // Full Sources + from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources } tasks.register("copyJar") { - dependsOn("build", ":library:jvmJar") from( "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", "../library/build/libs" @@ -324,21 +271,15 @@ tasks.register("makeJar") { tasks.withType { compilerOptions { jvmTarget.set(javaTarget) - jvmDefault.set(JvmDefaultMode.ENABLE) - optIn.addAll( - "com.lagradost.cloudstream3.InternalAPI", - "com.lagradost.cloudstream3.Prerelease", - ) + freeCompilerArgs.add("-Xjvm-default=all-compatibility") } } dokka { moduleName = "App" dokkaSourceSets { - configureEach { - suppress = name != "prereleaseDebug" + main { analysisPlatform = KotlinPlatform.JVM - displayName = "JVM" documentedVisibilities( VisibilityModifier.Public, VisibilityModifier.Protected diff --git a/app/lint.xml b/app/lint.xml deleted file mode 100644 index b2f5e8f2b..000000000 --- a/app/lint.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index e854356a0..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 @@ -55,6 +54,12 @@ class ExampleInstrumentedTest { return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView } } + @Test + fun providersExist() { + Assert.assertTrue(getAllProviders().isNotEmpty()) + println("Done providersExist") + } + @Throws private inline fun testAllLayouts( activity: Activity, @@ -83,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) @@ -130,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 84ef1fee0..000000000 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt +++ /dev/null @@ -1,154 +0,0 @@ -package com.lagradost.cloudstream3 - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.lagradost.cloudstream3.SkipSerializationTest -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}") - - val failures = mutableListOf() - - serializableClasses.forEach { kClass -> - runCatching { - val instance = Instancio.of(kClass.java).withMaxDepth(10).create() - - 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}") - }.onFailure { e -> - failures.add("FAILED ${kClass.qualifiedName}: ${e.message}") - } - } - - if (failures.isNotEmpty()) { - throw AssertionError("${failures.size} class(es) failed:\n${failures.joinToString("\n")}") - } - } - - @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) - @Test - fun isIdenticalDeserialization() { - val serializableClasses = findSerializableClasses("com.lagradost") - println("Number of serializable classes: ${serializableClasses.size}") - - val failures = mutableListOf() - - serializableClasses.forEach { kClass -> - runCatching { - val instance = Instancio.of(kClass.java).withMaxDepth(10).create() - // 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}") - }.onFailure { e -> - failures.add("FAILED ${kClass.qualifiedName}: ${e.message}") - } - } - - if (failures.isNotEmpty()) { - throw AssertionError("${failures.size} class(es) failed:\n${failures.joinToString("\n")}") - } - } - - // 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 } - && kClass.java.annotations.none { it is SkipSerializationTest } - && !kClass.isAbstract - } - } - - @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/UriSerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/UriSerializerTest.kt deleted file mode 100644 index 3ffd37124..000000000 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/UriSerializerTest.kt +++ /dev/null @@ -1,41 +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.SerialName -import kotlinx.serialization.Serializable -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -@Serializable -data class UriData( - @Serializable(with = UriSerializer::class) - @SerialName("uri") val uri: Uri = Uri.EMPTY, -) - -class UriSerializerTest { - - @Test - fun uriSerializerSerializesUriToString() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val result = data.toJson() - assertTrue(result.contains("https://example.com/path?query=1")) - } - - @Test - fun uriSerializerDeserializesStringToUri() { - val input = """{"uri":"https://example.com/path?query=1"}""" - val result = parseJson(input) - assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) - } - - @Test - fun uriSerializerRoundtripsCorrectly() { - val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) - val encoded = data.toJson() - val decoded = parseJson(encoded) - assertEquals(data.uri, decoded.uri) - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2..1a0b514a2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -16,53 +16,12 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + tools:targetApi="35"> - - - - - - - - - - - - + android:supportsPictureInPicture="true"> @@ -200,14 +142,7 @@ - - - - - - - @@ -231,7 +166,7 @@ - + @@ -244,6 +179,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 b2ef28348..003d79a77 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -1,78 +1,233 @@ package com.lagradost.cloudstream3 -/** - * Deprecated alias for CloudStreamApp for backwards compatibility with plugins. - * Use CloudStreamApp instead. - */ -@Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), - level = DeprecationLevel.ERROR -) -class AcraApplication { - companion object { +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import coil3.PlatformContext +import coil3.SingletonImageLoader +import com.lagradost.api.setContext +import com.lagradost.cloudstream3.mvvm.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 com.lagradost.cloudstream3.utils.ImageLoader +import kotlinx.coroutines.runBlocking +import org.acra.ACRA +import org.acra.ReportField +import org.acra.config.CoreConfiguration +import org.acra.data.CrashReportData +import org.acra.data.StringFormat +import org.acra.ktx.initAcra +import org.acra.sender.ReportSender +import org.acra.sender.ReportSenderFactory +import java.io.File +import java.io.FileNotFoundException +import java.io.PrintStream +import java.lang.ref.WeakReference +import java.util.Locale +import kotlin.concurrent.thread +import kotlin.system.exitProcess - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), - level = DeprecationLevel.ERROR - ) - val context get() = CloudStreamApp.context +class CustomReportSender : ReportSender { + // Sends all your crashes to google forms + override fun send(context: Context, errorContent: CrashReportData) { + /*println("Sending report") + val url = + "https://docs.google.com/forms/d/e/$id/formResponse" + val data = mapOf( + "entry.$entry" to errorContent.toJSON() + ) - @Deprecated( - message = "AcraApplication is deprecated, use CloudStreamApp instead", - replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), - level = DeprecationLevel.ERROR - ) - 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.ERROR - ) - 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.ERROR - ) - 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.ERROR - ) - 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.ERROR - ) - 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.ERROR - ) - 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.ERROR - ) - 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(), SingletonImageLoader.Factory { + + override fun onCreate() { + super.onCreate() + // if we want to initialise coil at earliest + // (maybe when loading an image or gif using in splash screen activity) + //ImageLoader.buildImageLoader(applicationContext) + + ExceptionHandler(filesDir.resolve("last_error")) { + val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) + startActivity(Intent.makeRestartActivityTask(intent!!.component)) + }.also { + exceptionHandler = it + Thread.setDefaultUncaughtExceptionHandler(it) + } + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + context = base + + initAcra { + //core configuration: + buildConfigClass = BuildConfig::class.java + reportFormat = StringFormat.JSON + + reportContent = listOf( + ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE, + ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, + ReportField.STACK_TRACE, + ) + + // removed this due to bug when starting the app, moved it to when it actually crashes + //each plugin you chose above can be configured in a block like this: + /*toast { + text = getString(R.string.acra_report_toast) + //opening this block automatically enables the plugin. + }*/ + } + } + + override fun newImageLoader(context: PlatformContext): coil3.ImageLoader { + // Coil Module will be initialized & setSafe globally when first loadImage() is invoked + return ImageLoader.buildImageLoader(applicationContext) + } + + companion object { + var exceptionHandler: ExceptionHandler? = null + + /** Use to get activity from Context */ + tailrec fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + } + + private var _context: WeakReference? = null + var context + get() = _context?.get() + private set(value) { + _context = WeakReference(value) + setContext(WeakReference(value)) + } + + fun getKeyClass(path: String, valueType: Class): T? { + return context?.getKey(path, valueType) + } + + fun setKeyClass(path: String, value: T) { + context?.setKey(path, value) + } + + fun removeKeys(folder: String): Int? { + return context?.removeKeys(folder) + } + + fun setKey(path: String, value: T) { + context?.setKey(path, value) + } + + fun setKey(folder: String, path: String, value: T) { + context?.setKey(folder, path, value) + } + + inline fun getKey(path: String, defVal: T?): T? { + return context?.getKey(path, defVal) + } + + inline fun getKey(path: String): T? { + return context?.getKey(path) + } + + inline fun getKey(folder: String, path: String): T? { + return context?.getKey(folder, path) + } + + inline fun getKey(folder: String, path: String, defVal: T?): T? { + return context?.getKey(folder, path, defVal) + } + + fun getKeys(folder: String): List? { + return context?.getKeys(folder) + } + + fun removeKey(folder: String, path: String) { + context?.removeKey(folder, path) + } + + fun removeKey(path: String) { + context?.removeKey(path) + } + + /** + * If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails. + * */ + fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) { + context?.openBrowser(url, fallbackWebview, fragment) + } + + /** Will fallback to webview if in TV layout */ + fun openBrowser(url: String, activity: FragmentActivity?) { + openBrowser( + url, + isLayout(TV or EMULATOR), + activity?.supportFragmentManager?.fragments?.lastOrNull() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt deleted file mode 100644 index 466ef5a00..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(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..02ace92d5 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,32 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.children -import androidx.core.view.isNotEmpty import androidx.preference.PreferenceManager import com.google.android.gms.cast.framework.CastSession import com.google.android.material.chip.ChipGroup import com.google.android.material.navigationrail.NavigationRailView -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ToastBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter -import com.lagradost.cloudstream3.ui.home.ParentItemAdapter -import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible +import com.lagradost.cloudstream3.ui.player.PlayerEventType import com.lagradost.cloudstream3.ui.player.Torrent -import com.lagradost.cloudstream3.ui.result.ActorAdaptor -import com.lagradost.cloudstream3.ui.result.EpisodeAdapter -import com.lagradost.cloudstream3.ui.result.ImageAdapter -import com.lagradost.cloudstream3.ui.search.SearchAdapter -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.ui.settings.Globals.updateTv -import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod +import com.lagradost.cloudstream3.utils.UIHelper +import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission +import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.UiText +import org.schabi.newpipe.extractor.NewPipe import java.lang.ref.WeakReference import java.util.Locale import kotlin.math.max import kotlin.math.min -import org.schabi.newpipe.extractor.NewPipe enum class FocusDirection { Start, @@ -101,24 +89,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 @@ -186,40 +167,27 @@ object CommonActivity { 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 +195,8 @@ 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) // FIXME this should be replaced } fun Context.updateLocale() { @@ -244,26 +208,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.onResultSafe(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,15 +251,13 @@ object CommonActivity { } } - /** Enters pip mode if it is both possible and desired to do so*/ private fun Activity.enterPIPMode() { - if (!isPipDesired || !this.isPIPPossible()) return - + if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try { enterPictureInPictureMode(PictureInPictureParams.Builder().build()) - } catch (_: Exception) { + } catch (e: Exception) { // Use fallback just in case @Suppress("DEPRECATION") enterPictureInPictureMode() @@ -307,18 +273,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 +315,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 +347,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 +380,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 +411,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 +489,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, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { + PlayerEventType.NextEpisode + } + + KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { + PlayerEventType.PrevEpisode + } + + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + PlayerEventType.Pause + } + + KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { + PlayerEventType.Play + } + + KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { + PlayerEventType.Lock + } + + KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> { + PlayerEventType.ToggleHide + } + + KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { + PlayerEventType.ToggleMute + } + + KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { + PlayerEventType.ShowMirrors + } + // OpenSubtitles shortcut + KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { + PlayerEventType.SearchSubtitlesOnline + } + + KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { + PlayerEventType.ShowSpeed + } + + KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { + PlayerEventType.Resize + } + + KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { + PlayerEventType.SkipOp + } + + KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { + PlayerEventType.SkipCurrentChapter + } + + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation + PlayerEventType.PlayPauseToggle + } + + else -> null + }?.let { playerEvent -> + playerEventListener?.invoke(playerEvent) + } + + //when (keyCode) { + // KeyEvent.KEYCODE_DPAD_CENTER -> { + // println("DPAD PRESSED") + // } + //} } /** overrides focus and custom key events */ @@ -569,7 +603,6 @@ object CommonActivity { else -> null } - // println("NEXT FOCUS : $nextView") if (nextView != null) { nextView.requestFocus() @@ -577,15 +610,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 +622,7 @@ object CommonActivity { // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", // Toast.LENGTH_LONG //) + } // if someone else want to override the focus then don't handle the event as it is already @@ -603,4 +632,4 @@ object CommonActivity { } return null } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt new file mode 100644 index 000000000..045a7963a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt @@ -0,0 +1,11 @@ +package com.lagradost.cloudstream3 + +import android.view.LayoutInflater +import androidx.annotation.LayoutRes +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.ui.HeaderViewDecoration + +fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) { + val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null) + view.addItemDecoration(HeaderViewDecoration(headerView)) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 5d39f6554..0d0a56a92 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -2,17 +2,16 @@ package com.lagradost.cloudstream3 import android.animation.ValueAnimator import android.annotation.SuppressLint -import android.app.Dialog 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.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 @@ -23,16 +22,15 @@ import android.widget.CheckBox import android.widget.ImageView import android.widget.LinearLayout import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IdRes import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView -import androidx.core.content.edit -import androidx.core.net.toUri +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.children -import androidx.core.view.get import androidx.core.view.isGone 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,9 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.initAll -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent @@ -82,28 +79,30 @@ 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 @@ -119,7 +118,6 @@ import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsGeneral @@ -157,31 +155,24 @@ 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.showProgress 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.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml import com.lagradost.cloudstream3.utils.txt import com.lagradost.safefile.SafeFile -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File @@ -191,7 +182,6 @@ import java.net.URLDecoder import java.nio.charset.Charset import kotlin.math.abs import kotlin.math.absoluteValue -import kotlin.reflect.full.createInstance import kotlin.system.exitProcess class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { @@ -202,21 +192,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa const val ANIMATED_OUTLINE: Boolean = false var lastError: String? = null - /** Update lastError variable based on error file, to check if app crashed. - * Can be called multiple times without changing the lastError variable changing. - **/ - fun setLastError(context: Context) { - if (lastError != null) return - - val errorFile = context.filesDir.resolve("last_error") - if (errorFile.exists() && errorFile.isFile) { - lastError = errorFile.readText(Charset.defaultCharset()) - errorFile.delete() - } else { - lastError = null - } - } - private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" @@ -288,7 +263,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // 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")) { @@ -297,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 @@ -328,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") @@ -354,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") @@ -364,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) { @@ -381,20 +350,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa START_ACTION_RESUME_LATEST ) } - } else if (str.startsWith(APP_STRING_SHARE)) { - try { - val data = str.substringAfter("$APP_STRING_SHARE:") - val parts = data.split("?", limit = 2) - loadResult( - String(base64DecodeArray(parts[1]), Charsets.UTF_8), - String(base64DecodeArray(parts[0]), Charsets.UTF_8), - "" - ) - return true - } catch (e: Exception) { - showToast("Invalid Uri", Toast.LENGTH_SHORT) - return false - } } else if (!isWebview) { if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) { this.navigate(R.id.navigation_downloads) @@ -410,39 +365,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa return true } - val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull() - if (matchedApi != null) { - loadResult(str, matchedApi.name, "") - return true + synchronized(apis) { + for (api in apis) { + if (str.startsWith(api.mainUrl)) { + loadResult(str, api.name, "") + return true + } + } } } } } return false } - - - fun centerView(view: View?) { - if (view == null) return - try { - Log.v(TAG, "centerView: $view") - val r = Rect(0, 0, 0, 0) - view.getDrawingRect(r) - val x = r.centerX() - val y = r.centerY() - val dx = r.width() / 2 //screenWidth / 2 - val dy = screenHeight / 2 - val r2 = Rect(x - dx, y - dy, x + dx, y + dy) - view.requestRectangleOnScreen(r2, false) - // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) - } catch (_: Throwable) { - } - } } - var lastPopup: SearchResponse? = null - var lastPopupJob: Job? = null fun loadPopup(result: SearchResponse, load: Boolean = true) { lastPopup = result val syncName = syncViewModel.syncName(result.apiName) @@ -458,8 +396,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) @@ -506,7 +443,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, @@ -521,7 +457,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, @@ -552,19 +488,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, @@ -572,15 +514,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, @@ -667,11 +604,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() { @@ -698,9 +642,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ } .setPositiveButton(R.string.yes) { _, _ -> if (dontShowAgainCheck.isChecked) { - settingsManager.edit(commit = true) { - putInt(getString(R.string.confirm_exit_key), 1) - } + settingsManager.edit().putInt(getString(R.string.confirm_exit_key), 1).commit() } // finish() causes a bug on some TVs where player // may keep playing after closing the app. @@ -725,11 +667,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) } @@ -738,54 +679,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (intent == null) return val str = intent.dataString loadCache() - handleAppIntentUrl(this, str, false, intent.extras) } 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) @@ -798,11 +698,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 } } @@ -811,29 +711,25 @@ 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::class.simpleName == custom.parentClassName - }?.let { - allProviders.add( - it::class.createInstance().apply { + 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 - } - ) - } + }) + } } } // it.hashCode() is not enough to make sure they are distinct - apis = allProviders.distinctBy { - it.lang + it.name + it.mainUrl + it::class.qualifiedName - } + apis = + allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name } APIHolder.apiMap = null } catch (e: Exception) { logError(e) @@ -846,6 +742,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 @@ -859,37 +756,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 @@ -1180,14 +1060,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 = @@ -1196,14 +1096,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) @@ -1213,17 +1110,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") ?: "" + 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) } @@ -1251,7 +1146,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, @@ -1283,26 +1178,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 **/ @@ -1344,7 +1219,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) { @@ -1361,11 +1236,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. @@ -1376,7 +1249,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 ) @@ -1384,14 +1257,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) @@ -1429,9 +1296,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa else -> { resultviewPreviewBookmark.isEnabled = false - resultviewPreviewBookmark.showProgress() - //resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) - //resultviewPreviewBookmark.setText(R.string.loading) + resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) + resultviewPreviewBookmark.setText(R.string.loading) } } } @@ -1517,17 +1383,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.loadImage( + d.posterImage ?: d.posterBackgroundImage + ) setUserData(syncViewModel.userData.value) setWatchStatus(viewModel.watchStatus.value) @@ -1630,6 +1488,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 @@ -1657,7 +1527,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) @@ -1680,6 +1552,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (navDestination.matchDestination(R.id.navigation_home)) { attachBackPressedCallback("MainActivity") { showConfirmExitDialog(settingsManager) + @Suppress("DEPRECATION") + window?.navigationBarColor = + colorFromAttribute(R.attr.primaryGrayBackground) + updateLocale() } } else detachBackPressedCallback("MainActivity") } @@ -1707,27 +1583,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( @@ -1736,7 +1602,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 { @@ -1775,104 +1640,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 -> @@ -1939,7 +1706,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() @@ -1965,7 +1732,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) } try { - if (getKey(HAS_DONE_SETUP_KEY, false) != true) { + if (getKey(HAS_DONE_SETUP_KEY, false) != true) { navController.navigate(R.id.navigation_setup_language) // If no plugins bring up extensions screen } else if (PluginManager.getPluginsOnline().isEmpty() @@ -2043,14 +1799,23 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa // } // } - attachBackPressedCallback("MainActivityDefault") { - setNavigationBarColorCompat(R.attr.primaryGrayBackground) - updateLocale() - runDefault() - } + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + @Suppress("DEPRECATION") + window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground) + updateLocale() - // Start the download queue - DownloadQueueManager.init(this) + // If we don't disable we end up in a loop with default behavior calling + // this callback as well, so we disable it, run default behavior, + // then re-enable this callback so it can be used for next back press. + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + } + ) } /** Biometric stuff **/ @@ -2073,4 +1838,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false } } -} +} \ No newline at end of file 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..cc64a6d39 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt @@ -6,8 +6,8 @@ import android.content.Context import android.content.Intent import androidx.core.content.FileProvider import androidx.core.net.toUri -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult @@ -21,8 +21,7 @@ import java.io.File fun updateDurationAndPosition(position: Long, duration: Long) { if (position <= 0 || duration <= 0) return - val episode = getKey("last_opened") ?: return - DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null) + DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration) ResultFragment.updateUI() } @@ -99,7 +98,7 @@ abstract class OpenInAppAction( intent.component = ComponentName(packageName, intentClass) } putExtra(context, intent, video, result, index) - setKey("last_opened", video) + setKey("last_opened_id", video.id) launchResult(intent) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt index f4e8768d8..7e8b1c97b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -12,63 +12,44 @@ 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.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.utils.ExtractorLinkType 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( + val allVideoClickActions = threadSafeListOf( // 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 // ... ) @@ -160,7 +141,7 @@ abstract class VideoClickAction { } } - fun uniqueId() = "$sourcePlugin:${this::class.qualifiedName}" + fun uniqueId() = "$sourcePlugin:${this::class.jvmName}" @Throws abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean @@ -201,4 +182,4 @@ abstract class VideoClickAction { CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG) } } -} +} \ 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 a2bb53a16..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt +++ /dev/null @@ -1,160 +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.SkipSerializationTest -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.serializers.UriSerializer -import com.lagradost.cloudstream3.utils.txt -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * 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) - } - - @Serializable - @SkipSerializationTest //.Uri has issues with Jackson - data class MinimalVideoLink( - @JsonProperty("uri") @SerialName("uri") - @Serializable(with = UriSerializer::class) - val uri: Uri?, - @JsonProperty("url") @SerialName("url") val url: String?, - @JsonProperty("mimeType") @SerialName("mimeType") val mimeType: String = "video/mp4", - @JsonProperty("name") @SerialName("name") val name: String?, - @JsonProperty("headers") @SerialName("headers") var headers: Map = mapOf(), - @JsonProperty("quality") @SerialName("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", - ) - } - } - - @Serializable - data class MinimalSubtitleLink( - @JsonProperty("url") @SerialName("url") val url: String, - @JsonProperty("mimeType") @SerialName("mimeType") val mimeType: String = "text/vtt", - @JsonProperty("name") @SerialName("name") val name: String?, - @JsonProperty("headers") @SerialName("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 - } -} 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..102f0ac8b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.net.toUri import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.updateDurationAndPosition @@ -44,7 +45,7 @@ open class MpvKtPackage( intent.apply { putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) - setDataAndType(link.url.toUri(), "video/*") + setDataAndType(Uri.parse(link.url), "video/*") // m3u8 plays, but changing sources feature is not available // makeTempM3U8Intent(activity, this, result) diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt index cd49eb994..68e619c92 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.net.toUri import com.lagradost.api.Log import com.lagradost.cloudstream3.actions.OpenInAppAction @@ -17,9 +18,6 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType // https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904 // https://mpv-android.github.io/mpv-android/intent.html -//https://github.com/marlboro-advance/mpvEx -class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity") - class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { override val sourceTypes = setOf( ExtractorLinkType.VIDEO, @@ -28,10 +26,10 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { ) } -open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( +open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction( txt(appName), packageName, - intentClass + "is.xyz.mpv.MPVActivity" ) { override val oneSource = true // mpv has poor playlist support on TV override suspend fun putExtra( @@ -46,7 +44,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv putExtra("title", video.name) if (index != null) { - setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") + setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*") } else { makeTempM3U8Intent(context, this, result) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt deleted file mode 100644 index e8bb93a99..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.core.net.toUri -import com.lagradost.api.Log -import com.lagradost.cloudstream3.actions.OpenInAppAction -import com.lagradost.cloudstream3.actions.updateDurationAndPosition -import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.txt - -/** https://github.com/Riteshp2001/mpvRx - * - * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132 - * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56 - * */ -class MpvRxPackage : OpenInAppAction( - appName = txt("mpvRx"), - packageName = "app.gyrolet.mpvrx", - intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity" -) { - override val oneSource = true - override suspend fun putExtra( - context: Context, - intent: Intent, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - intent.apply { - putExtra("title", video.name) - val link = result.links[index!!] - val headers = link.headers - - setData(link.url.toUri()) - if (headers.isNotEmpty()) { - // PlayerActivity expects a flat array: [key1, value1, key2, value2, ...] - val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray() - intent.putExtra("headers", flat) - } - /*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146 - intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray()) - intent.putExtra( - "subs.titles", - subs.map { it.name }.toTypedArray(), - ) - intent.putExtra( - "subs.langs", - subs.map { it.languageCode }.toTypedArray(), - ) - val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri() - intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf() )*/ - - if (video.tvType.isEpisodeBased()) { - video.season?.let { intent.putExtra("introdb_season", it) } - video.episode.let { intent.putExtra("introdb_episode", it) } - } - - val position = getViewPos(video.id)?.position - if (position != null) - putExtra("position", position.toInt()) - } - } - - override fun onResult(activity: Activity, intent: Intent?) { - val position = intent?.getIntExtra("position", -1) ?: -1 - val duration = intent?.getIntExtra("duration", -1) ?: -1 - Log.d("MPV", "Position: $position, Duration: $duration") - updateDurationAndPosition(position.toLong(), duration.toLong()) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/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..7c1b68c05 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt @@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.actions.temp import android.content.Context import android.content.Intent -import androidx.core.net.toUri +import android.net.Uri import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult @@ -33,7 +33,7 @@ class PlayInBrowserAction: VideoClickAction() { ) { val link = result.links.getOrNull(index ?: 0) ?: return val i = Intent(Intent.ACTION_VIEW) - i.data = link.url.toUri() + i.data = Uri.parse(link.url) launch(i) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt deleted file mode 100644 index 56512377b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.lagradost.cloudstream3.actions.temp - -import android.app.Activity -import android.content.Context -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.actions.VideoClickAction -import com.lagradost.cloudstream3.ui.player.ExtractorUri -import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.player.VideoGenerator -import com.lagradost.cloudstream3.ui.result.LinkLoadingResult -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.txt - -class PlayMirrorAction : VideoClickAction() { - override val name = txt(R.string.episode_action_play_mirror) - - override val oneSource = true - - override val isPlayer = true - - override val sourceTypes: Set = LOADTYPE_INAPP - - override fun shouldShow(context: Context?, video: ResultEpisode?) = true - - override suspend fun runAction( - context: Context?, - video: ResultEpisode, - result: LinkLoadingResult, - index: Int? - ) { - //Implemented a generator to handle the single - val activity = context as? Activity ?: return - val link = index?.let { result.links[it] } - val generatorMirror = object : VideoGenerator(listOf(video)) { - override val hasCache: Boolean = false - override val canSkipLoading: Boolean = false - override fun getId(index: Int): Int = video.id - - override suspend fun generateLinks( - clearCache: Boolean, - sourceTypes: Set, - callback: (Pair) -> Unit, - subtitleCallback: (SubtitleData) -> Unit, - offset: Int, - isCasting: Boolean - ): Boolean { - index?.let { callback(link to null) } - result.subs.forEach { subtitle -> subtitleCallback(subtitle) } - return true - } - } - - activity.navigate( - R.id.global_to_navigation_player, - GeneratorPlayer.newInstance( - generatorMirror, 0, result.syncData - ) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt index b6478b1d9..df4fcca81 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt @@ -6,7 +6,7 @@ import android.content.Intent import android.os.Build import androidx.core.net.toUri import com.lagradost.api.Log -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.actions.updateDurationAndPosition @@ -19,12 +19,7 @@ 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) { @@ -60,7 +55,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 { @@ -74,4 +69,4 @@ open class VlcPackage: OpenInAppAction( Log.d("VLC", "Position: $position, Duration: $duration") updateDurationAndPosition(position, duration) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt index 963221bb3..9f7eee7b8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import androidx.core.net.toUri import com.lagradost.cloudstream3.USER_AGENT @@ -37,7 +38,7 @@ class WebVideoCastPackage: OpenInAppAction( val link = result.links[index ?: 0] intent.apply { - setDataAndType(link.url.toUri(), "video/*") + setDataAndType(Uri.parse(link.url), "video/*") val title = video.name ?: video.headerName diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt index 1036a7055..e3916df01 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.actions.temp.fcast import android.content.Context -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.actions.VideoClickAction diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt index e2cf4f002..282ef834e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt @@ -7,7 +7,6 @@ import android.net.nsd.NsdServiceInfo import android.os.Build import android.os.ext.SdkExtensions import android.util.Log -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe class FcastManager { @@ -73,66 +72,52 @@ class FcastManager { } override fun onServiceFound(serviceInfo: NsdServiceInfo?) { - // Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback - safe { - if (serviceInfo == null) return@safe + if (serviceInfo == null) return - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( - Build.VERSION_CODES.TIRAMISU - ) >= 7 - ) { - nsdManager?.registerServiceInfoCallback( - serviceInfo, - Runnable::run, - object : NsdManager.ServiceInfoCallback { - override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { - Log.e(tag, "Service registration failed: $errorCode") - } - - override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { - Log.d( - tag, - "Service updated: ${serviceInfo.serviceName}," + - "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" - ) - synchronized(_currentDevices) { - _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } - _currentDevices.add(PublicDeviceInfo(serviceInfo)) - } - } - - override fun onServiceLost() { - Log.d(tag, "Service lost: ${serviceInfo.serviceName},") - synchronized(_currentDevices) { - _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } - } - } - - override fun onServiceInfoCallbackUnregistered() {} - }) - } else { - @Suppress("DEPRECATION") - nsdManager?.resolveService(serviceInfo, object : ResolveListener { - override fun onResolveFailed( - serviceInfo: NsdServiceInfo?, - errorCode: Int - ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( + Build.VERSION_CODES.TIRAMISU) >= 7) { + nsdManager?.registerServiceInfoCallback(serviceInfo, + Runnable::run, + object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.e(tag, "Service registration failed: $errorCode") } - - override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { - if (serviceInfo == null) return - + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.d(tag, + "Service updated: ${serviceInfo.serviceName}," + + "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" + ) synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } _currentDevices.add(PublicDeviceInfo(serviceInfo)) } - - Log.d( - tag, - "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" - ) } + override fun onServiceLost() { + Log.d(tag, "Service lost: ${serviceInfo.serviceName},") + synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } + } + } + override fun onServiceInfoCallbackUnregistered() {} }) - } + } else { + @Suppress("DEPRECATION") + nsdManager?.resolveService(serviceInfo, object : ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {} + + override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + + synchronized(_currentDevices) { + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } + + Log.d( + tag, + "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" + ) + } + }) } } @@ -183,9 +168,8 @@ class PublicDeviceInfo(serviceInfo: NsdServiceInfo) { val host: String? = if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( - Build.VERSION_CODES.TIRAMISU - ) >= 7 - ) { + Build.VERSION_CODES.TIRAMISU) >= 7 + ) { serviceInfo.hostAddresses.firstOrNull()?.hostAddress } else { @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/Packets.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/Packets.kt index d54791e89..26f5cec53 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/Packets.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/Packets.kt @@ -1,9 +1,5 @@ package com.lagradost.cloudstream3.actions.temp.fcast -import com.fasterxml.jackson.annotation.JsonProperty -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - // See https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 enum class Opcode(val value: Byte) { None(0), @@ -22,18 +18,18 @@ enum class Opcode(val value: Byte) { Pong(13); } -@Serializable + data class PlayMessage( - @JsonProperty("container") @SerialName("container") val container: String, - @JsonProperty("url") @SerialName("url") val url: String? = null, - @JsonProperty("content") @SerialName("content") val content: String? = null, - @JsonProperty("time") @SerialName("time") val time: Double? = null, - @JsonProperty("speed") @SerialName("speed") val speed: Double? = null, - @JsonProperty("headers") @SerialName("headers") val headers: Map? = null, + val container: String, + val url: String? = null, + val content: String? = null, + val time: Double? = null, + val speed: Double? = null, + val headers: Map? = null ) data class SeekMessage( - val time: Double, + val time: Double ) data class PlaybackUpdateMessage( @@ -41,26 +37,26 @@ data class PlaybackUpdateMessage( val time: Double, val duration: Double, val state: Int, - val speed: Double, + val speed: Double ) data class VolumeUpdateMessage( val generationTime: Long, - val volume: Double, + val volume: Double ) data class PlaybackErrorMessage( - val message: String, + val message: String ) data class SetSpeedMessage( - val speed: Double, + val speed: Double ) data class SetVolumeMessage( - val volume: Double, + val volume: Double ) data class VersionMessage( - val version: Long, + val version: Long ) 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/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt index 203a503e9..1565d92cf 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,36 +15,19 @@ 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*/ -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*/ -fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient { - safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } + 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() .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. @@ -70,6 +52,11 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie return baseClient } +//val Request.cookies: Map +// get() { +// return this.headers.getCookies("Cookie") +// } + private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) /** diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt index e1496db06..efa028d14 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import kotlin.Throws + abstract class Plugin : BasePlugin() { /** * Called when your Plugin is loaded @@ -25,7 +26,9 @@ abstract class Plugin : BasePlugin() { fun registerVideoClickAction(element: VideoClickAction) { Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") element.sourcePlugin = this.filename - VideoClickActionHolder.allVideoClickActions.add(element) + synchronized(VideoClickActionHolder.allVideoClickActions) { + VideoClickActionHolder.allVideoClickActions.add(element) + } } /** @@ -37,4 +40,4 @@ abstract class Plugin : BasePlugin() { * This will add a button in the settings allowing you to add custom settings */ var openSettings: ((context: Context) -> Unit)? = null -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 6054bbfaf..2a4e57656 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -13,7 +13,6 @@ import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast -import androidx.annotation.WorkerThread import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -21,32 +20,30 @@ import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.removePluginMapping +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.AutoDownloadMode -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.InternalAPI import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.PROVIDER_STATUS_OK import com.lagradost.cloudstream3.R 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.apmap 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.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings @@ -55,14 +52,12 @@ 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 kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import java.io.File import java.io.InputStreamReader @@ -75,15 +70,13 @@ const val EXTENSIONS_CHANNEL_NAME = "Extensions" const val EXTENSIONS_CHANNEL_DESCRIPT = "Extension notification channel" // Data class for internal storage -@Serializable data class PluginData( - @JsonProperty("internalName") @SerialName("internalName") val internalName: String, - @JsonProperty("url") @SerialName("url") val url: String?, - @JsonProperty("isOnline") @SerialName("isOnline") val isOnline: Boolean, - @JsonProperty("filePath") @SerialName("filePath") val filePath: String, - @JsonProperty("version") @SerialName("version") val version: Int, + @JsonProperty("internalName") val internalName: String, + @JsonProperty("url") val url: String?, + @JsonProperty("isOnline") val isOnline: Boolean, + @JsonProperty("filePath") val filePath: String, + @JsonProperty("version") val version: Int, ) { - @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -98,9 +91,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() ) } } @@ -154,7 +145,7 @@ object PluginManager { !it.filePath.contains(repositoryPath) } val file = File(repositoryPath) - safe { + normalSafeApiCall { if (file.exists()) file.deleteRecursively() } setKey(PLUGINS_KEY, plugins) @@ -176,11 +167,11 @@ object PluginManager { fun getPluginsOnline(): Array { - return getKey>(PLUGINS_KEY) ?: emptyArray() + return getKey(PLUGINS_KEY) ?: emptyArray() } fun getPluginsLocal(): Array { - return getKey>(PLUGINS_KEY_LOCAL) ?: emptyArray() + return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray() } private val CLOUD_STREAM_FOLDER = @@ -224,17 +215,17 @@ object PluginManager { // Helper class for updateAllOnlinePluginsAndLoadThem data class OnlinePluginData( val savedData: PluginData, - val onlineData: PluginWrapper, + val onlineData: Pair, ) { val isOutdated = - onlineData.plugin.version > savedData.version || onlineData.plugin.version == PLUGIN_VERSION_ALWAYS_UPDATE - val isDisabled = onlineData.plugin.status == PROVIDER_STATUS_DOWN + onlineData.second.version > savedData.version || onlineData.second.version == PLUGIN_VERSION_ALWAYS_UPDATE + val isDisabled = onlineData.second.status == PROVIDER_STATUS_DOWN fun validOnlineData(context: Context): Boolean { return getPluginPath( context, savedData.internalName, - onlineData.repositoryData.url + onlineData.first ).absolutePath == savedData.filePath } } @@ -264,37 +255,29 @@ 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 { - getRepoPlugins(it) ?: emptyList() - }.flatten().distinctBy { it.plugin.url } + val onlinePlugins = urls.toList().apmap { + getRepoPlugins(it.url)?.toList() ?: emptyList() + }.flatten().distinctBy { it.second.url } // Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated val outdatedPlugins = getPluginsOnline().map { savedData -> onlinePlugins - .filter { onlineData -> savedData.internalName == onlineData.plugin.internalName } + .filter { onlineData -> savedData.internalName == onlineData.second.internalName } .map { onlineData -> OnlinePluginData(savedData, onlineData) }.filter { it.validOnlineData(activity) } - }.flatten().distinctBy { it.onlineData.plugin.url } + }.flatten().distinctBy { it.onlineData.second.url } debugPrint { "Outdated plugins: ${outdatedPlugins.filter { it.isOutdated }}" @@ -302,21 +285,20 @@ 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) } else if (pluginData.isOutdated) { downloadPlugin( activity, - pluginData.onlineData.plugin.url, - pluginData.onlineData.plugin.fileHash, + pluginData.onlineData.second.url, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true ).let { success -> if (success) - updatedPlugins.add(pluginData.onlineData.plugin.name) + updatedPlugins.add(pluginData.onlineData.second.name) } } } @@ -342,32 +324,21 @@ 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 { - getRepoPlugins(it)?.toList() ?: emptyList() - }.flatten().distinctBy { it.plugin.url } + val onlinePlugins = urls.toList().apmap { + getRepoPlugins(it.url)?.toList() ?: emptyList() + }.flatten().distinctBy { it.second.url } val providerLang = activity.getApiProviderLangSettings() //Log.i(TAG, "providerLang => ${providerLang.toJson()}") // Iterate online repos and returns not downloaded plugins val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData -> - val sitePlugin = onlineData.plugin + val sitePlugin = onlineData.second val tvtypes = sitePlugin.tvTypes ?: listOf() //Don't include empty urls @@ -379,7 +350,7 @@ object PluginManager { } //Omit already existing plugins - if (getPluginPath(activity, sitePlugin.internalName, onlineData.repositoryData.url).exists()) { + if (getPluginPath(activity, sitePlugin.internalName, onlineData.first).exists()) { Log.i(TAG, "Skip > ${sitePlugin.internalName}") return@mapNotNull null } @@ -418,17 +389,16 @@ object PluginManager { } //Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}") - notDownloadedPlugins.amap { pluginData -> + notDownloadedPlugins.apmap { pluginData -> downloadPlugin( activity, - pluginData.onlineData.plugin.url, - pluginData.onlineData.plugin.fileHash, + pluginData.onlineData.second.url, pluginData.savedData.internalName, - pluginData.onlineData.repositoryData.url, + pluginData.onlineData.first, !pluginData.isDisabled ).let { success -> if (success) - newDownloadPlugins.add(pluginData.onlineData.plugin.name) + newDownloadPlugins.add(pluginData.onlineData.second.name) } } @@ -444,27 +414,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), @@ -475,46 +430,27 @@ 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()) { val res = dir.mkdirs() if (!res) { Log.w(TAG, "Failed to create local directories") - // We have tried to load local plugins, but exit early. - // This needs to be true to prevent the downloader waiting for plugins. - loadedLocalPlugins = true return } } @@ -535,7 +471,7 @@ object PluginManager { // Make sure all local plugins are fully refreshed. removeKey(PLUGINS_KEY_LOCAL) - sortedPlugins?.sortedBy { it.name }?.amap { file -> + sortedPlugins?.sortedBy { it.name }?.apmap { file -> try { val destinationFile = File(pluginDirectory, file.name) @@ -543,8 +479,7 @@ object PluginManager { // has been modified (check file length and modification time). if (!destinationFile.exists() || destinationFile.length() != file.length() || - destinationFile.lastModified() != file.lastModified() - ) { + destinationFile.lastModified() != file.lastModified()) { // Copy the file to the app-specific plugin directory file.copyTo(destinationFile, overwrite = true) @@ -567,19 +502,14 @@ object PluginManager { afterPluginsLoadedEvent.invoke(forceReload) } - /** @return true if safe mode is enabled in any possible way. */ - fun isSafeMode(): Boolean { - return checkSafeModeFile() || lastError != null - } - /** * This can be used to override any extension loading to fix crashes! * @return true if safe mode file is present **/ 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) } @@ -616,7 +546,7 @@ object PluginManager { return false } InputStreamReader(stream).use { reader -> - manifest = parseJson(reader.readText()) + manifest = parseJson(reader, BasePlugin.Manifest::class.java) } } @@ -657,15 +587,9 @@ object PluginManager { context.resources.configuration ) } - synchronized(plugins) { - plugins[filePath] = pluginInstance - } - synchronized(classLoaders) { - classLoaders[loader] = pluginInstance - } - synchronized(urlPlugins) { - urlPlugins[data.url ?: filePath] = pluginInstance - } + plugins[filePath] = pluginInstance + classLoaders[loader] = pluginInstance + urlPlugins[data.url ?: filePath] = pluginInstance if (pluginInstance is Plugin) { pluginInstance.load(context) } else { @@ -677,7 +601,7 @@ object PluginManager { } 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 ) @@ -701,33 +625,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 } } /** @@ -757,27 +673,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, @@ -820,84 +734,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) ?: emptyList() - }.flatten().distinctBy { it.plugin.url } - - val allPlugins = getPluginsOnline().flatMap { savedData -> - onlinePlugins - .filter { it.plugin.internalName == savedData.internalName } - .mapNotNull { onlineData -> - OnlinePluginData(savedData, onlineData).takeIf { it.validOnlineData(activity) } - } - }.distinctBy { it.onlineData.plugin.url } - - val updatedPlugins = mutableListOf() - - allPlugins.amap { pluginData -> - if (pluginData.isDisabled) { - Log.e( - "PluginManager", - "Unloading disabled plugin: ${pluginData.onlineData.plugin.name}" - ) - unloadPlugin(pluginData.savedData.filePath) - } else { - val existingFile = File(pluginData.savedData.filePath) - if (existingFile.exists()) existingFile.delete() - - if (downloadPlugin( - activity, - pluginData.onlineData.plugin.url, - pluginData.onlineData.plugin.fileHash, - pluginData.savedData.internalName, - existingFile, - true - ) - ) { - updatedPlugins.add(pluginData.onlineData.plugin.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 @@ -964,4 +800,4 @@ object PluginManager { return null } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index 3879ddca4..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,42 +1,37 @@ 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 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 kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +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.TimeUnit +import java.io.InputStream +import java.io.OutputStream /** * Comes with the app, always available in the app, non removable. - */ -@Serializable + * */ + data class Repository( - @JsonProperty("iconUrl") @SerialName("iconUrl") val iconUrl: String?, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("description") @SerialName("description") val description: String?, - @JsonProperty("manifestVersion") @SerialName("manifestVersion") val manifestVersion: Int, - @JsonProperty("pluginLists") @SerialName("pluginLists") val pluginLists: List, + @JsonProperty("name") val name: String, + @JsonProperty("description") val description: String?, + @JsonProperty("manifestVersion") val manifestVersion: Int, + @JsonProperty("pluginLists") val pluginLists: List ) /** @@ -45,81 +40,40 @@ data class Repository( * 1: Ok * 2: Slow * 3: Beta only - */ -@Serializable + * */ data class SitePlugin( // Url to the .cs3 file - @JsonProperty("url") @SerialName("url") val url: String, + @JsonProperty("url") val url: String, // Status to remotely disable the provider - @JsonProperty("status") @SerialName("status") val status: Int, + @JsonProperty("status") val status: Int, // Integer over 0, any change of this will trigger an auto update - @JsonProperty("version") @SerialName("version") val version: Int, + @JsonProperty("version") val version: Int, // Unused currently, used to make the api backwards compatible? // Set to 1 - @JsonProperty("apiVersion") @SerialName("apiVersion") val apiVersion: Int, + @JsonProperty("apiVersion") val apiVersion: Int, // Name to be shown in app - @JsonProperty("name") @SerialName("name") val name: String, + @JsonProperty("name") val name: String, // Name to be referenced internally. Separate to make name and url changes possible - @JsonProperty("internalName") @SerialName("internalName") val internalName: String, - @JsonProperty("authors") @SerialName("authors") val authors: List, - @JsonProperty("description") @SerialName("description") val description: String?, + @JsonProperty("internalName") val internalName: String, + @JsonProperty("authors") val authors: List, + @JsonProperty("description") val description: String?, // Might be used to go directly to the plugin repo in the future - @JsonProperty("repositoryUrl") @SerialName("repositoryUrl") val repositoryUrl: String?, + @JsonProperty("repositoryUrl") val repositoryUrl: String?, // These types are yet to be mapped and used, ignore for now - @JsonProperty("tvTypes") @SerialName("tvTypes") val tvTypes: List?, - // Most often a language tag like "en" or "zh-TW" - @JsonProperty("language") @SerialName("language") val language: String?, - @JsonProperty("iconUrl") @SerialName("iconUrl") val iconUrl: String?, + @JsonProperty("tvTypes") val tvTypes: List?, + @JsonProperty("language") val language: String?, + @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin - @JsonProperty("fileSize") @SerialName("fileSize") val fileSize: Long?, - @JsonProperty("fileHash") @SerialName("fileHash") val fileHash: String?, + @JsonProperty("fileSize") val fileSize: Long?, ) -@Serializable -data class PluginWrapper( - @JsonProperty("repository") @SerialName("repository") val repository: Repository, - @JsonProperty("repositoryData") @SerialName("repositoryData") val repositoryData: RepositoryData, - @JsonProperty("plugin") @SerialName("plugin") val plugin: SitePlugin -) { - companion object { - private val localRepository = Repository("", "", "", 1, emptyList()) - private val localRepositoryData = RepositoryData("", "", "") - fun getLocalPluginWrapper(plugin: SitePlugin): PluginWrapper { - return PluginWrapper( - localRepository, - localRepositoryData, - plugin - ) - } - } -} - object RepositoryManager { const val ONLINE_PLUGINS_FOLDER = "Extensions" 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) } + getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } + 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 { @@ -140,37 +94,32 @@ object RepositoryManager { else fixedUrl } } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) { - safeAsync { - if (fixedUrl.startsWith("!")) { - val response = app.get("https://py.md/${fixedUrl.removePrefix("!")}", allowRedirects = false) - val url = response.headers["Location"] ?: return@safeAsync null - if (url.startsWith("https://py.md/404")) return@safeAsync null - if (url.removeSuffix("/") == "https://py.md") return@safeAsync null - return@safeAsync url - } else { - val response = app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false) - val url = response.headers["Location"] ?: return@safeAsync null - if (url.startsWith("https://cutt.ly/404")) return@safeAsync null - if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null - return@safeAsync url + suspendSafeApiCall { + app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 -> + it2.headers["Location"]?.let { url -> + if (url.startsWith("https://cutt.ly/404")) return@suspendSafeApiCall null + if (url.removeSuffix("/") == "https://cutt.ly") return@suspendSafeApiCall null + return@suspendSafeApiCall url + } } } } else null } suspend fun parseRepository(url: String): Repository? { - return safeAsync { + return suspendSafeApiCall { // Take manifestVersion and such into account later - app.get(convertRawGitUrl(url), cacheTime = 5, cacheUnit = TimeUnit.MINUTES) - .parsedSafe() + app.get(convertRawGitUrl(url)).parsedSafe() } } private suspend fun parsePlugins(pluginUrls: String): List { // Take manifestVersion and such into account later return try { - app.get(convertRawGitUrl(pluginUrls), cacheTime = 5, cacheUnit = TimeUnit.MINUTES) - .parsed>().toList() + val response = app.get(convertRawGitUrl(pluginUrls)) + // Normal parsed function not working? + // return response.parsedSafe() + tryParseJson>(response.text)?.toList() ?: emptyList() } catch (t: Throwable) { logError(t) emptyList() @@ -179,68 +128,37 @@ object RepositoryManager { /** * Gets all plugins from repositories and pairs them with the repository url - */ - suspend fun getRepoPlugins(repositoryData: RepositoryData): List? { - val repo = parseRepository(repositoryData.url) ?: return null - val list = repo.pluginLists.amap { url -> + * */ + suspend fun getRepoPlugins(repositoryUrl: String): List>? { + val repo = parseRepository(repositoryUrl) ?: return null + return repo.pluginLists.amap { url -> parsePlugins(url).map { - PluginWrapper(repo, repositoryData, it) + repositoryUrl to it } }.flatten() - return list } 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 } } fun getRepositories(): Array { - return getKey>(REPOSITORIES_KEY) ?: emptyArray() + return getKey(REPOSITORIES_KEY) ?: emptyArray() } // Don't want to read before we write in another thread @@ -255,7 +173,7 @@ object RepositoryManager { /** * Also deletes downloaded repository plugins - */ + * */ suspend fun removeRepository(context: Context, repository: RepositoryData) { val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER) @@ -273,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 @@ -282,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 57f6f4750..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,88 +2,97 @@ package com.lagradost.cloudstream3.plugins import android.util.Log import android.widget.Toast -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 java.security.MessageDigest import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -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): Boolean = - getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false + 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 @@ -93,9 +102,7 @@ object VotingApi { } } - @Serializable - private data class CountifyResult( - @JsonProperty("id") @SerialName("id") val id: String? = null, - @JsonProperty("count") @SerialName("count") val count: Int? = null, + private data class Result( + val value: Int? ) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt deleted file mode 100644 index e07747a86..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt +++ /dev/null @@ -1,279 +0,0 @@ -package com.lagradost.cloudstream3.services - -import android.Manifest -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC -import android.os.Build.VERSION.SDK_INT -import android.os.IBinder -import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.MainActivity.Companion.lastError -import com.lagradost.cloudstream3.MainActivity.Companion.setLastError -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.debugAssert -import com.lagradost.cloudstream3.mvvm.debugWarning -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.system.measureTimeMillis -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class DownloadQueueService : Service() { - companion object { - const val TAG = "DownloadQueueService" - const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue" - const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service" - const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification." - const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique - @Volatile - var isRunning = false - - fun getIntent( - context: Context, - ): Intent { - return Intent(context, DownloadQueueService::class.java) - } - - private val _downloadInstances: MutableStateFlow> = - MutableStateFlow(emptyList()) - - /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances. - * Completed or failed instances are automatically removed by the download queue service. - * - */ - val downloadInstances: StateFlow> = - _downloadInstances - - private val totalDownloadFlow = - downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> - instances to queue - } - .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads -> - Triple(instances, queue, currentDownloads) - } - } - - - private val baseNotification by lazy { - val intent = Intent(this, MainActivity::class.java) - val pendingIntent = - PendingIntentCompat.getActivity(this, 0, intent, 0, false) - - val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0) - val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0) - - NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID) - .setOngoing(true) // Make it persistent - .setAutoCancel(false) - .setColorized(false) - .setOnlyAlertOnce(true) - .setSilent(true) - .setShowWhen(false) - // If low priority then the notification might not show :( - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setColor(this.colorFromAttribute(R.attr.colorPrimary)) - .setContentText(activeDownloads) - .setSubText(activeQueue) - .setContentIntent(pendingIntent) - .setSmallIcon(R.drawable.download_icon_load) - } - - - private fun updateNotification(context: Context, downloads: Int, queued: Int) { - if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) return - - val activeDownloads = - resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) - val activeQueue = - resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued) - - val newNotification = baseNotification - .setContentText(activeDownloads) - .setSubText(activeQueue) - .build() - - safe { - NotificationManagerCompat.from(context) - .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) - } - } - - // We always need to listen to events, even before the download is launched. - // Stopping link loading is an event which can trigger before downloading. - val downloadEventListener = { event: Pair -> - when (event.second) { - VideoDownloadManager.DownloadActionType.Stop -> { - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - removeKey(KEY_RESUME_IN_QUEUE, event.first.toString()) - DownloadQueueManager.cancelDownload(event.first) - } - - else -> {} - } - } - - @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) - override fun onCreate() { - isRunning = true - val context: Context = this // To make code more readable - - Log.d(TAG, "Download queue service started.") - this.createNotificationChannel( - DOWNLOAD_QUEUE_CHANNEL_ID, - DOWNLOAD_QUEUE_CHANNEL_NAME, - DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION - ) - if (SDK_INT >= 29) { - startForeground( - DOWNLOAD_QUEUE_NOTIFICATION_ID, - baseNotification.build(), - FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) - } else { - startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build()) - } - - downloadEvent += downloadEventListener - - val queueJob = ioSafe { - // Ensure this is up to date to prevent race conditions with MainActivity launches - setLastError(context) - // Early return, to prevent waiting for plugins in safe mode - if (lastError != null) return@ioSafe - - // Try to ensure all plugins are loaded before starting the downloader. - // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough - val timeout = 15.seconds - val timeTaken = withTimeoutOrNull(timeout) { - measureTimeMillis { - while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) { - delay(100.milliseconds) - } - } - } - - debugWarning({ timeTaken == null || timeTaken > 3_000 }, { - "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms" - }) - debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) - - totalDownloadFlow - .debounce { (instances, queue) -> - // Filter away incorrect transient queue states. - // For example when we pop the queue and add a download instance there exists a transient state where - // there is no queue and no download instances (leading to an early exit) - if (instances.isEmpty() && queue.isEmpty()) { - 500.milliseconds - } else { - 0.milliseconds - } - } - .takeWhile { (instances, queue) -> - // Stop if destroyed - isRunning - // Run as long as there is a queue to process - && (instances.isNotEmpty() || queue.isNotEmpty()) - // Run as long as there are no app crashes - && lastError == null - } - .collect { (_, queue, currentDownloads) -> - // Remove completed or failed - val newInstances = _downloadInstances.updateAndGet { currentInstances -> - currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled } - } - - val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) - val currentInstanceCount = newInstances.size - - val newDownloads = minOf( - // Cannot exceed the max downloads - maxOf(0, maxDownloads - currentInstanceCount), - // Cannot start more downloads than the queue size - queue.size - ) - - // Cant start multiple downloads at once. If this is rerun it may start too many downloads. - if (newDownloads > 0) { - _downloadInstances.update { instances -> - val downloadInstance = DownloadQueueManager.popQueue(context) - if (downloadInstance != null) { - downloadInstance.startDownload() - instances + downloadInstance - } else { - instances - } - } - } - - // The downloads actually displayed to the user with a notification - val currentVisualDownloads = - currentDownloads.size + newInstances.count { - currentDownloads.contains(it.downloadQueueWrapper.id) - .not() - } - // Just the queue - val currentVisualQueue = queue.size - - updateNotification(context, currentVisualDownloads, currentVisualQueue) - } - } - - // Stop self regardless of job outcome - queueJob.invokeOnCompletion { throwable -> - if (throwable != null) { - logError(throwable) - } - safe { - stopSelf() - } - } - } - - override fun onDestroy() { - Log.d(TAG, "Download queue service stopped.") - downloadEvent -= downloadEventListener - isRunning = false - super.onDestroy() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - return START_STICKY // We want the service restarted if its killed - } - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onTimeout(reason: Int) { - stopSelf() - Log.e(TAG, "Service stopped due to timeout: $reason") - } - -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt index 7134650ed..56ab43dbf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.TimeUnit @@ -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 ( 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..bfc6dacb6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt @@ -1,9 +1,18 @@ package com.lagradost.cloudstream3.subtitles +import androidx.annotation.WorkerThread import androidx.core.net.toUri +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.ui.player.SubtitleOrigin +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import okio.BufferedSource import okio.buffer import okio.sink @@ -11,6 +20,116 @@ import okio.source import java.io.File import java.util.zip.ZipInputStream +interface AbstractSubProvider { + val idPrefix: String + + @WorkerThread + @Throws + suspend fun search(query: SubtitleSearch): List? { + throw NotImplementedError() + } + + @WorkerThread + @Throws + suspend fun load(data: SubtitleEntity): String? { + throw NotImplementedError() + } + + @WorkerThread + @Throws + suspend fun SubtitleResource.getResources(data: SubtitleEntity) { + this.addUrl(load(data)) + } + + @WorkerThread + @Throws + suspend fun getResource(data: SubtitleEntity): SubtitleResource { + return SubtitleResource().apply { + this.getResources(data) + } + } +} + +class SubRepository(val api: AbstractSubProvider) { + companion object { + data class SavedSearchResponse( + val unixTime: Long, + val response: List, + val query: SubtitleSearch + ) + + data class SavedResourceResponse( + val unixTime: Long, + val response: SubtitleResource, + val query: SubtitleEntity + ) + + // maybe make this a generic struct? right now there is a lot of boilerplate + private val searchCache = threadSafeListOf() + private var searchCacheIndex: Int = 0 + private val resourceCache = threadSafeListOf() + private var resourceCacheIndex: Int = 0 + const val CACHE_SIZE = 20 + } + + val idPrefix: String get() = api.idPrefix + + @WorkerThread + suspend fun getResource(data: SubtitleEntity): Resource = safeApiCall { + synchronized(resourceCache) { + for (item in resourceCache) { + // 20 min save + if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { + return@safeApiCall item.response + } + } + } + + val returnValue = api.getResource(data) + synchronized(resourceCache) { + val add = SavedResourceResponse(unixTime, returnValue, data) + if (resourceCache.size > CACHE_SIZE) { + resourceCache[resourceCacheIndex] = add // rolling cache + resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE + } else { + resourceCache.add(add) + } + } + returnValue + } + + @WorkerThread + suspend fun search(query: SubtitleSearch): Resource> { + return safeApiCall { + synchronized(searchCache) { + for (item in searchCache) { + // 120 min save + if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { + return@safeApiCall item.response + } + } + } + + val returnValue = api.search(query) ?: throw ErrorLoadingException("Null subtitles") + + // only cache valid return values + if (returnValue.isNotEmpty()) { + val add = SavedSearchResponse(unixTime, returnValue, query) + synchronized(searchCache) { + if (searchCache.size > CACHE_SIZE) { + searchCache[searchCacheIndex] = add // rolling cache + searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE + } else { + searchCache.add(add) + } + } + } + returnValue + } + } + +} + /** * A builder for subtitle files. * @see addUrl @@ -91,3 +210,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 68fba9777..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,167 +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), - SubtitleRepo(subSourceApi) - ) - - 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), - SubtitleRepo(subSourceApi) - ) - 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 c0f0e4a03..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,265 +1,23 @@ package com.lagradost.cloudstream3.syncproviders -import com.fasterxml.jackson.annotation.JsonAlias -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.APIHolder.unixTime -import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.base64Encode -import com.lagradost.cloudstream3.splitUrlParameters -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonNames -import java.security.SecureRandom +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 -@Serializable -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") @SerialName("accessToken") - val accessToken: String? = null, - /** For OAuth a special refresh token is issues to refresh the access token. */ - @JsonProperty("refreshToken") @SerialName("refreshToken") - val refreshToken: String? = null, - /** In UnixTime (sec) when it expires */ - @JsonProperty("accessTokenLifetime") @SerialName("accessTokenLifetime") - val accessTokenLifetime: Long? = null, - /** In UnixTime (sec) when it expires */ - @JsonProperty("refreshTokenLifetime") @SerialName("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") @SerialName("payload") - val payload: String? = null, -) { - fun isAccessTokenExpired(marginSec: Long = 10L) = - accessTokenLifetime != null && unixTime + marginSec >= accessTokenLifetime + val createAccountUrl : String? - fun isRefreshTokenExpired(marginSec: Long = 10L) = - refreshTokenLifetime != null && unixTime + marginSec >= refreshTokenLifetime -} + // don't change this as all keys depend on it + val idPrefix: String -@OptIn(ExperimentalSerializationApi::class) // JsonNames is an experimental annotation for now -@Serializable -data class AuthUser( - /** Account display-name, can also be email if name does not exist */ - @JsonProperty("name") @SerialName("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") @SerialName("id") - val id: Int, - /** Profile picture URL */ - @JsonProperty("profilePicture") @SerialName("profilePicture") - val profilePicture: String? = null, - /** Profile picture Headers of the URL */ - @JsonProperty("profilePictureHeaders") @JsonAlias("profilePictureHeader") - @SerialName("profilePictureHeaders") @JsonNames("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. - */ -@Serializable -data class AuthData( - @JsonProperty("user") @SerialName("user") val user: AuthUser, - @JsonProperty("token") @SerialName("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 */ -@Serializable -data class AuthLoginResponse( - @JsonProperty("password") @SerialName("password") val password: String?, - @JsonProperty("username") @SerialName("username") val username: String?, - @JsonProperty("email") @SerialName("email") val email: String?, - @JsonProperty("server") @SerialName("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 { - @Deprecated( - message = "Use APIHolder.unixTime instead", - replaceWith = ReplaceWith( - expression = "APIHolder.unixTime", - imports = ["com.lagradost.cloudstream3.APIHolder"] - ), - level = DeprecationLevel.WARNING, - ) - val unixTime: Long - get() = APIHolder.unixTime - - @Deprecated( - message = "Use APIHolder.unixTimeMS instead", - replaceWith = ReplaceWith( - expression = "unixTimeMS", - imports = ["com.lagradost.cloudstream3.APIHolder.unixTimeMS"] - ), - level = DeprecationLevel.WARNING, - ) - val unixTimeMs: Long - get() = unixTimeMS - - fun splitRedirectUrl(redirectUrl: String): Map { - return splitUrlParameters( - 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 base64Encode(codeVerifierBytes).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 161001611..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.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, - val idPrefix: String, - ) - - 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.idPrefix == idPrefix && 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, idPrefix) - 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 62% 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..9d43685c8 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.utils.UiText +import me.xdrop.fuzzywuzzy.FuzzySearch +import java.util.Date + +interface SyncAPI : OAuth2API { + /** + * Set this to true if the user updates something on the list like watch status or score + **/ + var requireLibraryRefresh: Boolean + val mainUrl: String + + /** + * Allows certain providers to open pages from + * library links. + **/ + val syncIdName: SyncIdName + + /** + -1 -> None + 0 -> Watching + 1 -> Completed + 2 -> OnHold + 3 -> Dropped + 4 -> PlanToWatch + 5 -> ReWatching + */ + suspend fun score(id: String, status: AbstractSyncStatus): Boolean + + suspend fun getStatus(id: String): AbstractSyncStatus? + + suspend fun getResult(id: String): SyncResult? + + suspend fun search(name: String): List? + + suspend fun getPersonalLibrary(): LibraryMetadata? + + fun getIdFromUrl(url: String): String + + data class SyncSearchResult( + override val name: String, + override val apiName: String, + var syncId: String, + override val url: String, + override var posterUrl: String?, + override var type: TvType? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override var id: Int? = null, + ) : SearchResponse + + abstract class AbstractSyncStatus { + abstract var status: SyncWatchType + + /** 1-10 */ + abstract var score: Int? + abstract var watchedEpisodes: Int? + abstract var isFavorite: Boolean? + abstract var maxEpisodes: Int? + } + + + data class SyncStatus( + override var status: SyncWatchType, + /** 1-10 */ + override var score: Int?, + override var watchedEpisodes: Int?, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + ) : AbstractSyncStatus() + + data class SyncResult( + /**Used to verify*/ + var id: String, + + var totalEpisodes: Int? = null, + + var title: String? = null, + /**1-1000*/ + var publicScore: Int? = null, + /**In minutes*/ + var duration: Int? = null, + var synopsis: String? = null, + var airStatus: ShowStatus? = null, + var nextAiring: NextAiring? = null, + var studio: List? = null, + var genres: List? = null, + var synonyms: List? = null, + var trailers: List? = null, + var isAdult: Boolean? = null, + var posterUrl: String? = null, + var backgroundPosterUrl: String? = null, + + /** In unixtime */ + var startDate: Long? = null, + /** In unixtime */ + var endDate: Long? = null, + var recommendations: List? = null, + var nextSeason: SyncSearchResult? = null, + var prevSeason: SyncSearchResult? = null, + var actors: List? = null, + ) + + + data class Page( + val title: UiText, var items: List + ) { + fun sort(method: ListSorting?, query: String? = null) { + items = when (method) { + ListSorting.Query -> + if (query != null) { + items.sortedBy { + -FuzzySearch.partialRatio( + query.lowercase(), it.name.lowercase() + ) + } + } else items + ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating ?: 0) } + ListSorting.RatingLow -> items.sortedBy { (it.personalRating ?: 0) } + ListSorting.AlphabeticalA -> items.sortedBy { it.name } + ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() + ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } + ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } + ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } + ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } + else -> items + } + } + } + + data class LibraryMetadata( + val allLibraryLists: List, + val supportedListSorting: Set + ) + + data class LibraryList( + val name: UiText, + val items: List + ) + + data class LibraryItem( + override val name: String, + override val url: String, + /** + * Unique unchanging string used for data storage. + * This should be the actual id when you change scores and status + * since score changes from library might get added in the future. + **/ + val syncId: String, + val episodesCompleted: Int?, + val episodesTotal: Int?, + /** Out of 100 */ + val personalRating: Int?, + val lastUpdatedUnixTime: Long?, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override var posterHeaders: Map?, + override var quality: SearchQuality?, + val releaseDate: Date?, + override var id: Int? = null, + val plot : String? = null, + val rating: Int? = null, + val tags: List? = null + ) : SearchResponse +} \ 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 d3e7f22c7..68a4a9a54 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,93 +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.APIHolder -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.NextAiring -import com.lagradost.cloudstream3.R -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.utils.txt +import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear -import com.lagradost.cloudstream3.utils.txt -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +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" - - private val key = BuildConfig.ANILIST_KEY - 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 = APIHolder.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, @@ -99,16 +99,17 @@ 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 + return SyncAPI.SyncResult( season.id.toString(), nextAiring = season.nextAiringEpisode?.let { NextAiring( it.episode ?: return@let null, - (it.timeUntilAiring ?: return@let null) + APIHolder.unixTime + (it.timeUntilAiring ?: return@let null) + unixTime ) }, title = season.title?.userPreferred, @@ -140,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), @@ -156,15 +157,16 @@ class AniListApi : SyncAPI() { "youtube" -> listOf("https://www.youtube.com/watch?v=${season.trailer.id}") else -> null } - // TODO REST + //TODO REST ) } - 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 { @@ -258,24 +259,24 @@ class AniListApi : SyncAPI() { val data = mapOf( "query" to query, - "variables" to Variables( - search = name, - page = 1, - type = "ANIME", - ).toJson() + "variables" to + mapOf( + "search" to name, + "page" to 1, + "type" to "ANIME" + ).toJson() ) val res = app.post( "https://graphql.anilist.co/", - // headers = mapOf(), - data = data, // (if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) + //headers = mapOf(), + data = data,//(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) timeout = 5000 // REASONABLE TIMEOUT ).text.replace("\\", "") - return parseJson(res) + return res.toKotlinObject() } catch (e: Exception) { logError(e) } - return null } @@ -298,7 +299,7 @@ class AniListApi : SyncAPI() { .replace(")", "\\)") })""" ) - // println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}") + //println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}") val shows = searchShows(name.replace(blackListRegex, "")) shows?.data?.page?.media?.find { @@ -456,11 +457,25 @@ class AniListApi : SyncAPI() { cacheTime = 0, ).text - return tryParseJson(data) ?: throw ErrorLoadingException("Error parsing $data") + return tryParseJson(data) ?: throw ErrorLoadingException("Error parsing $data") } } - 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 @@ -504,99 +519,100 @@ class AniListApi : SyncAPI() { type = AniListStatusType.None, ) } + } - 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 + ) } - @Serializable - data class Variables( - @JsonProperty("search") @SerialName("search") val search: String, - @JsonProperty("page") @SerialName("page") val page: Int, - @JsonProperty("type") @SerialName("type") val type: String, - ) + 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 + } + } + } - @Serializable data class MediaRecommendation( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("title") @SerialName("title") val title: Title?, - @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?, - @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage?, - @JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?, + @JsonProperty("id") val id: Int, + @JsonProperty("title") val title: Title?, + @JsonProperty("idMal") val idMal: Int?, + @JsonProperty("coverImage") val coverImage: CoverImage?, + @JsonProperty("averageScore") val averageScore: Int? ) - @Serializable data class FullAnilistList( - @JsonProperty("data") @SerialName("data") val data: Data?, + @JsonProperty("data") val data: Data? ) - @Serializable data class CompletedAt( - @JsonProperty("year") @SerialName("year") val year: Int, - @JsonProperty("month") @SerialName("month") val month: Int, - @JsonProperty("day") @SerialName("day") val day: Int, + @JsonProperty("year") val year: Int, + @JsonProperty("month") val month: Int, + @JsonProperty("day") val day: Int ) - @Serializable data class StartedAt( - @JsonProperty("year") @SerialName("year") val year: String?, - @JsonProperty("month") @SerialName("month") val month: String?, - @JsonProperty("day") @SerialName("day") val day: String?, + @JsonProperty("year") val year: String?, + @JsonProperty("month") val month: String?, + @JsonProperty("day") val day: String? ) - @Serializable data class Title( - @JsonProperty("english") @SerialName("english") val english: String?, - @JsonProperty("romaji") @SerialName("romaji") val romaji: String?, + @JsonProperty("english") val english: String?, + @JsonProperty("romaji") val romaji: String? ) - @Serializable data class CoverImage( - @JsonProperty("medium") @SerialName("medium") val medium: String?, - @JsonProperty("large") @SerialName("large") val large: String?, - @JsonProperty("extraLarge") @SerialName("extraLarge") val extraLarge: String?, + @JsonProperty("medium") val medium: String?, + @JsonProperty("large") val large: String?, + @JsonProperty("extraLarge") val extraLarge: String? ) - @Serializable data class Media( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?, - @JsonProperty("season") @SerialName("season") val season: String?, - @JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int, - @JsonProperty("format") @SerialName("format") val format: String?, - @JsonProperty("episodes") @SerialName("episodes") val episodes: Int, - @JsonProperty("title") @SerialName("title") val title: Title, - @JsonProperty("description") @SerialName("description") val description: String?, - @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage, - @JsonProperty("synonyms") @SerialName("synonyms") val synonyms: List, - @JsonProperty("nextAiringEpisode") @SerialName("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, + @JsonProperty("id") val id: Int, + @JsonProperty("idMal") val idMal: Int?, + @JsonProperty("season") val season: String?, + @JsonProperty("seasonYear") val seasonYear: Int, + @JsonProperty("format") val format: String?, + //@JsonProperty("source") val source: String, + @JsonProperty("episodes") val episodes: Int, + @JsonProperty("title") val title: Title, + @JsonProperty("description") val description: String?, + @JsonProperty("coverImage") val coverImage: CoverImage, + @JsonProperty("synonyms") val synonyms: List, + @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, ) - @Serializable data class Entries( - @JsonProperty("status") @SerialName("status") val status: String?, - @JsonProperty("completedAt") @SerialName("completedAt") val completedAt: CompletedAt, - @JsonProperty("startedAt") @SerialName("startedAt") val startedAt: StartedAt, - @JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: Int, - @JsonProperty("progress") @SerialName("progress") val progress: Int, - @JsonProperty("score") @SerialName("score") val score: Int, - @JsonProperty("private") @SerialName("private") val private: Boolean, - @JsonProperty("media") @SerialName("media") val media: Media, + @JsonProperty("status") val status: String?, + @JsonProperty("completedAt") val completedAt: CompletedAt, + @JsonProperty("startedAt") val startedAt: StartedAt, + @JsonProperty("updatedAt") val updatedAt: Int, + @JsonProperty("progress") val progress: Int, + @JsonProperty("score") val score: Int, + @JsonProperty("private") val private: Boolean, + @JsonProperty("media") val media: Media ) { fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( @@ -608,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, @@ -623,39 +639,40 @@ class AniListApi : SyncAPI() { } } - @Serializable data class Lists( - @JsonProperty("status") @SerialName("status") val status: String?, - @JsonProperty("entries") @SerialName("entries") val entries: List, + @JsonProperty("status") val status: String?, + @JsonProperty("entries") val entries: List ) - @Serializable data class MediaListCollection( - @JsonProperty("lists") @SerialName("lists") val lists: List, + @JsonProperty("lists") val lists: List ) - @Serializable data class Data( - @JsonProperty("MediaListCollection") @SerialName("MediaListCollection") val mediaListCollection: MediaListCollection, + @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() @@ -682,9 +699,12 @@ 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 = """ query (${'$'}userID: Int = $userID, ${'$'}MEDIA: MediaType = $mediaType) { MediaListCollection (userId: ${'$'}userID, type: ${'$'}MEDIA) { @@ -723,56 +743,44 @@ class AniListApi : SyncAPI() { } } } - } + } """ - val text = postApi(auth.token, query) - return tryParseJson(text) + 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 { - nodes { - id - title { - romaji - } - } - } - } - }""" - val data = postApi(auth.token, q) + ToggleFavourite (animeId: ${'$'}animeId) { + anime { + nodes { + id + title { + romaji + } + } + } + } + }""" + val data = postApi(q) return data != "" } /** Used to query a saved MediaItem on the list to get the id for removal */ - @Serializable - data class MediaListItemRoot( - @JsonProperty("data") @SerialName("data") val data: MediaListItem? = null, - ) - - @Serializable - data class MediaListItem( - @JsonProperty("MediaList") @SerialName("MediaList") val mediaList: MediaListId? = null, - ) - - @Serializable - data class MediaListId( - @JsonProperty("id") @SerialName("id") val id: Long? = null, - ) + data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null) + data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null) + 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) { @@ -781,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 """ @@ -797,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 @@ -807,37 +815,45 @@ 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 { - id - name - avatar { - large - } - favourites { - anime { - nodes { - id + { + Viewer { + id + name + avatar { + large + } + favourites { + anime { + nodes { + id + } } } - } - } - }""" - 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 } @@ -861,356 +877,304 @@ class AniListApi : SyncAPI() { return seasons.toList() } - @Serializable data class SeasonResponse( - @JsonProperty("data") @SerialName("data") val data: SeasonData, + @JsonProperty("data") val data: SeasonData, ) - @Serializable data class SeasonData( - @JsonProperty("Media") @SerialName("Media") val media: SeasonMedia, + @JsonProperty("Media") val media: SeasonMedia, ) - @Serializable - data class RecommendedMedia( - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("title") @SerialName("title") val title: MediaTitle?, - @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: MediaCoverImage?, - ) - - @Serializable - data class CharacterMedia( - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("title") @SerialName("title") val title: MediaTitle?, - @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: MediaCoverImage?, - ) - - @Serializable data class SeasonMedia( - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("title") @SerialName("title") val title: MediaTitle?, - @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?, - @JsonProperty("format") @SerialName("format") val format: String?, - @JsonProperty("nextAiringEpisode") @SerialName("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, - @JsonProperty("relations") @SerialName("relations") val relations: SeasonEdges?, - @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: MediaCoverImage?, - @JsonProperty("duration") @SerialName("duration") val duration: Int?, - @JsonProperty("episodes") @SerialName("episodes") val episodes: Int?, - @JsonProperty("genres") @SerialName("genres") val genres: List?, - @JsonProperty("synonyms") @SerialName("synonyms") val synonyms: List?, - @JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?, - @JsonProperty("isAdult") @SerialName("isAdult") val isAdult: Boolean?, - @JsonProperty("trailer") @SerialName("trailer") val trailer: MediaTrailer?, - @JsonProperty("description") @SerialName("description") val description: String?, - @JsonProperty("characters") @SerialName("characters") val characters: CharacterConnection?, - @JsonProperty("recommendations") @SerialName("recommendations") val recommendations: RecommendationConnection?, + @JsonProperty("id") val id: Int?, + @JsonProperty("title") val title: MediaTitle?, + @JsonProperty("idMal") val idMal: Int?, + @JsonProperty("format") val format: String?, + @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, + @JsonProperty("relations") val relations: SeasonEdges?, + @JsonProperty("coverImage") val coverImage: MediaCoverImage?, + @JsonProperty("duration") val duration: Int?, + @JsonProperty("episodes") val episodes: Int?, + @JsonProperty("genres") val genres: List?, + @JsonProperty("synonyms") val synonyms: List?, + @JsonProperty("averageScore") val averageScore: Int?, + @JsonProperty("isAdult") val isAdult: Boolean?, + @JsonProperty("trailer") val trailer: MediaTrailer?, + @JsonProperty("description") val description: String?, + @JsonProperty("characters") val characters: CharacterConnection?, + @JsonProperty("recommendations") val recommendations: RecommendationConnection?, ) - @Serializable data class RecommendationConnection( - @JsonProperty("edges") @SerialName("edges") val edges: List = emptyList(), - @JsonProperty("nodes") @SerialName("nodes") val nodes: List = emptyList(), + @JsonProperty("edges") val edges: List = emptyList(), + @JsonProperty("nodes") val nodes: List = emptyList(), + //@JsonProperty("pageInfo") val pageInfo: PageInfo, ) - @Serializable data class RecommendationEdge( - @JsonProperty("node") @SerialName("node") val node: Recommendation, + //@JsonProperty("rating") val rating: Int, + @JsonProperty("node") val node: Recommendation, ) - @Serializable data class Recommendation( - @JsonProperty("mediaRecommendation") @SerialName("mediaRecommendation") val mediaRecommendation: RecommendedMedia?, + @JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia, ) - @Serializable data class CharacterName( - @JsonProperty("name") @SerialName("name") val first: String?, - @JsonProperty("middle") @SerialName("middle") val middle: String?, - @JsonProperty("last") @SerialName("last") val last: String?, - @JsonProperty("full") @SerialName("full") val full: String?, - @JsonProperty("native") @SerialName("native") val native: String?, - @JsonProperty("alternative") @SerialName("alternative") val alternative: List?, - @JsonProperty("alternativeSpoiler") @SerialName("alternativeSpoiler") val alternativeSpoiler: List?, - @JsonProperty("userPreferred") @SerialName("userPreferred") val userPreferred: String?, + @JsonProperty("name") val first: String?, + @JsonProperty("middle") val middle: String?, + @JsonProperty("last") val last: String?, + @JsonProperty("full") val full: String?, + @JsonProperty("native") val native: String?, + @JsonProperty("alternative") val alternative: List?, + @JsonProperty("alternativeSpoiler") val alternativeSpoiler: List?, + @JsonProperty("userPreferred") val userPreferred: String?, ) - @Serializable data class CharacterImage( - @JsonProperty("large") @SerialName("large") val large: String?, - @JsonProperty("medium") @SerialName("medium") val medium: String?, + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, ) - @Serializable data class Character( - @JsonProperty("name") @SerialName("name") val name: CharacterName?, - @JsonProperty("age") @SerialName("age") val age: String?, - @JsonProperty("image") @SerialName("image") val image: CharacterImage?, + @JsonProperty("name") val name: CharacterName?, + @JsonProperty("age") val age: String?, + @JsonProperty("image") val image: CharacterImage?, ) - @Serializable data class CharacterEdge( - @JsonProperty("id") @SerialName("id") val id: Int?, + @JsonProperty("id") val id: Int?, /** - * MAIN - A primary character role in the media - * SUPPORTING - A supporting character role in the media - * BACKGROUND - A background character in the media + MAIN + A primary character role in the media + + SUPPORTING + A supporting character role in the media + + BACKGROUND + A background character in the media */ - @JsonProperty("role") @SerialName("role") val role: String?, - @JsonProperty("name") @SerialName("name") val name: String?, - @JsonProperty("voiceActors") @SerialName("voiceActors") val voiceActors: List?, - @JsonProperty("favouriteOrder") @SerialName("favouriteOrder") val favouriteOrder: Int?, - @JsonProperty("media") @SerialName("media") val media: List?, - @JsonProperty("node") @SerialName("node") val node: Character?, + @JsonProperty("role") val role: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("voiceActors") val voiceActors: List?, + @JsonProperty("favouriteOrder") val favouriteOrder: Int?, + @JsonProperty("media") val media: List?, + @JsonProperty("node") val node: Character?, ) - @Serializable data class StaffImage( - @JsonProperty("large") @SerialName("large") val large: String?, - @JsonProperty("medium") @SerialName("medium") val medium: String?, + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, ) - @Serializable data class StaffName( - @JsonProperty("name") @SerialName("name") val first: String?, - @JsonProperty("middle") @SerialName("middle") val middle: String?, - @JsonProperty("last") @SerialName("last") val last: String?, - @JsonProperty("full") @SerialName("full") val full: String?, - @JsonProperty("native") @SerialName("native") val native: String?, - @JsonProperty("alternative") @SerialName("alternative") val alternative: List?, - @JsonProperty("userPreferred") @SerialName("userPreferred") val userPreferred: String?, + @JsonProperty("name") val first: String?, + @JsonProperty("middle") val middle: String?, + @JsonProperty("last") val last: String?, + @JsonProperty("full") val full: String?, + @JsonProperty("native") val native: String?, + @JsonProperty("alternative") val alternative: List?, + @JsonProperty("userPreferred") val userPreferred: String?, ) - @Serializable data class Staff( - @JsonProperty("image") @SerialName("image") val image: StaffImage?, - @JsonProperty("name") @SerialName("name") val name: StaffName?, - @JsonProperty("age") @SerialName("age") val age: Int?, + @JsonProperty("image") val image: StaffImage?, + @JsonProperty("name") val name: StaffName?, + @JsonProperty("age") val age: Int?, ) - @Serializable data class CharacterConnection( - @JsonProperty("edges") @SerialName("edges") val edges: List?, - @JsonProperty("nodes") @SerialName("nodes") val nodes: List?, + @JsonProperty("edges") val edges: List?, + @JsonProperty("nodes") val nodes: List?, + //@JsonProperty("pageInfo") pageInfo: PageInfo ) - @Serializable data class MediaTrailer( - @JsonProperty("id") @SerialName("id") val id: String?, - @JsonProperty("site") @SerialName("site") val site: String?, - @JsonProperty("thumbnail") @SerialName("thumbnail") val thumbnail: String?, + @JsonProperty("id") val id: String?, + @JsonProperty("site") val site: String?, + @JsonProperty("thumbnail") val thumbnail: String?, ) - @Serializable data class MediaCoverImage( - @JsonProperty("extraLarge") @SerialName("extraLarge") val extraLarge: String?, - @JsonProperty("large") @SerialName("large") val large: String?, - @JsonProperty("medium") @SerialName("medium") val medium: String?, - @JsonProperty("color") @SerialName("color") val color: String?, + @JsonProperty("extraLarge") val extraLarge: String?, + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, + @JsonProperty("color") val color: String?, ) - @Serializable data class SeasonNextAiringEpisode( - @JsonProperty("episode") @SerialName("episode") val episode: Int?, - @JsonProperty("timeUntilAiring") @SerialName("timeUntilAiring") val timeUntilAiring: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("timeUntilAiring") val timeUntilAiring: Int?, ) - @Serializable data class SeasonEdges( - @JsonProperty("edges") @SerialName("edges") val edges: List?, + @JsonProperty("edges") val edges: List?, ) - @Serializable data class SeasonEdge( - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("relationType") @SerialName("relationType") val relationType: String?, - @JsonProperty("node") @SerialName("node") val node: SeasonNode?, + @JsonProperty("id") val id: Int?, + @JsonProperty("relationType") val relationType: String?, + @JsonProperty("node") val node: SeasonNode?, ) - @Serializable data class AniListFavoritesMediaConnection( - @JsonProperty("nodes") @SerialName("nodes") val nodes: List, + @JsonProperty("nodes") val nodes: List, ) - @Serializable data class AniListFavourites( - @JsonProperty("anime") @SerialName("anime") val anime: AniListFavoritesMediaConnection, + @JsonProperty("anime") val anime: AniListFavoritesMediaConnection, ) - @Serializable data class MediaTitle( - @JsonProperty("romaji") @SerialName("romaji") val romaji: String?, - @JsonProperty("english") @SerialName("english") val english: String?, - @JsonProperty("native") @SerialName("native") val native: String?, - @JsonProperty("userPreferred") @SerialName("userPreferred") val userPreferred: String?, + @JsonProperty("romaji") val romaji: String?, + @JsonProperty("english") val english: String?, + @JsonProperty("native") val native: String?, + @JsonProperty("userPreferred") val userPreferred: String?, ) - @Serializable data class SeasonNode( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("format") @SerialName("format") val format: String?, - @JsonProperty("title") @SerialName("title") val title: Title?, - @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?, - @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage?, - @JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?, + @JsonProperty("id") val id: Int, + @JsonProperty("format") val format: String?, + @JsonProperty("title") val title: Title?, + @JsonProperty("idMal") val idMal: Int?, + @JsonProperty("coverImage") val coverImage: CoverImage?, + @JsonProperty("averageScore") val averageScore: Int? +// @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, ) - @Serializable data class AniListAvatar( - @JsonProperty("large") @SerialName("large") val large: String?, + @JsonProperty("large") val large: String?, ) - @Serializable data class AniListViewer( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("avatar") @SerialName("avatar") val avatar: AniListAvatar?, - @JsonProperty("favourites") @SerialName("favourites") val favourites: AniListFavourites?, + @JsonProperty("id") val id: Int?, + @JsonProperty("name") val name: String?, + @JsonProperty("avatar") val avatar: AniListAvatar?, + @JsonProperty("favourites") val favourites: AniListFavourites?, ) - @Serializable data class AniListData( - @JsonProperty("Viewer") @SerialName("Viewer") val viewer: AniListViewer?, + @JsonProperty("Viewer") val viewer: AniListViewer?, ) - @Serializable data class AniListRoot( - @JsonProperty("data") @SerialName("data") val data: AniListData?, + @JsonProperty("data") val data: AniListData?, ) - @Serializable data class AniListUser( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("picture") @SerialName("picture") val picture: String?, + @JsonProperty("id") val id: Int?, + @JsonProperty("name") val name: String?, + @JsonProperty("picture") val picture: String?, ) - @Serializable data class LikeNode( - @JsonProperty("id") @SerialName("id") val id: Int?, + @JsonProperty("id") val id: Int?, + //@JsonProperty("idMal") public int idMal; ) - @Serializable data class LikePageInfo( - @JsonProperty("total") @SerialName("total") val total: Int?, - @JsonProperty("currentPage") @SerialName("currentPage") val currentPage: Int?, - @JsonProperty("lastPage") @SerialName("lastPage") val lastPage: Int?, - @JsonProperty("perPage") @SerialName("perPage") val perPage: Int?, - @JsonProperty("hasNextPage") @SerialName("hasNextPage") val hasNextPage: Boolean?, + @JsonProperty("total") val total: Int?, + @JsonProperty("currentPage") val currentPage: Int?, + @JsonProperty("lastPage") val lastPage: Int?, + @JsonProperty("perPage") val perPage: Int?, + @JsonProperty("hasNextPage") val hasNextPage: Boolean?, ) - @Serializable data class LikeAnime( - @JsonProperty("nodes") @SerialName("nodes") val nodes: List?, - @JsonProperty("pageInfo") @SerialName("pageInfo") val pageInfo: LikePageInfo?, + @JsonProperty("nodes") val nodes: List?, + @JsonProperty("pageInfo") val pageInfo: LikePageInfo?, ) - @Serializable data class LikeFavourites( - @JsonProperty("anime") @SerialName("anime") val anime: LikeAnime?, + @JsonProperty("anime") val anime: LikeAnime?, ) - @Serializable data class LikeViewer( - @JsonProperty("favourites") @SerialName("favourites") val favourites: LikeFavourites?, + @JsonProperty("favourites") val favourites: LikeFavourites?, ) - @Serializable data class LikeData( - @JsonProperty("Viewer") @SerialName("Viewer") val viewer: LikeViewer?, + @JsonProperty("Viewer") val viewer: LikeViewer?, ) - @Serializable data class LikeRoot( - @JsonProperty("data") @SerialName("data") val data: LikeData?, + @JsonProperty("data") val data: LikeData?, ) - @Serializable data class AniListTitleHolder( - @JsonProperty("title") @SerialName("title") val title: Title?, - @JsonProperty("isFavourite") @SerialName("isFavourite") val isFavourite: Boolean?, - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("progress") @SerialName("progress") val progress: Int?, - @JsonProperty("episodes") @SerialName("episodes") val episodes: Int?, - @JsonProperty("score") @SerialName("score") val score: Int?, - @JsonProperty("type") @SerialName("type") val type: AniListStatusType?, + @JsonProperty("title") val title: Title?, + @JsonProperty("isFavourite") val isFavourite: Boolean?, + @JsonProperty("id") val id: Int?, + @JsonProperty("progress") val progress: Int?, + @JsonProperty("episodes") val episodes: Int?, + @JsonProperty("score") val score: Int?, + @JsonProperty("type") val type: AniListStatusType?, ) - @Serializable data class GetDataMediaListEntry( - @JsonProperty("progress") @SerialName("progress") val progress: Int?, - @JsonProperty("status") @SerialName("status") val status: String?, - @JsonProperty("score") @SerialName("score") val score: Int?, + @JsonProperty("progress") val progress: Int?, + @JsonProperty("status") val status: String?, + @JsonProperty("score") val score: Int?, ) - @Serializable data class Nodes( - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("mediaRecommendation") @SerialName("mediaRecommendation") val mediaRecommendation: MediaRecommendation?, + @JsonProperty("id") val id: Int?, + @JsonProperty("mediaRecommendation") val mediaRecommendation: MediaRecommendation? ) - @Serializable data class GetDataMedia( - @JsonProperty("isFavourite") @SerialName("isFavourite") val isFavourite: Boolean?, - @JsonProperty("episodes") @SerialName("episodes") val episodes: Int?, - @JsonProperty("title") @SerialName("title") val title: Title?, - @JsonProperty("mediaListEntry") @SerialName("mediaListEntry") val mediaListEntry: GetDataMediaListEntry?, + @JsonProperty("isFavourite") val isFavourite: Boolean?, + @JsonProperty("episodes") val episodes: Int?, + @JsonProperty("title") val title: Title?, + @JsonProperty("mediaListEntry") val mediaListEntry: GetDataMediaListEntry? ) - @Serializable data class Recommendations( - @JsonProperty("nodes") @SerialName("nodes") val nodes: List?, + @JsonProperty("nodes") val nodes: List? ) - @Serializable data class GetDataData( - @JsonProperty("Media") @SerialName("Media") val media: GetDataMedia?, + @JsonProperty("Media") val media: GetDataMedia?, ) - @Serializable data class GetDataRoot( - @JsonProperty("data") @SerialName("data") val data: GetDataData?, + @JsonProperty("data") val data: GetDataData?, ) - @Serializable data class GetSearchTitle( - @JsonProperty("romaji") @SerialName("romaji") val romaji: String?, + @JsonProperty("romaji") val romaji: String?, ) - @Serializable data class TrailerObject( - @JsonProperty("id") @SerialName("id") val id: String?, - @JsonProperty("thumbnail") @SerialName("thumbnail") val thumbnail: String?, - @JsonProperty("site") @SerialName("site") val site: String?, + @JsonProperty("id") val id: String?, + @JsonProperty("thumbnail") val thumbnail: String?, + @JsonProperty("site") val site: String?, ) - @Serializable data class GetSearchMedia( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?, - @JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int, - @JsonProperty("title") @SerialName("title") val title: GetSearchTitle, - @JsonProperty("startDate") @SerialName("startDate") val startDate: StartedAt, - @JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?, - @JsonProperty("meanScore") @SerialName("meanScore") val meanScore: Int?, - @JsonProperty("bannerImage") @SerialName("bannerImage") val bannerImage: String?, - @JsonProperty("trailer") @SerialName("trailer") val trailer: TrailerObject?, - @JsonProperty("nextAiringEpisode") @SerialName("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, - @JsonProperty("recommendations") @SerialName("recommendations") val recommendations: Recommendations?, - @JsonProperty("relations") @SerialName("relations") val relations: SeasonEdges?, + @JsonProperty("id") val id: Int, + @JsonProperty("idMal") val idMal: Int?, + @JsonProperty("seasonYear") val seasonYear: Int, + @JsonProperty("title") val title: GetSearchTitle, + @JsonProperty("startDate") val startDate: StartedAt, + @JsonProperty("averageScore") val averageScore: Int?, + @JsonProperty("meanScore") val meanScore: Int?, + @JsonProperty("bannerImage") val bannerImage: String?, + @JsonProperty("trailer") val trailer: TrailerObject?, + @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, + @JsonProperty("recommendations") val recommendations: Recommendations?, + @JsonProperty("relations") val relations: SeasonEdges? ) - @Serializable data class GetSearchPage( - @JsonProperty("Page") @SerialName("Page") val page: GetSearchData?, + @JsonProperty("Page") val page: GetSearchData?, ) - @Serializable data class GetSearchData( - @JsonProperty("media") @SerialName("media") val media: List?, + @JsonProperty("media") val media: List?, ) - @Serializable data class GetSearchRoot( - @JsonProperty("data") @SerialName("data") val data: GetSearchPage?, + @JsonProperty("data") val data: GetSearchPage?, ) -} +} \ No newline at end of file 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 c613aa6ea..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,658 +1,8 @@ package com.lagradost.cloudstream3.syncproviders.providers -import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.ShowStatus -import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AuthData -import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement -import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse -import com.lagradost.cloudstream3.syncproviders.AuthToken -import com.lagradost.cloudstream3.syncproviders.AuthUser -import com.lagradost.cloudstream3.syncproviders.SyncAPI -import com.lagradost.cloudstream3.syncproviders.SyncIdName -import com.lagradost.cloudstream3.ui.SyncWatchType -import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.txt -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import okhttp3.Interceptor -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import java.text.SimpleDateFormat -import java.time.LocalDate -import java.time.ZoneId -import java.util.Date -import java.util.Locale - -const val KITSU_MAX_SEARCH_LIMIT = 20 - -class KitsuApi: SyncAPI() { - override var name = "Kitsu" - override val idPrefix = "kitsu" - - private val apiUrl = "https://kitsu.io/api/edge" - private val fallbackApiUrl = "https://kitsu.app/api/edge" - private val oauthUrl = "https://kitsu.io/api/oauth" - private val fallbackOauthUrl = "https://kitsu.app/api/oauth" - override val hasInApp = true - override val mainUrl = "https://kitsu.app" - override val icon = R.drawable.kitsu_icon - override val syncIdName = SyncIdName.Kitsu - override val createAccountUrl = mainUrl - - override val supportedWatchTypes = setOf( - SyncWatchType.WATCHING, - SyncWatchType.COMPLETED, - SyncWatchType.PLANTOWATCH, - SyncWatchType.DROPPED, - SyncWatchType.ONHOLD, - SyncWatchType.NONE - ) - - override val inAppLoginRequirement = AuthLoginRequirement( - password = true, - email = true - ) - - private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request: Request = chain.request() - - try { - val response = chain.proceed(request); - if (response.isSuccessful) return response - response.close() - } catch (_: Exception) { - } - - val fallbackRequest: Request = request.newBuilder() - .url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl)) - .build() - - return chain.proceed(fallbackRequest) - } - } - - private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl) - private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl) - - override suspend fun login(form: AuthLoginResponse): AuthToken? { - val username = form.email ?: return null - val password = form.password ?: return null - - val grantType = "password" - - val token = app.post( - "$oauthUrl/token", - data = mapOf( - "grant_type" to grantType, - "username" to username, - "password" to password - ), - interceptor = oauthFallbackInterceptor - ).parsed() - - return AuthToken( - accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(), - refreshToken = token.refreshToken, - accessToken = token.accessToken, - ) - } - - override suspend fun refreshToken(token: AuthToken): AuthToken { - val res = app.post( - "$oauthUrl/token", - data = mapOf( - "grant_type" to "refresh_token", - "refresh_token" to token.refreshToken!! - ), - interceptor = oauthFallbackInterceptor - ).parsed() - - return AuthToken( - accessToken = res.accessToken, - refreshToken = res.refreshToken, - accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong() - ) - } - - override suspend fun user(token: AuthToken?): AuthUser? { - val user = app.get( - "$apiUrl/users?filter[self]=true", - headers = mapOf( - "Authorization" to "Bearer ${token?.accessToken ?: return null}" - ), cacheTime = 0, - interceptor = apiFallbackInterceptor - ).parsed() - - if (user.data.isEmpty()) { - return null - } - - return AuthUser( - id = user.data[0].id.toInt(), - name = user.data[0].attributes.name, - profilePicture = user.data[0].attributes.avatar?.original - ) - } - - override suspend fun search(auth: AuthData?, query: String): List? { - val auth = auth?.token?.accessToken ?: return null - val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") - val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" - - val res = app.get( - url, headers = mapOf( - "Authorization" to "Bearer $auth", - ), cacheTime = 0, - interceptor = apiFallbackInterceptor - ).parsed() - - return res.data.map { - val attributes = it.attributes - - val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" - - SyncSearchResult( - title, - this.name, - it.id, - "$mainUrl/anime/${it.id}/", - attributes.posterImage?.large ?: attributes.posterImage?.medium - ) - } - } - - override suspend fun load(auth : AuthData?, id: String): SyncResult? { - val auth = auth?.token?.accessToken ?: return null - if (id.toIntOrNull() == null) { - return null - } - - @Serializable - data class KitsuResponse( - @JsonProperty("data") @SerialName("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 { - return app.get( - url, headers = mapOf( - "Authorization" to "Bearer ${token.accessToken}", - ), - interceptor = apiFallbackInterceptor - ).parsed() - } - - @Serializable - data class ResponseToken( - @JsonProperty("token_type") @SerialName("token_type") val tokenType: String, - @JsonProperty("expires_in") @SerialName("expires_in") val expiresIn: Int, - @JsonProperty("access_token") @SerialName("access_token") val accessToken: String, - @JsonProperty("refresh_token") @SerialName("refresh_token") val refreshToken: String, - ) - - @Serializable - data class KitsuNode( - @JsonProperty("id") @SerialName("id") val id: String, - @JsonProperty("attributes") @SerialName("attributes") val attributes: KitsuNodeAttributes, - /* User list anime node */ - @JsonProperty("relationships") @SerialName("relationships") val relationships: KitsuRelationships?, - @JsonProperty("anime") @SerialName("anime") 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 - } - ) - } - - } - - @Serializable - data class KitsuAnimeAttributes( - @JsonProperty("titles") @SerialName("titles") val titles: KitsuTitles?, - @JsonProperty("canonicalTitle") @SerialName("canonicalTitle") val canonicalTitle: String?, - @JsonProperty("posterImage") @SerialName("posterImage") val posterImage: KitsuPosterImage?, - @JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?, - @JsonProperty("startDate") @SerialName("startDate") val startDate: String?, - @JsonProperty("endDate") @SerialName("endDate") val endDate: String?, - @JsonProperty("episodeCount") @SerialName("episodeCount") val episodeCount: Int?, - @JsonProperty("episodeLength") @SerialName("episodeLength") val episodeLength: Int?, - ) - - @Serializable - data class KitsuAnimeData( - @JsonProperty("id") @SerialName("id") val id: String, - @JsonProperty("attributes") @SerialName("attributes") val attributes: KitsuAnimeAttributes, - ) - - @Serializable - data class KitsuNodeAttributes( - /* General attributes */ - @JsonProperty("titles") @SerialName("titles") val titles: KitsuTitles?, - @JsonProperty("canonicalTitle") @SerialName("canonicalTitle") val canonicalTitle: String?, - @JsonProperty("posterImage") @SerialName("posterImage") val posterImage: KitsuPosterImage?, - @JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?, - @JsonProperty("startDate") @SerialName("startDate") val startDate: String?, - @JsonProperty("endDate") @SerialName("endDate") val endDate: String?, - @JsonProperty("episodeCount") @SerialName("episodeCount") val episodeCount: Int?, - @JsonProperty("episodeLength") @SerialName("episodeLength") val episodeLength: Int?, - /* User attributes */ - @JsonProperty("name") @SerialName("name") val name: String?, - @JsonProperty("location") @SerialName("location") val location: String?, - @JsonProperty("createdAt") @SerialName("createdAt") val createdAt: String?, - @JsonProperty("avatar") @SerialName("avatar") val avatar: KitsuUserAvatar?, - /* User list anime attributes */ - @JsonProperty("progress") @SerialName("progress") val progress: Int?, - @JsonProperty("ratingTwenty") @SerialName("ratingTwenty") val ratingTwenty: Int?, - @JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: String?, - @JsonProperty("status") @SerialName("status") val status: String?, - ) - - @Serializable - data class KitsuRelationships( - @JsonProperty("anime") @SerialName("anime") val anime: KitsuRelationshipsAnime?, - ) - - @Serializable - data class KitsuRelationshipsAnime( - @JsonProperty("links") @SerialName("links") val links: KitsuLinks?, - ) - - @Serializable - data class KitsuPosterImage( - @JsonProperty("large") @SerialName("large") val large: String?, - @JsonProperty("medium") @SerialName("medium") val medium: String?, - ) - - @Serializable - data class KitsuTitles( - @JsonProperty("en_jp") @SerialName("en_jp") val enJp: String?, - @JsonProperty("ja_jp") @SerialName("ja_jp") val jaJp: String?, - ) - - @Serializable - data class KitsuUserAvatar( - @JsonProperty("original") @SerialName("original") val original: String?, - ) - - @Serializable - data class KitsuLinks( - /* Pagination */ - @JsonProperty("first") @SerialName("first") val first: String?, - @JsonProperty("next") @SerialName("next") val next: String?, - @JsonProperty("last") @SerialName("last") val last: String?, - /* Relationships */ - @JsonProperty("related") @SerialName("related") val related: String?, - ) - - @Serializable - data class KitsuResponse( - @JsonProperty("links") @SerialName("links") val links: KitsuLinks?, - @JsonProperty("data") @SerialName("data") val data: List, - /* When requesting related info (User library entry -> anime) */ - @JsonProperty("included") @SerialName("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 (_: 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 @@ -670,7 +20,7 @@ object Kitsu { "https://kitsu.io/api/graphql", headers = headers, data = mapOf("query" to query) - ).parsed() + ).parsed() } private val cache: MutableMap, Map> = @@ -752,52 +102,44 @@ query { return map } - @Serializable data class KitsuResponse( - @JsonProperty("data") @SerialName("data") val data: Data? = null, + val data: Data? = null ) { - @Serializable data class Data( - @JsonProperty("lookupMapping") @SerialName("lookupMapping") val lookupMapping: LookupMapping? = null, + val lookupMapping: LookupMapping? = null ) - @Serializable data class LookupMapping( - @JsonProperty("id") @SerialName("id") val id: String? = null, - @JsonProperty("episodes") @SerialName("episodes") val episodes: Episodes? = null, + val id: String? = null, + val episodes: Episodes? = null ) - @Serializable data class Episodes( - @JsonProperty("nodes") @SerialName("nodes") val nodes: List? = null, + val nodes: List? = null ) - @Serializable data class Node( - @JsonProperty("number") @SerialName("number") val num: Int? = null, - @JsonProperty("titles") @SerialName("titles") val titles: Titles? = null, - @JsonProperty("description") @SerialName("description") val description: Description? = null, - @JsonProperty("thumbnail") @SerialName("thumbnail") val thumbnail: Thumbnail? = null, + @JsonProperty("number") + val num: Int? = null, + val titles: Titles? = null, + val description: Description? = null, + val thumbnail: Thumbnail? = null ) - @Serializable data class Description( - @JsonProperty("en") @SerialName("en") val en: String? = null, + val en: String? = null ) - @Serializable data class Thumbnail( - @JsonProperty("original") @SerialName("original") val original: Original? = null, + val original: Original? = null ) - @Serializable data class Original( - @JsonProperty("url") @SerialName("url") val url: String? = null, + val url: String? = null ) - @Serializable data class Titles( - @JsonProperty("canonical") @SerialName("canonical") val canonical: String? = null, + 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..2b51f7efd 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.utils.txt import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioWork @@ -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 f74102b13..4836eca13 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,227 +1,189 @@ 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.APIHolder -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.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.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.txt -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject +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" - - private val key = BuildConfig.MAL_KEY + 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() + } - @Serializable - data class Payload( - @JsonProperty("requestId") @SerialName("requestId") val requestId: Int, - @JsonProperty("codeVerifier") @SerialName("codeVerifier") 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 = APIHolder.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, this.name, node.id.toString(), "$mainUrl/anime/${node.id}/", - node.mainPicture?.large ?: node.mainPicture?.medium, + node.mainPicture?.large ?: node.mainPicture?.medium ) } } - override fun urlToId(url: String): String? = - Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() - - override suspend fun updateStatus( - auth: AuthData?, - id: String, - newStatus: SyncAPI.AbstractSyncStatus - ): Boolean { - return setScoreRequest( - auth?.token ?: return false, - id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(newStatus.status), - newStatus.score?.toInt(10), - newStatus.watchedEpisodes - ) + override fun getIdFromUrl(url: String): String { + return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() + } + + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + return setScoreRequest( + id.toIntOrNull() ?: return false, + fromIntToAnimeStatus(status.status.internalId), + status.score, + status.watchedEpisodes + ).also { + requireLibraryRefresh = requireLibraryRefresh || it + } } - @Serializable data class MalAnime( - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("title") @SerialName("title") val title: String?, - @JsonProperty("main_picture") @SerialName("main_picture") val mainPicture: MainPicture?, - @JsonProperty("alternative_titles") @SerialName("alternative_titles") val alternativeTitles: AlternativeTitles?, - @JsonProperty("start_date") @SerialName("start_date") val startDate: String?, - @JsonProperty("end_date") @SerialName("end_date") val endDate: String?, - @JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?, - @JsonProperty("mean") @SerialName("mean") val mean: Double?, - @JsonProperty("rank") @SerialName("rank") val rank: Int?, - @JsonProperty("popularity") @SerialName("popularity") val popularity: Int?, - @JsonProperty("num_list_users") @SerialName("num_list_users") val numListUsers: Int?, - @JsonProperty("num_scoring_users") @SerialName("num_scoring_users") val numScoringUsers: Int?, - @JsonProperty("nsfw") @SerialName("nsfw") val nsfw: String?, - @JsonProperty("created_at") @SerialName("created_at") val createdAt: String?, - @JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String?, - @JsonProperty("media_type") @SerialName("media_type") val mediaType: String?, - @JsonProperty("status") @SerialName("status") val status: String?, - @JsonProperty("genres") @SerialName("genres") val genres: ArrayList?, - @JsonProperty("my_list_status") @SerialName("my_list_status") val myListStatus: MyListStatus?, - @JsonProperty("num_episodes") @SerialName("num_episodes") val numEpisodes: Int?, - @JsonProperty("start_season") @SerialName("start_season") val startSeason: StartSeason?, - @JsonProperty("broadcast") @SerialName("broadcast") val broadcast: Broadcast?, - @JsonProperty("source") @SerialName("source") val source: String?, - @JsonProperty("average_episode_duration") @SerialName("average_episode_duration") val averageEpisodeDuration: Int?, - @JsonProperty("rating") @SerialName("rating") val rating: String?, - @JsonProperty("pictures") @SerialName("pictures") val pictures: ArrayList?, - @JsonProperty("background") @SerialName("background") val background: String?, - @JsonProperty("related_anime") @SerialName("related_anime") val relatedAnime: ArrayList?, - @JsonProperty("related_manga") @SerialName("related_manga") val relatedManga: ArrayList?, - @JsonProperty("recommendations") @SerialName("recommendations") val recommendations: ArrayList?, - @JsonProperty("studios") @SerialName("studios") val studios: ArrayList?, - @JsonProperty("statistics") @SerialName("statistics") val statistics: Statistics?, + @JsonProperty("id") val id: Int?, + @JsonProperty("title") val title: String?, + @JsonProperty("main_picture") val mainPicture: MainPicture?, + @JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?, + @JsonProperty("start_date") val startDate: String?, + @JsonProperty("end_date") val endDate: String?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("mean") val mean: Double?, + @JsonProperty("rank") val rank: Int?, + @JsonProperty("popularity") val popularity: Int?, + @JsonProperty("num_list_users") val numListUsers: Int?, + @JsonProperty("num_scoring_users") val numScoringUsers: Int?, + @JsonProperty("nsfw") val nsfw: String?, + @JsonProperty("created_at") val createdAt: String?, + @JsonProperty("updated_at") val updatedAt: String?, + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("status") val status: String?, + @JsonProperty("genres") val genres: ArrayList?, + @JsonProperty("my_list_status") val myListStatus: MyListStatus?, + @JsonProperty("num_episodes") val numEpisodes: Int?, + @JsonProperty("start_season") val startSeason: StartSeason?, + @JsonProperty("broadcast") val broadcast: Broadcast?, + @JsonProperty("source") val source: String?, + @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?, + @JsonProperty("rating") val rating: String?, + @JsonProperty("pictures") val pictures: ArrayList?, + @JsonProperty("background") val background: String?, + @JsonProperty("related_anime") val relatedAnime: ArrayList?, + @JsonProperty("related_manga") val relatedManga: ArrayList?, + @JsonProperty("recommendations") val recommendations: ArrayList?, + @JsonProperty("studios") val studios: ArrayList?, + @JsonProperty("statistics") val statistics: Statistics?, ) - @Serializable data class Recommendations( - @JsonProperty("node") @SerialName("node") val node: Node? = null, - @JsonProperty("num_recommendations") @SerialName("num_recommendations") val numRecommendations: Int? = null, + @JsonProperty("node") val node: Node? = null, + @JsonProperty("num_recommendations") val numRecommendations: Int? = null ) - @Serializable data class Studios( - @JsonProperty("id") @SerialName("id") val id: Int? = null, - @JsonProperty("name") @SerialName("name") val name: String? = null, + @JsonProperty("id") val id: Int? = null, + @JsonProperty("name") val name: String? = null ) - @Serializable data class MyListStatus( - @JsonProperty("status") @SerialName("status") val status: String? = null, - @JsonProperty("score") @SerialName("score") val score: Int? = null, - @JsonProperty("num_episodes_watched") @SerialName("num_episodes_watched") val numEpisodesWatched: Int? = null, - @JsonProperty("is_rewatching") @SerialName("is_rewatching") val isRewatching: Boolean? = null, - @JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String? = null, + @JsonProperty("status") val status: String? = null, + @JsonProperty("score") val score: Int? = null, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int? = null, + @JsonProperty("is_rewatching") val isRewatching: Boolean? = null, + @JsonProperty("updated_at") val updatedAt: String? = null ) - @Serializable data class RelatedAnime( - @JsonProperty("node") @SerialName("node") val node: Node? = null, - @JsonProperty("relation_type") @SerialName("relation_type") val relationType: String? = null, - @JsonProperty("relation_type_formatted") @SerialName("relation_type_formatted") val relationTypeFormatted: String? = null, + @JsonProperty("node") val node: Node? = null, + @JsonProperty("relation_type") val relationType: String? = null, + @JsonProperty("relation_type_formatted") val relationTypeFormatted: String? = null ) - @Serializable data class Status( - @JsonProperty("watching") @SerialName("watching") val watching: String? = null, - @JsonProperty("completed") @SerialName("completed") val completed: String? = null, - @JsonProperty("on_hold") @SerialName("on_hold") val onHold: String? = null, - @JsonProperty("dropped") @SerialName("dropped") val dropped: String? = null, - @JsonProperty("plan_to_watch") @SerialName("plan_to_watch") val planToWatch: String? = null, + @JsonProperty("watching") val watching: String? = null, + @JsonProperty("completed") val completed: String? = null, + @JsonProperty("on_hold") val onHold: String? = null, + @JsonProperty("dropped") val dropped: String? = null, + @JsonProperty("plan_to_watch") val planToWatch: String? = null ) - @Serializable data class Statistics( - @JsonProperty("status") @SerialName("status") val status: Status? = null, - @JsonProperty("num_list_users") @SerialName("num_list_users") val numListUsers: Int? = null, + @JsonProperty("status") val status: Status? = null, + @JsonProperty("num_list_users") val numListUsers: Int? = null ) private fun parseDate(string: String?): Long? { return try { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time - } catch (_: Exception) { + } catch (e: Exception) { null } } @@ -232,18 +194,18 @@ class MALApi : SyncAPI() { apiName = this.name, syncId = node.id.toString(), url = "$mainUrl/anime/${node.id}", - posterUrl = node.mainPicture?.large, + posterUrl = node.mainPicture?.large ) } - 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 @@ -252,13 +214,13 @@ 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) { "finished_airing" -> ShowStatus.Completed "currently_airing" -> ShowStatus.Ongoing - // "not_yet_aired" + //"not_yet_aired" else -> null }, nextAiring = null, @@ -282,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, @@ -306,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) { @@ -328,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 } } @@ -345,94 +298,141 @@ class MALApi : SyncAPI() { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( string ?: return null )?.time?.div(1000) - } catch (_: Exception) { + } catch (e: Exception) { null } } } - 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(), - ) + val request = + "$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId" + 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() - return AuthToken( - accessToken = res.accessToken, - refreshToken = res.refreshToken, - accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong(), - ) + private var requestId = 0 + private var codeVerifier = "" + + private fun storeToken(response: String) { + try { + if (response != "") { + val token = parseJson(response) + setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime)) + setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken) + setKey(accountId, MAL_TOKEN_KEY, token.accessToken) + requireLibraryRefresh = true + } + } catch (e: Exception) { + logError(e) + } + } + + 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 var requestIdCounter = 0 private val allTitles = hashMapOf() - @Serializable data class MalList( - @JsonProperty("data") @SerialName("data") val data: List, - @JsonProperty("paging") @SerialName("paging") val paging: Paging, + @JsonProperty("data") val data: List, + @JsonProperty("paging") val paging: Paging ) - @Serializable data class MainPicture( - @JsonProperty("medium") @SerialName("medium") val medium: String, - @JsonProperty("large") @SerialName("large") val large: String, + @JsonProperty("medium") val medium: String, + @JsonProperty("large") val large: String ) - @Serializable data class Node( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("title") @SerialName("title") val title: String, - @JsonProperty("main_picture") @SerialName("main_picture") val mainPicture: MainPicture?, - @JsonProperty("alternative_titles") @SerialName("alternative_titles") val alternativeTitles: AlternativeTitles?, - @JsonProperty("media_type") @SerialName("media_type") val mediaType: String?, - @JsonProperty("num_episodes") @SerialName("num_episodes") val numEpisodes: Int?, - @JsonProperty("status") @SerialName("status") val status: String?, - @JsonProperty("start_date") @SerialName("start_date") val startDate: String?, - @JsonProperty("end_date") @SerialName("end_date") val endDate: String?, - @JsonProperty("average_episode_duration") @SerialName("average_episode_duration") val averageEpisodeDuration: Int?, - @JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String?, - @JsonProperty("mean") @SerialName("mean") val mean: Double?, - @JsonProperty("genres") @SerialName("genres") val genres: List?, - @JsonProperty("rank") @SerialName("rank") val rank: Int?, - @JsonProperty("popularity") @SerialName("popularity") val popularity: Int?, - @JsonProperty("num_list_users") @SerialName("num_list_users") val numListUsers: Int?, - @JsonProperty("num_favorites") @SerialName("num_favorites") val numFavorites: Int?, - @JsonProperty("num_scoring_users") @SerialName("num_scoring_users") val numScoringUsers: Int?, - @JsonProperty("start_season") @SerialName("start_season") val startSeason: StartSeason?, - @JsonProperty("broadcast") @SerialName("broadcast") val broadcast: Broadcast?, - @JsonProperty("nsfw") @SerialName("nsfw") val nsfw: String?, - @JsonProperty("created_at") @SerialName("created_at") val createdAt: String?, - @JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String?, + @JsonProperty("id") val id: Int, + @JsonProperty("title") val title: String, + @JsonProperty("main_picture") val mainPicture: MainPicture?, + @JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?, + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("num_episodes") val numEpisodes: Int?, + @JsonProperty("status") val status: String?, + @JsonProperty("start_date") val startDate: String?, + @JsonProperty("end_date") val endDate: String?, + @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("mean") val mean: Double?, + @JsonProperty("genres") val genres: List?, + @JsonProperty("rank") val rank: Int?, + @JsonProperty("popularity") val popularity: Int?, + @JsonProperty("num_list_users") val numListUsers: Int?, + @JsonProperty("num_favorites") val numFavorites: Int?, + @JsonProperty("num_scoring_users") val numScoringUsers: Int?, + @JsonProperty("start_season") val startSeason: StartSeason?, + @JsonProperty("broadcast") val broadcast: Broadcast?, + @JsonProperty("nsfw") val nsfw: String?, + @JsonProperty("created_at") val createdAt: String?, + @JsonProperty("updated_at") val updatedAt: String? ) - @Serializable data class ListStatus( - @JsonProperty("status") @SerialName("status") val status: String?, - @JsonProperty("score") @SerialName("score") val score: Int, - @JsonProperty("num_episodes_watched") @SerialName("num_episodes_watched") val numEpisodesWatched: Int, - @JsonProperty("is_rewatching") @SerialName("is_rewatching") val isRewatching: Boolean, - @JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String, + @JsonProperty("status") val status: String?, + @JsonProperty("score") val score: Int, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) - @Serializable data class Data( - @JsonProperty("node") @SerialName("node") val node: Node, - @JsonProperty("list_status") @SerialName("list_status") val listStatus: ListStatus?, + @JsonProperty("node") val node: Node, + @JsonProperty("list_status") val listStatus: ListStatus?, ) { fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( @@ -441,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, @@ -449,52 +449,58 @@ 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} ) } } - @Serializable data class Paging( - @JsonProperty("next") @SerialName("next") val next: String?, + @JsonProperty("next") val next: String? ) - @Serializable data class AlternativeTitles( - @JsonProperty("synonyms") @SerialName("synonyms") val synonyms: List, - @JsonProperty("en") @SerialName("en") val en: String, - @JsonProperty("ja") @SerialName("ja") val ja: String, + @JsonProperty("synonyms") val synonyms: List, + @JsonProperty("en") val en: String, + @JsonProperty("ja") val ja: String ) - @Serializable data class Genres( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("name") @SerialName("name") val name: String, + @JsonProperty("id") val id: Int, + @JsonProperty("name") val name: String ) - @Serializable data class StartSeason( - @JsonProperty("year") @SerialName("year") val year: Int, - @JsonProperty("season") @SerialName("season") val season: String, + @JsonProperty("year") val year: Int, + @JsonProperty("season") val season: String ) - @Serializable data class Broadcast( - @JsonProperty("day_of_the_week") @SerialName("day_of_the_week") val dayOfTheWeek: String?, - @JsonProperty("start_time") @SerialName("start_time") val startTime: String?, + @JsonProperty("day_of_the_week") val dayOfTheWeek: String?, + @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() } @@ -521,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) + val data: MalList = getMalAnimeListSlice(offset) ?: break fullList.addAll(data.data) offset = data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() } @@ -545,28 +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 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 parseJson(res) + 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, @@ -589,7 +686,6 @@ class MALApi : SyncAPI() { @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( - token: AuthToken, id: Int, status: String? = null, score: Int? = null, @@ -598,86 +694,83 @@ class MALApi : SyncAPI() { val data = mapOf( "status" to status, "score" to score?.toString(), - "num_watched_episodes" to numWatchedEpisodes?.toString(), + "num_watched_episodes" to numWatchedEpisodes?.toString() ).filterValues { it != null } as Map 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 } - @Serializable + data class ResponseToken( - @JsonProperty("token_type") @SerialName("token_type") val tokenType: String, - @JsonProperty("expires_in") @SerialName("expires_in") val expiresIn: Int, - @JsonProperty("access_token") @SerialName("access_token") val accessToken: String, - @JsonProperty("refresh_token") @SerialName("refresh_token") val refreshToken: String, + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String, ) - @Serializable data class MalRoot( - @JsonProperty("data") @SerialName("data") val data: List, + @JsonProperty("data") val data: List, ) - @Serializable data class MalDatum( - @JsonProperty("node") @SerialName("node") val node: MalNode, - @JsonProperty("list_status") @SerialName("list_status") val listStatus: MalStatus, + @JsonProperty("node") val node: MalNode, + @JsonProperty("list_status") val listStatus: MalStatus, ) - @Serializable data class MalNode( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("title") @SerialName("title") val title: String, + @JsonProperty("id") val id: Int, + @JsonProperty("title") val title: String, + /* + also, but not used + main_picture -> + public string medium; + public string large; + */ ) - @Serializable data class MalStatus( - @JsonProperty("status") @SerialName("status") val status: String, - @JsonProperty("score") @SerialName("score") val score: Int, - @JsonProperty("num_episodes_watched") @SerialName("num_episodes_watched") val numEpisodesWatched: Int, - @JsonProperty("is_rewatching") @SerialName("is_rewatching") val isRewatching: Boolean, - @JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String, + @JsonProperty("status") val status: String, + @JsonProperty("score") val score: Int, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) - @Serializable data class MalUser( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("location") @SerialName("location") val location: String, - @JsonProperty("joined_at") @SerialName("joined_at") val joinedAt: String, - @JsonProperty("picture") @SerialName("picture") val picture: String?, + @JsonProperty("id") val id: Int, + @JsonProperty("name") val name: String, + @JsonProperty("location") val location: String, + @JsonProperty("joined_at") val joinedAt: String, + @JsonProperty("picture") val picture: String?, ) - @Serializable data class MalMainPicture( - @JsonProperty("large") @SerialName("large") val large: String?, - @JsonProperty("medium") @SerialName("medium") val medium: String?, + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, ) // Used for getDataAboutId() - @Serializable data class SmallMalAnime( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("title") @SerialName("title") val title: String?, - @JsonProperty("num_episodes") @SerialName("num_episodes") val numEpisodes: Int, - @JsonProperty("my_list_status") @SerialName("my_list_status") val myListStatus: MalStatus?, - @JsonProperty("main_picture") @SerialName("main_picture") val mainPicture: MalMainPicture?, + @JsonProperty("id") val id: Int, + @JsonProperty("title") val title: String?, + @JsonProperty("num_episodes") val numEpisodes: Int, + @JsonProperty("my_list_status") val myListStatus: MalStatus?, + @JsonProperty("main_picture") val mainPicture: MalMainPicture?, ) - @Serializable data class MalSearchNode( - @JsonProperty("node") @SerialName("node") val node: Node, + @JsonProperty("node") val node: Node, ) - @Serializable data class MalSearch( - @JsonProperty("data") @SerialName("data") val data: List, - // paging + @JsonProperty("data") val data: List, + //paging ) data class MalTitleHolder( 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 fca3e5df8..2e28f1945 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,112 +2,188 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.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.app -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 kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +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.2" + override fun intercept(chain: Interceptor.Chain): Response { + return chain.proceed( + chain.request().newBuilder() + .removeHeader("user-agent") + .addHeader("user-agent", userAgent) + .addHeader("Api-Key", API_KEY) + .build() + ) + } } private fun canDoRequest(): Boolean { - return unixTimeMS > currentCoolDown + return unixTimeMs > currentCoolDown } private fun throwIfCantDoRequest() { if (!canDoRequest()) { - throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMS) / 1000L}s") + throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMs) / 1000L}s") } } private fun throwGotTooManyRequests() { - currentCoolDown = unixTimeMS + COOLDOWN_DURATION + currentCoolDown = unixTimeMs + COOLDOWN_DURATION throw ErrorLoadingException("Too many requests") } - 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( "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 = APIHolder.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 @@ -120,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) @@ -151,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 @@ -165,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,15 +257,11 @@ class OpenSubtitlesApi : SubtitleAPI() { return results } - /** - * 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 + /* + Process data returned from search. + Returns string url for the subtitle file. + */ + override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? { throwIfCantDoRequest() val req = app.post( @@ -197,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}") @@ -221,64 +294,64 @@ class OpenSubtitlesApi : SubtitleAPI() { return null } - @Serializable + + data class SubtitleOAuthEntity( + var user: String, + var pass: String, + var accessToken: String, + ) + data class OAuthToken( - @JsonProperty("token") @SerialName("token") var token: String? = null, - @JsonProperty("status") @SerialName("status") var status: Int? = null, + @JsonProperty("token") var token: String? = null, + @JsonProperty("status") var status: Int? = null ) - @Serializable data class Results( - @JsonProperty("data") @SerialName("data") var data: List? = listOf(), + @JsonProperty("data") var data: List? = listOf() ) - @Serializable data class ResultData( - @JsonProperty("id") @SerialName("id") var id: String? = null, - @JsonProperty("type") @SerialName("type") var type: String? = null, - @JsonProperty("attributes") @SerialName("attributes") var attributes: ResultAttributes? = ResultAttributes(), + @JsonProperty("id") var id: String? = null, + @JsonProperty("type") var type: String? = null, + @JsonProperty("attributes") var attributes: ResultAttributes? = ResultAttributes() ) - @Serializable data class ResultAttributes( - @JsonProperty("subtitle_id") @SerialName("subtitle_id") var subtitleId: String? = null, - @JsonProperty("language") @SerialName("language") var language: String? = null, - @JsonProperty("release") @SerialName("release") var release: String? = null, - @JsonProperty("url") @SerialName("url") var url: String? = null, - @JsonProperty("files") @SerialName("files") var files: List? = listOf(), - @JsonProperty("feature_details") @SerialName("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(), - @JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Boolean? = null, + @JsonProperty("subtitle_id") var subtitleId: String? = null, + @JsonProperty("language") var language: String? = null, + @JsonProperty("release") var release: String? = null, + @JsonProperty("url") var url: String? = null, + @JsonProperty("files") var files: List? = listOf(), + @JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(), + @JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null, ) - @Serializable data class ResultFiles( - @JsonProperty("file_id") @SerialName("file_id") var fileId: Int? = null, - @JsonProperty("file_name") @SerialName("file_name") var fileName: String? = null, + @JsonProperty("file_id") var fileId: Int? = null, + @JsonProperty("file_name") var fileName: String? = null ) - @Serializable data class ResultDownloadLink( - @JsonProperty("link") @SerialName("link") var link: String? = null, - @JsonProperty("file_name") @SerialName("file_name") var fileName: String? = null, - @JsonProperty("requests") @SerialName("requests") var requests: Int? = null, - @JsonProperty("remaining") @SerialName("remaining") var remaining: Int? = null, - @JsonProperty("message") @SerialName("message") var message: String? = null, - @JsonProperty("reset_time") @SerialName("reset_time") var resetTime: String? = null, - @JsonProperty("reset_time_utc") @SerialName("reset_time_utc") var resetTimeUtc: String? = null, + @JsonProperty("link") var link: String? = null, + @JsonProperty("file_name") var fileName: String? = null, + @JsonProperty("requests") var requests: Int? = null, + @JsonProperty("remaining") var remaining: Int? = null, + @JsonProperty("message") var message: String? = null, + @JsonProperty("reset_time") var resetTime: String? = null, + @JsonProperty("reset_time_utc") var resetTimeUtc: String? = null ) - @Serializable data class ResultFeatureDetails( - @JsonProperty("year") @SerialName("year") var year: Int? = null, - @JsonProperty("title") @SerialName("title") var title: String? = null, - @JsonProperty("movie_name") @SerialName("movie_name") var movieName: String? = null, - @JsonProperty("imdb_id") @SerialName("imdb_id") var imdbId: Int? = null, - @JsonProperty("tmdb_id") @SerialName("tmdb_id") var tmdbId: Int? = null, - @JsonProperty("season_number") @SerialName("season_number") var seasonNumber: Int? = null, - @JsonProperty("episode_number") @SerialName("episode_number") var episodeNumber: Int? = null, - @JsonProperty("parent_imdb_id") @SerialName("parent_imdb_id") var parentImdbId: Int? = null, - @JsonProperty("parent_title") @SerialName("parent_title") var parentTitle: String? = null, - @JsonProperty("parent_tmdb_id") @SerialName("parent_tmdb_id") var parentTmdbId: Int? = null, - @JsonProperty("parent_feature_id") @SerialName("parent_feature_id") var parentFeatureId: Int? = null, + @JsonProperty("year") var year: Int? = null, + @JsonProperty("title") var title: String? = null, + @JsonProperty("movie_name") var movieName: String? = null, + @JsonProperty("imdb_id") var imdbId: Int? = null, + @JsonProperty("tmdb_id") var tmdbId: Int? = null, + @JsonProperty("season_number") var seasonNumber: Int? = null, + @JsonProperty("episode_number") var episodeNumber: Int? = null, + @JsonProperty("parent_imdb_id") var parentImdbId: Int? = null, + @JsonProperty("parent_title") var parentTitle: String? = null, + @JsonProperty("parent_tmdb_id") var parentTmdbId: Int? = null, + @JsonProperty("parent_feature_id") var parentFeatureId: 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 a57ec6716..92a181dd1 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,42 +2,38 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import androidx.core.net.toUri -import com.fasterxml.jackson.annotation.JsonIgnore +import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.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.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.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear -import com.lagradost.cloudstream3.utils.serializers.NonEmptySerializer import com.lagradost.cloudstream3.utils.txt -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KeepGeneratedSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear +import okhttp3.Interceptor +import okhttp3.Response import java.math.BigInteger import java.security.SecureRandom import java.text.SimpleDateFormat @@ -49,18 +45,25 @@ import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration -class SimklApi : SyncAPI() { - override val name = "Simkl" +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" - - override val redirectUrlIdentifier = "simkl" - override val hasOAuth2 = true - override val hasPin = true override var requireLibraryRefresh = true - override val mainUrl = "https://api.simkl.com" + 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() /** * This is required to override the reported last activity as simkl activites @@ -70,99 +73,68 @@ class SimklApi : SyncAPI() { private object SimklCache { private const val SIMKL_CACHE_KEY = "SIMKL_API_CACHE" + enum class CacheTimes(val value: String) { OneMonth("30d"), ThirtyMinutes("30m") } - @Serializable - private data class MediaObjectCacheEntry( - @JsonProperty("obj") @SerialName("obj") val obj: MediaObject?, - @JsonProperty("validUntil") @SerialName("validUntil") val validUntil: Long, - @JsonProperty("cacheTime") @SerialName("cacheTime") val cacheTime: Long = APIHolder.unixTime, - ) + private class SimklCacheWrapper( + @JsonProperty("obj") val obj: T?, + @JsonProperty("validUntil") val validUntil: Long, + @JsonProperty("cacheTime") val cacheTime: Long = unixTime, + ) { + /** Returns true if cache is newer than cacheDays */ + fun isFresh(): Boolean { + return validUntil > unixTime + } - @Serializable - private data class EpisodesCacheEntry( - @JsonProperty("obj") @SerialName("obj") val obj: Array?, - @JsonProperty("validUntil") @SerialName("validUntil") val validUntil: Long, - @JsonProperty("cacheTime") @SerialName("cacheTime") val cacheTime: Long = APIHolder.unixTime, - ) - - /** - * Minimal class used only to peek at an entry's expiry, without caring which of the - * concrete entry types above actually produced it. - */ - @Serializable - private data class CacheFreshness( - @JsonProperty("validUntil") @SerialName("validUntil") val validUntil: Long, - ) - - private fun Long.isFresh(): Boolean = this > APIHolder.unixTime - - private fun Long.remaining(): Duration { - val unixTime = APIHolder.unixTime - return if (this > unixTime) { - (this - unixTime).toDuration(DurationUnit.SECONDS) - } else { - Duration.ZERO + fun remainingTime(): Duration { + val unixTime = unixTime + return if (validUntil > unixTime) { + (validUntil - unixTime).toDuration(DurationUnit.SECONDS) + } else { + Duration.ZERO + } } } fun cleanOldCache() { getKeys(SIMKL_CACHE_KEY)?.forEach { - val isOld = getKey(it)?.validUntil?.isFresh() == false - if (isOld) removeKey(it) - } - } - - fun setMediaObject(path: String, value: MediaObject, cacheTime: Duration) { - debugPrint { "Set cache: $SIMKL_CACHE_KEY/$path for ${cacheTime.inWholeDays} days or ${cacheTime.inWholeSeconds} seconds." } - setKey( - SIMKL_CACHE_KEY, - path, - MediaObjectCacheEntry(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson(), - ) - } - - /** Gets the cached [MediaObject], if it's not fresh returns null and removes it from cache */ - fun getMediaObject(path: String): MediaObject? { - val cache = getKey(SIMKL_CACHE_KEY, path)?.let { - tryParseJson(it) - } - - return if (cache?.validUntil?.isFresh() == true) { - debugPrint { - "Cache hit at: $SIMKL_CACHE_KEY/$path. " + - "Remains fresh for ${cache.validUntil.remaining().inWholeDays} days or ${cache.validUntil.remaining().inWholeSeconds} seconds." + val isOld = AcraApplication.getKey>(it)?.isFresh() == false + if (isOld) { + removeKey(it) } - cache.obj - } else { - debugPrint { "Cache miss at: $SIMKL_CACHE_KEY/$path" } - removeKey(SIMKL_CACHE_KEY, path) - null } } - fun setEpisodes(path: String, value: Array, cacheTime: Duration) { + fun setKey(path: String, value: T, cacheTime: Duration) { debugPrint { "Set cache: $SIMKL_CACHE_KEY/$path for ${cacheTime.inWholeDays} days or ${cacheTime.inWholeSeconds} seconds." } setKey( SIMKL_CACHE_KEY, path, - EpisodesCacheEntry(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson(), + // Storing as plain sting is required to make generics work. + SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson() ) } - /** Gets the cached episode list, if it's not fresh returns null and removes it from cache */ - fun getEpisodes(path: String): Array? { + /** + * 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?.validUntil?.isFresh() == true) { + return if (cache?.isFresh() == true) { debugPrint { "Cache hit at: $SIMKL_CACHE_KEY/$path. " + - "Remains fresh for ${cache.validUntil.remaining().inWholeDays} days or ${cache.validUntil.remaining().inWholeSeconds} seconds." + "Remains fresh for ${cache.remainingTime().inWholeDays} days or ${cache.remainingTime().inWholeSeconds} seconds." } cache.obj } else { @@ -176,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" @@ -206,7 +182,7 @@ class SimklApi : SyncAPI() { ) ) ) - } catch (_: Exception) { + } catch (e: Exception) { null } } @@ -222,7 +198,7 @@ class SimklApi : SyncAPI() { enum class SimklListStatusType( var value: Int, @StringRes val stringRes: Int, - val originalName: String?, + val originalName: String? ) { Watching(0, R.string.type_watching, "watching"), Completed(1, R.string.type_completed, "completed"), @@ -241,92 +217,73 @@ class SimklApi : SyncAPI() { } } + // ------------------- @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = TokenRequest.Serializer::class) data class TokenRequest( - @JsonProperty("code") @SerialName("code") val code: String, - @JsonProperty("client_id") @SerialName("client_id") val clientId: String = CLIENT_ID, - @JsonProperty("client_secret") @SerialName("client_secret") val clientSecret: String = CLIENT_SECRET, - @JsonProperty("redirect_uri") @SerialName("redirect_uri") val redirectUri: String = "$APP_STRING://simkl", - @JsonProperty("grant_type") @SerialName("grant_type") val grantType: String = "authorization_code", - ) { - object Serializer : NonEmptySerializer(TokenRequest.generatedSerializer()) - } + @JsonProperty("code") val code: String, + @JsonProperty("client_id") val clientId: String = CLIENT_ID, + @JsonProperty("client_secret") val clientSecret: String = CLIENT_SECRET, + @JsonProperty("redirect_uri") val redirectUri: String = "$APP_STRING://simkl", + @JsonProperty("grant_type") val grantType: String = "authorization_code" + ) - @Serializable data class TokenResponse( /** No expiration date */ - @JsonProperty("access_token") @SerialName("access_token") val accessToken: String, - @JsonProperty("token_type") @SerialName("token_type") val tokenType: String, - @JsonProperty("scope") @SerialName("scope") val scope: String, + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("scope") val scope: String ) + // ------------------- /** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */ - @Serializable data class SettingsResponse( - @JsonProperty("user") @SerialName("user") val user: User, - @JsonProperty("account") @SerialName("account") val account: Account, + val user: User ) { - @Serializable data class User( - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("avatar") @SerialName("avatar") val avatar: String, // Url - ) - - @Serializable - data class Account( - @JsonProperty("id") @SerialName("id") val id: Int, + val name: String, + /** Url */ + val avatar: String ) } - @Serializable data class PinAuthResponse( - @JsonProperty("result") @SerialName("result") val result: String, - @JsonProperty("device_code") @SerialName("device_code") val deviceCode: String, - @JsonProperty("user_code") @SerialName("user_code") val userCode: String, - @JsonProperty("verification_url") @SerialName("verification_url") val verificationUrl: String, - @JsonProperty("expires_in") @SerialName("expires_in") val expiresIn: Int, - @JsonProperty("interval") @SerialName("interval") val interval: Int, + @JsonProperty("result") val result: String, + @JsonProperty("device_code") val deviceCode: String, + @JsonProperty("user_code") val userCode: String, + @JsonProperty("verification_url") val verificationUrl: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("interval") val interval: Int, ) - @Serializable data class PinExchangeResponse( - @JsonProperty("result") @SerialName("result") val result: String, - @JsonProperty("message") @SerialName("message") val message: String? = null, - @JsonProperty("access_token") @SerialName("access_token") val accessToken: String? = null, + @JsonProperty("result") val result: String, + @JsonProperty("message") val message: String? = null, + @JsonProperty("access_token") val accessToken: String? = null, ) - @Serializable + // ------------------- data class ActivitiesResponse( - @JsonProperty("all") @SerialName("all") val all: String?, - @JsonProperty("tv_shows") @SerialName("tv_shows") val tvShows: UpdatedAt, - @JsonProperty("anime") @SerialName("anime") val anime: UpdatedAt, - @JsonProperty("movies") @SerialName("movies") val movies: UpdatedAt, + @JsonProperty("all") val all: String?, + @JsonProperty("tv_shows") val tvShows: UpdatedAt, + @JsonProperty("anime") val anime: UpdatedAt, + @JsonProperty("movies") val movies: UpdatedAt, ) { - @Serializable data class UpdatedAt( - @JsonProperty("all") @SerialName("all") val all: String?, - @JsonProperty("removed_from_list") @SerialName("removed_from_list") val removedFromList: String?, - @JsonProperty("rated_at") @SerialName("rated_at") val ratedAt: String?, + @JsonProperty("all") val all: String?, + @JsonProperty("removed_from_list") val removedFromList: String?, + @JsonProperty("rated_at") val ratedAt: String?, ) } /** https://simkl.docs.apiary.io/#reference/tv/episodes/get-tv-show-episodes */ @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = EpisodeMetadata.Serializer::class) data class EpisodeMetadata( - @JsonProperty("title") @SerialName("title") val title: String?, - @JsonProperty("description") @SerialName("description") val description: String?, - @JsonProperty("season") @SerialName("season") val season: Int?, - @JsonProperty("episode") @SerialName("episode") val episode: Int, - @JsonProperty("img") @SerialName("img") val img: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("description") val description: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("img") val img: String? ) { - object Serializer : NonEmptySerializer(EpisodeMetadata.generatedSerializer()) - companion object { fun convertToEpisodes(list: List?): List? { return list?.map { @@ -346,58 +303,40 @@ class SimklApi : SyncAPI() { /** * https://simkl.docs.apiary.io/#introduction/about-simkl-api/standard-media-objects - * Useful for finding shows from metadata. + * Useful for finding shows from metadata */ @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = MediaObject.Serializer::class) - data class MediaObject( - @JsonProperty("title") @SerialName("title") val title: String?, - @JsonProperty("year") @SerialName("year") val year: Int?, - @JsonProperty("ids") @SerialName("ids") val ids: Ids?, - @JsonProperty("total_episodes") @SerialName("total_episodes") val totalEpisodes: Int? = null, - @JsonProperty("status") @SerialName("status") val status: String? = null, - @JsonProperty("poster") @SerialName("poster") val poster: String? = null, - @JsonProperty("type") @SerialName("type") val type: String? = null, - @JsonProperty("seasons") @SerialName("seasons") val seasons: List? = null, - @JsonProperty("episodes") @SerialName("episodes") val episodes: List? = null, + open class MediaObject( + @JsonProperty("title") val title: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("ids") val ids: Ids?, + @JsonProperty("total_episodes") val totalEpisodes: Int? = null, + @JsonProperty("status") val status: String? = null, + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("seasons") val seasons: List? = null, + @JsonProperty("episodes") val episodes: List? = null ) { - object Serializer : NonEmptySerializer(MediaObject.generatedSerializer()) - fun hasEnded(): Boolean { return status == "released" || status == "ended" } @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = Season.Serializer::class) data class Season( - @JsonProperty("number") @SerialName("number") val number: Int, - @JsonProperty("episodes") @SerialName("episodes") val episodes: List, + @JsonProperty("number") val number: Int, + @JsonProperty("episodes") val episodes: List ) { - object Serializer : NonEmptySerializer(Season.generatedSerializer()) - - @Serializable - data class Episode( - @JsonProperty("number") @SerialName("number") val number: Int, - ) + data class Episode(@JsonProperty("number") val number: Int) } @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = Ids.Serializer::class) data class Ids( - @JsonProperty("simkl") @SerialName("simkl") val simkl: Int?, - @JsonProperty("imdb") @SerialName("imdb") val imdb: String? = null, - @JsonProperty("tmdb") @SerialName("tmdb") val tmdb: String? = null, - @JsonProperty("mal") @SerialName("mal") val mal: String? = null, - @JsonProperty("anilist") @SerialName("anilist") val anilist: String? = null, + @JsonProperty("simkl") val simkl: Int?, + @JsonProperty("imdb") val imdb: String? = null, + @JsonProperty("tmdb") val tmdb: String? = null, + @JsonProperty("mal") val mal: String? = null, + @JsonProperty("anilist") val anilist: String? = null, ) { - object Serializer : NonEmptySerializer(Ids.generatedSerializer()) - companion object { fun fromMap(map: Map): Ids { return Ids( @@ -405,21 +344,20 @@ class SimklApi : SyncAPI() { imdb = map[SimklSyncServices.Imdb], tmdb = map[SimklSyncServices.Tmdb], mal = map[SimklSyncServices.Mal], - anilist = map[SimklSyncServices.AniList], + anilist = map[SimklSyncServices.AniList] ) } } } fun toSyncSearchResult(): SyncAPI.SyncSearchResult? { - val currentIds = this.ids return SyncAPI.SyncSearchResult( this.title ?: return null, "Simkl", - currentIds?.simkl?.toString() ?: return null, - getUrlFromId(currentIds.simkl), + this.ids?.simkl?.toString() ?: return null, + getUrlFromId(this.ids.simkl), this.poster?.let { getPosterUrl(it) }, - if (this.type == "movie") TvType.Movie else TvType.TvSeries, + if (this.type == "movie") TvType.Movie else TvType.TvSeries ) } } @@ -427,16 +365,16 @@ 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, private var addEpisodes: Pair?, List?>? = null, private var removeEpisodes: Pair?, List?>? = null, // Required for knowing if the status should be overwritten - private var onList: Boolean = false, + 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 { @@ -448,7 +386,11 @@ class SimklApi : SyncAPI() { fun status(newStatus: Int?, oldStatus: Int?) = apply { onList = oldStatus != null // Only set status if its new - this.status = if (newStatus != oldStatus) newStatus else null + if (newStatus != oldStatus) { + this.status = newStatus + } else { + this.status = null + } } fun episodes( @@ -457,6 +399,7 @@ class SimklApi : SyncAPI() { oldEpisodes: Int?, ) = apply { if (allEpisodes == null || newEpisodes == null) return@apply + fun getEpisodes(rawEpisodes: List) = if (rawEpisodes.any { it.season != null }) { EpisodeMetadata.convertToSeasons(rawEpisodes) to null @@ -467,34 +410,36 @@ class SimklApi : SyncAPI() { // Do not add episodes if there is no change if (newEpisodes > (oldEpisodes ?: 0)) { this.addEpisodes = getEpisodes(allEpisodes.take(newEpisodes)) + // Set to watching if episodes are added and there is no current status if (!onList) { status = SimklListStatusType.Watching.value } } - if ((oldEpisodes ?: 0) > newEpisodes) { this.removeEpisodes = getEpisodes(allEpisodes.drop(newEpisodes)) } } suspend fun execute(): Boolean { - val time = getDateTime(APIHolder.unixTime) - val headers = this.headers ?: emptyMap() + val time = getDateTime(unixTime) + return if (this.status == SimklListStatusType.None.value) { app.post( "$url/sync/history/remove", - json = HistoryRequest( + json = StatusRequest( shows = listOf(HistoryMediaObject(ids = ids)), - movies = emptyList(), + movies = emptyList() ), - headers = headers, + interceptor = interceptor ).isSuccessful } else { val statusResponse = this.status?.let { setStatus -> - val newStatus = SimklListStatusType.entries.firstOrNull { - it.value == setStatus - }?.originalName ?: SimklListStatusType.Watching.originalName!! + val newStatus = + SimklListStatusType.entries + .firstOrNull { it.value == setStatus }?.originalName + ?: SimklListStatusType.Watching.originalName!! + app.post( "${this.url}/sync/add-to-list", json = StatusRequest( @@ -504,40 +449,41 @@ class SimklApi : SyncAPI() { null, ids, newStatus, - ), - ), - movies = emptyList(), + ) + ), movies = emptyList() ), - headers = headers, + interceptor = interceptor ).isSuccessful } ?: true val episodeRemovalResponse = removeEpisodes?.let { (seasons, episodes) -> app.post( "${this.url}/sync/history/remove", - json = HistoryRequest( + json = StatusRequest( shows = listOf( HistoryMediaObject( ids = ids, seasons = seasons, - episodes = episodes, - ), + episodes = episodes + ) ), - movies = emptyList(), + movies = emptyList() ), - headers = headers, + interceptor = interceptor ).isSuccessful } ?: true // You cannot rate if you are planning to watch it. - val shouldRate = score != null && status != SimklListStatusType.Planning.value + val shouldRate = + score != null && status != SimklListStatusType.Planning.value val realScore = if (shouldRate) score else null + val historyResponse = // Only post if there are episodes or score to upload if (addEpisodes != null || shouldRate) { app.post( "${this.url}/sync/history", - json = HistoryRequest( + json = StatusRequest( shows = listOf( HistoryMediaObject( null, @@ -547,33 +493,31 @@ class SimklApi : SyncAPI() { addEpisodes?.second, realScore, realScore?.let { time }, - ), - ), - movies = emptyList(), + ) + ), movies = emptyList() ), - headers = headers, + interceptor = interceptor ).isSuccessful - } else true + } else { + true + } + statusResponse && episodeRemovalResponse && historyResponse } } } } - fun getHeaders(token: AuthToken): Map = mapOf( - "Authorization" to "Bearer ${token.accessToken}", - "simkl-api-key" to CLIENT_ID, - ) - suspend fun getEpisodes( simklId: Int?, type: String?, episodes: Int?, - hasEnded: Boolean?, + hasEnded: Boolean? ): Array? { if (simklId == null) return null + val cacheKey = "Episodes/$simklId" - val cache = SimklCache.getEpisodes(cacheKey) + val cache = SimklCache.getKey>(cacheKey) // Return cached result if its higher or equal the amount of episodes. if (cache != null && cache.size >= (episodes ?: 0)) { @@ -590,7 +534,6 @@ class SimklApi : SyncAPI() { }.toTypedArray() } } - val url = when (type) { "anime" -> "https://api.simkl.com/anime/episodes/$simklId" "tv" -> "https://api.simkl.com/tv/episodes/$simklId" @@ -605,86 +548,57 @@ class SimklApi : SyncAPI() { if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value // 1 Month cache - SimklCache.setEpisodes(cacheKey, it, Duration.parse(cacheTime)) + SimklCache.setKey(cacheKey, it, Duration.parse(cacheTime)) } } @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = HistoryMediaObject.Serializer::class) - data class HistoryMediaObject( - @JsonProperty("title") @SerialName("title") val title: String? = null, - @JsonProperty("year") @SerialName("year") val year: Int? = null, - @JsonProperty("ids") @SerialName("ids") val ids: MediaObject.Ids? = null, - @JsonProperty("seasons") @SerialName("seasons") val seasons: List? = null, - @JsonProperty("episodes") @SerialName("episodes") val episodes: List? = null, - @JsonProperty("rating") @SerialName("rating") val rating: Int? = null, - @JsonProperty("rated_at") @SerialName("rated_at") val ratedAt: String? = null, - ) { - object Serializer : NonEmptySerializer(HistoryMediaObject.generatedSerializer()) - } + class HistoryMediaObject( + @JsonProperty("title") title: String? = null, + @JsonProperty("year") year: Int? = null, + @JsonProperty("ids") ids: Ids? = null, + @JsonProperty("seasons") seasons: List? = null, + @JsonProperty("episodes") episodes: List? = null, + @JsonProperty("rating") val rating: Int? = null, + @JsonProperty("rated_at") val ratedAt: String? = null, + ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = RatingMediaObject.Serializer::class) - data class RatingMediaObject( - @JsonProperty("title") @SerialName("title") val title: String? = null, - @JsonProperty("year") @SerialName("year") val year: Int? = null, - @JsonProperty("ids") @SerialName("ids") val ids: MediaObject.Ids? = null, - @JsonProperty("rating") @SerialName("rating") val rating: Int, - @JsonProperty("rated_at") @SerialName("rated_at") val ratedAt: String? = getDateTime(APIHolder.unixTime), - ) { - object Serializer : NonEmptySerializer(RatingMediaObject.generatedSerializer()) - } + class RatingMediaObject( + @JsonProperty("title") title: String?, + @JsonProperty("year") year: Int?, + @JsonProperty("ids") ids: Ids?, + @JsonProperty("rating") val rating: Int, + @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime) + ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = StatusMediaObject.Serializer::class) - data class StatusMediaObject( - @JsonProperty("title") @SerialName("title") val title: String? = null, - @JsonProperty("year") @SerialName("year") val year: Int? = null, - @JsonProperty("ids") @SerialName("ids") val ids: MediaObject.Ids? = null, - @JsonProperty("to") @SerialName("to") val to: String, - @JsonProperty("watched_at") @SerialName("watched_at") val watchedAt: String? = getDateTime(APIHolder.unixTime), - ) { - object Serializer : NonEmptySerializer(StatusMediaObject.generatedSerializer()) - } + class StatusMediaObject( + @JsonProperty("title") title: String?, + @JsonProperty("year") year: Int?, + @JsonProperty("ids") ids: Ids?, + @JsonProperty("to") val to: String, + @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime) + ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = StatusRequest.Serializer::class) data class StatusRequest( - @JsonProperty("movies") @SerialName("movies") val movies: List, - @JsonProperty("shows") @SerialName("shows") val shows: List, - ) { - object Serializer : NonEmptySerializer(StatusRequest.generatedSerializer()) - } - - /** Same shape as [StatusRequest], for the endpoints that post [HistoryMediaObject]s instead. */ - @JsonInclude(JsonInclude.Include.NON_EMPTY) - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = HistoryRequest.Serializer::class) - data class HistoryRequest( - @JsonProperty("movies") @SerialName("movies") val movies: List, - @JsonProperty("shows") @SerialName("shows") val shows: List, - ) { - object Serializer : NonEmptySerializer(HistoryRequest.generatedSerializer()) - } + @JsonProperty("movies") val movies: List, + @JsonProperty("shows") val shows: List + ) /** https://simkl.docs.apiary.io/#reference/sync/get-all-items/get-all-items-in-the-user's-watchlist */ - @Serializable data class AllItemsResponse( - @JsonProperty("shows") @SerialName("shows") val shows: List = emptyList(), - @JsonProperty("anime") @SerialName("anime") val anime: List = emptyList(), - @JsonProperty("movies") @SerialName("movies") val movies: List = emptyList(), + @JsonProperty("shows") + val shows: List = emptyList(), + @JsonProperty("anime") + val anime: List = emptyList(), + @JsonProperty("movies") + val movies: List = emptyList(), ) { companion object { fun merge(first: AllItemsResponse?, second: AllItemsResponse?): AllItemsResponse { + // Replace the first item with the same id, or add the new item fun MutableList.replaceOrAddItem(newItem: T, predicate: (T) -> Boolean) { for (i in this.indices) { @@ -693,10 +607,10 @@ class SimklApi : SyncAPI() { return } } - this.add(newItem) } + // fun merge( first: List?, second: List? @@ -730,17 +644,15 @@ class SimklApi : SyncAPI() { fun toLibraryItem(): SyncAPI.LibraryItem } - @Serializable data class MovieMetadata( - @JsonProperty("last_watched_at") @SerialName("last_watched_at") override val lastWatchedAt: String?, - @JsonProperty("status") @SerialName("status") override val status: String, - @JsonProperty("user_rating") @SerialName("user_rating") override val userRating: Int?, - @JsonProperty("last_watched") @SerialName("last_watched") override val lastWatched: String?, - @JsonProperty("watched_episodes_count") @SerialName("watched_episodes_count") override val watchedEpisodesCount: Int?, - @JsonProperty("total_episodes_count") @SerialName("total_episodes_count") override val totalEpisodesCount: Int?, - @JsonProperty("movie") @SerialName("movie") val movie: ShowMetadata.Show, + @JsonProperty("last_watched_at") override val lastWatchedAt: String?, + @JsonProperty("status") override val status: String, + @JsonProperty("user_rating") override val userRating: Int?, + @JsonProperty("last_watched") override val lastWatched: String?, + @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, + @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, + val movie: ShowMetadata.Show ) : Metadata { - @JsonIgnore override fun getIds(): ShowMetadata.Show.Ids { return this.movie.ids } @@ -752,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, @@ -760,22 +672,20 @@ class SimklApi : SyncAPI() { null, null, this.movie.year?.toYear(), - movie.ids.simkl, + movie.ids.simkl ) } } - @Serializable data class ShowMetadata( - @JsonProperty("last_watched_at") @SerialName("last_watched_at") override val lastWatchedAt: String?, - @JsonProperty("status") @SerialName("status") override val status: String, - @JsonProperty("user_rating") @SerialName("user_rating") override val userRating: Int?, - @JsonProperty("last_watched") @SerialName("last_watched") override val lastWatched: String?, - @JsonProperty("watched_episodes_count") @SerialName("watched_episodes_count") override val watchedEpisodesCount: Int?, - @JsonProperty("total_episodes_count") @SerialName("total_episodes_count") override val totalEpisodesCount: Int?, - @JsonProperty("show") @SerialName("show") val show: Show, + @JsonProperty("last_watched_at") override val lastWatchedAt: String?, + @JsonProperty("status") override val status: String, + @JsonProperty("user_rating") override val userRating: Int?, + @JsonProperty("last_watched") override val lastWatched: String?, + @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, + @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, + @JsonProperty("show") val show: Show ) : Metadata { - @JsonIgnore override fun getIds(): Show.Ids { return this.show.ids } @@ -787,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, @@ -795,30 +705,28 @@ class SimklApi : SyncAPI() { null, null, this.show.year?.toYear(), - show.ids.simkl, + show.ids.simkl ) } - @Serializable data class Show( - @JsonProperty("title") @SerialName("title") val title: String, - @JsonProperty("poster") @SerialName("poster") val poster: String?, - @JsonProperty("year") @SerialName("year") val year: Int?, - @JsonProperty("ids") @SerialName("ids") val ids: Ids, + @JsonProperty("title") val title: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("ids") val ids: Ids, ) { - @Serializable data class Ids( - @JsonProperty("simkl") @SerialName("simkl") val simkl: Int, - @JsonProperty("slug") @SerialName("slug") val slug: String?, - @JsonProperty("imdb") @SerialName("imdb") val imdb: String?, - @JsonProperty("zap2it") @SerialName("zap2it") val zap2it: String?, - @JsonProperty("tmdb") @SerialName("tmdb") val tmdb: String?, - @JsonProperty("offen") @SerialName("offen") val offen: String?, - @JsonProperty("tvdb") @SerialName("tvdb") val tvdb: String?, - @JsonProperty("mal") @SerialName("mal") val mal: String?, - @JsonProperty("anidb") @SerialName("anidb") val anidb: String?, - @JsonProperty("anilist") @SerialName("anilist") val anilist: String?, - @JsonProperty("traktslug") @SerialName("traktslug") val traktslug: String?, + @JsonProperty("simkl") val simkl: Int, + @JsonProperty("slug") val slug: String?, + @JsonProperty("imdb") val imdb: String?, + @JsonProperty("zap2it") val zap2it: String?, + @JsonProperty("tmdb") val tmdb: String?, + @JsonProperty("offen") val offen: String?, + @JsonProperty("tvdb") val tvdb: String?, + @JsonProperty("mal") val mal: String?, + @JsonProperty("anidb") val anidb: String?, + @JsonProperty("anilist") val anilist: String?, + @JsonProperty("traktslug") val traktslug: String? ) { fun matchesId(database: SimklSyncServices, id: String): Boolean { return when (database) { @@ -835,9 +743,28 @@ class SimklApi : SyncAPI() { } } - private suspend fun getUser(token: AuthToken): SettingsResponse = - app.post("$mainUrl/users/settings", headers = getHeaders(token)) - .parsed() + /** + * Appends api keys to the requests + **/ + 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( + chain.request() + .newBuilder() + .addHeader("Authorization", "Bearer $token") + .addHeader("simkl-api-key", CLIENT_ID) + .build() + ) + } + } + + 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. @@ -846,7 +773,7 @@ class SimklApi : SyncAPI() { private val simklId: Int?, private val type: String?, private val totalEpisodeCount: Int?, - private val hasEnded: Boolean?, + private val hasEnded: Boolean? ) { suspend fun getEpisodes(): Array? { return getEpisodes(simklId, type, totalEpisodeCount, hasEnded) @@ -855,47 +782,43 @@ 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, override var isFavorite: Boolean? = null, override var maxEpisodes: Int? = null, - /** - * Save seen episodes separately to know the change from old to new. - * Required to remove seen episodes if count decreases. - */ + /** Save seen episodes separately to know the change from old to new. + * Required to remove seen episodes if count decreases */ val oldEpisodes: Int, - val oldStatus: String?, + 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 :/ // This could be some sort of reference system to make multiple IDs // point to the same key. - val idKey = realIds.toList().map { - "${it.first.originalName}=${it.second}" - }.sorted().joinToString() + val idKey = + realIds.toList().map { "${it.first.originalName}=${it.second}" }.sorted().joinToString() - val cachedObject = SimklCache.getMediaObject(idKey) + val cachedObject = SimklCache.getKey(idKey) val searchResult: MediaObject = cachedObject ?: (searchByIds(realIds)?.firstOrNull()?.also { result -> val cacheTime = if (result.hasEnded()) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value - SimklCache.setMediaObject(idKey, result, Duration.parse(cacheTime)) + SimklCache.setKey(idKey, result, Duration.parse(cacheTime)) }) ?: return null val episodeConstructor = SimklEpisodeConstructor( searchResult.ids?.simkl, searchResult.type, searchResult.totalEpisodes, - searchResult.hasEnded(), + 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) @@ -907,97 +830,115 @@ class SimklApi : SyncAPI() { return SimklSyncStatus( status = foundItem.status?.let { SyncWatchType.fromInternalId( - SimklListStatusType.fromString(it)?.value + SimklListStatusType.fromString( + it + )?.value ) - } ?: return null, - score = Score.from10(foundItem.userRating), + } + ?: return null, + score = foundItem.userRating, watchedEpisodes = foundItem.watchedEpisodesCount, maxEpisodes = searchResult.totalEpisodes, episodeConstructor = episodeConstructor, oldEpisodes = foundItem.watchedEpisodesCount ?: 0, oldScore = foundItem.userRating, - oldStatus = foundItem.status, + oldStatus = foundItem.status ) } 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, oldEpisodes = 0, oldStatus = null, - oldScore = null, + oldScore = null ) } } - override suspend fun updateStatus( - auth: AuthData?, - id: String, - newStatus: AbstractSyncStatus, - ): Boolean { - lastScoreTime = APIHolder.unixTime + override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { val parsedId = readIdFromString(id) - val simklStatus = newStatus as? SimklSyncStatus + lastScoreTime = unixTime + 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)) + // Get episodes only when required 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) + requireLibraryRefresh = true return builder.execute() } + /** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */ private suspend fun searchByIds(serviceMap: Map): Array? { if (serviceMap.isEmpty()) return emptyArray() + return app.get( "$mainUrl/search/id", params = mapOf("client_id" to CLIENT_ID) + serviceMap.map { (service, id) -> service.originalName to id } - ).parsedSafe>() + ).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) - 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, - ) + 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://${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() @@ -1006,70 +947,79 @@ class SimklApi : SyncAPI() { return app.get( "$mainUrl/sync/all-items/", params = params, - headers = getHeaders(auth.token), - ).parsedSafe() + 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, - activities?.movies?.removedFromList, - ).maxOf { getUnixTime(it) ?: -1 } - val lastRealUpdate = listOf( - activities?.tvShows?.all, - activities?.anime?.all, - activities?.movies?.all, - ).maxOf { getUnixTime(it) ?: -1 } + activities?.movies?.removedFromList + ).maxOf { + getUnixTime(it) ?: -1 + } + val lastRealUpdate = + listOf( + activities?.tvShows?.all, + activities?.anime?.all, + activities?.movies?.all, + ).maxOf { + getUnixTime(it) ?: -1 + } 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 - val baseMap = SimklListStatusType.entries - .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value } - .associate { - it.stringRes to emptyList() - } + + override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + val list = getSyncListSmart() ?: return null + + val baseMap = + SimklListStatusType.entries + .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value } + .associate { + it.stringRes to emptyList() + } val syncMap = listOf(list.anime, list.movies, list.shows) .flatten() - .groupBy { it.status } + .groupBy { + it.status + } .mapNotNull { (status, list) -> - val stringRes = status?.let { - SimklListStatusType.fromString(it)?.stringRes - } ?: return@mapNotNull null + val stringRes = + status?.let { SimklListStatusType.fromString(it)?.stringRes } + ?: return@mapNotNull null val libraryList = list.map { it.toLibraryItem() } stringRes to libraryList }.toMap() @@ -1088,54 +1038,75 @@ 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, expiresIn = pinAuthResp.expiresIn, - interval = pinAuthResp.interval, + interval = pinAuthResp.interval ) } - 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 - return AuthToken( - accessToken = pinAuthResp.accessToken ?: return null, - ) + "$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID" + ).parsedSafe() ?: return false + + if (pinAuthResp.accessToken != null) { + switchToNewAccount() + setKey(accountId, SIMKL_TOKEN_KEY, pinAuthResp.accessToken) + + val user = getUser() + if (user == null) { + removeKey(accountId, SIMKL_TOKEN_KEY) + switchToOldAccount() + return false + } + + 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 - return AuthToken( - accessToken = tokenResponse.accessToken, - ) - } + ).parsedSafe() ?: return false - 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, - ) + switchToNewAccount() + setKey(accountId, SIMKL_TOKEN_KEY, token.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 } } 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 317c3dc90..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,202 +3,157 @@ 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 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import java.util.concurrent.TimeUnit -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/v1" + 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 searchResponse = app.post( - url = "$APIURL/movie/search", - json = mapOf( - "includeSeasons" to false, - "limit" to 15, - "query" to query.imdbId!!, - "signal" to "{}" - ), - cacheTime = 120, - cacheUnit = TimeUnit.MINUTES, - ).parsedSafe() ?: return null - - val firstResult = searchResponse.results.firstOrNull() ?: return null - - val apiResponse = app.get( - url = "$APIURL${firstResult.link.replace("series", "subtitles")}", - cacheTime = 120, - cacheUnit = TimeUnit.MINUTES, - ).parsedSafe() ?: return null - - val filteredSubtitles = apiResponse.subtitles.filter { sub -> - sub.releaseType != "trailer" && - sub.language.equals(queryLang, true) - } - - // api doesn't has episode number or lang filtering - val subtitles = if (type == TvType.Movie) { - filteredSubtitles - } else { - val shouldContain = String.format( - null, - "E%02d", - query.epNumber + val searchRes = app.post( + url = "$APIURL/searchMovie", + data = mapOf( + "query" to query.imdbId!! + ) + ).parsedSafe() ?: return null + + val postData = if (type == TvType.TvSeries) { + mapOf( + "langs" to "[]", + "movieName" to searchRes.found.first().linkName, + "season" to "season-${query.seasonNumber}" + ) + } else { + mapOf( + "langs" to "[]", + "movieName" to searchRes.found.first().linkName, ) - filteredSubtitles.filter { sub -> - sub.releaseInfo.contains( - shouldContain - ) - } } - return subtitles.map { subtitle -> + val getMovieRes = app.post( + url = "$APIURL/getMovie", + data = postData + ).parsedSafe().let { + // api doesn't has episode number or lang filtering + if (type == TvType.Movie) { + it?.subs?.filter { sub -> + sub.lang == queryLang + } + } else { + it?.subs?.filter { sub -> + sub.releaseName!!.contains( + String.format( + null, + "E%02d", + query.epNumber + ) + ) && sub.lang == queryLang + } + } + } ?: return null + + return getMovieRes.map { subtitle -> AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, - name = subtitle.releaseInfo, - lang = subtitle.language, - data = subtitle.link, + name = subtitle.releaseName!!, + lang = subtitle.lang!!, + data = SubData( + movie = subtitle.linkName!!, + lang = subtitle.lang, + id = subtitle.subId.toString(), + ).toJson(), type = type, source = this.name, epNumber = query.epNumber, seasonNumber = query.seasonNumber, - isHearingImpaired = subtitle.hearingImpaired == 1, + isHearingImpaired = subtitle.hi == 1, ) } } - override suspend fun SubtitleResource.getResources( - auth: AuthData?, - subtitle: AbstractSubtitleEntities.SubtitleEntity - ) { - val data = app.get("$APIURL/subtitle/${subtitle.data}") - .parsedSafe() - ?: return + 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 data.lang, + "id" to parsedSub.id + ) + ).parsedSafe() ?: return this.addZipUrl( - "$APIURL/subtitle/download/${data.subtitle.downloadToken}" + "$DOWNLOADENDPOINT/${subRes.sub.downloadToken}" ) { name, _ -> name } } - - @Serializable - data class SearchRoot( - @JsonProperty("success") @SerialName("success") var success: Boolean? = null, - @JsonProperty("results") @SerialName("results") var results: ArrayList = arrayListOf(), - @JsonProperty("users") @SerialName("users") var users: ArrayList = arrayListOf() + data class ApiSearch( + @JsonProperty("success") val success: Boolean, + @JsonProperty("found") val found: List, ) - @Serializable - data class Users( - - @JsonProperty("id") @SerialName("id") var id: Int? = null, - @JsonProperty("displayname") @SerialName("displayname") var displayname: String? = null, - @JsonProperty("avatar") @SerialName("avatar") var avatar: String? = null, - @JsonProperty("badges") @SerialName("badges") var badges: ArrayList = arrayListOf() - + data class Found( + @JsonProperty("id") val id: Long, + @JsonProperty("title") val title: String, + @JsonProperty("seasons") val seasons: Long, + @JsonProperty("type") val type: String, + @JsonProperty("releaseYear") val releaseYear: Long, + @JsonProperty("linkName") val linkName: String, ) - @Serializable - data class Results( - @JsonProperty("id") @SerialName("id") var id: Int? = null, - @JsonProperty("title") @SerialName("title") var title: String? = null, - @JsonProperty("type") @SerialName("type") var type: String? = null, - @JsonProperty("link") @SerialName("link") var link: String, - @JsonProperty("releaseYear") @SerialName("releaseYear") var releaseYear: Int? = null, - @JsonProperty("poster") @SerialName("poster") var poster: String? = null, - @JsonProperty("subtitleCount") @SerialName("subtitleCount") var subtitleCount: String? = null, - @JsonProperty("rating") @SerialName("rating") var rating: Double? = null, - @JsonProperty("cast") @SerialName("cast") var cast: ArrayList = arrayListOf(), - @JsonProperty("genres") @SerialName("genres") var genres: ArrayList = arrayListOf(), - @JsonProperty("score") @SerialName("score") var score: Double? = null + data class ApiResponse( + @JsonProperty("success") val success: Boolean, + @JsonProperty("movie") val movie: Movie, + @JsonProperty("subs") val subs: List, ) - @Serializable - - data class ItemRoot( - - // @SerialName("media_type" ) var mediaType : String? = null, - @JsonProperty("subtitles") @SerialName("subtitles") var subtitles: ArrayList, - //@SerialName("movie" ) var movie : Movie? = Movie() - + data class Movie( + @JsonProperty("id") val id: Long? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("year") val year: Long? = null, + @JsonProperty("fullName") val fullName: String? = null, ) - @Serializable - data class Subtitles( - - @JsonProperty("id") @SerialName("id") var id: Int? = null, - @JsonProperty("language") @SerialName("language") var language: String, - @JsonProperty("release_type") @SerialName("release_type") var releaseType: String? = null, - @JsonProperty("release_info") @SerialName("release_info") var releaseInfo: String, - @JsonProperty("upload_date") @SerialName("upload_date") var uploadDate: String? = null, - @JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Int? = null, - @JsonProperty("caption") @SerialName("caption") var caption: String? = null, - @JsonProperty("rating") @SerialName("rating") var rating: String? = null, - @JsonProperty("uploader_id") @SerialName("uploader_id") var uploaderId: Int? = null, - @JsonProperty("uploader_displayname") @SerialName("uploader_displayname") var uploaderDisplayname: String? = null, - @JsonProperty("uploader_badges") @SerialName("uploader_badges") var uploaderBadges: ArrayList = arrayListOf(), - @JsonProperty("link") @SerialName("link") var link: String, - @JsonProperty("production_type") @SerialName("production_type") var productionType: String? = null, - @JsonProperty("last_subtitle") @SerialName("last_subtitle") var lastSubtitle: Boolean? = null - + data class Sub( + @JsonProperty("hi") val hi: Int? = null, + @JsonProperty("fullLink") val fullLink: String? = null, + @JsonProperty("linkName") val linkName: String? = null, + @JsonProperty("lang") val lang: String? = null, + @JsonProperty("releaseName") val releaseName: String? = null, + @JsonProperty("subId") val subId: Long? = null, ) - @Serializable - data class DownloadRoot( - @JsonProperty("subtitle") @SerialName("subtitle") var subtitle: Subtitle, - //@SerializedName("movie" ) var movie : Movie? = Movie(), - //@SerializedName("donationLinks" ) var donationLinks : DonationLinks? = DonationLinks(), - //@SerializedName("isDownloaded" ) var isDownloaded : Boolean? = null, - //@SerializedName("user_rated" ) var userRated : String? = null + data class SubData( + @JsonProperty("movie") val movie: String, + @JsonProperty("lang") val lang: String, + @JsonProperty("id") val id: String, ) - @Serializable - data class Subtitle( - - @JsonProperty("id") @SerialName("id") var id: Int? = null, - @JsonProperty("uploaded_at") @SerialName("uploaded_at") var uploadedAt: String? = null, - @JsonProperty("language") @SerialName("language") var language: String? = null, - @JsonProperty("rating") @SerialName("rating") var rating: String? = null, - //SerialName("rates" ) var rates : Rates? = Rates(), - @JsonProperty("uploaded_by") @SerialName("uploaded_by") var uploadedBy: Int? = null, - //@SerialName("contribs" ) var contribs : ArrayList = arrayListOf(), - @JsonProperty("release_info") @SerialName("release_info") var releaseInfo: ArrayList = arrayListOf(), - @JsonProperty("commentary") @SerialName("commentary") var commentary: String? = null, - @JsonProperty("files") @SerialName("files") var files: String? = null, - @JsonProperty("size") @SerialName("size") var size: String? = null, - @JsonProperty("downloads") @SerialName("downloads") var downloads: Int? = null, - @JsonProperty("comments") @SerialName("comments") var comments: Int? = null, - @JsonProperty("production_type") @SerialName("production_type") var productionType: String? = null, - @JsonProperty("release_type") @SerialName("release_type") var releaseType: String? = null, - @JsonProperty("episode") @SerialName("episode") var episode: String? = null, - @JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Int? = null, - @JsonProperty("foreign_parts") @SerialName("foreign_parts") var foreignParts: String? = null, - @JsonProperty("framerate") @SerialName("framerate") var framerate: String? = null, - @JsonProperty("preview") @SerialName("preview") var preview: String? = null, - @JsonProperty("user_uploaded") @SerialName("user_uploaded") var userUploaded: Boolean? = null, - @JsonProperty("download_token") @SerialName("download_token") var downloadToken: String - + data class SubTitleLink( + @JsonProperty("sub") val sub: SubToken, ) -} + + data class SubToken( + @JsonProperty("downloadToken") val downloadToken: String, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt index 19bd3b1a7..9cec1e1ca 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,73 +1,89 @@ 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 kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +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://api.subdl.com" + const val APIURL = "https://apiold.subdl.com" const val APIENDPOINT = "$APIURL/api/v1/subtitles" const val DOWNLOADENDPOINT = "https://dl.subdl.com" + const val SUBDL_SUBTITLES_USER_KEY: String = "subdl_user" + var currentSession: SubtitleOAuthEntity? = null } - override suspend fun 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}" @@ -81,8 +97,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( @@ -94,9 +110,7 @@ class SubDlApi : SubtitleAPI() { return req.parsedSafe()?.subtitles?.map { subtitle -> - val langTagIETF = - langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?: - subtitle.lang + val lang = subtitle.lang.replaceFirstChar { it.uppercase() } val resEpNum = subtitle.episode ?: query.epNumber val resSeasonNum = subtitle.season ?: query.seasonNumber val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie @@ -104,7 +118,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, @@ -115,155 +129,120 @@ 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 } } - @Serializable + private suspend fun initLogin(useremail: String, password: String): Boolean { + + val tokenResponse = app.post( + url = "$APIURL/login", + json = mapOf( + "email" to useremail, + "password" to password + ) + ).parsedSafe() + + if (tokenResponse?.token == null) return false + + val apiResponse = app.get( + url = "$APIURL/user/userApi", + headers = mapOf( + "Authorization" to "Bearer ${tokenResponse.token}" + ) + ).parsedSafe() + + if (apiResponse?.ok == false) return false + + setAuthKey( + SubtitleOAuthEntity( + userEmail = useremail, + pass = password, + name = tokenResponse.userData?.username ?: tokenResponse.userData?.name, + accessToken = tokenResponse.token, + apiKey = apiResponse?.apiKey + ) + ) + return true + } + + private fun getAuthKey(): SubtitleOAuthEntity? { + return getKey(accountId, SUBDL_SUBTITLES_USER_KEY) + } + + private fun setAuthKey(data: SubtitleOAuthEntity?) { + if (data == null) removeKey( + accountId, + SUBDL_SUBTITLES_USER_KEY + ) + currentSession = data + setKey(accountId, SUBDL_SUBTITLES_USER_KEY, data) + } + data class SubtitleOAuthEntity( - @JsonProperty("userEmail") @SerialName("userEmail") var userEmail: String, - @JsonProperty("pass") @SerialName("pass") var pass: String, - @JsonProperty("name") @SerialName("name") var name: String? = null, - @JsonProperty("accessToken") @SerialName("accessToken") var accessToken: String? = null, - @JsonProperty("apiKey") @SerialName("apiKey") var apiKey: String? = null, + @JsonProperty("userEmail") var userEmail: String, + @JsonProperty("pass") var pass: String, + @JsonProperty("name") var name: String? = null, + @JsonProperty("accessToken") var accessToken: String? = null, + @JsonProperty("apiKey") var apiKey: String? = null, ) - @Serializable data class OAuthTokenResponse( - @JsonProperty("token") @SerialName("token") val token: String, - @JsonProperty("userData") @SerialName("userData") val userData: UserData? = null, - @JsonProperty("status") @SerialName("status") val status: Boolean? = null, - @JsonProperty("message") @SerialName("message") val message: String? = null, + @JsonProperty("token") val token: String? = null, + @JsonProperty("userData") val userData: UserData? = null, + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("message") val message: String? = null, ) - @Serializable data class UserData( - @JsonProperty("email") @SerialName("email") val email: String, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("country") @SerialName("country") val country: String, - @JsonProperty("scStepCode") @SerialName("scStepCode") val scStepCode: String, - @JsonProperty("scVerified") @SerialName("scVerified") val scVerified: Boolean, - @JsonProperty("username") @SerialName("username") val username: String? = null, - @JsonProperty("scUsername") @SerialName("scUsername") val scUsername: String, + @JsonProperty("email") val email: String, + @JsonProperty("name") val name: String, + @JsonProperty("country") val country: String, + @JsonProperty("scStepCode") val scStepCode: String, + @JsonProperty("scVerified") val scVerified: Boolean, + @JsonProperty("username") val username: String? = null, + @JsonProperty("scUsername") val scUsername: String, ) - @Serializable data class ApiKeyResponse( - @JsonProperty("ok") @SerialName("ok") val ok: Boolean? = false, - @JsonProperty("api_key") @SerialName("api_key") val apiKey: String, - @JsonProperty("usage") @SerialName("usage") val usage: Usage? = null, + @JsonProperty("ok") val ok: Boolean? = false, + @JsonProperty("api_key") val apiKey: String? = null, + @JsonProperty("usage") val usage: Usage? = null, ) - @Serializable data class Usage( - @JsonProperty("total") @SerialName("total") val total: Long? = 0, - @JsonProperty("today") @SerialName("today") val today: Long? = 0, + @JsonProperty("total") val total: Long? = 0, + @JsonProperty("today") val today: Long? = 0, ) - @Serializable data class ApiResponse( - @JsonProperty("status") @SerialName("status") val status: Boolean? = null, - @JsonProperty("results") @SerialName("results") val results: List? = null, - @JsonProperty("subtitles") @SerialName("subtitles") val subtitles: List? = null, + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("results") val results: List? = null, + @JsonProperty("subtitles") val subtitles: List? = null, ) - @Serializable data class Result( - @JsonProperty("sd_id") @SerialName("sd_id") val sdId: Int? = null, - @JsonProperty("type") @SerialName("type") val type: String? = null, - @JsonProperty("name") @SerialName("name") val name: String? = null, - @JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String? = null, - @JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long? = null, - @JsonProperty("first_air_date") @SerialName("first_air_date") val firstAirDate: String? = null, - @JsonProperty("year") @SerialName("year") val year: Int? = null, + @JsonProperty("sd_id") val sdId: Int? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("tmdb_id") val tmdbId: Long? = null, + @JsonProperty("first_air_date") val firstAirDate: String? = null, + @JsonProperty("year") val year: Int? = null, ) - @Serializable data class Subtitle( - @JsonProperty("release_name") @SerialName("release_name") val releaseName: String, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("lang") @SerialName("lang") val lang: String, // subdl language code - @JsonProperty("author") @SerialName("author") val author: String? = null, - @JsonProperty("url") @SerialName("url") val url: String? = null, - @JsonProperty("subtitlePage") @SerialName("subtitlePage") val subtitlePage: String? = null, - @JsonProperty("season") @SerialName("season") val season: Int? = null, - @JsonProperty("episode") @SerialName("episode") val episode: Int? = null, - @JsonProperty("language") @SerialName("language") val language: String? = null, // full language name - @JsonProperty("hi") @SerialName("hi") val hearingImpaired: Boolean? = null, - ) - - // https://subdl.com/api-files/language_list.json - // most of it is IETF BPC 47 conformant tag - // but there are some exceptions - private val langTagIETF2subdl = mapOf( - "en-bg" to "BG_EN", // "Bulgarian_English" - "en-de" to "EN_DE", // "English_German" - "en-hu" to "HU_EN", // "Hungarian_English" - "en-nl" to "NL_EN", // "Dutch_English" - "pt-br" to "BR_PT", // "Brazillian Portuguese" - "zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?) - // "ar" to "AR", // "Arabic" - // "az" to "AZ", // "Azerbaijani" - // "be" to "BE", // "Belarusian" - // "bg" to "BG", // "Bulgarian" - // "bn" to "BN", // "Bengali" - // "bs" to "BS", // "Bosnian" - // "ca" to "CA", // "Catalan" - // "cs" to "CS", // "Czech" - // "da" to "DA", // "Danish" - // "de" to "DE", // "German" - // "el" to "EL", // "Greek" - // "en" to "EN", // "English" - // "eo" to "EO", // "Esperanto" - // "es" to "ES", // "Spanish" - // "et" to "ET", // "Estonian" - // "fa" to "FA", // "Farsi_Persian" - // "fi" to "FI", // "Finnish" - // "fr" to "FR", // "French" - // "he" to "HE", // "Hebrew" - // "hi" to "HI", // "Hindi" - // "hr" to "HR", // "Croatian" - // "hu" to "HU", // "Hungarian" - // "id" to "ID", // "Indonesian" - // "is" to "IS", // "Icelandic" - // "it" to "IT", // "Italian" - // "ja" to "JA", // "Japanese" - // "ka" to "KA", // "Georgian" - // "kl" to "KL", // "Greenlandic" - // "ko" to "KO", // "Korean" - // "ku" to "KU", // "Kurdish" - // "lt" to "LT", // "Lithuanian" - // "lv" to "LV", // "Latvian" - // "mk" to "MK", // "Macedonian" - // "ml" to "ML", // "Malayalam" - // "mni" to "MNI", // "Manipuri" - // "ms" to "MS", // "Malay" - // "my" to "MY", // "Burmese" - // "nl" to "NL", // "Dutch" - // "no" to "NO", // "Norwegian" - // "pl" to "PL", // "Polish" - // "pt" to "PT", // "Portuguese" - // "ro" to "RO", // "Romanian" - // "ru" to "RU", // "Russian" - // "si" to "SI", // "Sinhala" - // "sk" to "SK", // "Slovak" - // "sl" to "SL", // "Slovenian" - // "sq" to "SQ", // "Albanian" - // "sr" to "SR", // "Serbian" - // "sv" to "SV", // "Swedish" - // "ta" to "TA", // "Tamil" - // "te" to "TE", // "Telugu" - // "th" to "TH", // "Thai" - // "tl" to "TL", // "Tagalog" - // "tr" to "TR", // "Turkish" - // "uk" to "UK", // "Ukranian" - // "ur" to "UR", // "Urdu" - // "vi" to "VI", // "Vietnamese" - // "zh" to "ZH", // "Chinese BG code" + @JsonProperty("release_name") val releaseName: String, + @JsonProperty("name") val name: String, + @JsonProperty("lang") val lang: String, + @JsonProperty("author") val author: String? = null, + @JsonProperty("url") val url: String? = null, + @JsonProperty("subtitlePage") val subtitlePage: String? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + @JsonProperty("language") val language: String? = null, + @JsonProperty("hi") val hearingImpaired: Boolean? = null, ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index 8ec082520..9bbc5341a 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,23 @@ 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.DelicateCoroutinesApi +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 +49,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 +76,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() } } @@ -153,42 +133,41 @@ class APIRepository(val api: MainAPI) { delay(delta) } + @OptIn(DelicateCoroutinesApi::class) 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 +188,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 f00fd8031..e66b57ab1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -12,7 +12,9 @@ import android.widget.ImageView import android.widget.LinearLayout import android.widget.ListView import androidx.appcompat.app.AlertDialog -import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions @@ -35,24 +37,35 @@ import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs -import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import org.json.JSONObject +/*class SkipOpController(val view: ImageView) : UIController() { + init { + view.setImageResource(R.drawable.exo_controls_fastforward) + view.setOnClickListener { + remoteMediaClient?.let { + val options = MediaSeekOptions.Builder() + .setPosition(it.approximateStreamPosition + 85000) + it.seek(options.build()) + } + } + } +}*/ + private fun RemoteMediaClient.getItemIndex(): Int? { return try { val index = this.mediaQueue.itemIds.indexOf(this.currentItem?.itemId ?: 0) if (index < 0) null else index - } catch (_: Exception) { + } catch (e: Exception) { null } } @@ -79,41 +92,51 @@ class SkipNextEpisodeController(val view: ImageView) : UIController() { } } -@Serializable data class MetadataHolder( - @JsonProperty("apiName") @SerialName("apiName") val apiName: String, - @JsonProperty("isMovie") @SerialName("isMovie") val isMovie: Boolean, - @JsonProperty("title") @SerialName("title") val title: String?, - @JsonProperty("poster") @SerialName("poster") val poster: String?, - @JsonProperty("currentEpisodeIndex") @SerialName("currentEpisodeIndex") val currentEpisodeIndex: Int, - @JsonProperty("episodes") @SerialName("episodes") val episodes: List, - @JsonProperty("currentLinks") @SerialName("currentLinks") val currentLinks: List, - @JsonProperty("currentSubtitles") @SerialName("currentSubtitles") val currentSubtitles: List, + val apiName: String, + val isMovie: Boolean, + val title: String?, + val poster: String?, + val currentEpisodeIndex: Int, + val episodes: List, + val currentLinks: List, + val currentSubtitles: List ) -class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { +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 { + // lateinit var dialog: AlertDialog val holder = getCurrentMetaData() + if (holder != null) { val items = holder.currentLinks if (items.isNotEmpty() && remoteMediaClient?.currentItem != null) { - val subTracks = remoteMediaClient?.mediaInfo?.mediaTracks?.filter { it.type == MediaTrack.TYPE_TEXT } - ?: ArrayList() + val subTracks = + remoteMediaClient?.mediaInfo?.mediaTracks?.filter { it.type == MediaTrack.TYPE_TEXT } + ?: ArrayList() - val bottomSheetDialogBuilder = AlertDialog.Builder(view.context, R.style.AlertDialogCustomBlack) + val bottomSheetDialogBuilder = + AlertDialog.Builder(view.context, R.style.AlertDialogCustomBlack) bottomSheetDialogBuilder.setView(R.layout.sort_bottom_sheet) - val bottomSheetDialog = bottomSheetDialogBuilder.create() bottomSheetDialog.show() - - val providerList = bottomSheetDialog.findViewById(R.id.sort_providers)!! - val subtitleList = bottomSheetDialog.findViewById(R.id.sort_subtitles)!! + // bottomSheetDialog.setContentView(R.layout.sort_bottom_sheet) + val providerList = + bottomSheetDialog.findViewById(R.id.sort_providers)!! + val subtitleList = + bottomSheetDialog.findViewById(R.id.sort_subtitles)!! if (subTracks.isEmpty()) { - bottomSheetDialog.findViewById(R.id.sort_subtitles_holder)?.visibility = GONE + bottomSheetDialog.findViewById(R.id.sort_subtitles_holder)?.visibility = + GONE } else { - val arrayAdapter = ArrayAdapter(view.context, R.layout.sort_bottom_single_choice) + val arrayAdapter = + ArrayAdapter(view.context, R.layout.sort_bottom_single_choice) arrayAdapter.add(view.context.getString(R.string.no_subtitles)) arrayAdapter.addAll(subTracks.mapNotNull { it.name }) @@ -121,8 +144,10 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi subtitleList.adapter = arrayAdapter val currentTracks = remoteMediaClient?.mediaStatus?.activeTrackIds - val subtitleIndex = if (currentTracks == null) 0 else subTracks.map { it.id } - .indexOfFirst { currentTracks.contains(it) } + 1 + + val subtitleIndex = + if (currentTracks == null) 0 else subTracks.map { it.id } + .indexOfFirst { currentTracks.contains(it) } + 1 subtitleList.setSelection(subtitleIndex) subtitleList.setItemChecked(subtitleIndex, true) @@ -134,7 +159,9 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi ChromecastSubtitlesFragment.getCurrentSavedStyle().apply { val font = TextTrackStyle() font.setFontFamily(fontFamily ?: "Google Sans") - fontGenericFamily?.let { font.fontGenericFamily = it } + fontGenericFamily?.let { + font.fontGenericFamily = it + } font.windowColor = windowColor font.backgroundColor = backgroundColor @@ -151,7 +178,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi if (!it.status.isSuccess) { Log.e( "CHROMECAST", "Failed with status code:" + - it.status.statusCode + " > " + it.status.statusMessage + it.status.statusCode + " > " + it.status.statusMessage ) } } @@ -160,15 +187,17 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi } } - // https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages.MediaInformation + //https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages.MediaInformation val contentUrl = (remoteMediaClient?.currentItem?.media?.contentUrl ?: remoteMediaClient?.currentItem?.media?.contentId) - val sortingMethods = items.map { "${it.name} ${Qualities.getStringByInt(it.quality)}" } - .toTypedArray() + val sortingMethods = + items.map { "${it.name} ${Qualities.getStringByInt(it.quality)}" } + .toTypedArray() val sotringIndex = items.indexOfFirst { it.url == contentUrl } - val arrayAdapter = ArrayAdapter(view.context, R.layout.sort_bottom_single_choice) + val arrayAdapter = + ArrayAdapter(view.context, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(sortingMethods.toMutableList()) providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -178,8 +207,10 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi providerList.setOnItemClickListener { _, _, which, _ -> val epData = holder.episodes[holder.currentEpisodeIndex] + fun loadMirror(index: Int) { if (holder.currentLinks.size <= index) return + val mediaItem = getMediaInfo( epData, holder, @@ -189,35 +220,36 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi ) val startAt = remoteMediaClient?.approximateStreamPosition ?: 0 + + //remoteMediaClient.load(mediaItem, true, startAt) try { // THIS IS VERY IMPORTANT BECAUSE WE NEVER WANT TO AUTOLOAD THE NEXT EPISODE val currentIdIndex = remoteMediaClient?.getItemIndex() + val nextId = remoteMediaClient?.mediaQueue?.itemIds?.get( currentIdIndex?.plus(1) ?: 0 ) - if (currentIdIndex == null && nextId != null) { awaitLinks( remoteMediaClient?.queueInsertAndPlayItem( MediaQueueItem.Builder(mediaItem).build(), nextId, startAt, - JSONObject(), + JSONObject() ) - ) { loadMirror(index + 1) } + ) { + loadMirror(index + 1) + } } else { val mediaLoadOptions = MediaLoadOptions.Builder() .setPlayPosition(startAt) .setAutoplay(true) .build() - awaitLinks( - remoteMediaClient?.load( - mediaItem, - mediaLoadOptions - ) - ) { loadMirror(index + 1) } + awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) { + loadMirror(index + 1) + } } - } catch (_: Exception) { + } catch (e: Exception) { val mediaLoadOptions = MediaLoadOptions.Builder() .setPlayPosition(startAt) @@ -228,8 +260,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi } } } - loadMirror(which) + bottomSheetDialog.dismissSafe(activity) } } @@ -239,19 +271,23 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi private fun getCurrentMetaData(): MetadataHolder? { return try { - val data = remoteMediaClient?.mediaInfo?.customData?.toString() ?: return null - parseJson(data) - } catch (_: Exception) { + val data = remoteMediaClient?.mediaInfo?.customData?.toString() + data?.toKotlinObject() + } catch (e: Exception) { null } } var isLoadingMore = false + + override fun onMediaStatusUpdated() { super.onMediaStatusUpdated() val meta = getCurrentMetaData() - view.visibility = if ((meta?.currentLinks?.size ?: 0) > 1) VISIBLE else INVISIBLE + view.visibility = if ((meta?.currentLinks?.size + ?: 0) > 1 + ) VISIBLE else INVISIBLE try { if (meta != null && meta.episodes.size > meta.currentEpisodeIndex + 1) { val currentIdIndex = remoteMediaClient?.getItemIndex() ?: return @@ -263,13 +299,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val currentDuration = remoteMediaClient?.streamDuration val currentPosition = remoteMediaClient?.approximateStreamPosition if (currentDuration != null && currentPosition != null) - DataStoreHelper.setViewPosAndResume( - epData.id, - currentPosition, - currentDuration, - epData, - meta.episodes.getOrNull(index + 1), - ) + DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration) } catch (t: Throwable) { logError(t) } @@ -279,11 +309,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi ioSafe { val currentLinks = mutableSetOf() val currentSubs = mutableSetOf() + val generator = RepoLinkGenerator(listOf(epData)) + val isSuccessful = safeApiCall { generator.generateLinks( clearCache = false, - sourceTypes = LOADTYPE_CHROMECAST, + allowedTypes = LOADTYPE_CHROMECAST, callback = { it.first?.let { link -> currentLinks.add(link) @@ -291,9 +323,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi }, subtitleCallback = { currentSubs.add(it) }, - offset = 0, - isCasting = true, - ) + isCasting = true) } val sortedLinks = sortUrls(currentLinks) @@ -303,18 +333,32 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val jsonCopy = meta.copy( currentLinks = sortedLinks, currentSubtitles = sortedSubs, - currentEpisodeIndex = index, + currentEpisodeIndex = index ) - val done = JSONObject(jsonCopy.toJson()) + val done = + JSONObject(jsonCopy.toJson()) + val mediaInfo = getMediaInfo( epData, jsonCopy, 0, done, - sortedSubs, + sortedSubs ) + /*fun loadIndex(index: Int) { + println("LOAD INDEX::::: $index") + if (meta.currentLinks.size <= index) return + val info = getMediaInfo( + epData, + meta, + index, + done) + awaitLinks(remoteMediaClient?.load(info, true, 0)) { + loadIndex(index + 1) + } + }*/ activity.runOnUiThread { awaitLinks( remoteMediaClient?.queueAppendItem( @@ -323,6 +367,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi ) ) { println("FAILED TO LOAD NEXT ITEM") + // loadIndex(1) } isLoadingMore = false } @@ -345,7 +390,10 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi class SkipTimeController(val view: ImageView, forwards: Boolean) : UIController() { init { + //val settingsManager = PreferenceManager.getDefaultSharedPreferences() + //val time = settingsManager?.getInt("chromecast_tap_time", 30) ?: 30 val time = 30 + //view.setImageResource(if (forwards) R.drawable.netflix_skip_forward else R.drawable.netflix_skip_back) view.setImageResource(if (forwards) R.drawable.go_forward_30 else R.drawable.go_back_30) view.setOnClickListener { remoteMediaClient?.let { @@ -388,4 +436,4 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 302358538..78ad2a6bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui import android.content.Context import android.util.AttributeSet import android.view.View -import androidx.core.content.withStyledAttributes import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -155,9 +154,10 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att init { if (attrs != null) { - context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { - columnWidth = getDimensionPixelSize(0, -1) - } + val attrsArray = intArrayOf(android.R.attr.columnWidth) + val array = context.obtainStyledAttributes(attrs, attrsArray) + columnWidth = array.getDimensionPixelSize(0, -1) + array.recycle() } layoutManager = manager diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/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..b6326eb36 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt @@ -7,12 +7,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.RelativeLayout -import androidx.core.content.withStyledAttributes import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.toPx +import java.lang.ref.WeakReference class MyMiniControllerFragment : MiniControllerFragment() { @@ -25,15 +25,26 @@ class MyMiniControllerFragment : MiniControllerFragment() { // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { - if (currentColor == 0) { - context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { - if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { - currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) - } - } - } - super.onInflate(context, attributeSet, bundle) + + // somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks???? + if (currentColor == 0) { + WeakReference( + context.obtainStyledAttributes( + attributeSet, + R.styleable.CustomCast + ) + ).apply { + if (get() + ?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true + ) { + currentColor = + get() + ?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0 + } + get()?.recycle() + }.clear() + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/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..1e7e0f112 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt @@ -1,17 +1,16 @@ package com.lagradost.cloudstream3.ui.account -import android.os.Build import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding import coil3.transform.RoundedCornersTransformation import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding import com.lagradost.cloudstream3.databinding.AccountListItemBinding import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -20,174 +19,137 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class AccountAdapter( + private val accounts: List, private val accountSelectCallback: (DataStoreHelper.Account) -> Unit, private val accountCreateCallback: (DataStoreHelper.Account) -> Unit, private val accountEditCallback: (DataStoreHelper.Account) -> Unit, private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit -) : NoStateAdapter() { +) : RecyclerView.Adapter() { companion object { const val VIEW_TYPE_SELECT_ACCOUNT = 0 + const val VIEW_TYPE_ADD_ACCOUNT = 1 const val VIEW_TYPE_EDIT_ACCOUNT = 2 } + inner class AccountViewHolder(private val binding: ViewBinding) : + RecyclerView.ViewHolder(binding.root) { - override val footers: Int = 1 - var viewType = VIEW_TYPE_SELECT_ACCOUNT + fun bind(account: DataStoreHelper.Account?) { + when (binding) { + is AccountListItemBinding -> binding.apply { + if (account == null) return@apply - override fun customContentViewType(item: DataStoreHelper.Account): Int { - return viewType - } + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode - override fun onBindContent( - holder: ViewHolderState, - item: DataStoreHelper.Account, - position: Int - ) { - when (val binding = holder.view) { - is AccountListItemBinding -> binding.apply { - val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex - val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + accountName.text = account.name + accountImage.loadImage(account.image) + lockIcon.isVisible = account.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount - accountName.text = item.name - accountImage.loadImage(item.image) - lockIcon.isVisible = item.lockPin != null - outline.isVisible = !isTv && isLastUsedAccount + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } - if (isTv) { - // For emulator but this is fine on TV also - root.isFocusableInTouchMode = true - if (isLastUsedAccount) { - root.requestFocus() + root.foreground = ContextCompat.getDrawable( + root.context, + R.drawable.outline_drawable + ) + } else { + root.setOnLongClickListener { + showAccountEditDialog( + context = root.context, + account = account, + isNewAccount = false, + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } + ) + + true + } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + root.setOnClickListener { + accountSelectCallback.invoke(account) + } + } + + is AccountListItemEditBinding -> binding.apply { + if (account == null) return@apply + + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex + + accountName.text = account.name + accountImage.loadImage(account.image) { + RoundedCornersTransformation(10f) + } + lockIcon.isVisible = account.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount + + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } + root.foreground = ContextCompat.getDrawable( root.context, R.drawable.outline_drawable ) } - } else { - root.setOnLongClickListener { + + root.setOnClickListener { showAccountEditDialog( context = root.context, - account = item, + account = account, isNewAccount = false, - accountEditCallback = { account -> - accountEditCallback.invoke( - account - ) - }, - accountDeleteCallback = { account -> - accountDeleteCallback.invoke( - account - ) - } + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) } ) - - true } } - root.setOnClickListener { - accountSelectCallback.invoke(item) - } - } + is AccountListItemAddBinding -> binding.apply { + root.setOnClickListener { + val remainingImages = + DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } + .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet() - is AccountListItemEditBinding -> binding.apply { - val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + val image = + DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random()) + val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 - val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + val accountName = root.context.getString(R.string.account) - accountName.text = item.name - accountImage.loadImage(item.image) { - RoundedCornersTransformation(10f) - } - lockIcon.isVisible = item.lockPin != null - outline.isVisible = !isTv && isLastUsedAccount - - if (isTv) { - // For emulator but this is fine on TV also - root.isFocusableInTouchMode = true - if (isLastUsedAccount) { - root.requestFocus() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - root.foreground = ContextCompat.getDrawable( + showAccountEditDialog( root.context, - R.drawable.outline_drawable + DataStoreHelper.Account( + keyIndex = keyIndex, + name = "$accountName $keyIndex", + customImage = null, + defaultImageIndex = image + ), + isNewAccount = true, + accountEditCallback = { account -> accountCreateCallback.invoke(account) }, + accountDeleteCallback = {} ) } } - - root.setOnClickListener { - showAccountEditDialog( - context = root.context, - account = item, - isNewAccount = false, - accountEditCallback = { account -> accountEditCallback.invoke(account) }, - accountDeleteCallback = { account -> - accountDeleteCallback.invoke( - account - ) - } - ) - } } } } - override fun onBindFooter(holder: ViewHolderState) { - val binding = holder.view as? AccountListItemAddBinding ?: return - binding.apply { - root.setOnClickListener { - val accounts = this@AccountAdapter.immutableCurrentList - - val remainingImages = - DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } - .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) } - .toSet() - - val image = - DataStoreHelper.profileImages.indexOf( - remainingImages.randomOrNull() - ?: DataStoreHelper.profileImages.random() - ) - val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 - - val accountName = root.context.getString(R.string.account) - - showAccountEditDialog( - root.context, - DataStoreHelper.Account( - keyIndex = keyIndex, - name = "$accountName $keyIndex", - customImage = null, - defaultImageIndex = image - ), - isNewAccount = true, - accountEditCallback = { account -> accountCreateCallback.invoke(account) }, - accountDeleteCallback = {} - ) - } - } - } - - override fun onCreateFooter(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - AccountListItemAddBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - when (viewType) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder = + AccountViewHolder( + binding = when (viewType) { VIEW_TYPE_SELECT_ACCOUNT -> { AccountListItemBinding.inflate( LayoutInflater.from(parent.context), @@ -195,7 +157,13 @@ class AccountAdapter( false ) } - + VIEW_TYPE_ADD_ACCOUNT -> { + AccountListItemAddBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } VIEW_TYPE_EDIT_ACCOUNT -> { AccountListItemEditBinding.inflate( LayoutInflater.from(parent.context), @@ -203,9 +171,28 @@ class AccountAdapter( false ) } - else -> throw IllegalArgumentException("Invalid view type") } ) + + override fun onBindViewHolder(holder: AccountViewHolder, position: Int) { + holder.bind(accounts.getOrNull(position)) + } + + var viewType = 0 + + override fun getItemViewType(position: Int): Int { + if (viewType != 0 && position != accounts.count()) { + return viewType + } + + return when (position) { + accounts.count() -> VIEW_TYPE_ADD_ACCOUNT + else -> VIEW_TYPE_SELECT_ACCOUNT + } + } + + override fun getItemCount(): Int { + return accounts.count() + 1 } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt index 7725bad91..9f453b1ea 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,17 +16,12 @@ 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 @@ -37,10 +31,7 @@ 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.hideProgress -import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod -import com.lagradost.cloudstream3.utils.UIHelper.showProgress object AccountHelper { fun showAccountEditDialog( @@ -102,7 +93,6 @@ object AccountHelper { binding.accountImage.loadImage(account.image) binding.accountImage.setOnClickListener { // Roll the image forwards once - currentEditAccount = currentEditAccount.copy(customImage = null) currentEditAccount = currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size) binding.accountImage.loadImage(currentEditAccount.image) @@ -165,57 +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.isEmpty()) { - showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT) - return@setOnClickListener - } - applyBtt.showProgress() - 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.dismissSafe() - }, - onError = { _, _ -> - showToast( - R.string.edit_profile_image_error_invalid, - Toast.LENGTH_SHORT - ) - applyBtt.hideProgress() - }, - onCancel = { - applyBtt.hideProgress() - } - ) - .build() - imageLoader.enqueue(request) - } - sheetBinding.cancelBtt.setOnClickListener { - bottomSheetDialog.dismissSafe() - } - } - } } fun showPinInputDialog( @@ -278,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) } } } @@ -368,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() } @@ -398,6 +336,7 @@ object AccountHelper { activity.observe(viewModel.accounts) { liveAccounts -> recyclerView.adapter = AccountAdapter( + liveAccounts, accountSelectCallback = { account -> viewModel.handleAccountSelect(account, activity) builder.dismissSafe() @@ -405,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..012b0265c 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,19 @@ 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) + + @Suppress("DEPRECATION") + window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground) // Are we editing and coming from MainActivity? val isEditingFromMainActivity = intent.getBooleanExtra( @@ -54,24 +52,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 +75,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 +88,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 +107,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 +128,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { navigateToMainActivity() } }, - accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) } - ).apply { - submitList(liveAccounts) - } + accountDeleteCallback = { viewModel.handleAccountDelete(it,this) } + ) recyclerView.adapter = adapter @@ -156,13 +139,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 +160,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { } if (isEditingFromMainActivity) { - accountViewModel.setIsEditing(true) + viewModel.setIsEditing(true) } binding.editAccountButton.setOnClickListener { @@ -188,7 +171,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback { return@setOnClickListener } - accountViewModel.toggleIsEditing() + viewModel.toggleIsEditing() } if (isLayout(TV or EMULATOR)) { @@ -201,19 +184,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..a0e5cabc4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.ui.download -import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.ViewGroup @@ -8,18 +7,19 @@ import android.widget.CheckBox import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadHelper const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_DELETE_FILE = 1 @@ -27,7 +27,6 @@ const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2 const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 const val DOWNLOAD_ACTION_DOWNLOAD = 4 const val DOWNLOAD_ACTION_LONG_CLICK = 5 -const val DOWNLOAD_ACTION_CANCEL_PENDING = 6 const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_LOAD_RESULT = 1 @@ -35,22 +34,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1 sealed class VisualDownloadCached { abstract val currentBytes: Long abstract val totalBytes: Long - abstract val data: DownloadObjects.DownloadCached + abstract val data: VideoDownloadHelper.DownloadCached abstract var isSelected: Boolean data class Child( override val currentBytes: Long, override val totalBytes: Long, - override val data: DownloadObjects.DownloadEpisodeCached, + override val data: VideoDownloadHelper.DownloadEpisodeCached, override var isSelected: Boolean, ) : VisualDownloadCached() data class Header( override val currentBytes: Long, override val totalBytes: Long, - override val data: DownloadObjects.DownloadHeaderCached, + override val data: VideoDownloadHelper.DownloadHeaderCached, override var isSelected: Boolean, - val child: DownloadObjects.DownloadEpisodeCached?, + val child: VideoDownloadHelper.DownloadEpisodeCached?, val currentOngoingDownloads: Int, val totalDownloads: Int, ) : VisualDownloadCached() @@ -58,19 +57,19 @@ sealed class VisualDownloadCached { data class DownloadClickEvent( val action: Int, - val data: DownloadObjects.DownloadEpisodeCached + val data: VideoDownloadHelper.DownloadEpisodeCached ) data class DownloadHeaderClickEvent( val action: Int, - val data: DownloadObjects.DownloadHeaderCached + val data: VideoDownloadHelper.DownloadHeaderCached ) class DownloadAdapter( private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, private val onItemClickEvent: (DownloadClickEvent) -> Unit, private val onItemSelectionChanged: (Int, Boolean) -> Unit, -) : NoStateAdapter(DiffCallback()) { +) : ListAdapter(DiffCallback()) { private var isMultiDeleteState: Boolean = false @@ -79,224 +78,112 @@ class DownloadAdapter( private const val VIEW_TYPE_CHILD = 1 } + inner class DownloadViewHolder( + private val binding: ViewBinding + ) : RecyclerView.ViewHolder(binding.root) { - private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) { - if (binding !is DownloadHeaderEpisodeBinding || card == null) return + fun bind(card: VisualDownloadCached?) { + when (binding) { + is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header) + is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child) + } + } - val data = card.data - binding.apply { - episodeHolder.apply { - if (isMultiDeleteState) { - setOnClickListener { - toggleIsChecked(deleteCheckbox, data.id) + private fun bindHeader(card: VisualDownloadCached.Header?) { + if (binding !is DownloadHeaderEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + episodeHolder.apply { + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } } + setOnLongClickListener { toggleIsChecked(deleteCheckbox, data.id) true } - } else { - setOnLongClickListener { - onItemSelectionChanged.invoke(data.id, true) - true - } } - } - downloadHeaderPoster.apply { - loadImage(data.poster) - if (isMultiDeleteState) { - setOnClickListener { - toggleIsChecked(deleteCheckbox, data.id) - } - } else { - setOnClickListener { - onHeaderClickEvent.invoke( - DownloadHeaderClickEvent( - DOWNLOAD_ACTION_LOAD_RESULT, - data + downloadHeaderPoster.apply { + loadImage(data.poster) + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } else { + setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_LOAD_RESULT, + data + ) ) - ) + } + } + + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true } } + downloadHeaderTitle.text = data.name + val formattedSize = formatShortFileSize(itemView.context, card.totalBytes) - setOnLongClickListener { - toggleIsChecked(deleteCheckbox, data.id) - true - } - } - downloadHeaderTitle.text = data.name - val formattedSize = formatShortFileSize(binding.root.context, card.totalBytes) + if (card.child != null) { + handleChildDownload(card, formattedSize) + } else handleParentDownload(card, formattedSize) - if (card.child != null) { - handleChildDownload(card, formattedSize) - } else handleParentDownload(card, formattedSize) + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) - if (isMultiDeleteState) { - deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> - onItemSelectionChanged.invoke(data.id, isChecked) - } - } else deleteCheckbox.setOnCheckedChangeListener(null) - - deleteCheckbox.apply { - isVisible = isMultiDeleteState - isChecked = card.isSelected - } - } - } - - private fun DownloadHeaderEpisodeBinding.handleChildDownload( - card: VisualDownloadCached.Header, - formattedSize: String - ) { - card.child ?: return - downloadHeaderGotoChild.isVisible = false - - val posDur = getViewPos(card.data.id) - watchProgressContainer.isVisible = true - downloadHeaderEpisodeProgress.apply { - isVisible = posDur != null - posDur?.let { - val max = (it.duration / 1000).toInt() - val progress = (it.position / 1000).toInt() - - if (max > 0 && progress >= (0.95 * max).toInt()) { - playIcon.setImageResource(R.drawable.ic_baseline_check_24) - isVisible = false - } else { - playIcon.setImageResource(R.drawable.netflix_play) - this.max = max - this.progress = progress - isVisible = true + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected } } } - downloadButton.resetView() - val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) - if (status == DownloadStatusTell.IsDone) { - // We do this here instead if we are finished downloading - // so that we can use the value from the view model - // rather than extra unneeded disk operations and to prevent a - // delay in updating download icon state. - downloadButton.setProgress(card.currentBytes, card.totalBytes) - downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) - // We will let the view model handle this - downloadButton.doSetProgress = false - downloadButton.progressBar.progressDrawable = - downloadButton.getDrawableFromStatus(status) - ?.let { ContextCompat.getDrawable(downloadButton.context, it) } - downloadHeaderInfo.text = formattedSize - } else { - // We need to make sure we restore the correct progress - // when we refresh data in the adapter. - val drawable = downloadButton.getDrawableFromStatus(status)?.let { - ContextCompat.getDrawable(downloadButton.context, it) - } - downloadButton.statusView.setImageDrawable(drawable) - downloadButton.progressBar.progressDrawable = - ContextCompat.getDrawable( - downloadButton.context, - downloadButton.progressDrawable - ) - } + private fun DownloadHeaderEpisodeBinding.handleChildDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + card.child ?: return + downloadHeaderGotoChild.isVisible = false - downloadHeaderInfo.isVisible = true - downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) - downloadButton.isVisible = !isMultiDeleteState - - if (!isMultiDeleteState) { - episodeHolder.setOnClickListener { - onItemClickEvent.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - card.child - ) - ) - } - } - } - - private fun DownloadHeaderEpisodeBinding.handleParentDownload( - card: VisualDownloadCached.Header, - formattedSize: String - ) { - downloadButton.resetViewData() - watchProgressContainer.isVisible = false - downloadButton.isVisible = false - downloadHeaderEpisodeProgress.isVisible = false - downloadHeaderGotoChild.isVisible = !isMultiDeleteState - - try { - downloadHeaderInfo.isVisible = true - downloadHeaderInfo.text = - downloadHeaderInfo.context.getString(R.string.extra_info_format).format( - card.totalDownloads, - downloadHeaderInfo.context.resources.getQuantityString( - R.plurals.episodes, - card.totalDownloads - ), - formattedSize - ) - } catch (e: Exception) { - downloadHeaderInfo.text = null - logError(e) - } - - if (!isMultiDeleteState) { - episodeHolder.setOnClickListener { - onHeaderClickEvent.invoke( - DownloadHeaderClickEvent( - DOWNLOAD_ACTION_GO_TO_CHILD, - card.data - ) - ) - } - } - } - - private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) { - if (binding !is DownloadChildEpisodeBinding || card == null) return - - val data = card.data - binding.apply { - val posDur = getViewPos(data.id) - downloadChildEpisodeProgress.apply { + val posDur = getViewPos(card.data.id) + downloadHeaderEpisodeProgress.apply { isVisible = posDur != null posDur?.let { - val max = (it.duration / 1000).toInt() - val progress = (it.position / 1000).toInt() - - if (max > 0 && progress >= (0.95 * max).toInt()) { - downloadChildEpisodePlay.setImageResource(R.drawable.ic_baseline_check_24) - isVisible = false - } else { - downloadChildEpisodePlay.setImageResource(R.drawable.play_button_transparent) - this.max = max - this.progress = progress - isVisible = true - } + val visualPos = it.fixVisual() + max = (visualPos.duration / 1000).toInt() + progress = (visualPos.position / 1000).toInt() } } - downloadButton.resetView() - val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) + val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading // so that we can use the value from the view model // rather than extra unneeded disk operations and to prevent a // delay in updating download icon state. downloadButton.setProgress(card.currentBytes, card.totalBytes) - downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) // We will let the view model handle this downloadButton.doSetProgress = false downloadButton.progressBar.progressDrawable = downloadButton.getDrawableFromStatus(status) ?.let { ContextCompat.getDrawable(downloadButton.context, it) } - downloadChildEpisodeTextExtra.text = - formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) + downloadHeaderInfo.text = formattedSize } else { // We need to make sure we restore the correct progress // when we refresh data in the adapter. + downloadButton.resetView() val drawable = downloadButton.getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(downloadButton.context, it) } @@ -308,105 +195,199 @@ class DownloadAdapter( ) } - downloadButton.setDefaultClickListener( - data, - downloadChildEpisodeTextExtra, - onItemClickEvent - ) + downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) downloadButton.isVisible = !isMultiDeleteState - downloadChildEpisodeText.apply { - text = context.getNameFull(data.name, data.episode, data.season) - isSelected = true // Needed for text repeating + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + card.child + ) + ) + } + } + } + + private fun DownloadHeaderEpisodeBinding.handleParentDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + downloadButton.isVisible = false + downloadHeaderEpisodeProgress.isVisible = false + downloadHeaderGotoChild.isVisible = !isMultiDeleteState + + try { + downloadHeaderInfo.text = + downloadHeaderInfo.context.getString(R.string.extra_info_format).format( + card.totalDownloads, + downloadHeaderInfo.context.resources.getQuantityString( + R.plurals.episodes, + card.totalDownloads + ), + formattedSize + ) + } catch (e: Exception) { + downloadHeaderInfo.text = null + logError(e) } - downloadChildEpisodeHolder.setOnClickListener { - onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_GO_TO_CHILD, + card.data + ) + ) + } } + } - downloadChildEpisodeHolder.apply { - when { - isMultiDeleteState -> { - setOnClickListener { - toggleIsChecked(deleteCheckbox, data.id) - } - setOnLongClickListener { - toggleIsChecked(deleteCheckbox, data.id) - true - } + private fun bindChild(card: VisualDownloadCached.Child?) { + if (binding !is DownloadChildEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + val posDur = getViewPos(data.id) + downloadChildEpisodeProgress.apply { + isVisible = posDur != null + posDur?.let { + val visualPos = it.fixVisual() + max = (visualPos.duration / 1000).toInt() + progress = (visualPos.position / 1000).toInt() } + } - else -> { - setOnClickListener { - onItemClickEvent.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - data + val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) + // We will let the view model handle this + downloadButton.doSetProgress = false + downloadButton.progressBar.progressDrawable = + downloadButton.getDrawableFromStatus(status) + ?.let { ContextCompat.getDrawable(downloadButton.context, it) } + downloadChildEpisodeTextExtra.text = + formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) + } else { + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. + downloadButton.resetView() + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } + + downloadButton.setDefaultClickListener( + data, + downloadChildEpisodeTextExtra, + onItemClickEvent + ) + downloadButton.isVisible = !isMultiDeleteState + + downloadChildEpisodeText.apply { + text = context.getNameFull(data.name, data.episode, data.season) + isSelected = true // Needed for text repeating + } + + downloadChildEpisodeHolder.setOnClickListener { + onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) + } + + downloadChildEpisodeHolder.apply { + when { + isMultiDeleteState -> { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } + + else -> { + setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + data + ) ) - ) - } - - setOnLongClickListener { - onItemSelectionChanged.invoke(data.id, true) - true + } } } - } - } - if (isMultiDeleteState) { - deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> - onItemSelectionChanged.invoke(data.id, isChecked) + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } } - } else deleteCheckbox.setOnCheckedChangeListener(null) - deleteCheckbox.apply { - isVisible = isMultiDeleteState - isChecked = card.isSelected + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected + } } } } - override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder { val inflater = LayoutInflater.from(parent.context) val binding = when (viewType) { VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false) VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) else -> throw IllegalArgumentException("Invalid view type") } - return ViewHolderState(binding) + return DownloadViewHolder(binding) } - override fun onBindContent( - holder: ViewHolderState, - item: VisualDownloadCached, - position: Int - ) { - when (val binding = holder.view) { - is DownloadHeaderEpisodeBinding -> bindHeader( - binding, - item as? VisualDownloadCached.Header - ) - - is DownloadChildEpisodeBinding -> bindChild( - binding, - item as? VisualDownloadCached.Child - ) - } + override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) { + holder.bind(getItem(position)) } - override fun customContentViewType(item: VisualDownloadCached): Int { - return when (item) { + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { is VisualDownloadCached.Child -> VIEW_TYPE_CHILD is VisualDownloadCached.Header -> VIEW_TYPE_HEADER + else -> throw IllegalArgumentException("Invalid data type at position $position") } } - @SuppressLint("NotifyDataSetChanged") fun setIsMultiDeleteState(value: Boolean) { if (isMultiDeleteState == value) return isMultiDeleteState = value - notifyDataSetChanged() // This is shit, but what can you do? + notifyItemRangeChanged(0, itemCount) + } + + fun notifyAllSelected() { + currentList.indices.forEach { index -> + if (!currentList[index].isSelected) { + notifyItemChanged(index) + } + } + } + + fun notifySelectionStates() { + currentList.indices.forEach { index -> + if (currentList[index].isSelected) { + notifyItemChanged(index) + } + } } private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt index dae70ebd7..83e0d0167 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -4,8 +4,8 @@ import android.content.DialogInterface import android.net.Uri import androidx.appcompat.app.AlertDialog import com.google.android.material.snackbar.Snackbar -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -18,9 +18,8 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.VideoDownloadManager import kotlinx.coroutines.MainScope object DownloadButtonSetup { @@ -83,7 +82,7 @@ object DownloadButtonSetup { } else { val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) if (pkg != null) { - DownloadQueueManager.addToQueue(pkg.toWrapper()) + VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg) } else { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) @@ -96,7 +95,7 @@ object DownloadButtonSetup { DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = - VideoDownloadManager.getDownloadFileInfo( + VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( act, click.data.id )?.fileLength @@ -111,31 +110,24 @@ object DownloadButtonSetup { } } - DOWNLOAD_ACTION_CANCEL_PENDING -> { - DownloadQueueManager.cancelDownload(id) - } - DOWNLOAD_ACTION_PLAY_FILE -> { activity?.let { act -> - val parent = getKey( + val parent = getKey( DOWNLOAD_HEADER_CACHE, click.data.parentId.toString() ) ?: return val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) ?.mapNotNull { - getKey(it) + getKey(it) } ?.filter { it.parentId == click.data.parentId } val items = mutableListOf() - val allRelevantEpisodes = - episodes?.sortedWith(compareBy { - it.season ?: 0 - }.thenBy { it.episode }) + val allRelevantEpisodes = episodes?.sortedWith(compareBy { it.season ?: 0 }.thenBy { it.episode }) allRelevantEpisodes?.forEach { - val keyInfo = getKey( + val keyInfo = getKey( VideoDownloadManager.KEY_DOWNLOAD_INFO, it.id.toString() ) ?: return@forEach @@ -149,7 +141,7 @@ object DownloadButtonSetup { uri = Uri.EMPTY, id = it.id, parentId = it.parentId, - name = it.name ?: act.getString(R.string.downloaded_file), + name = act.getString(R.string.downloaded_file), season = it.season, episode = it.episode, headerName = parent.name, @@ -162,8 +154,7 @@ object DownloadButtonSetup { } act.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator(items), - items.indexOfFirst { it.id == click.data.id } + DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) } ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt index d44ea0020..1f5b9e337 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt @@ -1,35 +1,32 @@ package com.lagradost.cloudstream3.ui.download import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.text.format.Formatter.formatShortFileSize +import android.view.LayoutInflater import android.view.View -import androidx.core.view.isGone +import android.view.ViewGroup import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding -import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV -class DownloadChildFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate) -) { - - private val downloadViewModel: DownloadViewModel by activityViewModels() +class DownloadChildFragment : Fragment() { + private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentChildDownloadsBinding? = null companion object { fun newInstance(headerName: String, folder: String): Bundle { @@ -42,104 +39,99 @@ class DownloadChildFragment : BaseFragment( override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") - downloadViewModel.clearChildren() + binding = null super.onDestroyView() } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] + val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } - override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + /** + * We never want to retain multi-delete state + * when navigating to downloads. Setting this state + * immediately can sometimes result in the observer + * not being notified in time to update the UI. + * + * By posting to the main looper, we ensure that this + * operation is executed after the view has been fully created + * and all initializations are completed, allowing the + * observer to properly receive and handle the state change. + */ + Handler(Looper.getMainLooper()).post { + downloadsViewModel.setIsMultiDeleteState(false) + } + + /** + * We have to make sure selected items are + * cleared here as well so we don't run in an + * inconsistent state where selected items do + * not match the multi delete state we are in. + */ + downloadsViewModel.clearSelectedItems() + val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() return } - context?.let { downloadViewModel.updateChildList(it, folder) } - - binding.downloadChildToolbar.apply { + binding?.downloadChildToolbar?.apply { title = name if (isLayout(PHONE or EMULATOR)) { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationOnClickListener { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() } } setAppBarNoScrollFlagsOnTV() } - binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() + binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() - observe(downloadViewModel.childCards) { cards -> - when (cards) { - is Resource.Success -> { - if (cards.value.isEmpty()) { - dispatchBackPressed() - } - (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) - } - - else -> { - (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) - } + observe(downloadsViewModel.childCards) { + if (it.isEmpty()) { + activity?.onBackPressedDispatcher?.onBackPressed() + return@observe } + + (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it) } - - observe(downloadViewModel.selectedBytes) { - updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) - } - - - binding.apply { - btnDelete.setOnClickListener { view -> - downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) - } - - btnCancel.setOnClickListener { - downloadViewModel.cancelSelection() - } - - btnToggleAll.setOnClickListener { - val allSelected = downloadViewModel.isAllChildrenSelected() - if (allSelected) { - downloadViewModel.clearSelectedItems() - } else { - downloadViewModel.selectAllChildren() - } - } - } - - observeNullable(downloadViewModel.selectedItemIds) { selection -> - val isMultiDeleteState = selection != null - val adapter = binding.downloadChildList.adapter as? DownloadAdapter + observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter adapter?.setIsMultiDeleteState(isMultiDeleteState) - binding.downloadDeleteAppbar.isVisible = isMultiDeleteState - binding.downloadChildToolbar.isGone = isMultiDeleteState - - if (selection == null) { + binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState + if (!isMultiDeleteState) { activity?.detachBackPressedCallback("Downloads") - return@observeNullable - } - activity?.attachBackPressedCallback("Downloads") { - downloadViewModel.cancelSelection() + downloadsViewModel.clearSelectedItems() + binding?.downloadChildToolbar?.isVisible = true } + } + observe(downloadsViewModel.selectedBytes) { + updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) + } + observe(downloadsViewModel.selectedItemIds) { + handleSelectedChange(it) + updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) - updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) + binding?.btnDelete?.isVisible = it.isNotEmpty() + binding?.selectItemsText?.isVisible = it.isEmpty() - binding.btnDelete.isVisible = selection.isNotEmpty() - binding.selectItemsText.isVisible = selection.isEmpty() - - val allSelected = downloadViewModel.isAllChildrenSelected() + val allSelected = downloadsViewModel.isAllSelected() if (allSelected) { - binding.btnToggleAll.setText(R.string.deselect_all) - } else binding.btnToggleAll.setText(R.string.select_all) + binding?.btnToggleAll?.setText(R.string.deselect_all) + } else binding?.btnToggleAll?.setText(R.string.select_all) } val adapter = DownloadAdapter( @@ -147,18 +139,18 @@ class DownloadChildFragment : BaseFragment( { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> - downloadViewModel.handleSingleDelete(ctx, click.data.id) + downloadsViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { - downloadViewModel.addSelected(itemId) - } else downloadViewModel.removeSelected(itemId) + downloadsViewModel.addSelected(itemId) + } else downloadsViewModel.removeSelected(itemId) } ) - binding.downloadChildList.apply { + binding?.downloadChildList?.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter @@ -168,6 +160,43 @@ class DownloadChildFragment : BaseFragment( nextDown = FOCUS_SELF, ) } + + context?.let { downloadsViewModel.updateChildList(it, folder) } + fixPaddingStatusbar(binding?.downloadChildRoot) + } + + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadChildToolbar?.isVisible = false + activity?.attachBackPressedCallback("Downloads") { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnDelete?.setOnClickListener { + context?.let { ctx -> + downloadsViewModel.handleMultiDelete(ctx) + } + } + + binding?.btnCancel?.setOnClickListener { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnToggleAll?.setOnClickListener { + val allSelected = downloadsViewModel.isAllSelected() + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter + if (allSelected) { + adapter?.notifySelectionStates() + downloadsViewModel.clearSelectedItems() + } else { + adapter?.notifyAllSelected() + downloadsViewModel.selectAllItems() + } + } + + downloadsViewModel.setIsMultiDeleteState(true) + } } private fun updateDeleteButton(count: Int, selectedBytes: Long) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index abc432ef9..d8f666da2 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( @@ -72,135 +69,117 @@ class DownloadFragment : BaseFragment( override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") + binding = null super.onDestroyView() } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] + val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root } - override fun onBindingCreated(binding: FragmentDownloadsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) hideKeyboard() - binding.downloadAppbar.setAppBarNoScrollFlagsOnTV() - binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() + binding?.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("Downloads") + downloadsViewModel.clearSelectedItems() + // Prevent race condition and make sure + // we don't display it early + if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) { + binding?.downloadStorageAppbar?.isVisible = true } } } + observe(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) { @@ -279,6 +249,40 @@ class DownloadFragment : BaseFragment( } } + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadStorageAppbar?.isVisible = false + activity?.attachBackPressedCallback("Downloads") { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnDelete?.setOnClickListener { + context?.let { ctx -> + downloadsViewModel.handleMultiDelete(ctx) + } + } + + binding?.btnCancel?.setOnClickListener { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnToggleAll?.setOnClickListener { + val allSelected = downloadsViewModel.isAllSelected() + val adapter = binding?.downloadList?.adapter as? DownloadAdapter + if (allSelected) { + adapter?.notifySelectionStates() + downloadsViewModel.clearSelectedItems() + } else { + adapter?.notifyAllSelected() + downloadsViewModel.selectAllItems() + } + } + + downloadsViewModel.setIsMultiDeleteState(true) + } + } + private fun updateDeleteButton(count: Int, selectedBytes: Long) { val formattedSize = formatShortFileSize(context, selectedBytes) binding?.btnDelete?.text = @@ -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 19c006b28..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,132 +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.CloudStreamApp 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.downloadDeleteEvent -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 - init { - // Keep the Downloads list in sync when a download is deleted/cancelled from - // anywhere in the app (result page button, queue, notification, etc.). See - // onDownloadDeleted for the rationale (issue #1227). - downloadDeleteEvent += ::onDownloadDeleted - } + private val _selectedItemIds = MutableLiveData>(mutableSetOf()) + val selectedItemIds: LiveData> = _selectedItemIds - override fun onCleared() { - downloadDeleteEvent -= ::onDownloadDeleted - super.onCleared() - } + private var previousVisual: List? = null - fun cancelSelection() { - updateSelectedItems { 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 { @@ -139,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 @@ -315,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 @@ -334,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, @@ -370,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, @@ -385,33 +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 }) - } - - /** - * Refreshes the Downloads screen in real time when a download is deleted/cancelled. - */ - private fun onDownloadDeleted(id: Int) { - // Keep multi-select state consistent: forget the removed id if it was selected. - updateSelectedItems { it?.minus(id) } - - val context = CloudStreamApp.context ?: return - updateHeaderList(context) - postChildren(_childCards.success?.filterNot { it.data.id == id }) + 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) { @@ -465,7 +292,7 @@ class DownloadViewModel : ViewModel() { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { - context.getKey( + context.getKey( it ) } @@ -489,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() ) @@ -518,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) @@ -556,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 @@ -586,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 } @@ -595,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( @@ -609,4 +436,4 @@ class DownloadViewModel : ViewModel() { val names: List, val parentName: String? ) -} +} \ No newline at end of file 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 450dc9a61..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() } } } } @@ -164,11 +163,9 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : } } - fun downloadDeleteEvent(data: Int) { - if (data == persistentId) { - resetView() - } - } + /*fun downloadDeleteEvent(data: Int) { + + }*/ /*fun downloadEvent(data: Pair) { val (id, action) = data @@ -187,7 +184,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : override fun onAttachedToWindow() { VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent - VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent + // VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent // VideoDownloadManager.downloadEvent += ::downloadEvent VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent @@ -202,7 +199,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : override fun onDetachedFromWindow() { VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent - VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent + // VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent // VideoDownloadManager.downloadEvent -= ::downloadEvent VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent @@ -218,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 0e52baff6..e8daa4eb4 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,38 +5,26 @@ import android.app.Activity import android.content.Context import android.content.DialogInterface import android.content.Intent +import android.content.res.Configuration +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AbsListView -import android.widget.ArrayAdapter -import android.widget.ImageView -import android.widget.ListView -import android.widget.TextView -import android.widget.Toast -import androidx.activity.ComponentActivity +import android.widget.* import androidx.appcompat.app.AlertDialog -import androidx.core.net.toUri import androidx.core.view.isGone -import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity 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,54 +33,38 @@ 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.plugins.Plugin import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear -import com.lagradost.cloudstream3.ui.account.AccountViewModel -import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD -import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE -import com.lagradost.cloudstream3.ui.search.SearchAdapter +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.search.* import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings -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 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, @@ -121,7 +93,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, @@ -182,7 +153,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 @@ -203,17 +174,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 } @@ -237,7 +207,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) } } } @@ -245,12 +215,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 @@ -274,20 +241,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)), ) @@ -301,7 +266,6 @@ class HomeFragment : BaseFragment( header.homeSelectMovies, header.homeSelectAsian, header.homeSelectLivestreams, - header.homeSelectTorrents, header.homeSelectNsfw, header.homeSelectOthers ) @@ -320,7 +284,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) } } } @@ -410,50 +374,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 settingsIcon = view.findViewById(R.id.action_settings) - - val name = getItem(position) - titleText?.text = name - val providerApi = currentValidApis[position] - val isPinned = - pinnedphashset.contains(providerApi.name) - pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE - - val pluginInstance = providerApi.sourcePlugin?.let { PluginManager.plugins[it] } as? Plugin - val isDownloadedPluginWithSettings = pluginInstance?.openSettings != null && !isLayout(TV) - - settingsIcon.visibility = if (isDownloadedPluginWithSettings) View.VISIBLE else View.GONE - if (isDownloadedPluginWithSettings) { - settingsIcon.setOnClickListener { - try { - val activityContext = it.context.getActivity() ?: it.context - pluginInstance.openSettings?.invoke(activityContext) - } catch (e: Throwable) { - logError(e) - } - } - } - - return view - } - } + val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) listView?.adapter = arrayAdapter listView?.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -461,46 +383,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 { - val isPinned = pinnedphashset.contains(it.name) - - // Hide pinned NSFW when NSFW not selected. NSFW is distracting when not chosen. - if (isPinned && !preSelectedTypes.contains(TvType.NSFW)) { - if (it.supportedTypes.all { type -> type == TvType.NSFW }) return@filter false - } - - it.hasMainPage && (isPinned || 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 } @@ -509,21 +406,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, @@ -540,71 +422,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) @@ -618,156 +476,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) - } - - homePreviewSearchButton.setOnClickListener { _ -> - // Open blank screen. - homeViewModel.queryTextSubmit("") - } - - // Load value for toggling Tv layout real time clock. Hide by default at startup - // set visibility first, to apply a scroll effect later - context?.let { - if (isLayout(TV)) { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) - val toggleClock = - settingsManager.getBoolean( - getString(R.string.tv_layout_clock_key), - false - ) - binding.homeClock.isVisible = toggleClock - } else { - binding.homeClock.isVisible = false - } - } - - 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 - - // Move the TV layout real time clock out of the way too - // We check if we have the correct layout and if the clock is enabled - if(isLayout(TV) && binding.homeClock.isVisible) { - val scrollParent = binding.homeClock - - 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) @@ -776,6 +537,7 @@ class HomeFragment : BaseFragment( } + //Load value for toggling Random button. Hide at startup context?.let { val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) @@ -783,61 +545,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 } @@ -850,7 +603,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) @@ -860,50 +613,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() @@ -936,7 +665,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, @@ -948,44 +677,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 1020205ac..4c4dd2d84 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt @@ -1,27 +1,23 @@ package com.lagradost.cloudstream3.ui.home +import android.content.res.Configuration import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.core.view.isGone +import androidx.fragment.app.Fragment import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class HomeScrollAdapter( - val callback: ((View, Int, LoadResponse) -> Unit) -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.uniqueUrl == b.uniqueUrl && a.name == b.name -})) { + fragment: Fragment +) : NoStateAdapter(fragment) { var hasMoreItems: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { @@ -35,52 +31,34 @@ 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, item.posterHeaders) + binding.homeScrollPreview.loadImage(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, item.posterHeaders) + binding.homeScrollPreview.loadImage(posterUrl) } } } -} +} \ No newline at end of file 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..07f34ab60 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" ) ) @@ -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 e73a8c75a..bfac72067 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -7,59 +7,65 @@ 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 -import com.fasterxml.jackson.annotation.JsonProperty import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.allProviders -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.utils.txt -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs const val LIBRARY_FOLDER = "library_folder" + enum class LibraryOpenerType(@StringRes val stringRes: Int) { Default(R.string.action_default), Provider(R.string.none), @@ -69,21 +75,19 @@ enum class LibraryOpenerType(@StringRes val stringRes: Int) { } /** Used to store how the user wants to open said poster */ -@Serializable data class LibraryOpener( - @JsonProperty("openType") @SerialName("openType") val openType: LibraryOpenerType, - @JsonProperty("providerData") @SerialName("providerData") val providerData: ProviderLibraryData?, + val openType: LibraryOpenerType, + val providerData: ProviderLibraryData?, ) -@Serializable data class ProviderLibraryData( - @JsonProperty("apiName") @SerialName("apiName") val apiName: String, + val apiName: String ) -class LibraryFragment : BaseFragment( - BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) -) { +class LibraryFragment : Fragment() { companion object { + + val listLibraryItems = mutableListOf() fun newInstance() = LibraryFragment() /** @@ -94,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 -> @@ -106,52 +135,48 @@ class LibraryFragment : BaseFragment( super.onSaveInstanceState(outState) } - private fun updateRandomVisibility(binding: FragmentLibraryBinding) { - if (!toggleRandomButton) { - binding.libraryRandom.isGone = true - binding.libraryRandomButtonTv.isGone = true - return - } + private fun updateRandom() { val position = libraryViewModel.currentPage.value ?: 0 val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return - val hasItems = pages[position].items.isNotEmpty() - val isPhone = isLayout(PHONE) - - binding.libraryRandom.isVisible = isPhone && hasItems - binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems - } - - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = !isLayout(PHONE) - ) + if (toggleRandomButton) { + listLibraryItems.clear() + listLibraryItems.addAll(pages[position].items) + binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty() + } else { + binding?.libraryRandom?.isGone = true + } } @SuppressLint("ResourceType", "CutPasteId") - override fun onBindingCreated( - binding: FragmentLibraryBinding, - savedInstanceState: Bundle? - ) { - binding.sortFab.setOnClickListener(sortChangeClickListener) - binding.librarySort.setOnClickListener(sortChangeClickListener) + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + fixPaddingStatusbar(binding?.searchStatusBarPadding) - binding.libraryRoot.findViewById(androidx.appcompat.R.id.search_src_text) - ?.apply { - tag = "tv_no_focus_tag" - // Expand the Appbar when search bar is focused, fixing scroll up issue - setOnFocusChangeListener { _, _ -> - binding.searchBar.setExpanded(true) - } + binding?.sortFab?.setOnClickListener(sortChangeClickListener) + binding?.librarySort?.setOnClickListener(sortChangeClickListener) + + binding?.libraryRoot?.findViewById(androidx.appcompat.R.id.search_src_text)?.apply { + tag = "tv_no_focus_tag" + //Expand the Appbar when search bar is focused, fixing scroll up issue + setOnFocusChangeListener { _, _ -> + binding?.searchBar?.setExpanded(true) } + } + + // Set the color for the search exit icon to the correct theme text color + val searchExitIcon = + binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + val searchExitIconColor = TypedValue() + + activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true) + searchExitIcon?.setColorFilter(searchExitIconColor.data) val searchCallback = Runnable { - val newText = binding.mainSearch.query.toString() + val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable libraryViewModel.sort(ListSorting.Query, newText) } - binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { libraryViewModel.sort(ListSorting.Query, query) return true @@ -167,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 } @@ -179,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, @@ -201,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) + } + } } /** @@ -214,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, @@ -272,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 @@ -319,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 @@ -337,7 +371,7 @@ class LibraryFragment : BaseFragment( } val stopLoading = Runnable { - binding.apply { + binding?.apply { gridview.adapter = null libraryLoadingOverlay.isVisible = false libraryLoadingShimmer.stopShimmer() @@ -353,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) { @@ -381,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: @@ -438,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) } } @@ -476,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 @@ -490,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( @@ -548,10 +578,10 @@ class LibraryFragment : BaseFragment( } + @SuppressLint("NotifyDataSetChanged") override fun onConfigurationChanged(newConfig: Configuration) { + binding?.viewpager?.adapter?.notifyDataSetChanged() super.onConfigurationChanged(newConfig) - val adapter = binding?.viewpager?.adapter ?: return - adapter.notifyItemRangeChanged(0, adapter.itemCount) } private val sortChangeClickListener = View.OnClickListener { view -> @@ -559,8 +589,7 @@ class LibraryFragment : BaseFragment( txt(it.stringRes).asString(view.context) } - activity?.showBottomDialog( - methods, + activity?.showBottomDialog(methods, libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), txt(R.string.sort_by).asString(view.context), false, @@ -572,4 +601,4 @@ class LibraryFragment : BaseFragment( } } -class MenuSearchView(context: Context) : SearchView(context) +class MenuSearchView(context: Context) : SearchView(context) \ No newline at end of file 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..8f08970fe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -1,16 +1,61 @@ package com.lagradost.cloudstream3.ui.player +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimatedVectorDrawable +import android.media.metrics.PlaybackErrorEvent +import android.os.Build import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout import android.widget.ImageView -import androidx.annotation.OptIn +import android.widget.ProgressBar +import android.widget.Toast +import androidx.annotation.LayoutRes import androidx.annotation.StringRes -import androidx.media3.common.util.UnstableApi +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.media3.common.PlaybackException +import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView -import androidx.viewbinding.ViewBinding +import androidx.media3.ui.TimeBar +import androidx.preference.PreferenceManager +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.github.rubensousa.previewseekbar.PreviewBar +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import com.lagradost.cloudstream3.CommonActivity.canEnterPipMode +import com.lagradost.cloudstream3.CommonActivity.isInPIPMode +import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.playerEventListener +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.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 +import java.net.SocketTimeoutException enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -30,132 +75,643 @@ 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) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) + } else activity?.registerReceiver(pipReceiver, filter) + val isPlaying = player.getIsPlaying() + val isPlayingValue = + if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + updateIsPlaying(isPlayingValue, isPlayingValue) + } else { + // Restore the full-screen UI. + piphide?.isVisible = true + exitedPipMode() + pipReceiver?.let { + // Prevents java.lang.IllegalArgumentException: Receiver not registered + 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_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> { + showToast( + "${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", + gotoNext = true + ) + } + + PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, + PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> { + showToast( + "${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg", + gotoNext = true + ) + } + + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> { + showToast( + "${ctx.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg", + gotoNext = true + ) + } + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> { + showToast( + "${ctx.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg", + gotoNext = true + ) + } + + else -> { + showToast( + "${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", + gotoNext = false + ) + } + } + } + + is InvalidFileException -> { + showToast( + "${ctx.getString(R.string.source_error)}\n${exception.message}", + gotoNext = true + ) + } + + is SocketTimeoutException -> { + /** + * Ensures this is run on the UI thread to prevent issues + * caused by SocketTimeoutException in torrents. Running + * on another thread can break player interactions or + * prevent switching to the next source. + */ + activity?.runOnUiThread { + showToast( + "${ctx.getString(R.string.remote_error)}\n${exception.message}", + gotoNext = true + ) + } + } + + else -> { + exception.message?.let { + showToast( + it, + gotoNext = false + ) + } + } + } + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + if (player is CS3IPlayer) { + player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed + player.seekTime(-1) + } + } + + @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 + @Suppress("DEPRECATION") + 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) + // } + //} + + open fun onDownload(event : DownloadEvent) = Unit + + /** This receives the events from the player, if you want to append functionality you do it here, + * do note that this only receives events for UI changes, + * and returning early WONT stop it from changing in eg the player time or pause status */ + open fun mainCallback(event: PlayerEvent) { + // we don't want to spam DownloadEvent + if(event !is DownloadEvent) { + Log.i(TAG, "Handle event: $event") + } + when (event) { + is DownloadEvent -> { + onDownload(event) + } + is ResizedEvent -> { + playerDimensionsLoaded(event.width, event.height) + } + + is PlayerAttachedEvent -> { + playerUpdated(event.player) + } + + is SubtitlesUpdatedEvent -> { + subtitlesChanged() + } + + is TimestampSkippedEvent -> { + onTimestampSkipped(event.timestamp) + } + + is TimestampInvokedEvent -> { + onTimestamp(event.timestamp) + } + + is TracksChangedEvent -> { + onTracksInfoChanged() + } + + is EmbeddedSubtitlesFetchedEvent -> { + embeddedSubtitlesFetched(event.tracks) + } + + is ErrorEvent -> { + playerError(event.error) + } + + is RequestAudioFocusEvent -> { + requestAudioFocus() + } + + is EpisodeSeekEvent -> { + when (event.offset) { + -1 -> prevEpisode() + 1 -> nextEpisode() + else -> {} + } + } + + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + playerStatusChanged() + } + + is PositionEvent -> { + playerPositionChanged(position = event.toMs, duration = event.durationMs) + } + + is VideoEndedEvent -> { + context?.let { ctx -> + // Resets subtitle delay on ended video + player.setSubtitleOffset(0) + + // Only play next episode if autoplay is on (default) + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean( + ctx.getString(R.string.autoplay_next_key), + true + ) == true + ) { + player.handleEvent( + CSPlayerEvent.NextEpisode, + source = PlayerEventSource.Player + ) + } + } + } + + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } + + @SuppressLint("SetTextI18n", "UnsafeOptInUsageError") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + resizeMode = DataStoreHelper.resizeMode + resize(resizeMode, false) + + player.releaseCallbacks() + player.initCallbacks( + eventHandler = ::mainCallback, + requestedListeningPercentages = listOf( + SKIP_OP_VIDEO_PERCENTAGE, + PRELOAD_NEXT_EPISODE_PERCENTAGE, + NEXT_WATCH_EPISODE_PERCENTAGE, + UPDATE_SYNC_PROGRESS_PERCENTAGE, + ), + ) + + 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(androidx.media3.ui.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 d316f28cd..d8921e068 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -12,11 +12,9 @@ import android.os.Looper import android.util.Log import android.util.Rational import android.widget.FrameLayout -import androidx.annotation.AnyThread import androidx.annotation.MainThread import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog -import androidx.core.net.toUri import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_TEXT @@ -30,7 +28,6 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.VideoSize -// import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource @@ -40,43 +37,32 @@ 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.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.R @@ -85,40 +71,31 @@ 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.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus -import com.lagradost.cloudstream3.utils.CLEARKEY_DRM_UUID import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.CLEARKEY_UUID +import com.lagradost.cloudstream3.utils.WIDEVINE_UUID +import com.lagradost.cloudstream3.utils.PLAYREADY_UUID import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName -import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID -import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import kotlinx.coroutines.delay -import okhttp3.Interceptor -import org.chromium.net.CronetEngine import java.io.File -import java.security.SecureRandom import java.util.UUID -import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession -import kotlin.uuid.toJavaUuid const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" @@ -135,7 +112,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 +129,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 +144,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 @@ -209,42 +178,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 +224,6 @@ class CS3IPlayer : IPlayer { } override fun hasPreview(): Boolean { - // No previews on livestreams because the previews get outdated - if (exoPlayer?.isCurrentMediaItemDynamic == true) { - return false - } return imageGenerator.hasPreview() } @@ -378,47 +337,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 +385,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 +398,7 @@ class CS3IPlayer : IPlayer { this.id?.stripTrackId(), this.label, this.language, - this.sampleMimeType, + this.sampleMimeType ) } @@ -456,35 +409,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 +443,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 +505,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) } } } @@ -594,9 +556,6 @@ class CS3IPlayer : IPlayer { if (saveTime) updatedTime() - currentTextRenderer = null - currentSubtitleDecoder = null - exoPlayer?.apply { playWhenReady = false @@ -608,17 +567,13 @@ class CS3IPlayer : IPlayer { // 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 +581,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,68 +608,12 @@ 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. **/ var preferredAudioTrackLanguage: String? = null get() { - return field ?: getKey( + return field ?: getKey( "$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", field )?.also { @@ -733,97 +627,44 @@ 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 { + private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { + val provider = getApiFromNameNull(link.source) + val interceptor = provider?.getVideoInterceptor(link) val userAgent = link.headers.entries.find { it.key.equals("User-Agent", ignoreCase = true) - }?.value ?: USER_AGENT + }?.value val source = if (interceptor == null) { - if (engine == null) { - Log.d(TAG, "Using DefaultHttpDataSource for $link") - OkHttpDataSource.Factory(app.baseClient).setUserAgent(userAgent) - } else { - Log.d(TAG, "Using CronetDataSource for $link") - CronetDataSource.Factory(engine, Executors.newSingleThreadExecutor()) - .setUserAgent(userAgent) - .setConnectionTimeoutMs(CRONET_TIMEOUT_MS) - .setReadTimeoutMs(CRONET_TIMEOUT_MS) - .setResetTimeoutOnRedirects(true) - .setHandleSetCookieRequests(true) - } + DefaultHttpDataSource.Factory() //TODO USE app.baseClient + .setUserAgent(userAgent ?: USER_AGENT) + .setAllowCrossProtocolRedirects(true) //https://stackoverflow.com/questions/69040127/error-code-io-bad-http-status-exoplayer-android } else { - Log.d(TAG, "Using OkHttpDataSource for $link") val client = app.baseClient.newBuilder() .addInterceptor(interceptor) .build() - OkHttpDataSource.Factory(client).setUserAgent(userAgent) + OkHttpDataSource.Factory(client).setUserAgent(userAgent ?: USER_AGENT) } // Do no include empty referer, if the provider wants those they can use the header map. val refererMap = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) - - // These are extra headers the browser like to insert, not sure if we want to include them - // for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue. - val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization + val headers = mapOf( + "accept" to "*/*", + "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", + "sec-ch-ua-mobile" to "?0", + "sec-fetch-user" to "?1", + "sec-fetch-mode" to "navigate", + "sec-fetch-dest" to "video" + ) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) @@ -881,12 +722,223 @@ 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 -> + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) + val current = settingsManager.getInt(context.getString(R.string.software_decoding_key), -1) + val softwareDecoding = when(current) { + 0 -> true // yes + 1 -> false // no + // -1 = automatic + else -> { + // we do not want tv to have software decoding, because of crashes + !isLayout(TV) + } + } + + val factory = if (softwareDecoding) { + NextRenderersFactory(context).apply { + setEnableDecoderFallback(true) + setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + } + } else { + DefaultRenderersFactory(context) + } + + factory.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 + @Suppress("DEPRECATION") + 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 -> + when (drm.uuid) { + CLEARKEY_UUID -> { + val client = + OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) + 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_UUID, + PLAYREADY_UUID -> { + val client = + OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) + 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 { + factory.createMediaSource(item.mediaItem) + } + } else { + try { + val source = ConcatenatingMediaSource2.Builder() + mediaItemSlices.map { item -> + source.add( + // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 + ClippingMediaSource( + factory.createMediaSource(item.mediaItem), + item.durationUs + ) + ) + } + source.build() + } catch (_: IllegalArgumentException) { + @Suppress("DEPRECATION") + val source = + ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only + 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 +973,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 +989,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 +1042,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) { @@ -1060,328 +1083,11 @@ class CS3IPlayer : IPlayer { } } - 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) @@ -1405,32 +1111,16 @@ 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 @@ -1443,11 +1133,9 @@ class CS3IPlayer : IPlayer { 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 +1157,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 +1174,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 +1234,6 @@ class CS3IPlayer : IPlayer { exoPlayer?.prepare() } - // PlaylistStuckException usually happens when the player position is ahead of the live window. - // Seek to the default location in that case - error.cause is HlsPlaylistTracker.PlaylistStuckException -> { - val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0 - - // Seek to live head - val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0 - - if (aheadOfLive > 100) { - exoPlayer?.seekTo(position - aheadOfLive) - } else { - exoPlayer?.seekToDefaultPosition() - } - exoPlayer?.prepare() - } - - else -> { event(ErrorEvent(error)) } @@ -1598,6 +1262,9 @@ class CS3IPlayer : IPlayer { } Player.STATE_ENDED -> { + // Resets subtitle delay on ended video + setSubtitleOffset(0) + // Only play next episode if autoplay is on (default) if (PreferenceManager.getDefaultSharedPreferences(context) ?.getBoolean( @@ -1634,16 +1301,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 +1319,7 @@ class CS3IPlayer : IPlayer { // onTimestampInvoked?.invoke(payload) } ?.setLooper(Looper.getMainLooper()) - ?.setPosition(timestamp.timestamp.startMs) + ?.setPosition(timestamp.startMs) //?.setPayload(timestamp) ?.setDeleteAfterDelivery(false) ?.send() @@ -1666,6 +1333,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 +1377,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 +1394,20 @@ class CS3IPlayer : IPlayer { } private fun getSubSources( + onlineSourceFactory: HttpDataSource.Factory?, offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, - interceptor: Interceptor?, ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) + val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.getFixedUrl())) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) .setSelectionFlags(0) .build() when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.EMBEDDED_IN_VIDEO -> { + SubtitleOrigin.DOWNLOADED_FILE -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) SingleSampleMediaSource.Factory(offlineSourceFactory) @@ -1736,41 +1418,37 @@ class CS3IPlayer : IPlayer { } SubtitleOrigin.URL -> { - val dataSourceFactory = createOnlineSource(sub.headers, interceptor) - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(dataSourceFactory) - .createMediaSource(subConfig, TIME_UNSET) + if (onlineSourceFactory != null) { + activeSubtitles.add(sub) + SingleSampleMediaSource.Factory(onlineSourceFactory.apply { + if (sub.headers.isNotEmpty()) + this.setDefaultRequestProperties(sub.headers) + }) + .createMediaSource(subConfig, TIME_UNSET) + } else { + null + } + } + + SubtitleOrigin.EMBEDDED_IN_VIDEO -> { + if (offlineSourceFactory != null) { + activeSubtitles.add(sub) + SingleSampleMediaSource.Factory(offlineSourceFactory) + .createMediaSource(subConfig, TIME_UNSET) + } else { + null + } } } } return Pair(subSources, activeSubtitles) } - /** - * Creates audio media sources from ExtractorLink's audioTracks - * @param audioTracks List of audio tracks from ExtractorLink - * @return List of MediaSource for audio tracks - */ - private fun getAudioSources( - audioTracks: List, - interceptor: Interceptor?, - ): List { - return audioTracks.mapNotNull { audio -> - try { - val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url) - val dataSourceFactory = createOnlineSource(audio.headers, interceptor) - DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) - } catch (e: Exception) { - Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}") - null - } - } - } - override fun isActive(): Boolean { return exoPlayer != null } + @MainThread private fun loadTorrent(context: Context, link: ExtractorLink) { ioSafe { @@ -1820,7 +1498,7 @@ class CS3IPlayer : IPlayer { defaultSet ) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (_: Throwable) { + } catch (e: Throwable) { null } ?: default @@ -1840,7 +1518,7 @@ class CS3IPlayer : IPlayer { // this causes a *bug* that restarts all torrents from 0 // but I would call this a feature releasePlayer() - loadExo(context, listOf(), listOf()) + loadExo(context, listOf(), listOf(), null) } event( StatusEvent( @@ -1866,9 +1544,7 @@ class CS3IPlayer : IPlayer { DialogInterface.BUTTON_NEGATIVE -> { Torrent.hasAcceptedTorrentForThisSession = false - val errorMessage = - context.getString(R.string.torrent_not_accepted) - event(ErrorEvent(ErrorLoadingException(errorMessage))) + event(ErrorEvent(ErrorLoadingException("Not accepted torrent"))) } } } @@ -1877,8 +1553,7 @@ class CS3IPlayer : IPlayer { .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))) + event(ErrorEvent(ErrorLoadingException("Not accepted torrent"))) } .setPositiveButton(R.string.ok, dialogClickListener) .setNegativeButton(R.string.go_back, dialogClickListener) @@ -1893,7 +1568,7 @@ class CS3IPlayer : IPlayer { if (ignoreSSL) { // Disables ssl check val sslContext: SSLContext = SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom()) + sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom()) sslContext.createSSLEngine() HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession -> true @@ -1915,10 +1590,10 @@ 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 +1605,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 +1641,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()) - } - } - -} +} \ No newline at end of file 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..9975d80a1 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,12 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context -import android.text.Layout +import android.text.Spannable +import android.text.SpannableString 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 +18,7 @@ import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.dvb.DvbParser import androidx.media3.extractor.text.pgs.PgsParser import androidx.media3.extractor.text.ssa.SsaParser +import androidx.media3.extractor.text.subrip.SubripParser import androidx.media3.extractor.text.ttml.TtmlParser import androidx.media3.extractor.text.tx3g.Tx3gParser import androidx.media3.extractor.text.webvtt.Mp4WebvttParser @@ -34,8 +35,8 @@ import java.nio.charset.Charset /** * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not * enough to identify the subtitle format. - */ -@OptIn(UnstableApi::class) + **/ +@UnstableApi class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { companion object { fun updateForcedEncoding(context: Context) { @@ -51,24 +52,12 @@ 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) val bloatRegex = listOf( Regex( @@ -88,6 +77,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 +87,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 @@ -234,8 +130,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { // 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()) } + val trimmedText = data.trimStart { it.isWhitespace() || controlCharsRegex.matches(it.toString()) } //https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388 val subtitleParser = when { @@ -250,14 +145,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { ignoreCase = true )) -> SsaParser(fallbackFormat?.initializationData) - trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() + trimmedText.startsWith("1", ignoreCase = true) -> SubripParser() fallbackFormat != null -> { - when (fallbackFormat.sampleMimeType) { + when (val mimeType = fallbackFormat.sampleMimeType) { MimeTypes.TEXT_VTT -> WebvttParser() MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() MimeTypes.APPLICATION_TTML -> TtmlParser() - MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser() + MimeTypes.APPLICATION_SUBRIP -> SubripParser() MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) // These decoders are not converted to parsers yet // TODO @@ -283,7 +178,6 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { val currentSubtitleCues = mutableListOf() - override fun parse( data: ByteArray, offset: Int, @@ -293,18 +187,30 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { ) { 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 + // add an extra span here to change the subtitle + val edgeSize = currentStyle.edgeSize + val newCue = if (edgeSize == null) { + cue + } else { + CuesWithTiming(cue.cues.map { c -> + c.buildUpon().apply { + val customSpan = SpannableString.valueOf(text ?: return@apply) + customSpan.setSpan( + OutlineSpan(edgeSize), 0, customSpan.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + setText(customSpan) + }.build() + }, cue.startTimeUs, cue.durationUs) + } + val updatedCues = CuesWithTiming( newCue.cues, @@ -327,6 +233,10 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { ) realDecoder?.let { decoder -> if (decoder !is SsaParser) { + if (currentStyle.removeCaptions) + captionRegex.forEach { rgx -> + str = str.replace(rgx, "\n") + } if (currentStyle.removeBloat) bloatRegex.forEach { rgx -> str = str.replace(rgx, "\n") @@ -391,7 +301,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 +313,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..13f1637d9 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 || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) { + 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("DownloadedPlayerActivity") { finish() } } override fun onResume() { super.onResume() CommonActivity.setActivityInstance(this) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt index 85db33fc0..794dd762d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt @@ -6,7 +6,36 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType class ExtractorLinkGenerator( private val links: List, private val subtitles: List, -) : NoVideoGenerator(null) { +) : IGenerator { + override val hasCache = false + override val canSkipLoading = true + + override fun getCurrentId(): Int? { + return null + } + + override fun hasNext(): Boolean { + return false + } + + override fun getAll(): List? { + return null + } + + override fun hasPrev(): Boolean { + return false + } + + override fun getCurrent(offset: Int): Any? { + return null + } + + override fun goto(index: Int) {} + + override fun next() {} + + override fun prev() {} + override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt deleted file mode 100644 index 025267cc9..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.content.Context -import android.os.Looper -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.Renderer -import androidx.media3.exoplayer.text.TextOutput -import androidx.media3.exoplayer.text.TextRenderer -import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory - -@UnstableApi -class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { - /** Somehow the nextlib authors decided that we need a text renderer that causes - * "ERROR_CODE_FAILED_RUNTIME_CHECK". - * - * Core issue: https://github.com/anilbeesetti/nextlib/pull/158 - * Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718 - * */ - override fun buildTextRenderers( - context: Context, - output: TextOutput, - outputLooper: Looper, - extensionRendererMode: Int, - out: ArrayList - ) { - out.add(TextRenderer(output, outputLooper)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index d90b6043f..f248c4140 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,32 +5,36 @@ 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 import android.view.Surface import android.view.View import android.view.ViewGroup +import android.view.WindowInsets import android.view.WindowManager -import android.view.animation.AccelerateDecelerateInterpolator +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.view.animation.AlphaAnimation -import android.view.animation.DecelerateInterpolator +import android.view.animation.Animation +import android.view.animation.AnimationUtils import android.widget.LinearLayout import androidx.annotation.OptIn -import androidx.appcompat.app.AlertDialog import androidx.core.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 +43,70 @@ 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.utils.setText +import com.lagradost.cloudstream3.utils.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 +115,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 +143,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,102 +204,116 @@ open class FullScreenPlayer : AbstractPlayerFragment( throw NotImplementedError() } - open fun showEpisodesOverlay() { - throw NotImplementedError() - } + /** + * [isValidTouch] should be called on a [View] spanning across the screen for reliable results. + * + * Android has supported gesture navigation properly since API-30. We get the absolute screen dimens using + * [WindowManager.getCurrentWindowMetrics] and remove the stable insets + * {[WindowInsets.getInsetsIgnoringVisibility]} to get a safe perimeter. + * This approach supports any and all types of necessary system insets. + * + * @return false if the touch is on the status bar or navigation bar + * */ + private fun View.isValidTouch(rawX: Float, rawY: Float): Boolean { + // NOTE: screenWidth is without the navbar width when 3button nav is turned on. + if(Build.VERSION.SDK_INT >= 30) { + // real = absolute dimen without any default deductions like navbar width + val windowMetrics = (context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.currentWindowMetrics + val realScreenHeight = windowMetrics?.let { windowMetrics.bounds.bottom - windowMetrics.bounds.top } ?: screenHeight + val realScreenWidth = windowMetrics?.let { windowMetrics.bounds.right - windowMetrics.bounds.left } ?: screenWidth - open fun isThereEpisodes(): Boolean { - return false + val insets = rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + val isOutsideHeight = rawY < insets.top || rawY > (realScreenHeight - insets.bottom) + val isOutsideWidth = if(windowMetrics == null) rawX < screenWidth + else rawX < insets.left || rawX > (realScreenWidth - insets.right) + + return !(isOutsideWidth || isOutsideHeight) + } else { + val statusHeight = statusBarHeight ?: 0 + return rawY > statusHeight && rawX < screenWidth + } } 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 - - sView.animateY(move.toFloat()) - } - - private fun View.animateY(value: Float) { - ObjectAnimator.ofFloat(this, "translationY", value).apply { - duration = 200 - start() - } - } - - private fun View.animateX(value: Float) { - ObjectAnimator.ofFloat(this, "translationX", value).apply { - duration = 200 - start() - } - } - protected fun animateLayoutChanges() { + if (isShowing) { + updateUIVisibility() + } else { + playerBinding?.playerHolder?.postDelayed({ updateUIVisibility() }, 200) + } + + val titleMove = if (isShowing) 0f else -50.toPx.toFloat() + playerBinding?.playerVideoTitle?.let { + ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { + duration = 200 + start() + } + } + playerBinding?.playerVideoTitleRez?.let { + ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { + duration = 200 + start() + } + } + val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() + playerBinding?.bottomPlayerBar?.let { + ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { + duration = 200 + start() + } + } + + val fadeTo = if (isShowing) 1f else 0f + val fadeAnimation = AlphaAnimation(1f - fadeTo, fadeTo) + + fadeAnimation.duration = 100 + fadeAnimation.fillAfter = true + + @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() playerBinding?.apply { - - if (isLayout(PHONE)) { // isEnabled also disables the onKeyDown - exoProgress.isEnabled = isShowing // Prevent accidental clicks/drags + playerOpenSource.let { + ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { + duration = 200 + start() + } } - if (isShowing) { - updateUIVisibility() - } else { - toggleEpisodesOverlay(false) - playerHolder.postDelayed({ updateUIVisibility() }, 200) - } - - val titleMove = if (isShowing) 0f else -50.toPx.toFloat() - - listOfNotNull( - playerVideoTitleHolder, - playerVideoTitleRez, - playerVideoInfo, - playerGoBackHolder, - playerVideoClock, - ).forEach { - it.animateY(titleMove) - } - - playerMetadataScrim.animateY(1f) - - val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() - bottomPlayerBar.animateY(playerBarMove) - - if (isLayout(PHONE)) { - playerEpisodesButton.animateX(if (isShowing) 0f else 50.toPx.toFloat()) - } - - val fadeTo = if (isShowing) 1f else 0f - val fadeAnimation = AlphaAnimation(1f - fadeTo, fadeTo) - - fadeAnimation.duration = 100 - fadeAnimation.fillAfter = true - - animateLayoutChangesForSubtitles() - - val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat() - - playerOpenSource.animateY(playerSourceMove) - if (!isLocked) { - playerHostView?.gestureHelper?.animateCenterControls(fadeTo) + playerFfwdHolder.alpha = 1f + playerRewHolder.alpha = 1f + // player_pause_play_holder?.alpha = 1f shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) + playerFfwdHolder.startAnimation(fadeAnimation) + playerRewHolder.startAnimation(fadeAnimation) + playerPausePlay.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) + + /*if (isBuffering) { + player_pause_play?.isVisible = false + player_pause_play_holder?.isVisible = false + } else { + player_pause_play?.isVisible = true + player_pause_play_holder?.startAnimation(fadeAnimation) + player_pause_play?.startAnimation(fadeAnimation) + }*/ + //player_buffering?.startAnimation(fadeAnimation) } bottomPlayerBar.startAnimation(fadeAnimation) @@ -349,7 +325,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 = @@ -365,7 +341,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - else -> playerHostView?.dynamicOrientation() ?: return + else -> dynamicOrientation() } activity.requestedOrientation = orientation } @@ -379,14 +355,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 @@ -407,7 +383,7 @@ open class FullScreenPlayer : AbstractPlayerFragment( else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - else -> orientation = playerHostView?.dynamicOrientation() ?: return + else -> orientation = dynamicOrientation() } activity.requestedOrientation = orientation } @@ -418,63 +394,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() } @@ -507,27 +473,23 @@ 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 - currentOffset + val totalPosition = playerPosition - subtitleDelay subtitleAdapter?.updateTime(totalPosition) subtitleAdapter?.getLatestActiveItem(totalPosition) @@ -552,27 +514,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 initialSubtitlePosition = (player.getPosition() ?: 0) - subtitleDelay subtitleAdapter = - SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> + 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) @@ -600,109 +559,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() { @@ -711,7 +708,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) { @@ -723,37 +719,41 @@ open class FullScreenPlayer : AbstractPlayerFragment( } val fadeTo = if (isLocked) 0f else 1f - playerHostView?.gestureHelper?.animateCenterControls(fadeTo) playerBinding?.apply { - val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { + val fadeAnimation = AlphaAnimation(playerVideoTitle.alpha, fadeTo).apply { duration = 100 fillAfter = true } updateUIVisibility() + // MENUS + //centerMenu.startAnimation(fadeAnimation) + playerPausePlay.startAnimation(fadeAnimation) + playerFfwdHolder.startAnimation(fadeAnimation) + playerRewHolder.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) - if (hasEpisodes) - playerEpisodesButton.startAnimation(fadeAnimation) - // player_media_route_button?.startAnimation(fadeAnimation) - // video_bar.startAnimation(fadeAnimation) + //if (hasEpisodes) + // player_episodes_button?.startAnimation(fadeAnimation) + //player_media_route_button?.startAnimation(fadeAnimation) + //video_bar.startAnimation(fadeAnimation) - // TITLE + //TITLE playerVideoTitleRez.startAnimation(fadeAnimation) - playerVideoInfo.startAnimation(fadeAnimation) playerEpisodeFiller.startAnimation(fadeAnimation) - playerVideoTitleHolder.startAnimation(fadeAnimation) + playerVideoTitle.startAnimation(fadeAnimation) playerTopHolder.startAnimation(fadeAnimation) // BOTTOM playerLockHolder.startAnimation(fadeAnimation) - // player_go_back_holder?.startAnimation(fadeAnimation) + //player_go_back_holder?.startAnimation(fadeAnimation) + shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) } updateLockUI() } - private fun updateUIVisibility() { + open fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -764,23 +764,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 || playerVideoTitle.text.isBlank() - 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 } } @@ -788,290 +787,529 @@ 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 = view.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 @@ -1083,8 +1321,8 @@ open class FullScreenPlayer : AbstractPlayerFragment( updateLockUI() updateUIVisibility() animateLayoutChanges() - playerHostView?.gestureHelper?.resetFastForwardText() - playerHostView?.gestureHelper?.resetRewindText() + resetFastForwardText() + resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -1093,35 +1331,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), @@ -1135,6 +1447,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 @@ -1143,15 +1465,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()) @@ -1159,15 +1494,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() } @@ -1177,13 +1517,13 @@ open class FullScreenPlayer : AbstractPlayerFragment( } playerBinding?.apply { + if (isLayout(TV or EMULATOR)) { mapOf( playerGoBack to playerGoBackText, playerRestart to playerRestartText, playerGoForward to playerGoForwardText, downloadHeaderToggle to downloadHeaderToggleText, - playerEpisodesButton to playerEpisodesButtonText ).forEach { (button, text) -> button.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) { @@ -1191,22 +1531,26 @@ 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 { - // Switch focus for a better UX, as otherwise it is reset to a random button like "back button" - if(skipChapterButton.hasFocus()) { - playerPausePlay.requestFocus() - } player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } @@ -1255,8 +1599,18 @@ open class FullScreenPlayer : AbstractPlayerFragment( showSubtitleOffsetDialog() } + playerRew.setOnClickListener { + autoHide() + rewind() + } + + playerFfwd.setOnClickListener { + autoHide() + fastForward() + } + playerGoBack.setOnClickListener { - activity?.popCurrentPage("FullScreenPlayer") + activity?.popCurrentPage() } playerSourcesBtt.setOnClickListener { @@ -1267,21 +1621,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 -> { @@ -1290,9 +1643,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 { @@ -1302,10 +1657,10 @@ open class FullScreenPlayer : AbstractPlayerFragment( } } + @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) - rotatedManually = true } } @@ -1316,7 +1671,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) } @@ -1326,48 +1681,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 4495a5602..77332329b 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,12 +3,9 @@ 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.graphics.Typeface import android.os.Build import android.os.Bundle import android.text.Spanned @@ -26,36 +23,24 @@ 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.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.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.CloudStreamApp -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId 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 @@ -69,146 +54,125 @@ 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.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.subtitles.AbstractSubApi import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubRepository import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders -import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType -import com.lagradost.cloudstream3.ui.player.source_priority.LinkSource -import com.lagradost.cloudstream3.ui.player.source_priority.ProfileSettings import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog -import com.lagradost.cloudstream3.ui.result.ACTION_CLICK_DEFAULT -import com.lagradost.cloudstream3.ui.result.EpisodeAdapter -import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultFragment -import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo -import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.SyncViewModel -import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageTagIETF -import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1 import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.languages -import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -import com.lagradost.cloudstream3.utils.UIHelper.hideProgress import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import com.lagradost.cloudstream3.utils.UIHelper.showProgress 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.lang.ref.WeakReference import java.util.Calendar -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.abs -@OptIn(UnstableApi::class) 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 + }.map { SubRepository(it) } 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 } } @@ -226,11 +190,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() { @@ -241,11 +204,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 @@ -267,196 +230,6 @@ 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) @@ -496,9 +269,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun loadLink(link: VideoLink?, sameEpisode: Boolean) { + private fun loadLink(link: Pair?, sameEpisode: Boolean) { if (link == null) return - isPlayerActive.set(true) + // manage UI binding?.playerLoadingOverlay?.isVisible = false val isTorrent = @@ -506,16 +279,15 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.downloadHeader?.isVisible = false playerBinding?.downloadHeaderToggle?.isVisible = isTorrent - if (!isLayout(PHONE)) { - playerBinding?.downloadBothHeader?.isVisible = isTorrent - } showDownloadProgress(DownloadEvent(0, 0, 0, null)) - // uiReset() // Removed due to UX - + uiReset() currentSelectedLink = link + currentMeta = viewModel.getMeta() + nextMeta = viewModel.getNextMeta() // setEpisodes(viewModel.getAllMeta() ?: emptyList()) + isActive = true setPlayerDimen(null) setTitle() if (!sameEpisode) @@ -525,7 +297,6 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link - val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -534,18 +305,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) } } @@ -583,25 +379,25 @@ class GeneratorPlayer : FullScreenPlayer() { if (entry.lang.isBlank() || !withLanguage) { return entry.name } - val language = fromTagToLanguageName(entry.lang.trim()) ?: entry.lang + val language = fromTwoLettersToLanguage(entry.lang.trim()) ?: entry.lang return "$language ${entry.name}" } 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 + val layout = R.layout.sort_bottom_single_choice_double_text val arrayAdapter = object : ArrayAdapter(dialog.context, layout) { @@ -627,6 +423,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) @@ -639,10 +436,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) @@ -662,7 +458,7 @@ class GeneratorPlayer : FullScreenPlayer() { currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener } - var currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() + var currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() fun setSubtitlesList(list: List) { @@ -674,8 +470,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(androidx.appcompat.R.attr.colorAccent)) binding.searchLoadingBar.progressTintList = color binding.searchLoadingBar.indeterminateTintList = color @@ -727,13 +522,13 @@ 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))) { + when (val response = it.search(search)) { is Resource.Success -> { response.value } @@ -775,79 +570,55 @@ 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) } } binding.applyBtt.setOnClickListener { - val currentSubtitle = currentSubtitle - if (currentSubtitle == null) { - dialog.dismissSafe() - return@setOnClickListener - } + currentSubtitle?.let { currentSubtitle -> + providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> + ioSafe { + when (val apiResource = api.getResource(currentSubtitle)) { + is Resource.Success -> { + val subtitles = apiResource.value.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 + ) + } + if (subtitles.isEmpty()) { + showToast(R.string.no_subtitles) + return@ioSafe + } + runOnMainThread { + addAndSelectSubtitles(*subtitles.toTypedArray()) + } + } - val api = providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix } - if (api == null) { - dialog.dismissSafe() - return@setOnClickListener - } + is Resource.Failure -> { + showToast(apiResource.errorString) + } - binding.applyBtt.showProgress() - ioSafe { - val apiResource = - Resource.fromResult(api.resource(currentSubtitle)) - binding.applyBtt.hideProgress() - when (apiResource) { - 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 - ) + is Resource.Loading -> { + // not possible + } } - if (subtitles.isEmpty()) { - showToast(R.string.no_subtitles) - return@ioSafe - } - dialog.dismissSafe() - runOnMainThread { - addAndSelectSubtitles(*subtitles.toTypedArray()) - } - } - - is Resource.Failure -> { - showToast(apiResource.errorString) - } - - is Resource.Loading -> { - // not possible } } } + dialog.dismissSafe() } dialog.setOnDismissListener { @@ -860,6 +631,7 @@ class GeneratorPlayer : FullScreenPlayer() { //dialog.subtitles_search_year?.setText(currentTempMeta.year) } + @OptIn(UnstableApi::class) private fun openSubPicker() { try { subsPathPicker.launch( @@ -885,21 +657,22 @@ class GeneratorPlayer : FullScreenPlayer() { vararg subtitleData: SubtitleData ) { if (subtitleData.isEmpty()) return - val ctx = context ?: return val selectedSubtitle = subtitleData.first() - viewModel.addSubtitles(subtitleData.toSet()) + val ctx = context ?: return + + val subs = currentSubs + subtitleData // this is used instead of observe(viewModel._currentSubs), because observe is too slow - player.setActiveSubtitles(viewModel.state.subtitles) + player.setActiveSubtitles(subs) // Save current time as to not reset player to 00:00 player.saveData() player.reloadPlayer(ctx) - setSubtitles(selectedSubtitle, false) + setSubtitles(selectedSubtitle) + viewModel.addSubtitles(subtitleData.toSet()) selectSourceDialog?.dismissSafe() - selectSourceDialog = null showToast( String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), @@ -910,10 +683,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, @@ -928,7 +701,6 @@ class GeneratorPlayer : FullScreenPlayer() { val subtitleData = SubtitleData( name, - "", uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), @@ -940,6 +712,10 @@ class GeneratorPlayer : FullScreenPlayer() { } } + private var selectSourceDialog: Dialog? = null +// var selectTracksDialog: AlertDialog? = null + + /** Will toast both when an error is found and when a subtitle is selected, * so only use from a user click and not a background process */ private fun addFirstSub(query: SubtitleSearch) = @@ -950,11 +726,9 @@ class GeneratorPlayer : FullScreenPlayer() { // first come first served with these subtitles // we might want to change it to prefer different sources when used multiple times, // however caching might make this random after the first click too - subsProviders.toList().amap { provider -> - val success = when (val result = Resource.fromResult( - provider.search( - query = query - ) + subsProviders.amap { provider -> + val success = when (val result = provider.search( + query = query )) { is Resource.Failure -> { // scope might cancel, so we do an extra check @@ -980,22 +754,35 @@ class GeneratorPlayer : FullScreenPlayer() { break } - val subtitleResources = provider.resource(subtitleEntry).getOrNull() ?: continue + val subtitleResources = + when (val result = provider.getResource(subtitleEntry)) { + is Resource.Failure -> { + continue + } + + is Resource.Loading -> { + // unreachable + continue + } + + is Resource.Success -> { + result.value + } + } val subtitles = subtitleResources.getSubtitles().map { resource -> SubtitleData( - originalName = resource.name ?: getName(subtitleEntry, true), - nameSuffix = "", + name = resource.name ?: getName(subtitleEntry, true), url = resource.url, origin = resource.origin, mimeType = resource.url.toSubtitleMimeType(), headers = subtitleEntry.headers, - languageCode = subtitleEntry.lang, + 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) { + if (this.isActive && !currentSubs.containsAll(subtitles) && !hasSelectASubtitle) { hasSelectASubtitle = true runOnMainThread { addAndSelectSubtitles(*subtitles.toTypedArray()) @@ -1018,20 +805,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 @@ -1045,10 +830,8 @@ class GeneratorPlayer : FullScreenPlayer() { var shouldDismiss = true binding.subtitleSettingsBtt.setOnClickListener { - safe { - val subtitlesFragment = SubtitlesFragment() - subtitlesFragment.systemBarsAddPadding = true - subtitlesFragment.show(this.parentFragmentManager, "SubtitleSettings") + normalSafeApiCall { + SubtitlesFragment().show(this.parentFragmentManager, "SubtitleSettings") } } @@ -1060,7 +843,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 @@ -1072,7 +855,6 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) - selectSourceDialog = null openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } @@ -1083,7 +865,7 @@ class GeneratorPlayer : FullScreenPlayer() { val metadata = getMetaData() val queryName = metadata.name ?: currentLoadResponse?.name if (queryName != null) { - val currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() + val currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() val loadFromFirstSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null ) as TextView @@ -1093,7 +875,6 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromFirstSubsFooter.setOnClickListener { sourceDialog.dismissSafe(activity) - selectSourceDialog = null showToast(R.string.loading) addFirstSub( SubtitleSearch( @@ -1104,7 +885,7 @@ class GeneratorPlayer : FullScreenPlayer() { aniListId = currentLoadResponse?.getAniListId()?.toInt(), epNumber = metadata.episode, seasonNumber = metadata.season, - lang = currentLanguageTagIETF.ifBlank { null }, + lang = currentLanguageTwoLetters.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) ) @@ -1115,29 +896,21 @@ class GeneratorPlayer : FullScreenPlayer() { var sourceIndex = 0 var startSource = 0 - // Filtered and sorted links - var currentHiddenFooter: View? = null - var filteredLinks: List = emptyList() + var sortedUrls = emptyList>() fun refreshLinks(qualityProfile: Int) { - val currentLinkUsed = currentSelectedLink - // Always display current linkFooter - val sortedLinks = viewModel.state.sortLinks(qualityProfile) - - filteredLinks = sortedLinks.filter { it.shouldUseLink || it.link == currentLinkUsed } - - if (sortedLinks.isEmpty()) { + sortedUrls = sortLinks(qualityProfile) + if (sortedUrls.isEmpty()) { sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true } else { - startSource = filteredLinks.indexOfFirst { it.link == currentLinkUsed } + startSource = sortedUrls.indexOf(currentSelectedLink) sourceIndex = startSource val sourcesArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - sourcesArrayAdapter.addAll(filteredLinks.map { displayLink -> - val (link, uri) = displayLink.link + sourcesArrayAdapter.addAll(sortedUrls.map { (link, uri) -> val name = link?.name ?: uri?.name ?: "NULL" "$name ${Qualities.getStringByInt(link?.quality)}" }) @@ -1151,35 +924,6 @@ class GeneratorPlayer : FullScreenPlayer() { sourceIndex = which providerList.setItemChecked(which, true) } - - providerList.setOnItemLongClickListener { _, _, position, _ -> - sortedLinks.getOrNull(position)?.link?.first?.url?.let { - clipboardHelper( - txt(R.string.video_source), - it - ) - } - true - } - - val hiddenLinks = sortedLinks.size - filteredLinks.size - providerList.removeFooterView(currentHiddenFooter) - - if (hiddenLinks > 0) { - val hiddenLinksFooter: TextView = layoutInflater.inflate( - R.layout.sort_bottom_footer_add_choice, null - ) as TextView - - providerList.addFooterView(hiddenLinksFooter, null, false) - currentHiddenFooter = hiddenLinksFooter - - val hiddenLinksText = - ctx.resources.getQuantityString(R.plurals.links_hidden, hiddenLinks) - .format(hiddenLinks) - hiddenLinksFooter.text = hiddenLinksText - hiddenLinksFooter.setCompoundDrawables(null, null, null, null) - hiddenLinksFooter.setTypeface(null, Typeface.ITALIC) - } } } @@ -1190,74 +934,22 @@ 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) + subsArrayAdapter.addAll(currentSubtitles.map { it.name.html() }) 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. @@ -1268,35 +960,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) { @@ -1310,28 +980,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 { @@ -1359,20 +1017,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 @@ -1380,25 +1034,25 @@ 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 } } if (init) { - filteredLinks.getOrNull(sourceIndex)?.let { - loadLink(it.link, true) + sortedUrls.getOrNull(sourceIndex)?.let { + loadLink(it, true) } } sourceDialog.dismissSafe(activity) - selectSourceDialog = null } } } catch (e: Exception) { @@ -1421,14 +1075,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 @@ -1471,56 +1122,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 @@ -1535,15 +1152,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) @@ -1552,8 +1166,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) { @@ -1561,25 +1175,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } + override fun playerError(exception: Throwable) { - currentSelectedLink?.let { link -> - viewModel.modifyState { this.addError(link) } - } - - 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.qualifiedName}, " + - "message=${exception.message}, url=$currentUrl, headers=$headers, " + - "referer=$referer, position=${player.getPosition() ?: "unknown"}, " + - "duration=${player.getDuration() ?: "unknown"}, " + - "isPlaying=${player.getIsPlaying()}", exception - ) - + Log.i(TAG, "playerError = $currentSelectedLink") if (!hasNextMirror()) { viewModel.forceClearCache = true } @@ -1588,129 +1186,53 @@ class GeneratorPlayer : FullScreenPlayer() { private fun noLinksFound() { viewModel.forceClearCache = true - val hiddenLinks = viewModel.state.sortLinks(currentQualityProfile).count { !it.shouldUseLink } - - context?.let { ctx -> - // Display that there are hidden links to the user. - if (hiddenLinks > 0) { - val noLinksString = ctx.getString(R.string.no_links_found_toast) - val hiddenString = - ctx.resources.getQuantityString(R.plurals.links_hidden, hiddenLinks) - .format(hiddenLinks) - val toastText = "$noLinksString\n($hiddenString)" - showToast(toastText, Toast.LENGTH_SHORT) - } else { - showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) - } - } + showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) activity?.popCurrentPage() } 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 firstAvailableLink = links.firstOrNull { it.shouldUseLink }?.link - if (firstAvailableLink == null) { + val links = sortLinks(currentQualityProfile) + if (links.isEmpty()) { noLinksFound() return } - // Atomic operation to prevent double loading - if (!isPlayerActive.compareAndSet(false, true)) { - return - } - loadLink(firstAvailableLink, 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() }?.take(6)?.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.html() - } else { - descView.isVisible = false - - } + loadLink(links.first(), 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() - } - } - - private fun getNextLink(): DisplayLink? { - val links = viewModel.state.sortLinks(currentQualityProfile) - val currentIndex = links.indexOfFirst { it.link == currentSelectedLink } - val nextPotentialLink = - links.withIndex().firstOrNull { it.index > currentIndex && it.value.shouldUseLink } - return nextPotentialLink?.value + isNextEpisode = true + player.release() + viewModel.loadLinksPrev() } override fun hasNextMirror(): Boolean { - return getNextLink() != null + val links = sortLinks(currentQualityProfile) + return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val nextLink = getNextLink() - if (nextLink == null) { + val links = sortLinks(currentQualityProfile) + if (links.isEmpty()) { noLinksFound() return } - loadLink(nextLink.link, true) + val newIndex = links.indexOf(currentSelectedLink) + 1 + if (newIndex >= links.size) { + noLinksFound() + return + } + + loadLink(links[newIndex], true) } override fun onDestroy() { @@ -1742,15 +1264,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) { @@ -1763,10 +1319,8 @@ class GeneratorPlayer : FullScreenPlayer() { if (settingsManager.getBoolean( ctx.getString(R.string.episode_sync_enabled_key), true ) - ) { - maxEpisodeSet = meta.episode - sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode) - } + ) maxEpisodeSet = meta.episode + sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode) } } @@ -1781,12 +1335,8 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerSkipEpisode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true - else -> { - val hasNextEpisode = viewModel.hasNextEpisode() == true - playerBinding?.playerGoForward?.isVisible = hasNextEpisode - playerBinding?.playerGoForwardRoot?.isVisible = hasNextEpisode - } - + else -> + playerBinding?.playerGoForwardRoot?.isVisible = viewModel.hasNextEpisode() == true } if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { @@ -1798,28 +1348,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) @@ -1827,9 +1382,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) @@ -1841,39 +1396,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 @@ -1920,6 +1467,8 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } + + @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1938,105 +1487,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() } } @@ -2052,13 +1525,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 @@ -2079,12 +1570,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 @@ -2093,7 +1578,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 @@ -2113,11 +1603,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) @@ -2131,154 +1621,27 @@ 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 - viewModel.modifyState { setError(emptyList()) } - 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() } - - // Set up TV clock visibility - if (isLayout(TV)) { - val showTvClock = settingsManager.getBoolean(ctx.getString(R.string.tv_layout_clock_key), false) - playerBinding?.playerVideoClock?.isVisible = showTvClock - } else { - playerBinding?.playerVideoClock?.isVisible = false - } } unwrapBundle(savedInstanceState) @@ -2286,25 +1649,20 @@ class GeneratorPlayer : FullScreenPlayer() { sync.updateUserData() - preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() + preferredAutoSelectSubtitles = getAutoSelectLanguageISO639_1() - val selectedLink = currentSelectedLink - if (selectedLink == null) { + if (currentSelectedLink == null) { viewModel.loadLinks() - } else { - // Recreated view, so we need to recreate the - loadLink(selectedLink, true) } - binding.overlayLoadingSkipButton.setOnClickListener { - // Mark as "success" early - viewModel.modifyState { - copy(loading = Resource.Success(Unit)) - } + binding?.overlayLoadingSkipButton?.setOnClickListener { + startPlayer() } - binding.playerLoadingGoBack.setOnClickListener { - exitPlayer() + binding?.playerLoadingGoBack?.setOnClickListener { + exitFullscreen() + player.release() + activity?.popCurrentPage() } playerBinding?.downloadHeader?.setOnClickListener { @@ -2317,29 +1675,14 @@ class GeneratorPlayer : FullScreenPlayer() { } } - observe(viewModel.currentStamps) { (stamps, instance) -> - if (instance != viewModel.state.instance) return@observe // Outdated observe + observe(viewModel.currentStamps) { stamps -> player.addTimeStamps(stamps) } - observe(viewModel.currentSubtitles) { (subtitles, instance) -> - if (instance != viewModel.state.instance) return@observe // Outdated observe - player.setActiveSubtitles(subtitles) - - // If the file is downloaded then do not select auto select the subtitles - // Downloaded subtitles cannot be selected immediately after loading since - // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles - // Resulting in unselecting the downloaded subtitle - if (subtitles.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { - autoSelectSubtitles() - } - } - observe(viewModel.loadingLinks) { (loading, instance) -> - if (instance != viewModel.state.instance) return@observe // Outdated observe - - when (loading) { + observe(viewModel.loadingLinks) { + when (it) { is Resource.Loading -> { - releasePlayer() + startLoading() } is Resource.Success -> { @@ -2351,35 +1694,21 @@ class GeneratorPlayer : FullScreenPlayer() { } is Resource.Failure -> { - showToast(loading.errorString, Toast.LENGTH_LONG) + showToast(it.errorString, Toast.LENGTH_LONG) startPlayer() } } } - observe(viewModel.currentLinks) { (_, 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 sortedLinks = viewModel.state.sortLinks(currentQualityProfile) - val usableLinks = sortedLinks.count { link -> link.shouldUseLink } - - val turnVisible = usableLinks > 0 && viewModel.generator?.canSkipLoading == true - val wasGone = binding.overlayLoadingSkipButton.isGone - - binding.overlayLoadingSkipButton.apply { - isVisible = turnVisible - - if (usableLinks == 0) { - setText(R.string.skip_loading) - } else { - @SuppressLint("SetTextI18n") - text = "${context.getString(R.string.skip_loading)} (${usableLinks})" - } - } - - 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 } ) { @@ -2388,7 +1717,33 @@ class GeneratorPlayer : FullScreenPlayer() { } if (turnVisible && wasGone) { - binding.overlayLoadingSkipButton.requestFocus() + binding?.overlayLoadingSkipButton?.requestFocus() + } + } + + observe(viewModel.currentSubs) { set -> + val setOfSub = mutableSetOf() + if (langFilterList.isNotEmpty() && filterSubByLang) { + Log.i("subfilter", "Filtering subtitle") + langFilterList.forEach { lang -> + Log.i("subfilter", "Lang: $lang") + setOfSub += set.filter { + it.name.contains(lang, ignoreCase = true) || + it.origin != SubtitleOrigin.URL + } + } + currentSubs = setOfSub + } else { + currentSubs = set + } + player.setActiveSubtitles(set) + + // If the file is downloaded then do not select auto select the subtitles + // Downloaded subtitles cannot be selected immediately after loading since + // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles + // Resulting in unselecting the downloaded subtitle + if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { + autoSelectSubtitles() } } } @@ -2399,4 +1754,4 @@ inline fun Bundle.getSafeSerializable(key: String): T if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable( key, T::class.java - ) + ) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt index 3ab46ce21..4aaee7bb7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt @@ -25,27 +25,27 @@ val LOADTYPE_CHROMECAST = setOf( val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() -abstract class NoVideoGenerator(val id : Int?) : VideoGenerator(emptyList()) { - override val hasCache = false - override val canSkipLoading = false - override fun getId(index: Int): Int? = id -} +interface IGenerator { + val hasCache: Boolean + val canSkipLoading: Boolean -abstract class VideoGenerator(val videos: List) { - abstract val hasCache: Boolean - abstract val canSkipLoading: Boolean - abstract fun getId(index : Int) : Int? + fun hasNext(): Boolean + fun hasPrev(): Boolean + fun next() + fun prev() + fun goto(index: Int) - fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex - fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 + fun getCurrentId(): Int? // this is used to save data or read data about this id + fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null + fun getAll(): List? // this us used to get the metadata about all entries, not needed - @Throws - abstract suspend fun generateLinks( + /* not safe, must use try catch */ + suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int, - isCasting: Boolean + offset: Int = 0, + isCasting: Boolean = false ): Boolean } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 034237266..9b8ad294d 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() @@ -163,7 +180,6 @@ interface Track { val id: String? val label: String? val language: String? - val sampleMimeType : String? } data class VideoTrack( @@ -172,23 +188,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 +213,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 +236,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 +247,7 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() - fun addTimeStamps(timeStamps: List) + fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, @@ -287,8 +300,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..542699e8b 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,74 +1,27 @@ 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()) @@ -84,12 +37,12 @@ object OfflinePlaybackHelper { name = name ?: getString(activity, R.string.downloaded_file), // well not the same as a normal id, but we take it as users may want to // play downloaded files and save the location - id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode() + id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull() + ?.hashCode() ) ) - ), 0 - ), - replacePlayerNavOptions + ) + ) ) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index cb8cf8bff..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,236 +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.ProfileSettings -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.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?, -) - -data class DisplayLink( - val link: VideoLink, - // If the link should be displayed and used by the player - val shouldUseLink: Boolean, - val priority: 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 erroredLinks: 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() - - /** - * The cache is guaranteed to be up to date link-wise due to the immutable links. - * However, hideNegativeSources and hideErrorSources could be updated, which requires clearing the cache. - */ - fun clearSortedLinksCache() = sortedLinks.clear() - - private fun hasLinkErrored(link: VideoLink): Boolean { - return erroredLinks.any { it == link } - } - - private fun VideoLink.toDisplayLink( - qualityProfile: Int, - hideNegativeSources: Boolean, - hideErrorSources: Boolean - ): DisplayLink { - val priority = getLinkPriority(qualityProfile, this.first) - val shouldHideLink = - (hideNegativeSources && priority < 0) || (hideErrorSources && hasLinkErrored(this)) - val displayLink = DisplayLink(this, !shouldHideLink, priority) - - return displayLink - } - - // 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 { - sortedLinks[qualityProfile]?.let { - return it - } - - val hideNegativeSources = - QualityDataHelper.getProfileSetting(qualityProfile, ProfileSettings.HideNegativeSources) - val hideErrorSources = - QualityDataHelper.getProfileSetting(qualityProfile, ProfileSettings.HideErrorSources) - - return links.map { link -> - // negative because we want to sort highest quality first - link.toDisplayLink(qualityProfile, hideNegativeSources, hideErrorSources) - }.sortedBy { - // negative because we want to sort highest quality first - -it.priority - }.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()) - - @Contract(pure = true) - fun addError(item: VideoLink): VideoState = copy(erroredLinks = erroredLinks.add(item)) - - @Contract(pure = true) - fun setError(items: Collection): VideoState = copy(erroredLinks = items.toPersistentSet()) -} - -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 @@ -254,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 @@ -289,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 ) } } @@ -310,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 f62bad58d..0893527d4 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 @@ -8,14 +8,10 @@ import androidx.annotation.OptIn import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView -import com.fasterxml.jackson.annotation.JsonIgnore import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable enum class SubtitleStatus { IS_ACTIVE, @@ -30,51 +26,35 @@ 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" - */ -@Serializable + * @param languageCode Not guaranteed to follow any standard. Could be something like "English 4" or "en". + * */ data class SubtitleData( - @SerialName("originalName") val originalName: String, - @SerialName("nameSuffix") val nameSuffix: String, - @SerialName("url") val url: String, - @SerialName("origin") val origin: SubtitleOrigin, - @SerialName("mimeType") val mimeType: String, - @SerialName("headers") val headers: Map, - @SerialName("languageCode") val languageCode: String?, + val name: String, + val url: String, + val origin: SubtitleOrigin, + val mimeType: String, + val headers: Map, + val languageCode: String? ) { - /** Internal ID for media3, unique for each link. */ - @JsonIgnore + /** Internal ID for exoplayer, unique for each link*/ fun getId(): String { return if (origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) url else "$url|$name" } - /** Returns true if langCode is the same as the IETF tag */ - fun matchesLanguageCode(langCode: String): Boolean { - return getIETF_tag() == langCode - } - - /** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */ - @JsonIgnore - fun getIETF_tag(): String? { - return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true) - } - - @SerialName("name") val name = "$originalName $nameSuffix" - /** * Gets the URL, but tries to fix it if it is malformed. */ - @JsonIgnore fun getFixedUrl(): String { // Some extensions fail to include the protocol, this helps with that. val fixedSubUrl = if (this.url.startsWith("//")) { "https:${this.url}" - } else this.url + } else { + this.url + } return fixedSubUrl } } @@ -96,7 +76,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 { @@ -110,13 +91,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 ) } } @@ -132,9 +112,16 @@ class PlayerSubtitleHelper { } fun setSubStyle(style: SaveCaptionStyle) { + subStyle = style Log.i(TAG, "SET STYLE = $style") + setSubtitleViewStyle(subtitleView, style) subtitleView?.translationY = -style.elevation.toPx.toFloat() - setSubtitleViewStyle(subtitleView, style, true) + 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?) { @@ -147,4 +134,4 @@ class PlayerSubtitleHelper { setSubStyle(it) } } -} +} \ No newline at end of file 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()) { + if(TORRENT_SERVER_URL.isEmpty()) { throw ErrorLoadingException("Not initialized") } return app.post( @@ -85,7 +83,7 @@ object Torrent { /** 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()) { + if(TORRENT_SERVER_URL.isEmpty()) { return false } return try { @@ -106,7 +104,7 @@ object Torrent { /** Removes a single torrent from the server registry */ private suspend fun rem(hash: String): Boolean { - if (TORRENT_SERVER_URL.isEmpty()) { + if(TORRENT_SERVER_URL.isEmpty()) { return false } return try { @@ -128,7 +126,7 @@ object Torrent { /** Removes all torrents from the server, and returns if it is successful */ suspend fun clearAll(): Boolean { - if (TORRENT_SERVER_URL.isEmpty()) { + if(TORRENT_SERVER_URL.isEmpty()) { return true } return try { @@ -166,8 +164,10 @@ object Torrent { /** 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()) { + suspend fun get( + hash: String, + ): TorrentStatus { + if(TORRENT_SERVER_URL.isEmpty()) { throw ErrorLoadingException("Not initialized") } return app.post( @@ -184,7 +184,7 @@ object Torrent { /** 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()) { + if(TORRENT_SERVER_URL.isEmpty()) { throw ErrorLoadingException("Not initialized") } return app.post( @@ -197,14 +197,18 @@ object Torrent { ).parsed() } - /** Spins up the torrent server. */ + /** Spins up the torrent server. + * + * FYI this will throw a os.Exit(1) if port is taken and is not currently checked, + * so if someone complains then add an extra check. */ private suspend fun setup(dir: String): Boolean { - go.Seq.load() + Seq.load() + if (echo()) { return true } val port = TorrServer.startTorrentServer(dir, 0) - if (port < 0) { + if(port < 0) { return false } TORRENT_SERVER_URL = "http://127.0.0.1:$port" @@ -223,15 +227,14 @@ object Torrent { } val status = add(link.url) - return newExtractorLink( + return ExtractorLink( source = link.source, - name = link.name, + link.name, url = status.streamUrl(link.url), - type = ExtractorLinkType.VIDEO - ) { - this.referer = "" - this.quality = link.quality - } to status + type = ExtractorLinkType.VIDEO, + referer = "", + quality = link.quality + ) to status } private val trackers = listOf( @@ -278,55 +281,94 @@ object Torrent { // 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 - @Serializable data class TorrentRequest( - @JsonProperty("action") @SerialName("action") val action: String, - @JsonProperty("hash") @SerialName("hash") val hash: String = "", - @JsonProperty("link") @SerialName("link") val link: String = "", - @JsonProperty("title") @SerialName("title") val title: String = "", - @JsonProperty("poster") @SerialName("poster") val poster: String = "", - @JsonProperty("data") @SerialName("data") val data: String = "", - @JsonProperty("save_to_db") @SerialName("save_to_db") val saveToDB: Boolean = false, + @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 - @Serializable data class TorrentStatus( - @JsonProperty("title") @SerialName("title") var title: String, - @JsonProperty("poster") @SerialName("poster") var poster: String, - @JsonProperty("data") @SerialName("data") var data: String?, - @JsonProperty("timestamp") @SerialName("timestamp") var timestamp: Long, - @JsonProperty("name") @SerialName("name") var name: String?, - @JsonProperty("hash") @SerialName("hash") var hash: String?, - @JsonProperty("stat") @SerialName("stat") var stat: Int, - @JsonProperty("stat_string") @SerialName("stat_string") var statString: String, - @JsonProperty("loaded_size") @SerialName("loaded_size") var loadedSize: Long?, - @JsonProperty("torrent_size") @SerialName("torrent_size") var torrentSize: Long?, - @JsonProperty("preloaded_bytes") @SerialName("preloaded_bytes") var preloadedBytes: Long?, - @JsonProperty("preload_size") @SerialName("preload_size") var preloadSize: Long?, - @JsonProperty("download_speed") @SerialName("download_speed") var downloadSpeed: Double?, - @JsonProperty("upload_speed") @SerialName("upload_speed") var uploadSpeed: Double?, - @JsonProperty("total_peers") @SerialName("total_peers") var totalPeers: Int?, - @JsonProperty("pending_peers") @SerialName("pending_peers") var pendingPeers: Int?, - @JsonProperty("active_peers") @SerialName("active_peers") var activePeers: Int?, - @JsonProperty("connected_seeders") @SerialName("connected_seeders") var connectedSeeders: Int?, - @JsonProperty("half_open_peers") @SerialName("half_open_peers") var halfOpenPeers: Int?, - @JsonProperty("bytes_written") @SerialName("bytes_written") var bytesWritten: Long?, - @JsonProperty("bytes_written_data") @SerialName("bytes_written_data") var bytesWrittenData: Long?, - @JsonProperty("bytes_read") @SerialName("bytes_read") var bytesRead: Long?, - @JsonProperty("bytes_read_data") @SerialName("bytes_read_data") var bytesReadData: Long?, - @JsonProperty("bytes_read_useful_data") @SerialName("bytes_read_useful_data") var bytesReadUsefulData: Long?, - @JsonProperty("chunks_written") @SerialName("chunks_written") var chunksWritten: Long?, - @JsonProperty("chunks_read") @SerialName("chunks_read") var chunksRead: Long?, - @JsonProperty("chunks_read_useful") @SerialName("chunks_read_useful") var chunksReadUseful: Long?, - @JsonProperty("chunks_read_wasted") @SerialName("chunks_read_wasted") var chunksReadWasted: Long?, - @JsonProperty("pieces_dirtied_good") @SerialName("pieces_dirtied_good") var piecesDirtiedGood: Long?, - @JsonProperty("pieces_dirtied_bad") @SerialName("pieces_dirtied_bad") var piecesDirtiedBad: Long?, - @JsonProperty("duration_seconds") @SerialName("duration_seconds") var durationSeconds: Double?, - @JsonProperty("bit_rate") @SerialName("bit_rate") var bitRate: String?, - @JsonProperty("file_stats") @SerialName("file_stats") var fileStats: List?, - @JsonProperty("trackers") @SerialName("trackers") var trackers: List?, + @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 = @@ -342,10 +384,12 @@ object Torrent { } } - @Serializable data class TorrentFileStat( - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("path") @SerialName("path") val path: String?, - @JsonProperty("length") @SerialName("length") val length: Long?, + @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..821bccd6a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt @@ -10,25 +10,45 @@ import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.palette.graphics.Palette +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.AppContextUtils import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.drawableToBitmap -import com.lagradost.cloudstream3.utils.setText class ProfilesAdapter( - val usedProfile: Int?, + override val items: MutableList, + val usedProfile: Int, val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, ) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.id == b.id - })) { + AppContextUtils.DiffAdapter( + items, + comparison = { first: QualityDataHelper.QualityProfile, second: QualityDataHelper.QualityProfile -> + first.id == second.id + }) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return ProfilesViewHolder( + PlayerQualityProfileItemBinding.inflate(LayoutInflater.from(parent.context),parent,false) + ) + } - companion object { - private val art = arrayOf( + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is ProfilesViewHolder -> holder.bind(items[position], position) + } + } + + private var currentItem: Pair? = null + + fun getCurrentProfile(): QualityDataHelper.QualityProfile? { + return currentItem?.second + } + + inner class ProfilesViewHolder( + val binding: PlayerQualityProfileItemBinding, + ) : RecyclerView.ViewHolder(binding.root) { + private val art = listOf( R.drawable.profile_bg_teal, R.drawable.profile_bg_blue, R.drawable.profile_bg_dark_blue, @@ -37,101 +57,67 @@ class ProfilesAdapter( R.drawable.profile_bg_red, R.drawable.profile_bg_orange, ) - } - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - PlayerQualityProfileItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } + fun bind(item: QualityDataHelper.QualityProfile, index: Int) { + val priorityText: TextView = binding.profileText + val profileBg: ImageView = binding.profileImageBackground + val wifiText: TextView = binding.textIsWifi + val dataText: TextView = binding.textIsMobileData + val outline: View = binding.outline + val cardView: View = binding.cardView - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is PlayerQualityProfileItemBinding -> { - clearImage(binding.profileImageBackground) + priorityText.text = item.name.asString(itemView.context) + dataText.isVisible = item.type == QualityDataHelper.QualityProfileType.Data + wifiText.isVisible = item.type == QualityDataHelper.QualityProfileType.WiFi + + fun setCurrentItem() { + val prevIndex = currentItem?.first + // Prevent UI bug when re-selecting the item quickly + if (prevIndex == index) { + return + } + currentItem = index to item + clickCallback.invoke(prevIndex, index) } - } - } - override fun onBindContent( - holder: ViewHolderState, - item: QualityDataHelper.QualityProfile, - position: Int - ) { - val binding = holder.view as? PlayerQualityProfileItemBinding ?: return + outline.isVisible = currentItem?.second?.id == item.id + val drawableResId = art[index % art.size] + profileBg.loadImage(drawableResId) - val priorityText: TextView = binding.profileText - val profileBg: ImageView = binding.profileImageBackground - val wifiText: TextView = binding.textIsWifi - val dataText: TextView = binding.textIsMobileData - val downloadText: TextView = binding.textIsDownloadData - val outline: View = binding.outline - val cardView: View = binding.cardView - val itemView = holder.itemView - - priorityText.setText(item.name) - dataText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Data) - wifiText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.WiFi) - downloadText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Download) - - fun setCurrentItem() { - val prevIndex = currentItem - // Prevent UI bug when re-selecting the item quickly - if (prevIndex == position) { - return - } - currentItem = position - clickCallback.invoke(prevIndex, position) - } - - outline.isVisible = currentItem == position - val drawableResId = art[position % art.size] - profileBg.loadImage(drawableResId) - - val drawable = ContextCompat.getDrawable(itemView.context, drawableResId) - if (drawable != null) { - // Convert Drawable to Bitmap - val bitmap = drawableToBitmap(drawable) - if (bitmap != null) { - // Use Palette to extract colors from the bitmap - Palette.from(bitmap).generate { palette -> - val color = palette?.getDarkVibrantColor( - ContextCompat.getColor( - itemView.context, - R.color.dubColorBg + val drawable = ContextCompat.getDrawable(itemView.context, drawableResId) + if (drawable != null) { + // Convert Drawable to Bitmap + val bitmap = drawableToBitmap(drawable) + if (bitmap != null) { + // Use Palette to extract colors from the bitmap + Palette.from(bitmap).generate { palette -> + val color = palette?.getDarkVibrantColor( + ContextCompat.getColor( + itemView.context, + R.color.dubColorBg + ) ) - ) - if (color != null) { - wifiText.backgroundTintList = ColorStateList.valueOf(color) - dataText.backgroundTintList = ColorStateList.valueOf(color) - downloadText.backgroundTintList = ColorStateList.valueOf(color) + if (color != null) { + wifiText.backgroundTintList = ColorStateList.valueOf(color) + dataText.backgroundTintList = ColorStateList.valueOf(color) + } } } } - } - val textStyle = - if (item.id == usedProfile) { - Typeface.BOLD - } else { - Typeface.NORMAL + val textStyle = + if (item.id == usedProfile) { + Typeface.BOLD + } else { + Typeface.NORMAL + } + + priorityText.setTypeface(null, textStyle) + + cardView.setOnClickListener { + setCurrentItem() } - - priorityText.setTypeface(null, textStyle) - - cardView.setOnClickListener { - setCurrentItem() } } - - private var currentItem: Int? = null - - fun getCurrentProfile(): QualityDataHelper.QualityProfile? { - return currentItem?.let { index -> immutableCurrentList.getOrNull(index) } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt index d8d47bd10..0922bdb5a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt @@ -1,36 +1,22 @@ package com.lagradost.cloudstream3.ui.player.source_priority import androidx.annotation.StringRes -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys -import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey -import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount -import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities -import java.util.EnumMap -import java.util.concurrent.ConcurrentHashMap -import kotlin.also -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" - const val VIDEO_PROFILE_SETTINGS = "video_profile_settings" - - // 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 **/ @@ -47,50 +33,26 @@ 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 ) - - // Map profile and name to priority - val sourcePriorityCache: ConcurrentHashMap> = ConcurrentHashMap() - fun getSourcePriority(profile: Int, name: String?): Int { if (name == null) return DEFAULT_SOURCE_PRIORITY - - return sourcePriorityCache[profile]?.get(name) ?: (getKey( + return getKey( "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, DEFAULT_SOURCE_PRIORITY - ) ?: DEFAULT_SOURCE_PRIORITY).also { - sourcePriorityCache.getOrPut(profile) { hashMapOf() } - sourcePriorityCache[profile]?.set(name, it) - } - } - - fun getAllSourcePriorityNames(profile: Int): List { - val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" - return getKeys(folder)?.map { key -> - key.substringAfter("$folder/") - } ?: emptyList() + ) ?: DEFAULT_SOURCE_PRIORITY } 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) - } - - sourcePriorityCache[profile]?.set(name, priority) + setKey("$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, priority) } fun setProfileName(profile: Int, name: String?) { @@ -107,17 +69,12 @@ object QualityDataHelper { ?: txt(R.string.profile_number, profile) } - // Map profile and quality to priority - val qualityPriorityCache: ConcurrentHashMap> = ConcurrentHashMap() fun getQualityPriority(profile: Int, quality: Qualities): Int { - return qualityPriorityCache[profile]?.get(quality) ?: (getKey( + return getKey( "$currentAccount/$VIDEO_QUALITY_PRIORITY/$profile", quality.value.toString(), quality.defaultPriority - )?.also { - qualityPriorityCache.getOrPut(profile) { EnumMap(Qualities::class.java) } - qualityPriorityCache[profile]?.set(quality, it) - }) ?: quality.defaultPriority + ) ?: quality.defaultPriority } fun setQualityPriority(profile: Int, quality: Qualities, priority: Int) { @@ -126,58 +83,18 @@ object QualityDataHelper { quality.value.toString(), priority ) - qualityPriorityCache[profile]?.set(quality, priority) } - fun setProfileSetting(profile: Int, setting: ProfileSettings, value: T) { - val folder = "$currentAccount/$VIDEO_PROFILE_SETTINGS/$profile" - // Prevent unnecessary keys - if (value == setting.defaultValue) { - removeKey(folder, setting.key) + fun getQualityProfileType(profile: Int): QualityProfileType { + return getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") ?: QualityProfileType.None + } + + fun setQualityProfileType(profile: Int, type: QualityProfileType?) { + val path = "$currentAccount/$VIDEO_PROFILE_TYPE/$profile" + if (type == QualityProfileType.None) { + removeKey(path) } else { - setKey(folder, setting.key, value) - } - } - - inline fun getProfileSetting(profile: Int, setting: ProfileSettings): T { - val folder = "$currentAccount/$VIDEO_PROFILE_SETTINGS/$profile" - val value = getKey(folder, setting.key) - return value ?: setting.defaultValue - } - - @Suppress("DEPRECATION") - fun getQualityProfileTypes(profile: Int): Set { - val newKey = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" - // Use arrays for to make with work with setKey properly (weird crashes otherwise) - val newProfiles = getKey>(newKey)?.toSet() - - // Migrate to new profile key - if (newProfiles == null) { - val oldProfile = - getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") - val newSet = oldProfile?.let { arrayOf(it) } ?: arrayOf() - setKey(newKey, newSet) - return newSet.toSet() - } else { - 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) } } @@ -189,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 { @@ -230,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" }) @@ -240,27 +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) } - } -} - -sealed class ProfileSettings(val key: String, val defaultValue: T) { - object HideErrorSources : ProfileSettings("hide_error_sources", false) - object HideNegativeSources : ProfileSettings("hide_negative_sources", false) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt index 6a0f12e9a..19e98138c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt @@ -2,78 +2,47 @@ package com.lagradost.cloudstream3.ui.player.source_priority import android.app.Dialog import androidx.annotation.StyleRes -import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding -import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getAllSourcePriorityNames import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles -import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -import com.lagradost.cloudstream3.utils.setText -/** Simplified ExtractorLink for the quality profile dialog */ -data class LinkSource( - val source: String -) { - constructor(extractorLink: ExtractorLink) : this(extractorLink.source) -} - - -class QualityProfileDialog private constructor( +class QualityProfileDialog( val activity: FragmentActivity, @StyleRes val themeRes: Int, - private val links: List, - private val usedProfile: Int?, - private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?, - private val useProfileSelection: Boolean + private val links: List, + private val usedProfile: Int, + private val profileSelectionCallback: (QualityDataHelper.QualityProfile) -> Unit ) : Dialog(activity, themeRes) { - constructor( - activity: FragmentActivity, - @StyleRes themeRes: Int, - links: List, - usedProfile: Int, - profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit), - ) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true) - - constructor( - activity: FragmentActivity, - @StyleRes themeRes: Int, - links: List - ) : this(activity, themeRes, links, null, null, false) - - companion object { - // Run on IO as this may be a heavy operation - suspend fun getAllDefaultSources(): List = ioWork { - getProfiles().flatMap { - getAllSourcePriorityNames(it.id) - }.distinct().map { LinkSource(it) } - } - } - override fun show() { + val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false) - setContentView(binding.root) - fixSystemBarsPadding(binding.root) + setContentView(binding.root)//R.layout.player_quality_profile_dialog) + /*val profilesRecyclerView: RecyclerView = profiles_recyclerview + val useBtt: View = use_btt + val editBtt: View = edit_btt + val cancelBtt: View = cancel_btt + val defaultBtt: View = set_default_btt + val currentProfileText: TextView = currently_selected_profile_text + val selectedItemActionsHolder: View = selected_item_holder*/ binding.apply { fun getCurrentProfile(): QualityDataHelper.QualityProfile? { return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile() } fun refreshProfiles() { - if (usedProfile != null) { - currentlySelectedProfileText.setText(getProfileName(usedProfile)) - } - (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) + currentlySelectedProfileText.text = getProfileName(usedProfile).asString(context) + (profilesRecyclerview.adapter as? ProfilesAdapter)?.updateList(getProfiles()) } profilesRecyclerview.adapter = ProfilesAdapter( + mutableListOf(), usedProfile, ) { oldIndex: Int?, newIndex: Int -> profilesRecyclerview.adapter?.notifyItemChanged(newIndex) @@ -96,52 +65,37 @@ class QualityProfileDialog private constructor( setDefaultBtt.setOnClickListener { val currentProfile = getCurrentProfile() ?: return@setOnClickListener - val choices = - QualityDataHelper.QualityProfileType.entries.filter { it != QualityDataHelper.QualityProfileType.None } + val choices = QualityDataHelper.QualityProfileType.entries + .filter { it != QualityDataHelper.QualityProfileType.None } val choiceNames = choices.map { txt(it.stringRes).asString(context) } - val selectedIndices = choices.mapIndexed { index, type -> index to type } - .filter { currentProfile.types.contains(it.second) }.map { it.first } - activity.showMultiDialog( + activity.showBottomDialog( choiceNames, - selectedIndices, + choices.indexOf(currentProfile.type), txt(R.string.set_default).asString(context), + false, {}, { index -> - val pickedChoices = index.mapNotNull { choices.getOrNull(it) } - - pickedChoices.forEach { pickedChoice -> - // Remove previous picks - if (pickedChoice.unique) { - getProfiles().filter { it.types.contains(pickedChoice) }.forEach { - QualityDataHelper.removeQualityProfileType(it.id, pickedChoice) - } + val pickedChoice = choices.getOrNull(index) ?: return@showBottomDialog + // Remove previous picks + if (pickedChoice.unique) { + getProfiles().filter { it.type == pickedChoice }.forEach { + QualityDataHelper.setQualityProfileType(it.id, null) } - - QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice) } + QualityDataHelper.setQualityProfileType(currentProfile.id, pickedChoice) refreshProfiles() }) } - cancelBtt.isVisible = useProfileSelection - useBtt.isVisible = useProfileSelection - applyBtt.isVisible = !useProfileSelection + cancelBtt.setOnClickListener { + this@QualityProfileDialog.dismissSafe() + } - if (useProfileSelection) { - cancelBtt.setOnClickListener { - this@QualityProfileDialog.dismissSafe() - } - - useBtt.setOnClickListener { - getCurrentProfile()?.let { - profileSelectionCallback?.invoke(it) - this@QualityProfileDialog.dismissSafe() - } - } - } else { - applyBtt.setOnClickListener { + useBtt.setOnClickListener { + getCurrentProfile()?.let { + profileSelectionCallback.invoke(it) this@QualityProfileDialog.dismissSafe() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt index 604ec3149..4c74ec80f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt @@ -8,14 +8,14 @@ import androidx.appcompat.app.AlertDialog import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SourcePriorityDialog( val ctx: Context, - @StyleRes val themeRes: Int, - val links: List, + @StyleRes themeRes: Int, + 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,83 +24,77 @@ 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 + val saveBtt = binding.saveBtt + val exitBtt = binding.closeBtt + val helpBtt = binding.helpBtt - binding.apply { - profileTextEditable.setText( - QualityDataHelper.getProfileName(profile.id).asString(context) - ) - profileTextEditable.hint = txt(R.string.profile_number, profile.id).asString(context) + profileText.setText(QualityDataHelper.getProfileName(profile.id).asString(context)) + profileText.hint = txt(R.string.profile_number, profile.id).asString(context) - sortSources.adapter = PriorityAdapter( - ).apply { - val sortedLinks = links.map { link -> - SourcePriority( - null, - link.source, - QualityDataHelper.getSourcePriority(profile.id, link.source) - ) - }.distinctBy { it.name }.sortedBy { -it.priority } + sourcesRecyclerView.adapter = PriorityAdapter( + links.map { link -> + SourcePriority( + null, + link.source, + QualityDataHelper.getSourcePriority(profile.id, link.source) + ) + }.distinctBy { it.name }.sortedBy { -it.priority }.toMutableList() + ) - submitList(sortedLinks) + qualitiesRecyclerView.adapter = PriorityAdapter( + Qualities.entries.mapNotNull { + SourcePriority( + it, + Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, + QualityDataHelper.getQualityPriority(profile.id, it) + ) + }.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 qualities = qualityAdapter?.items ?: emptyList() + val sources = sourcesAdapter?.items ?: emptyList() + + qualities.forEach { + val data = it.data as? Qualities ?: return@forEach + QualityDataHelper.setQualityPriority(profile.id, data, it.priority) } - sortQualities.adapter = PriorityAdapter( - ).apply { - submitList(Qualities.entries.mapNotNull { - SourcePriority( - it, - Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, - QualityDataHelper.getQualityPriority(profile.id, it) - ) - }.sortedBy { -it.priority }) + sources.forEach { + QualityDataHelper.setSourcePriority(profile.id, it.name, it.priority) } - @Suppress("UNCHECKED_CAST") // We know the types - saveBtt.setOnClickListener { - val qualityAdapter = sortQualities.adapter as? PriorityAdapter - val sourcesAdapter = sortSources.adapter as? PriorityAdapter + qualityAdapter?.updateList(qualities.sortedBy { -it.priority }) + sourcesAdapter?.updateList(sources.sortedBy { -it.priority }) - val qualities = qualityAdapter?.immutableCurrentList ?: emptyList() - val sources = sourcesAdapter?.immutableCurrentList ?: emptyList() - - qualities.forEach { - QualityDataHelper.setQualityPriority(profile.id, it.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 }) - - val savedProfileName = profileTextEditable.text.toString() - if (savedProfileName.isBlank()) { - QualityDataHelper.setProfileName(profile.id, null) - } else { - QualityDataHelper.setProfileName(profile.id, savedProfileName) - } - updatedCallback.invoke() - } - - closeBtt.setOnClickListener { - dismissSafe() - } - - helpBtt.setOnClickListener { - AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { - setMessage(R.string.quality_profile_help) - }.show() - } - - settingsBtt.setOnClickListener { - SourceProfileSettingsDialog(ctx, themeRes, profile.id).show() + val savedProfileName = profileText.text.toString() + if (savedProfileName.isBlank()) { + QualityDataHelper.setProfileName(profile.id, null) + } else { + QualityDataHelper.setProfileName(profile.id, savedProfileName) } + updatedCallback.invoke() } + + exitBtt.setOnClickListener { + this.dismissSafe() + } + + helpBtt.setOnClickListener { + AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { + setMessage(R.string.quality_profile_help) + }.show() + } + super.show() } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourceProfileSettingsDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourceProfileSettingsDialog.kt deleted file mode 100644 index c26955b5e..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourceProfileSettingsDialog.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.lagradost.cloudstream3.ui.player.source_priority - -import android.app.Dialog -import android.content.Context -import android.view.LayoutInflater -import androidx.annotation.StyleRes -import com.lagradost.cloudstream3.databinding.SourceProfileSettingsDialogBinding -import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding - -class SourceProfileSettingsDialog( - val ctx: Context, - @StyleRes themeRes: Int, - val profile: Int -) : Dialog(ctx, themeRes) { - override fun show() { - val binding = - SourceProfileSettingsDialogBinding.inflate(LayoutInflater.from(ctx), null, false) - setContentView(binding.root) - fixSystemBarsPadding(binding.root) - - binding.apply { - var hideErrorSources = QualityDataHelper.getProfileSetting(profile, ProfileSettings.HideErrorSources) - var hideNegativeSources = QualityDataHelper.getProfileSetting(profile, ProfileSettings.HideNegativeSources) - - profileHideErrorSources.isChecked = hideErrorSources - profileHideErrorSources.setOnCheckedChangeListener { _, bool -> - hideErrorSources = bool - } - - profileHideNegativeSources.isChecked = hideNegativeSources - profileHideNegativeSources.setOnCheckedChangeListener { _, bool -> - hideNegativeSources = bool - } - - applyBtt.setOnClickListener { - QualityDataHelper.setProfileSetting(profile, ProfileSettings.HideErrorSources, hideErrorSources) - QualityDataHelper.setProfileSetting(profile, ProfileSettings.HideNegativeSources, hideNegativeSources) - dismissSafe() - } - - cancelBtt.setOnClickListener { - dismissSafe() - } - } - super.show() - } -} \ No newline at end of file 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 fe8c617f2..b9893193b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt @@ -5,18 +5,13 @@ import android.content.Intent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.Animation -import android.view.animation.OvershootInterpolator -import android.view.animation.ScaleAnimation 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 @@ -24,138 +19,163 @@ import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class ActorAdaptor( private var nextFocusUpId: Int? = null, private val focusCallback: (View?) -> Unit = {} -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.actor.name == b.actor.name -})) { - companion object { - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 10) } - } +) : RecyclerView.Adapter() { + data class ActorMetaData( + var isInverted: Boolean, + val actor: ActorData, + ) - // Easier to store it here than to store it in the ActorData - val inverted: HashMap = hashMapOf() + private val actors: MutableList = mutableListOf() - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return CardViewHolder( + CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + focusCallback ) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is CastItemBinding -> { - clearImage(binding.actorImage) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is CardViewHolder -> { + holder.bind(actors[position].actor, actors[position].isInverted, position) { + actors[position].isInverted = !actors[position].isInverted + this.notifyItemChanged(position) + } } } } - override fun onUpdateContent(holder: ViewHolderState, item: ActorData, position: Int) { - when (val binding = holder.view) { - is CastItemBinding -> { - val anim: Animation = ScaleAnimation( - 0.8f, 1f, - 0.8f, 1f, - Animation.RELATIVE_TO_SELF, 0.5f, - Animation.RELATIVE_TO_SELF, 0.5f - ) - anim.fillAfter = true - anim.duration = 200 - anim.interpolator = OvershootInterpolator() - binding.voiceActorImageHolder2.startAnimation(anim) - } - } - super.onUpdateContent(holder, item, position) + override fun getItemCount(): Int { + return actors.size } - 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) + private fun updateActorList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + ActorDiffCallback(this.actors, newList) + ) - val (mainImg, vaImage) = if (!isInverted || item.voiceActor?.image.isNullOrBlank()) { - Pair(item.actor.image, item.voiceActor?.image) - } else { - Pair(item.voiceActor?.image, item.actor.image) - } + actors.clear() + actors.addAll(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 - } + diffResult.dispatchUpdatesTo(this) + } - itemView.setOnFocusChangeListener { v, hasFocus -> - if (hasFocus) { - focusCallback(v) - } - } + 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.setOnClickListener { - inverted[item] = !isInverted - this.onUpdateContent(holder, getItem(position), position) - } + private inner class CardViewHolder( + val binding: CastItemBinding, + private val focusCallback: (View?) -> Unit = {} + ) : + RecyclerView.ViewHolder(binding.root) { - 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) - } + fun bind(actor: ActorData, isInverted: Boolean, position: Int, callback: (Int) -> Unit) { + val (mainImg, vaImage) = if (!isInverted || actor.voiceActor?.image.isNullOrBlank()) { + Pair(actor.actor.image, actor.voiceActor?.image) + } else { + Pair(actor.voiceActor?.image, actor.actor.image) + } + + // Fix tv focus escaping the recyclerview + if (position == 0) { + itemView.nextFocusLeftId = R.id.result_cast_items + } else if ((position - 1) == itemCount) { + itemView.nextFocusRightId = R.id.result_cast_items + } + nextFocusUpId?.let { + itemView.nextFocusUpId = it + } + + itemView.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focusCallback(v) + } + } + + itemView.setOnClickListener { + callback(position) + } + + itemView.setOnLongClickListener { + if (isLayout(PHONE)) { + Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra(SearchManager.QUERY, actor.actor.name) + }.also { intent -> + itemView.context.packageManager?.let { pm -> + if (intent.resolveActivity(pm) != null) { + itemView.context.startActivity(intent) } } } - true } + true + } - binding.apply { - actorImage.loadImage(mainImg) + binding.apply { + actorImage.loadImage(mainImg) - actorName.text = item.actor.name - item.role?.let { - actorExtra.context?.getString( - when (it) { - ActorRole.Main -> { - R.string.actor_main - } - - ActorRole.Supporting -> { - R.string.actor_supporting - } - - ActorRole.Background -> { - R.string.actor_background - } + actorName.text = actor.actor.name + actor.role?.let { + actorExtra.context?.getString( + when (it) { + ActorRole.Main -> { + R.string.actor_main + } + + ActorRole.Supporting -> { + R.string.actor_supporting + } + + ActorRole.Background -> { + R.string.actor_background } - )?.let { text -> - actorExtra.isVisible = true - actorExtra.text = text } - } ?: item.roleString?.let { + )?.let { text -> actorExtra.isVisible = true - actorExtra.text = it - } ?: run { - actorExtra.isVisible = false + actorExtra.text = text } + } ?: actor.roleString?.let { + actorExtra.isVisible = true + actorExtra.text = it + } ?: run { + actorExtra.isVisible = false + } - if (item.voiceActor == null) { - voiceActorImageHolder.isVisible = false - voiceActorName.isVisible = false - } else { - voiceActorName.text = item.voiceActor?.name - if (!vaImage.isNullOrEmpty()) - voiceActorImageHolder.isVisible = true - voiceActorImage.loadImage(vaImage) - } + if (actor.voiceActor == null) { + voiceActorImageHolder.isVisible = false + voiceActorName.isVisible = false + } else { + voiceActorName.text = actor.voiceActor?.name + if (!vaImage.isNullOrEmpty()) + voiceActorImageHolder.isVisible = true + voiceActorImage.loadImage(vaImage) } } } } } + +class ActorDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].actor.actor.name == newList[newItemPosition].actor.actor.name + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index 5e5504164..51acd15a4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -1,37 +1,31 @@ package com.lagradost.cloudstream3.ui.result +import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.core.view.setPadding import androidx.preference.PreferenceManager -import coil3.dispose +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ResultEpisodeBinding import com.lagradost.cloudstream3.databinding.ResultEpisodeLargeBinding import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html -import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import java.text.DateFormat @@ -44,6 +38,7 @@ import java.util.Locale * @see VideoClickActionHolder */ const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 + const val ACTION_CHROME_CAST_EPISODE = 4 const val ACTION_CHROME_CAST_MIRROR = 5 @@ -64,74 +59,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 +142,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.loadImage(card.poster) + + if (card.rating != null) { + episodeRating.text = episodeRating.context?.getString(R.string.rated_format) + ?.format(card.rating.toFloat() / 10f) + } else { + episodeRating.text = "" + } + + episodeRating.isGone = episodeRating.text.isNullOrBlank() + + episodeDescript.apply { + text = card.description.html() + isGone = text.isNullOrBlank() + + var isExpanded = false + setOnClickListener { + if (isLayout(TV)) { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) + } else { + isExpanded = !isExpanded + maxLines = if (isExpanded) { + Integer.MAX_VALUE + } else 4 + } + } + } + + if (card.airDate != null) { + val isUpcoming = unixTimeMS < card.airDate + + if (isUpcoming) { + episodePlayIcon.isVisible = false + episodeUpcomingIcon.isVisible = !episodePoster.isVisible + episodeDate.setText( + com.lagradost.cloudstream3.utils.txt( + R.string.episode_upcoming_format, + secondsToReadable( + card.airDate.minus(unixTimeMS).div(1000).toInt(), + "" + ) + ) + ) + } else { + episodeUpcomingIcon.isVisible = false + + val formattedAirDate = SimpleDateFormat.getDateInstance( + DateFormat.LONG, + Locale.getDefault() + ).apply { + }.format(Date(card.airDate)) + + episodeDate.setText(txt(formattedAirDate)) + } + } else { + episodeDate.isVisible = false + } + + episodeRuntime.setText( + txt( + card.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } + ) + ) + + if (isLayout(EMULATOR or PHONE)) { + episodePoster.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + } + + episodePoster.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) + return@setOnLongClickListener true + } + } + } + + itemView.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + } + + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false + } + + itemView.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) + return@setOnLongClickListener true + } + + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport + } + } + + class EpisodeCardViewHolderSmall( + val binding: ResultEpisodeBinding, + private val hasDownloadSupport: Boolean, + private val clickCallback: (EpisodeClickEvent) -> Unit, + private val downloadClickCallback: (DownloadClickEvent) -> Unit, + ) : RecyclerView.ViewHolder(binding.root) { + @SuppressLint("SetTextI18n") + fun bind(card: ResultEpisode) { + binding.episodeHolder.layoutParams.apply { + width = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT + } + + binding.apply { + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + VideoDownloadHelper.DownloadEpisodeCached( + name = card.name, + poster = card.poster, + episode = card.episode, + season = card.season, + id = card.id, + parentId = card.parentId, + 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 +397,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 71909c5d3..c12f01183 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,10 +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 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable const val START_ACTION_RESUME_LATEST = 1 const val START_ACTION_LOAD_EP = 2 @@ -36,32 +26,32 @@ enum class VideoWatchState { Watched } -@Serializable data class ResultEpisode( - @SerialName("headerName") val headerName: String, - @SerialName("name") val name: String?, - @SerialName("poster") val poster: String?, - @SerialName("episode") val episode: Int, - @SerialName("seasonIndex") val seasonIndex: Int?, // this is the "season" index used season names - @SerialName("season") val season: Int?, // this is the display - @SerialName("data") val data: String, - @SerialName("apiName") val apiName: String, - @SerialName("id") val id: Int, - @SerialName("index") val index: Int, - @SerialName("position") val position: Long, // time in MS - @SerialName("duration") val duration: Long, // duration in MS - @SerialName("score") val score: Score?, - @SerialName("description") val description: String?, - @SerialName("isFiller") val isFiller: Boolean?, - @SerialName("tvType") val tvType: TvType, - @SerialName("parentId") val parentId: Int, - /** Conveys if the episode itself is marked as watched. */ - @SerialName("videoWatchState") val videoWatchState: VideoWatchState, - /** Sum of all previous season episode counts + episode. */ - @SerialName("totalEpisodeIndex") val totalEpisodeIndex: Int? = null, - @SerialName("airDate") val airDate: Long? = null, - @SerialName("runTime") val runTime: Int? = null, - @SerialName("seasonData") val seasonData: SeasonData? = null, + val headerName: String, + val name: String?, + val poster: String?, + val episode: Int, + val seasonIndex: Int?, // this is the "season" index used season names + val season: Int?, // this is the display + val data: String, + val apiName: String, + val id: Int, + val index: Int, + val position: Long, // time in MS + val duration: Long, // duration in MS + val rating: Int?, + val description: String?, + val isFiller: Boolean?, + val tvType: TvType, + val parentId: Int, + /** + * Conveys if the episode itself is marked as watched + **/ + val videoWatchState: VideoWatchState, + /** Sum of all previous season episode counts + episode */ + val totalEpisodeIndex: Int? = null, + val airDate: Long? = null, + val runTime: Int? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -91,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, @@ -99,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, ) } @@ -169,7 +157,7 @@ object ResultFragment { fun newInstance( url: String, apiName: String, - name: String, + name : String, startAction: Int = 0, startValue: Int = 0 ): Bundle { @@ -184,10 +172,9 @@ object ResultFragment { } fun updateUI(id: Int? = null) { - // updateUIListener?.invoke() + // updateUIListener?.invoke() updateUIEvent.invoke(id) } - val updateUIEvent = Event() //private var updateUIListener: (() -> Unit)? = null @@ -215,7 +202,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() { @@ -232,52 +222,14 @@ object ResultFragment { data class StoredData( val url: String, val apiName: String, - val name: String, + val name : String, val showFillers: Boolean, val dubStatus: DubStatus, val start: AutoResume?, val playerAction: Int, - val restart: Boolean, + val restart : Boolean, ) - fun bindLogo( - url: String?, - headers: Map?, - logoView: ImageView, - titleView: TextView - ) { - // Cancel it, as we want to remove the listener onSuccess race condition - logoView.dispose() - - if (url.isNullOrBlank()) { - logoView.isVisible = false - titleView.isVisible = true - return - } - - logoView.isVisible = true - titleView.isVisible = false - - logoView.loadImage( - imageData = UiImage.Image(url, headers = headers), - builder = { - listener( - onSuccess = { _, _ -> - logoView.isVisible = true - titleView.isVisible = false - }, - onError = { _, _ -> - logoView.isVisible = false - titleView.isVisible = true - }, - onCancel = { - // If we manually cancel, then it should not do anything - } - ) - } - ) - } - fun Fragment.getStoredData(): StoredData? { val context = this.context ?: this.activity ?: return null val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -347,6 +299,8 @@ object ResultFragment { context?.updateHasTrailers() activity?.loadCache() + //activity?.fixPaddingStatusbar(result_barstatus) + /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams backParameter.setMargins( backParameter.leftMargin, @@ -356,6 +310,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..9f7e3b6e3 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,71 @@ 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.VideoDownloadHelper import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml -import com.lagradost.cloudstream3.utils.txt -import java.net.URLEncoder -import java.util.concurrent.ConcurrentLinkedDeque -import kotlin.math.roundToInt -open class ResultFragmentPhone : BaseFragment( - BindingCreator.Inflate(FragmentResultSwipeBinding::inflate) -), PlayerView.Callbacks { +open class ResultFragmentPhone : FullScreenPlayer() { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { @@ -118,115 +89,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 +144,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 +179,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 +214,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 +231,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 +294,10 @@ open class ResultFragmentPhone : BaseFragment( override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel - activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) - context?.let { ctx -> - playerHostView?.onResume(ctx) - playerHostView?.setupKeyEventListener() + activity?.let { + @Suppress("DEPRECATION") + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) } super.onResume() PanelsChildGestureRegionObserver.Provider.get() @@ -418,44 +306,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 +342,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 +354,11 @@ open class ResultFragmentPhone : BaseFragment( // ===== ===== ===== - binding.resultSearch.isGone = storedData.name.isBlank() - binding.resultSearch.setOnClickListener { + binding?.resultSearch?.isGone = storedData.name.isBlank() + binding?.resultSearch?.setOnClickListener { QuickSearchFragment.pushSearch(activity, storedData.name) } - + resultBinding?.apply { resultReloadConnectionerror.setOnClickListener { viewModel.load( @@ -515,7 +384,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 +395,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 +426,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 +458,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 - ) + ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data).asStringNull(context) ?: "" + showToast(com.lagradost.cloudstream3.utils.txt(message, name), Toast.LENGTH_SHORT) } context?.let { openBatteryOptimizationSettings(it) } } @@ -652,12 +474,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 - ) + ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data).asStringNull(context) ?: "" + showToast(com.lagradost.cloudstream3.utils.txt(message, name), Toast.LENGTH_SHORT) } } mediaRouteButton.apply { @@ -678,7 +496,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 +517,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 +526,9 @@ open class ResultFragmentPhone : BaseFragment( recommendationBinding?.apply { resultRecommendationsList.apply { spanCount = 3 - setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( + ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) @@ -729,13 +552,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 +565,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 +575,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 +602,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 +615,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 +627,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 +652,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 +670,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) @@ -949,32 +713,12 @@ open class ResultFragmentPhone : BaseFragment( 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 - ) - } + 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 - ) - } + 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 - ) - var isExpanded = false resultDescription.apply { setTextHtml(d.plotText) @@ -991,15 +735,8 @@ open class ResultFragmentPhone : BaseFragment( resultComingSoon.isVisible = d.comingSoon resultDataHolder.isGone = d.comingSoon - val prefs = - androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) - val showCast = prefs.getBoolean( - root.context.getString(R.string.show_cast_in_details_key), - true - ) - - resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) + resultCastItems.isGone = d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.updateList(d.actors ?: emptyList()) if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap @@ -1013,7 +750,7 @@ open class ResultFragmentPhone : BaseFragment( syncModel.addFromUrl(d.url) } - binding.apply { + binding?.apply { resultSearch.isGone = d.title.isBlank() resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) @@ -1022,23 +759,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 +777,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 +790,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(com.lagradost.cloudstream3.utils.txt(R.string.title), resultTitle.text) true } } @@ -1099,17 +824,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 +839,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 +892,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 +909,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 +922,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 +951,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 +983,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 +1020,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 +1075,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 +1099,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 +1120,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 +1137,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 +1151,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 +1195,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 ce67763d0..ea055376e 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,43 @@ 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) -) { +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 +72,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 +114,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 +147,15 @@ 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 { com.lagradost.cloudstream3.utils.txt( + it + ) to it }) resultRecommendationsFilterSelection.select(apiNames.indexOf(matchAgainst)) } ?: run { resultRecommendationsFilterSelection.isVisible = false @@ -192,7 +181,11 @@ class ResultFragmentTv : BaseFragment( } override fun onResume() { - activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + activity?.let { + @Suppress("DEPRECATION") + it.window?.navigationBarColor = + it.colorFromAttribute(R.attr.primaryBlackBackground) + } afterPluginsLoadedEvent += ::reloadViewModel super.onResume() } @@ -233,13 +226,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 +236,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 +257,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 +269,7 @@ class ResultFragmentTv : BaseFragment( if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(false) - binding.apply { + binding?.apply { val views = listOf( resultPlayMovieButton, resultPlaySeriesButton, @@ -306,7 +290,7 @@ class ResultFragmentTv : BaseFragment( redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(true) - binding.apply { + binding?.apply { val views = listOf( resultDubSelection, resultSeasonSelection, @@ -331,7 +315,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 +325,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 +393,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 +422,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 +439,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 +488,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 +514,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 +532,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 +559,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 +580,43 @@ 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) ?: "" + ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data).asStringNull(context) ?: "" CommonActivity.showToast( - txt( + com.lagradost.cloudstream3.utils.txt( message, name - ), Toast.LENGTH_SHORT - ) + ), 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 +627,40 @@ 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) ?: "" + ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data).asStringNull(context) ?: "" CommonActivity.showToast( - txt( + com.lagradost.cloudstream3.utils.txt( message, name - ), Toast.LENGTH_SHORT - ) + ), 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 +674,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 +727,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 +766,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 +783,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 +828,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 +853,7 @@ class ResultFragmentTv : BaseFragment( resultCastText.setText(d.actorsText) resultNextAiring.setText(d.nextAiringEpisode) resultNextAiringTime.setText(d.nextAiringDate) - resultPoster.loadImage(d.posterImage, headers = d.posterHeaders) + resultPoster.loadImage(d.posterImage) var isExpanded = false resultDescription.apply { @@ -889,7 +865,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 +885,21 @@ class ResultFragmentTv : BaseFragment( R.drawable.profile_bg_red, R.drawable.profile_bg_teal ).random() - - backgroundPoster.loadImage(d.posterBackgroundImage, headers = d.posterHeaders) { + //Change poster crop area to 20% from Top + backgroundPoster.cropYCenterOffsetPct = 0.20F + + backgroundPoster.loadImage(d.posterBackgroundImage) { error { getImageFromDrawable(context ?: return@error null, error) } } - - bindLogo( - url = d.logoUrl, - headers = d.posterHeaders, - titleView = resultTitle, - logoView = backgroundPosterWatermarkBadgeHolder - ) - comingSoon = d.comingSoon resultTvComingSoon.isVisible = d.comingSoon - populateChips(resultTag, d.tags) - val prefs = - androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) - val showCast = prefs.getBoolean( - root.context.getString(R.string.show_cast_in_details_key), - true + UIHelper.populateChips(resultTag, d.tags) + resultCastItems.isGone = d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.updateList( + d.actors ?: emptyList() ) - resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) - if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap resultMetaContentRating.width = 0 @@ -945,7 +910,9 @@ class ResultFragmentTv : BaseFragment( } } - is Resource.Loading -> {} + is Resource.Loading -> { + + } is Resource.Failure -> { resultErrorText.text = @@ -962,4 +929,4 @@ class ResultFragmentTv : BaseFragment( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index 3b1471e6a..5253974e8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -3,76 +3,40 @@ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator import android.content.Context import android.content.res.Configuration -import android.os.Build import android.os.Bundle +import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import androidx.core.view.ViewCompat import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.ui.player.CSPlayerEvent -import com.lagradost.cloudstream3.ui.player.CSPlayerLoading import com.lagradost.cloudstream3.ui.player.PlayerEventSource import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -class ResultTrailerPlayer : ResultFragmentPhone() { +open class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false override var hasPipModeSupport = false companion object { - const val TAG = "ResultTrailerPlayer" + const val TAG = "RESULT_TRAILER" } private var playerWidthHeight: Pair? = null - private var introVisible = true - - // Single-tap on empty player area: toggle controls. - override fun onSingleTap() { - if (introVisible) return - if (isShowing) uiReset() else showControls() - } - - private fun showControls() { - if (introVisible) return - isShowing = true - updateUIVisibility() - playerHostView?.scheduleAutoHide() - } - - override fun isUIShowing(): Boolean = isShowing - - override fun onAutoHideUI() { - if (player.getIsPlaying()) uiReset() - } - - override fun onHidePlayerUI() = uiReset() - - // When the hold-speedup gesture fires, hide controls so the video is unobstructed. - // The speedup button show/hide and speed change are handled by PlayerView. - override fun onHoldSpeedUp(show: Boolean) { - if (show && isShowing) uiReset() - } - - override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { - if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { - isShowing = true - showControls() - } else playerHostView?.scheduleAutoHide() - } override fun nextEpisode() {} + override fun prevEpisode() {} - override fun playerPositionChanged(position: Long, duration: Long) {} + + override fun playerPositionChanged(position: Long, duration : Long) {} + override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -82,28 +46,18 @@ class ResultTrailerPlayer : ResultFragmentPhone() { } private fun fixPlayerSize() { - binding?.apply { - if (isFullScreenPlayer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - ViewCompat.setOnApplyWindowInsetsListener(root, null) - root.overlay.clear() - } - root.setPadding(0, 0, 0, 0) - } else { - fixSystemBarsPadding(root) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - ViewCompat.requestApplyInsets(root) - } - } - } - playerWidthHeight?.let { (w, h) -> - if (w <= 0 || h <= 0) return@let + if(w <= 0 || h <= 0) return@let val orientation = context?.resources?.configuration?.orientation ?: return - val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) screenWidth else screenHeight + val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + screenWidth + } else { + screenHeight + } + //result_trailer_loading?.isVisible = false resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer @@ -111,30 +65,35 @@ class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.fragmentTrailer?.playerBackground?.apply { isVisible = true - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to - ) + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to + ) } playerBinding?.playerIntroPlay?.apply { - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT - ) + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + resultBinding?.resultTopHolder?.measuredHeight + ?: FrameLayout.LayoutParams.MATCH_PARENT + ) } if (playerBinding?.playerIntroPlay?.isGone == true) { resultBinding?.resultTopHolder?.apply { + val anim = ValueAnimator.ofInt( measuredHeight, if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to ) - anim.addUpdateListener { va -> - val v = va.animatedValue as Int - val lp: ViewGroup.LayoutParams = layoutParams - lp.height = v - layoutParams = lp + anim.addUpdateListener { valueAnimator -> + val `val` = valueAnimator.animatedValue as Int + val layoutParams: ViewGroup.LayoutParams = + layoutParams + layoutParams.height = `val` + setLayoutParams(layoutParams) } anim.duration = 200 anim.start() @@ -143,14 +102,9 @@ class ResultTrailerPlayer : ResultFragmentPhone() { } } - override fun playerDimensionsLoaded(width: Int, height: Int) { + override fun playerDimensionsLoaded(width: Int, height : Int) { playerWidthHeight = width to height fixPlayerSize() - // Apply autorotation when fullscreen (lockRotation = true). - // PlayerView already set isVerticalOrientation before this callback fires. - if (lockRotation) { - activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return - } } override fun showMirrorsDialogue() {} @@ -160,39 +114,33 @@ class ResultTrailerPlayer : ResultFragmentPhone() { context: Context, loadResponse: LoadResponse?, dismissCallback: () -> Unit - ) {} - - override fun subtitlesChanged() {} - override fun embeddedSubtitlesFetched(subtitles: List) {} - override fun onTracksInfoChanged() {} - override fun exitedPipMode() {} - - override fun onSeekPreviewText(text: String?) { - playerBinding?.playerTimeText?.apply { - isVisible = text != null - if (text != null) this.text = text - } + ) { } + override fun subtitlesChanged() {} + + override fun embeddedSubtitlesFetched(subtitles: List) {} + override fun onTracksInfoChanged() {} + + override fun exitedPipMode() {} private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen - playerHostView?.isFullScreen = fullscreen - playerBinding?.playerFullscreen?.setImageResource( - if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24 - ) + playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) if (fullscreen) { - playerHostView?.enterFullscreen() + enterFullscreen() binding?.apply { resultTopBar.isVisible = false resultFullscreenHolder.isVisible = true resultMainHolder.isVisible = false } + resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) binding?.resultFullscreenHolder?.addView(view) } + } else { binding?.apply { resultTopBar.isVisible = true @@ -203,55 +151,36 @@ class ResultTrailerPlayer : ResultFragmentPhone() { resultBinding?.resultSmallscreenHolder?.addView(view) } } - playerHostView?.exitFullscreen() + exitFullscreen() } fixPlayerSize() uiReset() if (isFullScreenPlayer) { - activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) } - } else { - activity?.detachBackPressedCallback("ResultTrailerPlayer") - } + activity?.attachBackPressedCallback("ResultTrailerPlayer") { + updateFullscreen(false) + } + } else activity?.detachBackPressedCallback("ResultTrailerPlayer") } override fun updateUIVisibility() { super.updateUIVisibility() - playerBinding?.apply { - playerGoBackHolder.isVisible = false - val controlsVisible = isShowing && !introVisible - playerTopHolder.isVisible = controlsVisible - playerVideoHolder.isVisible = controlsVisible - shadowOverlay.isVisible = controlsVisible - playerPausePlayHolderHolder.isVisible = - controlsVisible && playerHostView?.currentPlayerStatus != CSPlayerLoading.IsBuffering - } - // Fade center controls in/out; also resets stale fillAfter alpha from seek animations. - playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) + playerBinding?.playerGoBackHolder?.isVisible = false } - override fun playerStatusChanged() { - if (introVisible) { - playerBinding?.playerPausePlayHolderHolder?.isVisible = false + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + playerBinding?.playerFullscreen?.setOnClickListener { + updateFullscreen(!isFullScreenPlayer) } - } - - override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { - super.onBindingCreated(binding, savedInstanceState) - - playerHostView?.videoOutline = playerBinding?.videoOutline - playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() - - playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.isGone = true - introVisible = false player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + updateUIVisibility() fixPlayerSize() - showControls() } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index c519e0de2..46f4ef652 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,8 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData -import com.lagradost.cloudstream3.utils.Editor -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorLinkType -import com.lagradost.cloudstream3.utils.FillerEpisodeCheck -import com.lagradost.cloudstream3.utils.INFER_TYPE -import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.UiText -import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager -import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle -import com.lagradost.cloudstream3.utils.loadExtractor -import com.lagradost.cloudstream3.utils.newExtractorLink -import com.lagradost.cloudstream3.utils.txt -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.isActive -import kotlinx.coroutines.job -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import java.util.concurrent.TimeUnit /** This starts at 1 */ @@ -177,7 +115,6 @@ data class ResultData( val posterImage: String?, val posterBackgroundImage: String?, - val logoUrl: String?, val plotText: UiText, val apiName: UiText, val ratingText: UiText?, @@ -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) { @@ -299,11 +227,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 +246,17 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { TvType.Live -> R.string.live_singular TvType.Others -> R.string.other_singular TvType.NSFW -> R.string.nsfw_singular - TvType.Music -> R.string.music_singular + TvType.Music -> R.string.music_singlar TvType.AudioBook -> R.string.audio_book_singular - TvType.CustomMedia -> R.string.custom_media_singular - TvType.Audio -> R.string.audio_singular - TvType.Podcast -> R.string.podcast_singular - TvType.Video -> R.string.video_singular + TvType.CustomMedia -> R.string.custom_media_singluar + TvType.Audio -> R.string.audio_singluar + TvType.Podcast -> R.string.podcast_singluar } ), yearText = txt(year?.toString()), 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 +266,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 +280,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 +296,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 +324,6 @@ data class ResumeWatchingStatus( data class LinkLoadingResult( val links: List, val subs: List, - val syncData: HashMap ) sealed class SelectPopup { @@ -449,7 +374,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 +399,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 +452,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,29 +496,6 @@ class ResultViewModel2 : ViewModel() { return this?.firstOrNull { it.season == season } } - fun seasonToTxt(seasonData: SeasonData?, season: Int?): UiText? { - if (season == 0) { - return txt(R.string.no_season) - } - - // If displaySeason is null then only show the name! - return if (seasonData?.name != null && seasonData.displaySeason == null) { - txt(seasonData.name) - } else { - val suffix = seasonData?.name?.let { " $it" } ?: "" - txt( - R.string.season_format, - txt(R.string.season), - seasonData?.displaySeason ?: season, - suffix - ) - } - } - - private fun List?.getSeasonTxt(season: Int?): UiText? = - seasonToTxt(getSeason(season), season) - - private fun filterName(name: String?): String? { if (name == null) return null Regex("^[eE]pisode [0-9]*(.*)").find(name)?.groupValues?.get(1)?.let { @@ -725,6 +614,222 @@ class ResultViewModel2 : ViewModel() { index to list }.toMap() } + + private fun downloadSubtitle( + context: Context?, + link: ExtractorSubtitleLink, + fileName: String, + folder: String + ) { + ioSafe { + VideoDownloadManager.downloadThing( + context ?: return@ioSafe, + link, + "$fileName ${link.name}", + folder, + if (link.url.contains(".srt")) "srt" else "vtt", + false, + null, createNotificationCallback = {} + ) + } + } + + private fun getFolder(currentType: TvType, titleName: String): String { + return if (currentType.isEpisodeBased()) { + val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName) + "${currentType.getFolderPrefix()}/$sanitizedFileName" + } else currentType.getFolderPrefix() + } + + private fun downloadSubtitle( + context: Context?, + link: SubtitleData, + meta: VideoDownloadManager.DownloadEpisodeMetadata, + ) { + context?.let { ctx -> + val fileName = VideoDownloadManager.getFileName(ctx, meta) + val folder = getFolder(meta.type ?: return, meta.mainName) + downloadSubtitle( + ctx, + ExtractorSubtitleLink(link.name, link.url, ""), + fileName, + folder + ) + } + } + + fun startDownload( + context: Context?, + episode: ResultEpisode, + currentIsMovie: Boolean, + currentHeaderName: String, + currentType: TvType, + currentPoster: String?, + apiName: String, + parentId: Int, + url: String, + links: List, + subs: List? + ) { + try { + if (context == null) return + + val meta = + getMeta( + episode, + currentHeaderName, + apiName, + currentPoster, + currentIsMovie, + currentType + ) + + val folder = getFolder(currentType, currentHeaderName) + + val src = "$DOWNLOAD_NAVIGATE_TO/$parentId" // url ?: return@let + + // SET VISUAL KEYS + setKey( + DOWNLOAD_HEADER_CACHE, + parentId.toString(), + VideoDownloadHelper.DownloadHeaderCached( + apiName = apiName, + url = url, + type = currentType, + name = currentHeaderName, + poster = currentPoster, + id = parentId, + cacheTime = System.currentTimeMillis(), + ) + ) + + setKey( + DataStore.getFolderName( + DOWNLOAD_EPISODE_CACHE, + parentId.toString() + ), // 3 deep folder for faster acess + episode.id.toString(), + VideoDownloadHelper.DownloadEpisodeCached( + name = episode.name, + poster = episode.poster, + episode = episode.episode, + season = episode.season, + id = episode.id, + parentId = parentId, + rating = episode.rating, + description = episode.description, + cacheTime = System.currentTimeMillis(), + ) + ) + + // DOWNLOAD VIDEO + VideoDownloadManager.downloadEpisodeUsingWorker( + context, + src,//url ?: return, + folder, + meta, + links + ) + + // 1. Checks if the lang should be downloaded + // 2. Makes it into the download format + // 3. Downloads it as a .vtt file + val downloadList = SubtitlesFragment.getDownloadSubsLanguageISO639_1() + subs?.let { subsList -> + subsList.filter { + downloadList.contains( + SubtitleHelper.fromLanguageToTwoLetters( + it.name, + true + ) + ) + } + .map { ExtractorSubtitleLink(it.name, it.url, "") }.take(3) + .forEach { link -> + val fileName = VideoDownloadManager.getFileName(context, meta) + downloadSubtitle(context, link, fileName, folder) + } + } + } catch (e: Exception) { + logError(e) + } + } + + suspend fun downloadEpisode( + activity: Activity?, + episode: ResultEpisode, + currentIsMovie: Boolean, + currentHeaderName: String, + currentType: TvType, + currentPoster: String?, + apiName: String, + parentId: Int, + url: String, + ) { + ioSafe { + val generator = RepoLinkGenerator(listOf(episode)) + val currentLinks = mutableSetOf() + val currentSubs = mutableSetOf() + generator.generateLinks(clearCache = false, allowedTypes = LOADTYPE_INAPP_DOWNLOAD, callback = { + it.first?.let { link -> + currentLinks.add(link) + } + }, subtitleCallback = { sub -> + currentSubs.add(sub) + }) + + if (currentLinks.isEmpty()) { + main { + showToast( + R.string.no_links_found_toast, + Toast.LENGTH_SHORT + ) + } + return@ioSafe + } else { + main { + showToast( + R.string.download_started, + Toast.LENGTH_SHORT + ) + } + } + + startDownload( + activity, + episode, + currentIsMovie, + currentHeaderName, + currentType, + currentPoster, + apiName, + parentId, + url, + sortUrls(currentLinks), + sortSubs(currentSubs), + ) + } + } + + private fun getMeta( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): VideoDownloadManager.DownloadEpisodeMetadata { + return VideoDownloadManager.DownloadEpisodeMetadata( + episode.id, + VideoDownloadManager.sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } } private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) @@ -794,7 +899,7 @@ class ResultViewModel2 : ViewModel() { response.syncData, plot = response.plot, tags = response.tags, - score = response.score + rating = response.rating ) ) } @@ -816,12 +921,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 +991,7 @@ class ResultViewModel2 : ViewModel() { response.year, response.syncData, plot = response.plot, - score = response.score, + rating = response.rating, tags = response.tags ) ) @@ -903,28 +1003,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 +1062,7 @@ class ResultViewModel2 : ViewModel() { response.year, response.syncData, plot = response.plot, - score = response.score, + rating = response.rating, tags = response.tags ) ) @@ -1104,7 +1182,7 @@ class ResultViewModel2 : ViewModel() { } private fun getImdbIdFromSyncData(syncData: Map?): String? { - return safe { + return normalSafeApiCall { val imdbId = readIdFromString( syncData?.get(AccountManager.simklApi.idPrefix) )[SimklSyncServices.Imdb] @@ -1113,7 +1191,7 @@ class ResultViewModel2 : ViewModel() { } private fun getTMDbIdFromSyncData(syncData: Map?): String? { - return safe { + return normalSafeApiCall { val tmdbId = readIdFromString( syncData?.get(AccountManager.simklApi.idPrefix) )[SimklSyncServices.Tmdb] @@ -1198,22 +1276,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 +1296,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 +1326,6 @@ class ResultViewModel2 : ViewModel() { } } - fun skipLoading() { - currentLoadLinkJob?.cancelChildren() - currentLoadLinkJob = null - } - private suspend fun CoroutineScope.loadLinks( result: ResultEpisode, isVisible: Boolean, @@ -1280,9 +1344,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 +1353,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 +1375,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 +1390,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 +1412,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 +1488,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 +1508,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 +1521,8 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ).toWrapper() - ) + ) + } showToast( R.string.download_started, Toast.LENGTH_SHORT @@ -1544,25 +1561,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 +1591,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( @@ -1675,7 +1645,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 +1656,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 +1678,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 +1700,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 +1711,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 +1724,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 +1798,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 +1824,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 +1858,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 +1882,9 @@ class ResultViewModel2 : ViewModel() { } else { _episodes.postValue( Resource.Success( - getSortedEpisodes( - getEpisodes( - currentIndex ?: return, - currentRange ?: return, - ), currentSorting ?: return + getEpisodes( + currentIndex ?: return, + currentRange ?: return ) ) ) @@ -1975,24 +1912,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 +1921,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 +1955,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 +2027,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 +2065,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 +2099,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 +2155,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 +2169,6 @@ class ResultViewModel2 : ViewModel() { totalIndex, airDate = episode.date, runTime = episode.runTime, - seasonData = seasonData, ) val season = ep.seasonIndex ?: 0 @@ -2375,7 +2267,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 +2303,7 @@ class ResultViewModel2 : ViewModel() { it.startEpisode >= (preferStartEpisode ?: 0) } ?: ranger?.lastOrNull() - postEpisodeRange(min, range, DataStoreHelper.resultsSortingMode) + postEpisodeRange(min, range) postResume() } @@ -2453,33 +2359,23 @@ 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, + headers = trailerData.headers, + type = INFER_TYPE ) ) to arrayListOf() } else { @@ -2554,7 +2450,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 +2460,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 +2483,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 +2525,8 @@ class ResultViewModel2 : ViewModel() { _page.postValue( Resource.Failure( false, + null, + null, "This provider does not exist" ) ) @@ -2678,7 +2574,7 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - DownloadObjects.DownloadHeaderCached( + VideoDownloadHelper.DownloadHeaderCached( apiName = apiName, url = validUrl, type = loadResponse.type, @@ -2706,4 +2602,4 @@ class ResultViewModel2 : ViewModel() { } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt index 4231819dd..ad5d89d18 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt @@ -2,11 +2,10 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.databinding.ResultSelectionBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UiText @@ -14,56 +13,93 @@ import com.lagradost.cloudstream3.utils.setText typealias SelectData = Pair -class SelectAdaptor(val callback: (Any) -> Unit) : - NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.second == b.second - }, contentSame = { a, b -> - a == b - })) { +class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter() { + private val selection: MutableList = mutableListOf() private var selectedIndex: Int = -1 - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - ResultSelectionBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return SelectViewHolder( + ResultSelectionBinding.inflate(LayoutInflater.from(parent.context), parent, false), + + //LayoutInflater.from(parent.context).inflate(R.layout.result_selection, parent, false), ) } - override fun onBindContent(holder: ViewHolderState, item: SelectData, position: Int) { - when (val binding = holder.view) { - is ResultSelectionBinding -> { - binding.root.apply { - if (isLayout(TV)) { - isFocusable = true - isFocusableInTouchMode = true - } - - isSelected = position == selectedIndex - setText(item.first) - setOnClickListener { - callback.invoke(item.second) - } - } + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is SelectViewHolder -> { + holder.bind(selection[position], position == selectedIndex, callback) } } } - override fun onViewDetachedFromWindow(holder: ViewHolderState) { - if (holder.itemView.hasFocus()) { + override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + if(holder.itemView.hasFocus()) { holder.itemView.clearFocus() } } + override fun getItemCount(): Int { + return selection.size + } + fun select(newIndex: Int, recyclerView: RecyclerView?) { - if (recyclerView == null) return - if (newIndex == selectedIndex) return + if(recyclerView == null) return + if(newIndex == selectedIndex) return val oldIndex = selectedIndex selectedIndex = newIndex notifyItemChanged(selectedIndex) notifyItemChanged(oldIndex) } + + fun updateSelectionList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + SelectDataCallback(this.selection, newList) + ) + + selection.clear() + selection.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + + private class SelectViewHolder( + binding: ResultSelectionBinding, + ) : + RecyclerView.ViewHolder(binding.root) { + private val item: MaterialButton = binding.root + + fun bind( + data: SelectData, isSelected: Boolean, callback: (Any) -> Unit + ) { + if (isLayout(TV)) { + item.isFocusable = true + item.isFocusableInTouchMode = true + } + + item.isSelected = isSelected + item.setText(data.first) + item.setOnClickListener { + callback.invoke(data.second) + } + } + } } + +class SelectDataCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].second == newList[newItemPosition].second + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt index 3161a1204..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) { @@ -182,10 +179,9 @@ class SyncViewModel : ViewModel() { fun publishUserData() = ioSafe { Log.i(TAG, "publishUserData") val user = userData.value - _userDataResponse.postValue(Resource.Loading()) 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() @@ -207,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}") + } + } } } } @@ -218,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 @@ -253,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 + } } } } @@ -274,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 @@ -286,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/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..38691186e 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(androidx.appcompat.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()*/ } -} + +} \ No newline at end of file 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 793d96734..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,96 +2,98 @@ 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 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -@Serializable data class SearchHistoryItem( - @JsonProperty("searchedAt") @SerialName("searchedAt") val searchedAt: Long, - @JsonProperty("searchText") @SerialName("searchText") val searchText: String, - @JsonProperty("type") @SerialName("type") val type: List, - @JsonProperty("key") @SerialName("key") val key: String, + @JsonProperty("searchedAt") val searchedAt: Long, + @JsonProperty("searchText") val searchText: String, + @JsonProperty("type") val type: List, + @JsonProperty("key") val key: String, ) 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..9b4e06a09 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 @@ -11,6 +10,8 @@ import androidx.cardview.widget.CardView import androidx.core.view.isVisible import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager +import coil3.request.error +import coil3.toBitmap import com.lagradost.cloudstream3.AnimeSearchResponse import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LiveSearchResponse @@ -22,12 +23,11 @@ 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.UIHelper.createPaletteAsync import com.lagradost.cloudstream3.utils.getImageFromDrawable object SearchResultBuilder { @@ -50,7 +50,7 @@ object SearchResultBuilder { itemView: View, nextFocusUp: Int? = null, nextFocusDown: Int? = null, - colorCallback: ((Palette) -> Unit)? = null + colorCallback : ((Palette) -> Unit)? = null ) { val cardView: ImageView = itemView.findViewById(R.id.imageView) val cardText: TextView? = itemView.findViewById(R.id.imageText) @@ -67,7 +67,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 +76,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,12 +124,17 @@ 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) } + cardView.loadImage(card.posterUrl, card.posterHeaders) { + error { getImageFromDrawable(itemView.context, R.drawable.default_cover) } + val posterUrl = card.posterUrl + if (posterUrl != null && colorCallback != null) { + this.listener(onSuccess = { _,success -> + val bitmap = success.image.toBitmap() + createPaletteAsync(posterUrl, bitmap, colorCallback) + }) } - } else cardView.loadImage(R.drawable.default_cover) - + } + fun click(view: View?) { clickCallback.invoke( SearchClickCallback( @@ -175,7 +172,7 @@ object SearchResultBuilder { bg.isFocusable = false bg.isFocusableInTouchMode = false - if (!isLayout(TV)) { + if(!isLayout(TV)) { bg.setOnClickListener { click(it) } @@ -185,7 +182,7 @@ object SearchResultBuilder { } } // - // + // // itemView.setOnClickListener { @@ -219,9 +216,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 +247,6 @@ object SearchResultBuilder { } } } - is DataStoreHelper.ResumeWatchingResult -> { val pos = card.watchPos?.fixVisual() if (pos != null) { @@ -258,15 +254,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 +297,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 a22c2fcef..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt +++ /dev/null @@ -1,78 +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 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * 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" - - @Serializable - data class TmdbSearchResult( - @JsonProperty("results") @SerialName("results") val results: List?, - ) - - @Serializable - data class TmdbSearchItem( - @JsonProperty("media_type") @SerialName("media_type") val mediaType: String?, - @JsonProperty("title") @SerialName("title") val title: String?, - @JsonProperty("name") @SerialName("name") val name: String?, - @JsonProperty("original_title") @SerialName("original_title") val originalTitle: String?, - @JsonProperty("original_name") @SerialName("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 suspend 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..ba53f96f1 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,65 @@ 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.syncproviders.AuthAPI import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -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!!) { + + @SuppressLint("StringFormatInvalid") + fun bind(card: AuthAPI.LoginInfo) { + // just in case name is null account index will show, should never happened + binding?.apply { + accountName.text = card.name ?: "%s %d".format( + binding.accountName.context.getString(R.string.account), + card.accountIndex + ) + accountProfilePicture.isVisible = true + accountProfilePicture.loadImage(card.profilePicture) + + itemView.setOnClickListener { + clickCallback.invoke(AccountClickCallback(0, itemView, card)) + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt index 93e469a4d..aa513d87a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt @@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui.settings import android.app.UiModeManager import android.content.Context import android.content.res.Configuration -import android.content.res.Resources import android.os.Build import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R @@ -45,11 +44,6 @@ object Globals { layoutId = layoutIntCorrected() } - /** Returns true if the current orientation is landscape. */ - fun isLandscape(): Boolean = - isLayout(TV or EMULATOR) || - Resources.getSystem().configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - /** Returns true if the layout is any of the flags, * so isLayout(TV or EMULATOR) is a valid statement for checking if the layout is in the emulator * or tv. Auto will become the "TV" or the "PHONE" layout. diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt index 365990646..7fcfefb7b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt @@ -2,30 +2,25 @@ package com.lagradost.cloudstream3.ui.settings import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.ItemLogcatBinding -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -class LogcatAdapter() : NoStateAdapter( - diffCallback = BaseDiffCallback( - itemSame = String::equals, - contentSame = String::equals - ) -) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - return ViewHolderState( - ItemLogcatBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) +class LogcatAdapter( + private val logs: List +) : RecyclerView.Adapter() { + + inner class LogViewHolder( + val binding: ItemLogcatBinding + ) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder { + val binding = ItemLogcatBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return LogViewHolder(binding) } - override fun onBindContent(holder: ViewHolderState, item: String, position: Int) { - (holder.view as? ItemLogcatBinding)?.apply { - logText.text = item - } + override fun onBindViewHolder(holder: LogViewHolder, position: Int) { + holder.binding.logText.text = logs[position] } + + override fun getItemCount(): Int = logs.count() } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 75ba55674..daeab0500 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,34 @@ 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.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 @@ -65,21 +58,17 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogTe 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.UIHelper.hideProgress -import com.lagradost.cloudstream3.utils.UIHelper.showProgress import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import qrcode.QRCode -class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { +class SettingsAccount : PreferenceFragmentCompat(), BiometricCallback { companion object { /** Used by nginx plugin too */ - @SuppressLint("StringFormatInvalid") fun showLoginInfo( activity: FragmentActivity?, - api: AuthRepo, - info: AuthUser?, - index: Int, + api: AccountManager, + info: AuthAPI.LoginInfo ) { if (activity == null) return val binding: AccountManagmentBinding = @@ -89,25 +78,17 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { .setView(binding.root) val dialog = builder.show() - binding.accountMainProfilePictureHolder.isVisible = - !info?.profilePicture.isNullOrEmpty() - binding.accountMainProfilePicture.loadImage(info?.profilePicture) + binding.accountMainProfilePictureHolder.isVisible = !info.profilePicture.isNullOrEmpty() + binding.accountMainProfilePicture.loadImage(info.profilePicture) + - binding.accountLogout.isVisible = info != null binding.accountLogout.setOnClickListener { - 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 @@ -121,8 +102,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) @@ -136,275 +117,248 @@ 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.loadImage(qrCodeImage) + } + + val expirationMillis = + pinCodeData.expiresIn.times(1000).toLong() + + object : CountDownTimer(expirationMillis, 1000) { + + override fun onTick(millisUntilFinished: Long) { + val secondsUntilFinished = + millisUntilFinished.div(1000).toInt() + + binding.deviceAuthValidationCounter.setText( + txt( + R.string.device_pin_counter_text, + secondsUntilFinished.div(60), + secondsUntilFinished.rem(60) + ) + ) + + ioSafe { + if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.handleDeviceAuth(pinCodeData)) { + showToast( + txt( + R.string.authenticated_user, + api.name + ) + ) + dialog.dismissSafe(activity) + cancel() + } + } + } + + override fun onFinish() { + showToast(R.string.device_pin_expired_message) + dialog.dismissSafe(activity) + } + + }.start() + } + } catch (e: Exception) { + logError(e) } } } - - 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, - ) - binding.applyBtt.showProgress() - 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 ) - } finally { - binding.applyBtt.hideProgress() + + 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) } } } @@ -412,10 +366,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() { @@ -423,7 +376,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { } override fun onAuthenticationSuccess() { - if (isAuthEnabled(context ?: return)) { + if (isAuthEnabled(context?: return)) { updateAuthPreference(true) BackupUtils.backup(activity) activity?.showBottomDialogText( @@ -454,10 +407,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) @@ -469,24 +422,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 6943e5cf4..f256a31e7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -2,7 +2,9 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import android.widget.ImageView import androidx.annotation.StringRes import androidx.core.view.children @@ -16,21 +18,17 @@ import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.MainSettingsBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthRepo -import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.errorProfilePic import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable @@ -42,9 +40,7 @@ import java.util.Date import java.util.Locale import java.util.TimeZone -class SettingsFragment : BaseFragment( - BaseFragment.BindingCreator.Inflate(MainSettingsBinding::inflate) -) { +class SettingsFragment : Fragment() { companion object { fun PreferenceFragmentCompat?.getPref(id: Int): Preference? { if (this == null) return null @@ -126,6 +122,7 @@ class SettingsFragment : BaseFragment( } } } + UIHelper.fixPaddingStatusbar(settingsToolbar) } fun Fragment?.setUpToolbar(@StringRes title: Int) { @@ -138,20 +135,11 @@ class SettingsFragment : BaseFragment( setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) children.firstOrNull { it is ImageView }?.tag = getString(R.string.tv_no_focus_tag) setNavigationOnClickListener { - safe { activity?.onBackPressedDispatcher?.onBackPressed() } + activity?.onBackPressedDispatcher?.onBackPressed() } } } - } - - fun Fragment.setSystemBarsPadding() { - view?.let { - fixSystemBarsPadding( - it, - padLeft = isLayout(TV or EMULATOR), - padBottom = isLandscape() - ) - } + UIHelper.fixPaddingStatusbar(settingsToolbar) } fun getFolderSize(dir: File): Long { @@ -169,15 +157,24 @@ class SettingsFragment : BaseFragment( } } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: MainSettingsBinding) { + var binding: MainSettingsBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val localBinding = MainSettingsBinding.inflate(inflater, container, false) + binding = localBinding + return localBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { fun navigate(id: Int) { activity?.navigate(id, Bundle()) } @@ -186,25 +183,26 @@ 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 -> + binding?.settingsProfilePic?.let { imageView -> imageView.loadImage(pic) { // Fallback to random error drawable error { getImageFromDrawable(context ?: return@error null, errorProfilePic) } } } - binding.settingsProfileText.text = login.name + binding?.settingsProfileText?.text = login.name return true // sync profile exists + } return false // not syncing } // display local account information if not syncing - if (!hasProfilePictureFromAccountManagers(AccountManager.allApis)) { + if (!hasProfilePictureFromAccountManagers(accountManagers)) { val activity = activity ?: return val currentAccount = try { DataStoreHelper.accounts.firstOrNull { @@ -216,11 +214,11 @@ class SettingsFragment : BaseFragment( null } - binding.settingsProfilePic.loadImage(currentAccount?.image) - binding.settingsProfileText.text = currentAccount?.name + binding?.settingsProfilePic?.loadImage(currentAccount?.image) + binding?.settingsProfileText?.text = currentAccount?.name } - binding.apply { + binding?.apply { listOf( settingsGeneral to R.id.action_navigation_global_to_navigation_settings_general, settingsPlayer to R.id.action_navigation_global_to_navigation_settings_player, @@ -247,19 +245,17 @@ class SettingsFragment : BaseFragment( } } - val appVersion = BuildConfig.VERSION_NAME - val commitHash = activity?.currentCommitHash() ?: "" - val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.MEDIUM, + 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 b04f9a4a2..b6c2960dc 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,21 +1,20 @@ 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.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.JsonAlias 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 @@ -24,9 +23,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 @@ -44,108 +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 kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonNames -import java.util.Locale +import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath // Change local language settings in the app. fun getCurrentLocale(context: Context): String { - val conf = context.resources.configuration - return ConfigurationCompat.getLocales(conf).get(0)?.toLanguageTag() ?: "en" + val res = context.resources + val conf = res.configuration + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + conf?.locales?.get(0)?.toString() ?: "en" + } else { + @Suppress("DEPRECATION") + conf?.locale?.toString() ?: "en" + } } -/** - * List of app supported languages. - * Language code shall be a IETF BCP 47 conformant tag - * - * See locales on: - * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json - * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry - * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml - * https://iso639-3.sil.org/code_tables/639/data/all -*/ +// idk, if you find a way of automating this it would be great +// https://www.iemoji.com/view/emoji/1794/flags/antarctica +// Emoji Character Encoding Data --> C/C++/Java Src +// https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes leave blank for auto val appLanguages = arrayListOf( /* begin language list */ - Pair("Afrikaans", "af"), - Pair("Azərbaycan dili", "az"), - Pair("Bahasa Indonesia", "in"), - Pair("Bahasa Melayu", "ms"), - Pair("català", "ca"), - 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("Shqip мова", "sq"), - 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("беларуская мова", "be"), - Pair("български", "bg"), - Pair("македонски", "mk"), - Pair("русский", "ru"), - Pair("українська", "uk"), - Pair("עברית", "iw"), - Pair("اردو", "ur"), - Pair("العربية", "ar"), - Pair("اللهجة النجدية", "ars"), - Pair("عربي شامي", "apc"), - Pair("فارسی", "fa"), - Pair("کوردیی ناوەندی", "ckb"), - Pair("नेपाली", "ne"), - Pair("हिन्दी", "hi"), - Pair("অসমীয়া", "as"), - Pair("বাংলা", "bn"), - Pair("ଓଡ଼ିଆ", "or"), - Pair("தமிழ்", "ta"), - Pair("ಕನ್ನಡ", "kn"), - Pair("മലയാളം", "ml"), - Pair("ဗမာစာ", "my"), - Pair("ትግርኛ", "ti"), - Pair("አማርኛ", "am"), - Pair("中文", "zh"), - Pair("日本語 (にほんご)", "ja"), - Pair("正體中文(臺灣)", "zh-TW"), - Pair("한국어", "ko"), + Triple("", "Afrikaans", "af"), + Triple("", "عربي شامي", "ajp"), + Triple("", "አማርኛ", "am"), + Triple("", "العربية", "ar"), + Triple("", "اللهجة النجدية", "ars"), + Triple("", "অসমীয়া", "as"), + Triple("", "azərbaycan dili", "az"), + Triple("", "български", "bg"), + Triple("", "বাংলা", "bn"), + Triple("\uD83C\uDDE7\uD83C\uDDF7", "português brasileiro", "bp"), + Triple("", "čeština", "cs"), + Triple("", "Deutsch", "de"), + Triple("", "Ελληνικά", "el"), + Triple("", "English", "en"), + Triple("", "Esperanto", "eo"), + Triple("", "español", "es"), + Triple("", "فارسی", "fa"), + Triple("", "fil", "fil"), + Triple("", "français", "fr"), + Triple("", "galego", "gl"), + Triple("", "हिन्दी", "hi"), + Triple("", "hrvatski", "hr"), + Triple("", "magyar", "hu"), + Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"), + Triple("", "italiano", "it"), + Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"), + Triple("", "日本語 (にほんご)", "ja"), + Triple("", "ಕನ್ನಡ", "kn"), + Triple("", "한국어", "ko"), + Triple("", "lietuvių kalba", "lt"), + Triple("", "latviešu valoda", "lv"), + Triple("", "македонски", "mk"), + Triple("", "മലയാളം", "ml"), + Triple("", "bahasa Melayu", "ms"), + Triple("", "Malti", "mt"), + Triple("", "ဗမာစာ", "my"), + Triple("", "नेपाली", "ne"), + Triple("", "Nederlands", "nl"), + Triple("", "norsk nynorsk", "nn"), + Triple("", "norsk bokmål", "no"), + Triple("", "ଓଡ଼ିଆ", "or"), + Triple("", "polski", "pl"), + Triple("\uD83C\uDDF5\uD83C\uDDF9", "português", "pt"), + Triple("\uD83E\uDD8D", "mmmm... monke", "qt"), + Triple("", "română", "ro"), + Triple("", "русский", "ru"), + Triple("", "slovenčina", "sk"), + Triple("", "Soomaaliga", "so"), + Triple("", "svenska", "sv"), + Triple("", "தமிழ்", "ta"), + Triple("", "ትግርኛ", "ti"), + Triple("", "Tagalog", "tl"), + Triple("", "Türkçe", "tr"), + Triple("", "українська", "uk"), + Triple("", "اردو", "ur"), + Triple("", "Tiếng Việt", "vi"), + Triple("", "中文", "zh"), + Triple("\uD83C\uDDF9\uD83C\uDDFC", "正體中文(臺灣)", "zh-rTW"), /* end language list */ -).sortedBy { it.first.lowercase(Locale.ROOT) } // ye, we go alphabetical, so ppl don't put their lang on top +).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top -fun Pair.nameNextToFlagEmoji(): String { - // fallback to [A][A] -> [?] question mak flag - val flag = SubtitleHelper.getFlagFromIso(this.second) ?: "\ud83c\udde6\ud83c\udde6" - - return "$flag\u00a0${this.first}" // \u00a0 non-breaking space -} - -class SettingsGeneral : BasePreferenceFragmentCompat() { +class SettingsGeneral : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_general) @@ -153,32 +135,25 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { setToolBarScrollFlags() } - @OptIn(ExperimentalSerializationApi::class) // JsonNames is an experimental annotation for now - @Serializable data class CustomSite( - @JsonProperty("parentClassName") @JsonAlias("parentJavaClass") - @SerialName("parentClassName") @JsonNames("parentJavaClass") - val parentClassName: String, // ::class.simpleName - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("url") @SerialName("url") val url: String, - @JsonProperty("lang") @SerialName("lang") val lang: String, + @JsonProperty("parentJavaClass") // javaClass.simpleName + val parentJavaClass: String, + @JsonProperty("name") + val name: String, + @JsonProperty("url") + val url: String, + @JsonProperty("lang") + val lang: String, ) - companion object { - fun Fragment.pickDownloadPath(uri: Uri?, path: String?) { - if (uri == null) return - - val context = context ?: CloudStreamApp.context ?: return - val visual = path ?: uri.toString() - PreferenceManager.getDefaultSharedPreferences(context).edit { - putString(getString(R.string.download_path_key), uri.toString()) - putString(context.getString(R.string.download_path_key_visual), visual) - } - } - } - private val pathPicker = getChooseFolderLauncher { uri, path -> - pickDownloadPath(uri, path) + val context = context ?: AcraApplication.context ?: return@getChooseFolderLauncher + (path ?: uri.toString()).let { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(getString(R.string.download_path_key), uri.toString()) + .putString(getString(R.string.download_path_key_visual), it) + .apply() + } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -192,20 +167,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) @@ -227,7 +204,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, @@ -251,14 +228,13 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { val url = binding.siteUrlInput.text?.toString() val lang = binding.siteLangInput.text?.toString() val realLang = if (lang.isNullOrBlank()) provider.lang else lang - val simpleName = provider::class.simpleName - if (url.isNullOrBlank() || name.isNullOrBlank() || simpleName == null) { + if (url.isNullOrBlank() || name.isNullOrBlank() || realLang.length != 2) { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } val current = getCurrent() - val newSite = CustomSite(simpleName, name, url, realLang) + val newSite = CustomSite(provider.javaClass.simpleName, name, url, realLang) current.add(newSite) setKey(USER_PROVIDER_API, current.toTypedArray()) // reload apis @@ -337,16 +313,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 { @@ -362,24 +338,18 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } ?: emptyList() } - settingsManager.edit { putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false) } + settingsManager.edit().putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false).apply() getPref(R.string.jsdelivr_proxy_key)?.setOnPreferenceChangeListener { _, newValue -> setKey(getString(R.string.jsdelivr_proxy_key), newValue) return@setOnPreferenceChangeListener true } - getPref(R.string.download_parallel_key)?.setOnPreferenceChangeListener { _, _ -> - // Notify that the queue logic has been changed - DownloadQueueManager.forceRefreshQueue() - return@setOnPreferenceChangeListener true - } - getPref(R.string.download_path_key)?.setOnPreferenceClickListener { val dirs = getDownloadDirs() val currentDir = settingsManager.getString(getString(R.string.download_path_key_visual), null) - ?: context?.let { ctx -> DownloadFileManagement.getDefaultDir(ctx)?.filePath() } + ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf(getString(R.string.custom)), @@ -398,10 +368,10 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { // Sets both visual and actual paths. // key = used path // visual = visual path - settingsManager.edit { - putString(getString(R.string.download_path_key), dirs[it]) - putString(getString(R.string.download_path_key_visual), dirs[it]) - } + settingsManager.edit() + .putString(getString(R.string.download_path_key), dirs[it]) + .putString(getString(R.string.download_path_key_visual), dirs[it]) + .apply() } } return@setOnPreferenceClickListener true @@ -422,15 +392,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 294928115..2eaea61bf 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,11 +80,10 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.limit_title), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } @@ -106,48 +98,30 @@ class SettingsPlayer : BasePreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.software_decoding), true, - {} - ) { - settingsManager.edit { - putInt(getString(R.string.software_decoding_key), prefValues[it]) - } + {}) { + settingsManager.edit() + .putInt(getString(R.string.software_decoding_key), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } - getPref(R.string.prefer_limit_show_player_info)?.setOnPreferenceClickListener { - val ctx = context ?: return@setOnPreferenceClickListener false + getPref(R.string.prefer_limit_title_rez_key)?.setOnPreferenceClickListener { + val prefNames = resources.getStringArray(R.array.limit_title_rez_pref_names) + val prefValues = resources.getIntArray(R.array.limit_title_rez_pref_values) + val current = settingsManager.getInt(getString(R.string.prefer_limit_title_rez_key), 3) - val prefNames = resources.getStringArray(R.array.title_info_pref_names) - val keys = resources.getStringArray(R.array.title_info_pref_values) - - // Player defaults - val playerDefaults = mapOf( - ctx.getString(R.string.show_name_key) to true, - ctx.getString(R.string.show_resolution_key) to true, - ctx.getString(R.string.show_media_info_key) to false - ) - - val selectedIndices = keys.map { key -> - settingsManager.getBoolean(key, playerDefaults[key] ?: false) - }.mapIndexedNotNull { index, enabled -> - if (enabled) index else null - } - - activity?.showMultiDialog( + activity?.showBottomDialog( prefNames.toList(), - selectedIndices, + prefValues.indexOf(current), getString(R.string.limit_title_rez), - {} - ) { selected -> - settingsManager.edit { - for ((index, key) in keys.withIndex()) { - putBoolean(key, selected.contains(index)) - } - } + true, + {}) { + settingsManager.edit() + .putInt(getString(R.string.prefer_limit_title_rez_key), prefValues[it]) + .apply() } - - true + return@setOnPreferenceClickListener true } getPref(R.string.hide_player_control_names_key)?.hideOn(TV) @@ -169,11 +143,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,45 +167,33 @@ 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 } getPref(R.string.player_default_key)?.setOnPreferenceClickListener { - // Pair each player with its display name, dropping any with none, - // which would mean something is definitely wrong. - val players = VideoClickActionHolder.getPlayers(activity).mapNotNull { player -> - (player.name.asStringNull(activity) ?: player::class.simpleName)?.let { player to it } - } - + val players = VideoClickActionHolder.getPlayers(activity) val prefNames = buildList { - add(getString(R.string.player_settings_play_in_app)) // built-in player display name - addAll(players.map { (_, name) -> name }) + add(getString(R.string.player_settings_play_in_app)) + addAll(players.map { it.name.asStringNull(activity) ?: it.javaClass.simpleName }) } - val prefValues = buildList { - add("") // "" = built-in player, matches default - addAll(players.map { (player, _) -> player.uniqueId() }) + add("") + addAll(players.map { it.uniqueId() }) } - val current = settingsManager.getString(getString(R.string.player_default_key), "") ?: "" + activity?.showBottomDialog( prefNames.toList(), - prefValues.indexOf(current), // finds index of currently selected player + 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 } @@ -247,21 +207,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) @@ -274,11 +219,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 } @@ -294,11 +238,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 } @@ -306,20 +249,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 5f86a3a5a..014bd93fa 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) } @@ -108,9 +74,9 @@ class SettingsUI : BasePreferenceFragmentCompat() { dismissCallback = {}, callback = { try { - settingsManager.edit { - putInt(getString(R.string.app_layout_key), prefValues[it]) - } + settingsManager.edit() + .putInt(getString(R.string.app_layout_key), prefValues[it]) + .apply() context?.updateTv() activity?.recreate() } catch (e: Exception) { @@ -150,12 +116,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 +153,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,21 +179,16 @@ 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.tv_layout_clock_key)?.hideOn(PHONE or EMULATOR) - getPref(R.string.confirm_exit_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.confirm_exit) val prefValues = resources.getIntArray(R.array.confirm_exit_values) @@ -242,9 +201,9 @@ class SettingsUI : BasePreferenceFragmentCompat() { showApply = true, dismissCallback = {}, callback = { selectedOption -> - settingsManager.edit { - putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) - } + settingsManager.edit() + .putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) + .apply() } ) return@setOnPreferenceClickListener true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index c04215594..8dd89f744 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -5,24 +5,22 @@ 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.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.network.initClient -import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.services.BackupWorkManager -import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom @@ -32,14 +30,13 @@ import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded -import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.txt import java.io.BufferedReader import java.io.InputStreamReader @@ -49,7 +46,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -class SettingsUpdates : BasePreferenceFragmentCompat() { +class SettingsUpdates : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_updates) @@ -58,14 +55,12 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } private val pathPicker = getChooseFolderLauncher { uri, path -> - if(uri == null) return@getChooseFolderLauncher - - val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher + val context = context ?: AcraApplication.context ?: return@getChooseFolderLauncher (path ?: uri.toString()).let { - PreferenceManager.getDefaultSharedPreferences(context).edit { - putString(getString(R.string.backup_path_key), uri.toString()) - putString(getString(R.string.backup_dir_key), it) - } + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(getString(R.string.backup_path_key), uri.toString()) + .putString(getString(R.string.backup_dir_key), it) + .apply() } } @@ -89,13 +84,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,7 +104,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { activity?.restorePrompt() return@setOnPreferenceClickListener true } - getPref(R.string.backup_path_key)?.hideOn(EMULATOR)?.setOnPreferenceClickListener { + getPref(R.string.backup_path_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { val dirs = getBackupDirsForDisplay() val currentDir = settingsManager.getString(getString(R.string.backup_dir_key), null) @@ -122,8 +115,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { dirs.indexOf(currentDir), getString(R.string.backup_path_title), true, - {} - ) { + {}) { // Last = custom if (it == dirs.size) { try { @@ -135,10 +127,10 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { // Sets both visual and actual paths. // path = used uri // dir = dir path - settingsManager.edit { - putString(getString(R.string.backup_path_key), dirs[it]) - putString(getString(R.string.backup_dir_key), dirs[it]) - } + settingsManager.edit() + .putString(getString(R.string.backup_path_key), dirs[it]) + .putString(getString(R.string.backup_dir_key), dirs[it]) + .apply() } } return@setOnPreferenceClickListener true @@ -163,7 +155,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { logError(e) // kinda ironic } - val adapter = LogcatAdapter().apply { submitList(logList) } + val adapter = LogcatAdapter(logList) binding.logcatRecyclerView.layoutManager = LinearLayoutManager(pref.context) binding.logcatRecyclerView.adapter = adapter @@ -207,21 +199,19 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) - // Use legacy installer as default until we make the new installer completely reliable val currentInstaller = - settingsManager.getInt(getString(R.string.apk_installer_key), 1) + settingsManager.getInt(getString(R.string.apk_installer_key), 0) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, - {} - ) { num -> + {}) { num -> try { - settingsManager.edit { - putInt(getString(R.string.apk_installer_key), prefValues[num]) - } + settingsManager.edit() + .putInt(getString(R.string.apk_installer_key), prefValues[num]) + .apply() } catch (e: Exception) { logError(e) } @@ -229,29 +219,18 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.manual_check_update_key)?.let { pref -> - pref.summary = BuildConfig.VERSION_NAME - pref.setOnPreferenceClickListener { - ioSafe { - if (activity?.runAutoUpdate(false) == false) { - activity?.runOnUiThread { - showToast( - R.string.no_update_found, - Toast.LENGTH_SHORT - ) - } + getPref(R.string.manual_check_update_key)?.setOnPreferenceClickListener { + ioSafe { + if (activity?.runAutoUpdate(false) == false) { + activity?.runOnUiThread { + showToast( + R.string.no_update_found, + Toast.LENGTH_SHORT + ) } } - return@setOnPreferenceClickListener true - } - } - - getPref(R.string.install_prerelease_key)?.let { pref -> - pref.isVisible = BuildConfig.FLAVOR == "stable" - pref.setOnPreferenceClickListener { - activity?.installPreReleaseIfNeeded() - return@setOnPreferenceClickListener true } + return@setOnPreferenceClickListener true } getPref(R.string.auto_download_plugins_key)?.setOnPreferenceClickListener { @@ -266,26 +245,17 @@ 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 { + return normalSafeApiCall { context?.let { ctx -> val defaultDir = BackupUtils.getDefaultBackupDir(ctx)?.filePath() val first = listOf(defaultDir) @@ -297,4 +267,4 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { } } ?: emptyList() } -} +} \ No newline at end of file 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 107227497..bd1e219d0 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,17 +4,18 @@ 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.MenuItem import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.SearchView 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 @@ -25,13 +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.setRecycledViewPool +import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog @@ -39,16 +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.UIHelper.hideProgress -import com.lagradost.cloudstream3.utils.UIHelper.showProgress -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() - private val pluginViewModel: PluginsViewModel 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( @@ -59,6 +65,8 @@ class ExtensionsFragment : BaseFragment( this.layoutParams = param } + private val extensionViewModel: ExtensionsViewModel by activityViewModels() + override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::reloadRepositories @@ -74,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, @@ -106,32 +113,32 @@ 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 } } } adapter = RepoAdapter(false, { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, - PluginsFragment.newInstance(it) + PluginsFragment.newInstance( + it.name, + it.url, + false + ) ) }, { 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() } @@ -142,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() @@ -150,17 +159,38 @@ class ExtensionsFragment : BaseFragment( }) } - observe(extensionViewModel.repositories) { repos -> - binding.repoRecyclerView.isVisible = repos.isNotEmpty() - binding.blankRepoScreen.isVisible = repos.isEmpty() - (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(repos.toList()) - pluginViewModel.updatePluginList(binding.root.context, repos.toList()) + observe(extensionViewModel.repositories) { + 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 } @@ -180,78 +210,17 @@ class ExtensionsFragment : BaseFragment( } } - binding.pluginStorageAppbar.setOnClickListener { + binding?.pluginStorageAppbar?.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, - PluginsFragment.newLocalInstance( + PluginsFragment.newInstance( getString(R.string.extensions), + "", + true ) ) } - binding.pluginRecyclerView.apply { - setLinearListLayout( - isHorizontal = false, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) - setRecycledViewPool(PluginAdapter.sharedPool) - adapter = - PluginAdapter(true) { - val urls = extensionViewModel.repositories.value?.toList() ?: emptyList() - pluginViewModel.handlePluginAction(activity, urls, it, false) - } - } - - observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> - (binding.pluginRecyclerView.adapter as? PluginAdapter)?.submitList(list) - if (scrollToTop) { - binding.pluginRecyclerView.scrollToPosition(0) - } - } - - binding.settingsToolbar.apply { - val searchItem = menu?.findItem(R.id.search_button) - val searchView = searchItem?.actionView as? SearchView - - searchItem?.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionCollapse(p0: MenuItem): Boolean { - binding.pluginRecyclerView.isVisible = false - binding.repoRecyclerView.isVisible = true - return true - - } - - override fun onMenuItemActionExpand(p0: MenuItem): Boolean { - binding.pluginRecyclerView.isVisible = true - binding.repoRecyclerView.isVisible = false - return true - } - }) - - // Don't go back if active query - setNavigationOnClickListener { - if (searchView?.isIconified == false) { - searchView.isIconified = true - } else { - dispatchBackPressed() - } - } - - searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - pluginViewModel.search(query) - return true - } - - override fun onQueryTextChange(newText: String?): Boolean { - pluginViewModel.search(newText) - return true - } - }) - } - - val addRepositoryClick = View.OnClickListener { val ctx = context ?: return@OnClickListener val binding = AddRepoInputBinding.inflate(LayoutInflater.from(ctx), null, false) @@ -261,37 +230,29 @@ 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() - if (urlInput.isNullOrEmpty()) { - showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT) - return@secondListener - } - binding.applyBtt.showProgress() ioSafe { - try { - val url = RepositoryManager.parseRepoUrl(urlInput) - if (url.isNullOrBlank()) { + val url = binding.repoUrlInput.text?.toString() + ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } + if (url.isNullOrBlank()) { + main { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) - return@ioSafe } + } else { val repository = RepositoryManager.parseRepository(url) // Exit if wrong repository @@ -302,35 +263,32 @@ 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() - dialog.dismissSafe(activity) // Only dismiss if the repo was added - - val plugins = RepositoryManager.getRepoPlugins(newRepo) + val plugins = RepositoryManager.getRepoPlugins(url) if (plugins.isNullOrEmpty()) { showToast(R.string.no_plugins_found_error, Toast.LENGTH_LONG) - return@ioSafe + } else { + this@ExtensionsFragment.activity?.addRepositoryDialog( + fixedName, + url, + ) } - - this@ExtensionsFragment.activity?.addRepositoryDialog( - newRepo - ) - } finally { - binding.applyBtt.hideProgress() } } + dialog.dismissSafe(activity) } binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } - val isTv = isLayout(TV) - binding.apply { + binding?.apply { addRepoButton.isGone = isTv addRepoButtonImageviewHolder.isVisible = isTv @@ -343,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 d30962598..ebe9fc888 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 @@ -15,17 +15,11 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIE import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -@Serializable data class RepositoryData( - @JsonProperty("iconUrl") @SerialName("iconUrl") val iconUrl: String?, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("url") @SerialName("url") val url: String, -) { - constructor(name: String, url: String): this(null, name, url) -} + @JsonProperty("name") val name: String, + @JsonProperty("url") val url: String +) const val REPOSITORIES_KEY = "REPOSITORIES_KEY" @@ -55,16 +49,16 @@ class ExtensionsViewModel : ViewModel() { ?: emptyArray()) + PREBUILT_REPOSITORIES val onlinePlugins = urls.toList().amap { - RepositoryManager.getRepoPlugins(it)?.toList() ?: emptyList() - }.flatten().distinctBy { it.plugin.url } + RepositoryManager.getRepoPlugins(it.url)?.toList() ?: emptyList() + }.flatten().distinctBy { it.second.url } // Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated - val outdatedPlugins = getPluginsOnline().flatMap { savedData -> - onlinePlugins.filter { onlineData -> savedData.internalName == onlineData.plugin.internalName } + val outdatedPlugins = getPluginsOnline().map { savedData -> + onlinePlugins.filter { onlineData -> savedData.internalName == onlineData.second.internalName } .map { onlineData -> PluginManager.OnlinePluginData(savedData, onlineData) } - }.distinctBy { it.onlineData.plugin.url } + }.flatten().distinctBy { it.onlineData.second.url } val total = onlinePlugins.count() val disabled = outdatedPlugins.count { it.isDisabled } @@ -93,4 +87,4 @@ class ExtensionsViewModel : ViewModel() { val urls = repos() _repositories.postValue(urls) } -} +} \ No newline at end of file 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 2ff06cd6a..15228b260 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt @@ -8,210 +8,93 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.viewbinding.ViewBinding -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.plugins.PluginWrapper -import com.lagradost.cloudstream3.ui.BaseDiffCallback -import com.lagradost.cloudstream3.ui.NoStateAdapter -import com.lagradost.cloudstream3.ui.ViewHolderState -import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.plugins.VotingApi.getVotes +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.setText -import com.lagradost.cloudstream3.utils.txt +import org.junit.Assert +import org.junit.Test import java.text.DecimalFormat import kotlin.math.floor import kotlin.math.log10 import kotlin.math.pow data class PluginViewData( - val pluginWrapper: PluginWrapper, + 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 showRepositoryNames: Boolean = false, val iconClickCallback: (PluginWrapper) -> Unit, -) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> - a.pluginWrapper.plugin.internalName == b.pluginWrapper.plugin.internalName && a.pluginWrapper.repositoryData.url == b.pluginWrapper.repositoryData.url -})) { - override fun onCreateContent(parent: ViewGroup): ViewHolderState { - val layout = if (isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item + val iconClickCallback: (Plugin) -> Unit +) : + RecyclerView.Adapter() { + private val plugins: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val layout = if(isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item 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.pluginWrapper.plugin - val disabled = metadata.status == PROVIDER_STATUS_DOWN - val name = metadata.name.removeSuffix("Provider") - val alpha = if (disabled) 0.6f else 1f - val isLocal = !item.pluginWrapper.plugin.url.startsWith("http") - binding.mainText.alpha = alpha - binding.subText.alpha = alpha - - binding.repositoryNameText.isVisible = showRepositoryNames - if (showRepositoryNames) { - val name = item.pluginWrapper.repositoryData.name - binding.repositoryNameText.text = name - } else { - binding.repositoryNameText.text = "" - } - - 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.pluginWrapper) - } - itemView.setOnClickListener { - if (isLocal) return@setOnClickListener - - val sheet = PluginDetailsFragment(item) - val activity = itemView.context.getActivity() as AppCompatActivity - sheet.show(activity.supportFragmentManager, "PluginDetails") - } - //if (itemView.context?.isTrueTvSettings() == false) { - // val siteUrl = metadata.repositoryUrl - // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { - // itemView.setOnClickListener { - // openBrowser(siteUrl) - // } - // } - //} - - if (item.isDownloaded) { - // On local plugins page the filepath is provided instead of url. - val plugin = - (PluginManager.urlPlugins[metadata.url] - ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin - - if (plugin?.openSettings != null) { - binding.actionSettings.isVisible = true - binding.actionSettings.setOnClickListener { - try { - plugin.openSettings?.invoke(itemView.context) - } catch (e: Throwable) { - Log.e( - "PluginAdapter", - "Failed to open $name settings: ${ - Log.getStackTraceString(e) - }" - ) - } - } - } else { - binding.actionSettings.isVisible = false - } - } else { - binding.actionSettings.isVisible = false - } - - val url = metadata.iconUrl?.replace( - "%size%", - "$iconSize" - )?.replace( - "%exact_size%", - "$iconSizeExact" + fun updateList(newList: List) { + val diffResult = DiffUtil.calculateDiff( + PluginDiffCallback(this.plugins, newList) ) - if (url.isNullOrBlank()) { - binding.entryIcon.loadImage(R.drawable.ic_baseline_extension_24) - } else { - binding.entryIcon.loadImage( - url - ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } + plugins.clear() + plugins.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + /* + private var storedPlugins: Array = reloadStoredPlugins() + + private fun reloadStoredPlugins(): Array { + return PluginManager.getPluginsOnline().also { storedPlugins = it } + }*/ + + // Clear coil image because setImageResource doesn't override + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is PluginViewHolder) { + holder.binding.entryIcon.loadImage(R.drawable.ic_github_logo) } - - binding.extVersion.isVisible = true - binding.extVersion.text = "v${metadata.version}" - - if (metadata.language.isNullOrBlank()) { - binding.langIcon.isVisible = false - } else { - binding.langIcon.isVisible = true - binding.langIcon.text = getNameNextToFlagEmoji(metadata.language) ?: metadata.language - } - - //val oldRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount - - binding.extVotes.isVisible = false - - // Disable this for now as the vote api is down, this will also significantly improve the lag - // from doing all these network requests - /*if (!isLocal) { - ioSafe { - metadata.getVotes().main { votes -> - val currentRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount - - // Only set the text if the view is correctly rendered - if (currentRecycleCount == oldRecycleCount) { - binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(votes))) - binding.extVotes.isVisible = true - } - } - } - }*/ - - if (metadata.fileSize != null) { - binding.extFilesize.isVisible = true - binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) - } else { - binding.extFilesize.isVisible = false - } - - binding.mainText.setText( - if (disabled) txt( - R.string.single_plugin_disabled, - name - ) else txt(name) - ) - - binding.subText.isGone = metadata.description.isNullOrBlank() - binding.subText.text = metadata.description.html() + super.onViewRecycled(holder) } companion object { - // A high count as we can render in the entire list as the same time - val sharedPool = - newSharedPool { setMaxRecycledViews(CONTENT, 15) } - private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max if (current >= target) return current @@ -220,14 +103,14 @@ class PluginAdapter( // DO NOT MOVE, as running this test will result in ExceptionInInitializerError on prerelease due to static variables using Resources.getSystem() // this test function is only to show how the function works - /*@Test + @Test fun testFindClosestBase2() { Assert.assertEquals(16, findClosestBase2(0)) Assert.assertEquals(256, findClosestBase2(170)) Assert.assertEquals(256, findClosestBase2(256)) Assert.assertEquals(512, findClosestBase2(257)) Assert.assertEquals(512, findClosestBase2(700)) - }*/ + } private val iconSizeExact = 32.toPx private val iconSize by lazy { @@ -248,4 +131,136 @@ class PluginAdapter( } } } + + inner class PluginViewHolder(val binding: RepositoryItemBinding) : + RecyclerView.ViewHolder(binding.root) { + + @SuppressLint("SetTextI18n") + fun bind( + data: PluginViewData, + ) { + val metadata = data.plugin.second + val disabled = metadata.status == PROVIDER_STATUS_DOWN + val name = metadata.name.removeSuffix("Provider") + val alpha = if (disabled) 0.6f else 1f + val isLocal = !data.plugin.second.url.startsWith("http") + binding.mainText.alpha = alpha + binding.subText.alpha = alpha + + val drawableInt = if (data.isDownloaded) + R.drawable.ic_baseline_delete_outline_24 + else R.drawable.netflix_download + + binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false + binding.actionButton.setImageResource(drawableInt) + + binding.actionButton.setOnClickListener { + iconClickCallback.invoke(data.plugin) + } + itemView.setOnClickListener { + if (isLocal) return@setOnClickListener + + val sheet = PluginDetailsFragment(data) + val activity = itemView.context.getActivity() as AppCompatActivity + sheet.show(activity.supportFragmentManager, "PluginDetails") + } + //if (itemView.context?.isTrueTvSettings() == false) { + // val siteUrl = metadata.repositoryUrl + // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { + // itemView.setOnClickListener { + // openBrowser(siteUrl) + // } + // } + //} + + if (data.isDownloaded) { + // On local plugins page the filepath is provided instead of url. + val plugin = + (PluginManager.urlPlugins[metadata.url] ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin + + if (plugin?.openSettings != null) { + binding.actionSettings.isVisible = true + binding.actionSettings.setOnClickListener { + try { + plugin.openSettings!!.invoke(itemView.context) + } catch (e: Throwable) { + Log.e( + "PluginAdapter", + "Failed to open $name settings: ${ + Log.getStackTraceString(e) + }" + ) + } + } + } else { + binding.actionSettings.isVisible = false + } + } else { + binding.actionSettings.isVisible = false + } + + binding.entryIcon.loadImage( + metadata.iconUrl?.replace( + "%size%", + "$iconSize" + )?.replace( + "%exact_size%", + "$iconSizeExact" + ) + ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } + + binding.extVersion.isVisible = true + binding.extVersion.text = "v${metadata.version}" + + if (metadata.language.isNullOrBlank()) { + binding.langIcon.isVisible = false + } else { + binding.langIcon.isVisible = true + binding.langIcon.text = + "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" + } + + binding.extVotes.isVisible = false + if (!isLocal) { + ioSafe { + metadata.getVotes().main { + binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(it))) + binding.extVotes.isVisible = true + } + } + } + + + if (metadata.fileSize != null) { + binding.extFilesize.isVisible = true + binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) + } else { + binding.extFilesize.isVisible = false + } + binding.mainText.setText( + if (disabled) txt( + R.string.single_plugin_disabled, + name + ) else txt(name) + ) + binding.subText.isGone = metadata.description.isNullOrBlank() + binding.subText.text = metadata.description.html() + } + } +} + +class PluginDiffCallback( + private val oldList: List, + private val newList: List +) : + DiffUtil.Callback() { + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition].plugin.second.internalName == newList[newItemPosition].plugin.second.internalName && oldList[oldItemPosition].plugin.first == newList[newItemPosition].plugin.first + + override fun getOldListSize() = oldList.size + + override fun getNewListSize() = newList.size + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = + oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt index 06a95d527..80be3cf4b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt @@ -1,36 +1,33 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.content.res.ColorStateList +import android.os.Bundle import android.text.format.Formatter.formatFileSize import android.util.Log +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.core.view.isVisible -import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentPluginDetailsBinding import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.VotingApi.canVote import com.lagradost.cloudstream3.plugins.VotingApi.getVotes import com.lagradost.cloudstream3.plugins.VotingApi.hasVoted import com.lagradost.cloudstream3.plugins.VotingApi.vote -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.BaseBottomSheetDialogFragment -import com.lagradost.cloudstream3.ui.BaseFragment -import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.getImageFromDrawable -class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFragment( - BaseFragment.BindingCreator.Inflate(FragmentPluginDetailsBinding::inflate) -) { + +class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragment() { companion object { private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { @@ -45,17 +42,26 @@ class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFra } } - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = isLandscape(), - padLeft = isLayout(TV or EMULATOR) - ) + override fun onDestroyView() { + binding = null + super.onDestroyView() } - override fun onBindingCreated(binding: FragmentPluginDetailsBinding) { - val metadata = data.pluginWrapper.plugin - binding.apply { + 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) } @@ -79,9 +85,9 @@ class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFra ", " ) pluginLang.text = if (metadata.language == null) - getString(R.string.no_data) - else - getNameNextToFlagEmoji(metadata.language) ?: metadata.language + getString(R.string.no_data) + else + "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" githubBtn.setOnClickListener { if (metadata.repositoryUrl != null) { @@ -135,7 +141,7 @@ class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFra } private fun updateVoting(value: Int) { - val metadata = data.pluginWrapper.plugin + val metadata = data.plugin.second binding?.apply { pluginVotes.text = value.toString() if (metadata.hasVoted()) { 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 a7b24d3ae..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,118 +1,112 @@ 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.lifecycle.ViewModelProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson 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_DATA = "data" +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 lateinit var pluginViewModel: PluginsViewModel +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 onBindingCreated(binding: FragmentPluginsBinding) { - pluginViewModel = ViewModelProvider(this)[PluginsViewModel::class.java] + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) // 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()}") } } - val repositoryData = arguments?.getString(PLUGINS_BUNDLE_DATA)?.let { data -> - tryParseJson(data) - } + val name = arguments?.getString(PLUGINS_BUNDLE_NAME) + 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 (repositoryData == null) { - dispatchBackPressed() + if (url == null || name == null) { + activity?.onBackPressedDispatcher?.onBackPressed() return } setToolBarScrollFlags() - setUpToolbar(repositoryData.name) - binding.settingsToolbar.apply { + setUpToolbar(name) + binding?.settingsToolbar?.apply { setOnMenuItemClickListener { menuItem -> when (menuItem?.itemId) { R.id.download_all -> { - PluginsViewModel.downloadAll(activity, repositoryData, pluginViewModel) + PluginsViewModel.downloadAll(activity, url, pluginViewModel) } 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() } } @@ -130,9 +124,12 @@ class PluginsFragment : BaseFragment( if (searchView?.isIconified == false) { searchView.isIconified = true } else { - dispatchBackPressed() + activity?.onBackPressedDispatcher?.onBackPressed() } } + searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) pluginViewModel.search(null) + } searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { @@ -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, listOf(repositoryData), 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, listOf(repositoryData)) - binding.tvtypesChipsScroll.root.isVisible = true + pluginViewModel.updatePluginList(context, url) + 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 -> @@ -206,17 +203,21 @@ class PluginsFragment : BaseFragment( } companion object { - fun newInstance(repositoryData: RepositoryData): Bundle { + fun newInstance(name: String, url: String, isLocal: Boolean): Bundle { return Bundle().apply { - putString(PLUGINS_BUNDLE_DATA, repositoryData.toJson()) - putBoolean(PLUGINS_BUNDLE_LOCAL, false) - } - } - fun newLocalInstance(name: String): Bundle { - return Bundle().apply { - putString(PLUGINS_BUNDLE_DATA, RepositoryData("", name, "").toJson()) - putBoolean(PLUGINS_BUNDLE_LOCAL, true) + putString(PLUGINS_BUNDLE_NAME, name) + putString(PLUGINS_BUNDLE_URL, url) + putBoolean(PLUGINS_BUNDLE_LOCAL, isLocal) } } + +// class RepoSearchView(context: Context) : android.widget.SearchView(context) { +// var onActionViewCollapsed = {} +// +// override fun onActionViewCollapsed() { +// onActionViewCollapsed() +// } +// } + } } \ No newline at end of file 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 09c202d43..a6f914898 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -17,15 +17,16 @@ import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.getPluginPath -import com.lagradost.cloudstream3.plugins.PluginWrapper import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.plugins.SitePlugin import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import com.lagradost.cloudstream3.utils.Levenshtein +import me.xdrop.fuzzywuzzy.FuzzySearch import java.io.File +typealias Plugin = Pair /** * The boolean signifies if the plugin list should be scrolled to the top, used for searching. * */ @@ -35,32 +36,17 @@ 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.pluginWrapper.plugin.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 { - private val repositoryCache: MutableMap> = mutableMapOf() + private val repositoryCache: MutableMap> = mutableMapOf() const val TAG = "PLG" private fun isDownloaded( @@ -72,33 +58,32 @@ class PluginsViewModel : ViewModel() { } private suspend fun getPlugins( - repository: RepositoryData, + repositoryUrl: String, canUseCache: Boolean = true - ): List { - Log.i(TAG, "getPlugins = $repository") - if (canUseCache && repositoryCache.containsKey(repository.url)) { - repositoryCache[repository.url]?.let { + ): List { + Log.i(TAG, "getPlugins = $repositoryUrl") + if (canUseCache && repositoryCache.containsKey(repositoryUrl)) { + repositoryCache[repositoryUrl]?.let { return it } } - - return RepositoryManager.getRepoPlugins(repository) - ?.also { repositoryCache[repository.url] = it } ?: emptyList() + return RepositoryManager.getRepoPlugins(repositoryUrl) + ?.also { repositoryCache[repositoryUrl] = it } ?: emptyList() } /** * @param viewModel optional, updates the plugins livedata for that viewModel if included * */ - fun downloadAll(activity: Activity?, repository: RepositoryData, viewModel: PluginsViewModel?) = + fun downloadAll(activity: Activity?, repositoryUrl: String, viewModel: PluginsViewModel?) = ioSafe { if (activity == null) return@ioSafe - val plugins = getPlugins(repository) + val plugins = getPlugins(repositoryUrl) - plugins.filter { pluginWrapper -> + plugins.filter { plugin -> !isDownloaded( activity, - pluginWrapper.plugin.internalName, - repository.url + plugin.second.internalName, + repositoryUrl ) }.also { list -> main { @@ -123,13 +108,12 @@ class PluginsViewModel : ViewModel() { Toast.LENGTH_SHORT ) } - }.amap { (_, repo, metadata) -> + }.amap { (repo, metadata) -> PluginManager.downloadPlugin( activity, metadata.url, - metadata.fileHash, metadata.internalName, - repo.url, + repo, metadata.status != PROVIDER_STATUS_DOWN ) }.main { list -> @@ -142,7 +126,7 @@ class PluginsViewModel : ViewModel() { ), Toast.LENGTH_SHORT ) - viewModel?.updatePluginListPrivate(activity, listOf(repository)) + viewModel?.updatePluginListPrivate(activity, repositoryUrl) } else if (list.isNotEmpty()) { showToast(R.string.download_failed, Toast.LENGTH_SHORT) } @@ -156,32 +140,31 @@ class PluginsViewModel : ViewModel() { * */ fun handlePluginAction( activity: Activity?, - repositoryUrls: List, - pluginWrapper: PluginWrapper, + repositoryUrl: String, + plugin: Plugin, isLocal: Boolean ) = ioSafe { - Log.i(TAG, "handlePluginAction = ${repositoryUrls}, $pluginWrapper, $isLocal") + Log.i(TAG, "handlePluginAction = $repositoryUrl, $plugin, $isLocal") if (activity == null) return@ioSafe - val (_, repositoryData, metadata) = pluginWrapper + val (repo, metadata) = plugin - val file = if (isLocal) File(pluginWrapper.plugin.url) else getPluginPath( + val file = if (isLocal) File(plugin.second.url) else getPluginPath( activity, - pluginWrapper.plugin.internalName, - pluginWrapper.repositoryData.url + plugin.second.internalName, + plugin.first ) val (success, message) = if (file.exists()) { PluginManager.deletePlugin(file) to R.string.plugin_deleted } else { - val isEnabled = pluginWrapper.plugin.status != PROVIDER_STATUS_DOWN + val isEnabled = plugin.second.status != PROVIDER_STATUS_DOWN val message = if (isEnabled) R.string.plugin_loaded else R.string.plugin_downloaded PluginManager.downloadPlugin( activity, metadata.url, - metadata.fileHash, metadata.internalName, - repositoryData.url, + repo, isEnabled ) to message } @@ -197,23 +180,20 @@ class PluginsViewModel : ViewModel() { if (isLocal) updatePluginListLocal() else - updatePluginListPrivate(activity, repositoryUrls) + updatePluginListPrivate(activity, repositoryUrl) } - private suspend fun updatePluginListPrivate(context: Context, repositories: List) { + private suspend fun updatePluginListPrivate(context: Context, repositoryUrl: String) { val isAdult = PreferenceManager.getDefaultSharedPreferences(context) .getStringSet(context.getString(R.string.prefer_media_type_key), emptySet()) ?.contains(TvType.NSFW.ordinal.toString()) == true - val plugins = repositories.flatMap { repositoryUrl -> - getPlugins(repositoryUrl) - } - + val plugins = getPlugins(repositoryUrl) val list = plugins.filter { // Show all non-nsfw plugins or all if nsfw is enabled - it.plugin.tvTypes?.contains(TvType.NSFW.name) != true || isAdult + it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult }.map { plugin -> - PluginViewData(plugin, isDownloaded(context, plugin.plugin.internalName, plugin.repositoryData.url)) + PluginViewData(plugin, isDownloaded(context, plugin.second.internalName, plugin.first)) } this.plugins = list @@ -226,45 +206,28 @@ class PluginsViewModel : ViewModel() { private fun List.filterTvTypes(): List { if (tvTypes.isEmpty()) return this return this.filter { - (it.pluginWrapper.plugin.tvTypes?.any { type -> tvTypes.contains(type) } == true) || - (tvTypes.contains(TvType.Others.name) && (it.pluginWrapper.plugin.tvTypes + (it.plugin.second.tvTypes?.any { type -> tvTypes.contains(type) } == true) || + (tvTypes.contains(TvType.Others.name) && (it.plugin.second.tvTypes ?: emptyList()).isEmpty()) } } private fun List.filterLang(): List { - if (selectedLanguages.isEmpty()) return this // do not filter + if (languages.isEmpty()) return this return this.filter { - if (it.pluginWrapper.plugin.language == null) { - return@filter selectedLanguages.contains("none") + if (it.plugin.second.language == null) { + return@filter languages.contains("none") } - selectedLanguages.contains(it.pluginWrapper.plugin.language.lowercase()) + languages.contains(it.plugin.second.language) } } private fun List.sortByQuery(query: String?): List { - return if (query.isNullOrBlank()) { + return if (query == null) { // Return list to base state if no query - this.sortedBy { it.pluginWrapper.plugin.name } + this.sortedBy { it.plugin.second.name } } else { - this.mapNotNull { - // Try matching name - val score = Levenshtein.partialRatio( - it.pluginWrapper.plugin.name.lowercase(), - query.lowercase() - ).takeIf { score -> score > 80 } ?: - // Fallback to description, but limit characters to reduce lag - it.pluginWrapper.plugin.description?.lowercase()?.take(64) - ?.let { description -> - Levenshtein.partialRatio( - description, - query.lowercase() - ) - }?.takeIf { score -> score > 80 } ?: return@mapNotNull null - it to score - }.sortedBy { - -it.second - }.map { it.first } + this.sortedBy { -FuzzySearch.partialRatio(it.plugin.second.name.lowercase(), query.lowercase()) } } } @@ -274,24 +237,16 @@ 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") + updatePluginListPrivate(context, repositoryUrl) } - fun updatePluginList(context: Context?, repositories: List) = - viewModelScope.launchSafe { - if (context == null) return@launchSafe - Log.i(TAG, "updatePluginList = $repositories") - updatePluginListPrivate(context, repositories) - } - fun search(query: String?) { currentQuery = query _filteredPlugins.postValue( - true to plugins.filterTvTypes().filterLang().sortByQuery(query) + true to (filteredPlugins.value?.second?.sortByQuery(query) ?: emptyList()) ) } @@ -304,7 +259,7 @@ class PluginsViewModel : ViewModel() { val downloadedPlugins = (PluginManager.getPluginsOnline() + PluginManager.getPluginsLocal()) .distinctBy { it.filePath } .map { - PluginViewData(PluginWrapper.getLocalPluginWrapper(it.toSitePlugin()), true) + PluginViewData("" to it.toSitePlugin(), true) } plugins = downloadedPlugins 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..9d6333144 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.utils.txt import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper -import com.lagradost.cloudstream3.utils.getImageFromDrawable -import com.lagradost.cloudstream3.utils.txt class RepoAdapter( val isSetup: Boolean, @@ -22,11 +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,109 @@ class RepoAdapter( LayoutInflater.from(parent.context), parent, false + ) //R.layout.repository_item_tv else R.layout.repository_item + return RepoViewHolder( + layout ) - return ViewHolderState(layout) } - override fun onClearView(holder: ViewHolderState) { - when (val binding = holder.view) { - is RepositoryItemBinding -> clearImage(binding.entryIcon) - is RepositoryItemTvBinding -> clearImage(binding.entryIcon) + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is RepoViewHolder -> { + holder.bind(repositories[position]) + } } } - override fun onBindContent(holder: ViewHolderState, item: RepositoryData, position: Int) { - val isPrebuilt = PREBUILT_REPOSITORIES.contains(item) - val drawable = - if (isSetup) R.drawable.netflix_download else R.drawable.ic_baseline_delete_outline_24 - when (val binding = holder.view) { - is RepositoryItemTvBinding -> { - binding.apply { - // Only shows icon if on setup or if it isn't a prebuilt repo. - // No delete buttons on prebuilt repos. - if (!isPrebuilt || isSetup) { - actionButton.setImageResource(drawable) - } + override fun getItemCount(): Int { + return repositories.size + } - actionButton.setOnClickListener { - imageClickCallback(item) - } + fun updateList(newList: Array) { + val diffResult = DiffUtil.calculateDiff( + RepoDiffCallback(this.repositories, newList) + ) - repositoryItemRoot.setOnClickListener { - clickCallback(item) - } - mainText.text = item.name - subText.text = item.url - if (!item.iconUrl.isNullOrEmpty()) { - entryIcon.loadImage(item.iconUrl) { - error( - getImageFromDrawable( - binding.root.context, - R.drawable.ic_github_logo - ) - ) + repositories.clear() + repositories.addAll(newList) + + diffResult.dispatchUpdatesTo(this) + } + + 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 index dfc931174..9e126b7a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt @@ -4,17 +4,14 @@ import android.content.Intent import android.net.Uri import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment -import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.AcraApplication import com.lagradost.safefile.SafeFile fun Fragment.getChooseFolderLauncher(dirSelected: (uri: Uri?, path: String?) -> Unit) = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> // It lies, it can be null if file manager quits. - if(uri == null) { - dirSelected(null, null) - return@registerForActivityResult - } - val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult + if (uri == null) return@registerForActivityResult + val context = context ?: AcraApplication.context ?: return@registerForActivityResult // RW perms for the path val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt index 544dd9335..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, null) - }).apply { submitList(repositories.toList()) } + PluginsViewModel.downloadAll(activity, it.url, null) + }).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..fc7fe093f 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,47 +1,63 @@ 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 @@ -51,21 +67,24 @@ class SetupFragmentLanguage : BaseFragment( } val current = getCurrentLocale(ctx) - val languageTagsIETF = appLanguages.map { it.second } - val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } - val currentIndex = languageTagsIETF.indexOf(current) + val languageCodes = appLanguages.map { it.third } + val languageNames = appLanguages.map { (emoji, name, iso) -> + val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } + "$flag $name" + } + val index = languageCodes.indexOf(current) arrayAdapter.addAll(languageNames) listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1.setItemChecked(currentIndex, true) + listview1.setItemChecked(index, true) - listview1.setOnItemClickListener { _, _, selectedLangIndex, _ -> - val langTagIETF = languageTagsIETF[selectedLangIndex] - CommonActivity.setLocale(activity, langTagIETF) - settingsManager.edit { - putString(getString(R.string.locale_key), langTagIETF) - } + listview1.setOnItemClickListener { _, _, position, _ -> + val code = languageCodes[position] + CommonActivity.setLocale(activity, code) + settingsManager.edit().putString(getString(R.string.locale_key), code) + .apply() + activity?.recreate() } nextBtt.setOnClickListener { @@ -84,10 +103,10 @@ 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..9de6038e3 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 ab64e9993..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,44 +21,37 @@ 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 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable const val CHROME_SUBTITLE_KEY = "chome_subtitle_settings" -@Serializable data class SaveChromeCaptionStyle( - @JsonProperty("fontFamily") @SerialName("fontFamily") var fontFamily: String? = null, - @JsonProperty("fontGenericFamily") @SerialName("fontGenericFamily") var fontGenericFamily: Int? = null, - @JsonProperty("backgroundColor") @SerialName("backgroundColor") var backgroundColor: Int = 0x00FFFFFF, // transparent - @JsonProperty("edgeColor") @SerialName("edgeColor") var edgeColor: Int = Color.BLACK, // BLACK - @JsonProperty("edgeType") @SerialName("edgeType") var edgeType: Int = EDGE_TYPE_OUTLINE, - @JsonProperty("foregroundColor") @SerialName("foregroundColor") var foregroundColor: Int = Color.WHITE, - @JsonProperty("fontScale") @SerialName("fontScale") var fontScale: Float = 1.05f, - @JsonProperty("windowColor") @SerialName("windowColor") var windowColor: Int = Color.TRANSPARENT, + @JsonProperty("fontFamily") var fontFamily: String? = null, + @JsonProperty("fontGenericFamily") var fontGenericFamily: Int? = null, + @JsonProperty("backgroundColor") var backgroundColor: Int = 0x00FFFFFF, // transparent + @JsonProperty("edgeColor") var edgeColor: Int = Color.BLACK, // BLACK + @JsonProperty("edgeType") var edgeType: Int = EDGE_TYPE_OUTLINE, + @JsonProperty("foregroundColor") var foregroundColor: Int = Color.WHITE, + @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() @@ -101,7 +95,7 @@ class ChromecastSubtitlesFragment : BaseFragment(CHROME_SUBTITLE_KEY) ?: defaultState + return getKey(CHROME_SUBTITLE_KEY) ?: defaultState } private val defaultState = SaveChromeCaptionStyle() @@ -148,6 +142,23 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsEdgeType?.setFocusableInTv() + binding?.subsEdgeType?.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, @@ -245,15 +254,15 @@ class ChromecastSubtitlesFragment : BaseFragment + binding?.subsFontSize?.setFocusableInTv() + binding?.subsFontSize?.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), @@ -286,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", @@ -318,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 bf77ef247..d0183e4c9 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,20 +6,20 @@ 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.DialogFragment import androidx.media3.common.text.Cue import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat @@ -27,198 +27,70 @@ 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 kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable 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" -@Serializable -data class SaveCaptionStyle( - @JsonProperty("foregroundColor") @SerialName("foregroundColor") var foregroundColor: Int, - @JsonProperty("backgroundColor") @SerialName("backgroundColor") var backgroundColor: Int, - @JsonProperty("windowColor") @SerialName("windowColor") var windowColor: Int, - @OptIn(UnstableApi::class) - @JsonProperty("edgeType") @SerialName("edgeType") var edgeType: @CaptionStyleCompat.EdgeType Int, - @JsonProperty("edgeColor") @SerialName("edgeColor") var edgeColor: Int, - @FontRes @JsonProperty("typeface") @SerialName("typeface") var typeface: Int?, - @JsonProperty("typefaceFilePath") @SerialName("typefaceFilePath") var typefaceFilePath: String?, - @JsonProperty("elevation") @SerialName("elevation") var elevation: Int, // in dp - @JsonProperty("fixedTextSize") @SerialName("fixedTextSize") var fixedTextSize: Float?, // in sp - @Px @JsonProperty("edgeSize") @SerialName("edgeSize") var edgeSize: Float? = null, - @JsonProperty("removeCaptions") @SerialName("removeCaptions") var removeCaptions: Boolean = false, - @JsonProperty("removeBloat") @SerialName("removeBloat") var removeBloat: Boolean = true, - /** Apply caps lock to the text */ - @JsonProperty("upperCase") @SerialName("upperCase") var upperCase: Boolean = false, - /** Apply bold to the text */ - @JsonProperty("bold") @SerialName("bold") var bold: Boolean = false, - /** Apply italic to the text */ - @JsonProperty("italic") @SerialName("italic") var italic: Boolean = false, - /** in px, background radius, aka how round the background (backgroundColor) on each row is */ - @JsonProperty("backgroundRadius") @SerialName("backgroundRadius") var backgroundRadius: Float? = null, - /** The SSA_ALIGNMENT */ - @JsonProperty("alignment") @SerialName("alignment") var alignment: Int? = null, +data class SaveCaptionStyle @OptIn(UnstableApi::class) constructor( + @JsonProperty("foregroundColor") var foregroundColor: Int, + @JsonProperty("backgroundColor") var backgroundColor: Int, + @JsonProperty("windowColor") var windowColor: Int, + @JsonProperty("edgeType") var edgeType: @CaptionStyleCompat.EdgeType Int, + @JsonProperty("edgeColor") var edgeColor: Int, + @FontRes + @JsonProperty("typeface") var typeface: Int?, + @JsonProperty("typefaceFilePath") var typefaceFilePath: String?, + /**in dp**/ + @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, ) const val DEF_SUBS_ELEVATION = 20 -@OptIn(UnstableApi::class) -class SubtitlesFragment : BaseDialogFragment( - BaseFragment.BindingCreator.Inflate(SubtitleSettingsBinding::inflate) -) { +@OptIn(androidx.media3.common.util.UnstableApi::class) +class SubtitlesFragment : DialogFragment() { companion object { val applyStyleEvent = Event() - private val captionRegex = Regex("""(-\s?|)[\[({][\S\s]*?[])}]\s*""") - fun setSubtitleViewStyle( - view: SubtitleView?, - data: SaveCaptionStyle, - applyElevation: Boolean - ) { + fun setSubtitleViewStyle(view: SubtitleView?, data: SaveCaptionStyle) { if (view == null) return val ctx = view.context ?: return val style = ctx.fromSaveToStyle(data) view.setStyle(style) - - if (applyElevation) { - view.setPadding( - view.paddingLeft, data.elevation.toPx, view.paddingRight, view.paddingBottom - ) - } - - view.clipToPadding = false - view.clipChildren = false - - // 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 { 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, @@ -256,15 +128,12 @@ 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( + return getKey(SUBTITLE_KEY) ?: SaveCaptionStyle( foregroundColor = getDefColor(0), backgroundColor = getDefColor(2), windowColor = getDefColor(3), @@ -274,7 +143,7 @@ class SubtitlesFragment : BaseDialogFragment( typefaceFilePath = null, elevation = DEF_SUBS_ELEVATION, fixedTextSize = null, - )).also { cachedSubtitleStyle = it } + ) } private fun Context.getSavedFonts(): List { @@ -290,17 +159,21 @@ 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 { - return getKey>(SUBTITLE_DOWNLOAD_KEY) ?: listOf("en") + fun getDownloadSubsLanguageISO639_1(): List { + return getKey(SUBTITLE_DOWNLOAD_KEY) ?: listOf("en") } - fun getAutoSelectLanguageTagIETF(): String { - return getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" + fun getAutoSelectLanguageISO639_1(): String { + return getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" } } @@ -331,13 +204,25 @@ class SubtitlesFragment : BaseDialogFragment( private fun Context.updateState() { val text = getString(R.string.subtitles_example_text) val fixedText = SpannableString.valueOf(if (state.upperCase) text.uppercase() else text) - setSubtitleViewStyle(binding?.subtitleText, state, false) + state.edgeSize?.let { size -> + fixedText.setSpan( + OutlineSpan(size), + 0, + fixedText.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + + setSubtitleViewStyle(binding?.subtitleText, state) 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() ) ) @@ -356,6 +241,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 @@ -366,35 +268,30 @@ class SubtitlesFragment : BaseDialogFragment( override fun onStart() { super.onStart() - dialog?.window?.setWindowAnimations(R.style.DialogFullscreenPlayer) + dialog?.window?.setWindowAnimations(R.style.DialogFullscreen) } override fun getTheme(): Int { - return R.style.DialogFullscreenPlayer + return R.style.DialogFullscreen } - var systemBarsAddPadding = isLayout(TV or EMULATOR) - override fun fixLayout(view: View) { - fixSystemBarsPadding( - view, - padBottom = systemBarsAddPadding || isLandscape(), - padLeft = systemBarsAddPadding - ) - } - - override fun onBindingCreated(binding: SubtitleSettingsBinding) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) hide = arguments?.getBoolean("hide") ?: true val popFragment = arguments?.getBoolean("popFragment") ?: false onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - binding.subsImportText.text = getString(R.string.subs_import_text).format( + binding?.subsImportText?.text = getString(R.string.subs_import_text).format( context?.getExternalFilesDir(null)?.absolutePath.toString() + "/Fonts" ) + fixPaddingStatusbar(binding?.subsRoot) + state = getCurrentSavedStyle() context?.updateState() val isTvTrueSettings = isLayout(TV) + fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvTrueSettings } @@ -418,7 +315,7 @@ class SubtitlesFragment : BaseDialogFragment( return@setOnLongClickListener true } } - binding.apply { + binding?.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) @@ -434,7 +331,7 @@ class SubtitlesFragment : BaseDialogFragment( // tbh this should not be a dialog if it has so many values val elevationTypes = listOf( 0 to textView.context.getString(R.string.none) - ) + (1..40).map { x -> + ) + (1..30).map { x -> val i = x * 10 i to "${i}dp" } @@ -461,62 +358,6 @@ 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( @@ -567,7 +408,7 @@ class SubtitlesFragment : BaseDialogFragment( dismissCallback ) { index -> state.fixedTextSize = fontSizes.map { it.first }[index] - textView.context.updateState() + //textView.context.updateState() // font size not changed } } @@ -605,21 +446,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() + //textView.context.updateState() // font size not changed showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } @@ -640,9 +469,10 @@ class SubtitlesFragment : BaseDialogFragment( subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx).edit { - putBoolean(getString(R.string.filter_sub_lang_key), b) - } + PreferenceManager.getDefaultSharedPreferences(ctx) + .edit() + .putBoolean(getString(R.string.filter_sub_lang_key), b) + .apply() } } @@ -704,29 +534,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]) } } @@ -738,26 +567,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()) } } @@ -779,6 +600,7 @@ class SubtitlesFragment : BaseDialogFragment( applyBtt.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) + it.context.fromSaveToStyle(state) if (popFragment) { activity?.popCurrentPage() } else { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt new file mode 100644 index 000000000..820a01f9f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt @@ -0,0 +1,139 @@ +package com.lagradost.cloudstream3.utils + +import android.util.Log +import androidx.annotation.StringRes +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import java.lang.Long.min + +object EpisodeSkip { + private const val TAG = "EpisodeSkip" + + enum class SkipType(@StringRes name: Int) { + Opening(R.string.skip_type_op), + Ending(R.string.skip_type_ed), + Recap(R.string.skip_type_recap), + MixedOpening(R.string.skip_type_mixed_op), + MixedEnding(R.string.skip_type_mixed_ed), + Credits(R.string.skip_type_creddits), + Intro(R.string.skip_type_creddits), + } + + data class SkipStamp( + val type: SkipType, + val skipToNextEpisode: Boolean, + val startMs: Long, + val endMs: Long, + ) { + val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt( + R.string.skip_type_format, + txt(type.name) + ) + } + + private val cachedStamps = HashMap>() + + private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean { + return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh + } + + suspend fun getStamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + hasNextEpisode: Boolean, + ): List { + cachedStamps[episode.id]?.let { list -> + return list + } + + val out = mutableListOf() + Log.i(TAG, "Requesting SkipStamp from ${data.syncData}") + + if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) { + data.getMalId()?.toIntOrNull()?.let { malId -> + val (resultLength, stamps) = AniSkip.getResult( + malId, + episode.episode, + episodeDurationMs + ) ?: return@let null + // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work + val dur = min(episodeDurationMs, resultLength) + stamps.mapNotNull { stamp -> + val skipType = when (stamp.skipType) { + "op" -> SkipType.Opening + "ed" -> SkipType.Ending + "recap" -> SkipType.Recap + "mixed-ed" -> SkipType.MixedEnding + "mixed-op" -> SkipType.MixedOpening + else -> null + } ?: return@mapNotNull null + val end = (stamp.interval.endTime * 1000.0).toLong() + val start = (stamp.interval.startTime * 1000.0).toLong() + SkipStamp( + type = skipType, + skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode( + end, + dur + ), + startMs = start, + endMs = end + ) + }.let { list -> + out.addAll(list) + } + } + } + if (out.isNotEmpty()) + cachedStamps[episode.id] = out + return out + } +} + +// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt +// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md +object AniSkip { + private const val TAG = "AniSkip" + suspend fun getResult( + malId: Int, + episodeNumber: Int, + episodeLength: Long + ): Pair>? { + return try { + val url = + "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}" + Log.i(TAG, "Requesting $url") + + val a = app.get(url) + val res = a.parsed() + Log.i(TAG, "Found ${res.found} with ${res.results?.size} results") + if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null + } catch (t: Throwable) { + Log.i(TAG, "error = ${t.message}") + logError(t) + null + } + } + + data class AniSkipResponse( + @JsonSerialize val found: Boolean, + @JsonSerialize val results: List?, + @JsonSerialize val message: String?, + @JsonSerialize val statusCode: Int + ) + + data class Stamp( + @JsonSerialize val interval: AniSkipInterval, + @JsonSerialize val skipType: String, + @JsonSerialize val skipId: String, + @JsonSerialize val episodeLength: Double + ) + + data class AniSkipInterval( + @JsonSerialize val startTime: Double, + @JsonSerialize val endTime: Double + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 8d322fd04..34096f76a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -6,6 +6,7 @@ import android.app.Activity import android.app.Activity.RESULT_CANCELED import android.app.NotificationChannel import android.app.NotificationManager +import android.content.ContentValues import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -18,9 +19,13 @@ import android.media.tv.TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities +import android.net.Uri import android.os.Build +import android.os.Environment import android.os.Handler import android.os.Looper +import android.os.ParcelFileDescriptor +import android.provider.MediaStore import android.text.Spanned import android.util.Log import android.view.View @@ -32,7 +37,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread import androidx.appcompat.app.AlertDialog -import androidx.core.net.toUri import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned import androidx.core.widget.ContentLoadingProgressBar @@ -40,6 +44,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.tvprovider.media.tv.PreviewChannelHelper @@ -54,8 +59,8 @@ import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.wrappers.Wrappers import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.APIHolder.apis +import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity import com.lagradost.cloudstream3.AllLanguagesName -import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus @@ -68,7 +73,7 @@ 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,15 +90,25 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache import java.io.File +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.net.URL +import java.net.URLDecoder import java.util.concurrent.Executor import java.util.concurrent.Executors object AppContextUtils { + fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) { + for (i in 0..maxViewTypeId) + recycledViewPool.setMaxRecycledViews(i, maxPoolSize) + } + fun RecyclerView.isRecyclerScrollable(): Boolean { val layoutManager = this.layoutManager as? LinearLayoutManager? @@ -119,9 +134,9 @@ object AppContextUtils { //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), @@ -145,12 +160,11 @@ object AppContextUtils { } } - /** 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) { @@ -168,10 +182,10 @@ object AppContextUtils { ) .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) .setTitle(title) - .setPosterArtUri(card.posterUrl?.toUri()) - .setIntentUri((card.id?.let { + .setPosterArtUri(Uri.parse(card.posterUrl)) + .setIntentUri(Uri.parse(card.id?.let { "$APP_STRING_RESUME_WATCHING://$it" - } ?: card.url).toUri()) + } ?: card.url)) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( resumeWatching?.updateTime ?: System.currentTimeMillis() @@ -317,7 +331,7 @@ object AppContextUtils { val context = this continueWatchingLock.withLock { // A way to get all last watched timestamps - val timeStampHashMap = HashMap() + val timeStampHashMap = HashMap() getAllResumeStateIds()?.forEach { id -> val lastWatched = getLastWatched(id) ?: return@forEach timeStampHashMap[lastWatched.parentId] = lastWatched @@ -362,22 +376,52 @@ object AppContextUtils { } } - /** Sort subtitles by names */ + @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 { - // Be aware, sorting by "$originalName $nameSuffix" causes "a (b) 1" < "a 1", - // where "originalName then nameSuffix" preserves "a 1" < "a (b) 1", because we do not compare '(' and '1'. - return subs - .sortedWith( - compareBy { subtitle: SubtitleData -> subtitle.originalName } - .thenBy { subtitle: SubtitleData -> subtitle.nameSuffix }) + 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) } + 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 } @@ -436,14 +480,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 @@ -468,8 +504,9 @@ object AppContextUtils { } ?: default val langs = this.getApiProviderLangSettings() val hasUniversal = langs.contains(AllLanguagesName) - val allApis = + val allApis = synchronized(apis) { apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } + } return if (currentPrefMedia.isEmpty()) { allApis } else { @@ -523,12 +560,12 @@ object AppContextUtils { fun Activity.loadRepository(url: String) { ioSafe { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe - val data = RepositoryData( - repo.iconUrl ?: "", - repo.name, - url + RepositoryManager.addRepository( + RepositoryData( + repo.name, + url + ) ) - RepositoryManager.addRepository(data) main { showToast( getString(R.string.player_loaded_subtitles, repo.name), @@ -536,12 +573,52 @@ object AppContextUtils { ) } afterRepositoryLoadedEvent.invoke(true) - addRepositoryDialog(data) + addRepositoryDialog(repo.name, url) + } + } + + 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( - repositoryData: RepositoryData + repositoryName: String, + repositoryURL: String, ) { val repos = RepositoryManager.getRepositories() @@ -551,7 +628,9 @@ object AppContextUtils { navigate( R.id.global_to_navigation_settings_plugins, PluginsFragment.newInstance( - repositoryData, + repositoryName, + repositoryURL, + false, ) ) } @@ -559,7 +638,7 @@ object AppContextUtils { runOnUiThread { AlertDialog.Builder(this).apply { - setTitle(repositoryData.name) + setTitle(repositoryName) setMessage(R.string.download_all_plugins_from_repo) setPositiveButton(R.string.open_downloaded_repo) { _, _ -> openAddedRepo() @@ -576,7 +655,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)) @@ -593,7 +672,7 @@ object AppContextUtils { ) = (this.getActivity() ?: activity)?.runOnUiThread { try { val intent = Intent(Intent.ACTION_VIEW) - intent.data = url.toUri() + intent.data = Uri.parse(url) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) // activityResultRegistry is used to fall back to webview if a browser is missing @@ -632,17 +711,16 @@ object AppContextUtils { } } - // Deprecate after next stable - /* @Deprecated( - message = "Use splitUrlParameters instead.", - replaceWith = ReplaceWith( - expression = "splitUrlParameters(url.toString())", - imports = ["com.lagradost.cloudstream3.splitUrlParameters"], - ), - level = DeprecationLevel.WARNING, - ) */ - fun splitQuery(url: java.net.URL): Map { - return com.lagradost.cloudstream3.splitUrlParameters(url.toString()) + fun splitQuery(url: URL): Map { + val queryPairs: MutableMap = LinkedHashMap() + val query: String = url.query + val pairs = query.split("&").toTypedArray() + for (pair in pairs) { + val idx = pair.indexOf("=") + queryPairs[URLDecoder.decode(pair.substring(0, idx), "UTF-8")] = + URLDecoder.decode(pair.substring(idx + 1), "UTF-8") + } + return queryPairs } /**| S1:E2 Hello World @@ -678,18 +756,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()) @@ -710,7 +776,7 @@ object AppContextUtils { fun loadResult( url: String, apiName: String, - name: String, + name : String, startAction: Int = 0, startValue: Int = 0 ) { @@ -720,7 +786,7 @@ object AppContextUtils { fun FragmentActivity.loadResult( url: String, apiName: String, - name: String, + name : String, startAction: Int = 0, startValue: Int = 0 ) { @@ -845,9 +911,102 @@ object AppContextUtils { } } + // Copied from https://github.com/videolan/vlc-android/blob/master/application/vlc-android/src/org/videolan/vlc/util/FileUtils.kt + @SuppressLint("Range") + fun Context.getUri(data: Uri?): Uri? { + var uri = data + val ctx = this + if (data != null && data.scheme == "content") { + // Mail-based apps - download the stream to a temporary file and play it + if ("com.fsck.k9.attachmentprovider" == data.host || "gmail-ls" == data.host) { + var inputStream: InputStream? = null + var os: OutputStream? = null + var cursor: Cursor? = null + try { + cursor = ctx.contentResolver.query( + data, + arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, null + ) + if (cursor != null && cursor.moveToFirst()) { + val filename = + cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)) + .replace("/", "") + inputStream = ctx.contentResolver.openInputStream(data) + if (inputStream == null) return data + os = + FileOutputStream(Environment.getExternalStorageDirectory().path + "/Download/" + filename) + val buffer = ByteArray(1024) + var bytesRead = inputStream.read(buffer) + while (bytesRead >= 0) { + os.write(buffer, 0, bytesRead) + bytesRead = inputStream.read(buffer) + } + uri = + Uri.fromFile(File(Environment.getExternalStorageDirectory().path + "/Download/" + filename)) + } + } catch (e: Exception) { + return null + } finally { + inputStream?.close() + os?.close() + cursor?.close() + } + } else if (data.authority == "media") { + uri = this.contentResolver.query( + data, + arrayOf(MediaStore.Video.Media.DATA), null, null, null + )?.use { + val columnIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA) + if (it.moveToFirst()) Uri.fromFile(File(it.getString(columnIndex))) + ?: data else data + } + //uri = MediaUtils.getContentMediaUri(data) + /*} else if (data.authority == ctx.getString(R.string.tv_provider_authority)) { + println("TV AUTHORITY") + //val medialibrary = Medialibrary.getInstance() + //val media = medialibrary.getMedia(data.lastPathSegment!!.toLong()) + uri = null//media.uri*/ + } else { + val inputPFD: ParcelFileDescriptor? + try { + inputPFD = ctx.contentResolver.openFileDescriptor(data, "r") + if (inputPFD == null) return data + uri = Uri.parse("fd://" + inputPFD.fd) + // Cursor returnCursor = + // getContentResolver().query(data, null, null, null, null); + // if (returnCursor != null) { + // if (returnCursor.getCount() > 0) { + // int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + // if (nameIndex > -1) { + // returnCursor.moveToFirst(); + // title = returnCursor.getString(nameIndex); + // } + // } + // returnCursor.close(); + // } + } catch (e: FileNotFoundException) { + Log.e("TAG", "${e.message} for $data", e) + return null + } catch (e: IllegalArgumentException) { + Log.e("TAG", "${e.message} for $data", e) + return null + } catch (e: IllegalStateException) { + Log.e("TAG", "${e.message} for $data", e) + return null + } catch (e: NullPointerException) { + Log.e("TAG", "${e.message} for $data", e) + return null + } catch (e: SecurityException) { + Log.e("TAG", "${e.message} for $data", e) + return null + } + }// Media or MMS URI + } + return uri + } + fun Context.isUsingMobileData(): Boolean { - val connectionManager = - getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + 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) @@ -900,4 +1059,4 @@ object AppContextUtils { } 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..9f77597b0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -2,61 +2,31 @@ package com.lagradost.cloudstream3.utils import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback -import java.lang.ref.WeakReference import java.util.WeakHashMap object BackPressedCallbackHelper { + private val backPressedCallbacks = WeakHashMap>() - private val backPressedCallbacks = - WeakHashMap>() - - class CallbackHelper( - private val activityRef: WeakReference, - private val callback: OnBackPressedCallback - ) { - fun runDefault() { - val activity = activityRef.get() ?: return - val wasEnabled = callback.isEnabled - callback.isEnabled = false - try { - activity.onBackPressedDispatcher.onBackPressed() - } finally { - callback.isEnabled = wasEnabled - } - } - } - - fun ComponentActivity.attachBackPressedCallback( - id: String, - callback: CallbackHelper.() -> Unit - ) { + fun ComponentActivity.attachBackPressedCallback(id: String, callback: () -> Unit) { val callbackMap = backPressedCallbacks.getOrPut(this) { mutableMapOf() } + if (callbackMap.containsKey(id)) return - // We use WeakReference to protect against potential leaks. - val activityRef = WeakReference(this) val newCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - CallbackHelper(activityRef, this).callback() + callback.invoke() } } - callbackMap[id] = newCallback + onBackPressedDispatcher.addCallback(this, newCallback) } - fun ComponentActivity.disableBackPressedCallback(id : String) { - backPressedCallbacks[this]?.get(id)?.isEnabled = false - } - - fun ComponentActivity.enableBackPressedCallback(id : String) { - backPressedCallbacks[this]?.get(id)?.isEnabled = true - } - fun ComponentActivity.detachBackPressedCallback(id: String) { val callbackMap = backPressedCallbacks[this] ?: return + callbackMap[id]?.let { callback -> - callback.remove() + callback.isEnabled = false callbackMap.remove(id) } @@ -64,4 +34,4 @@ object BackPressedCallbackHelper { backPressedCallbacks.remove(this) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 0a561b471..9bc00a992 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.widget.Toast @@ -10,61 +11,69 @@ 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.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs +import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.QUEUE_KEY -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE -import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.VideoDownloadManager.StreamData +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import okhttp3.internal.closeQuietly +import java.io.File import java.io.IOException import java.io.OutputStream import java.io.PrintWriter import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale object BackupUtils { /** * 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 @@ -72,44 +81,7 @@ object BackupUtils { "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" + "backup_dir_path_key" ) /** false if key should not be contained in backup */ @@ -120,24 +92,24 @@ object BackupUtils { private var restoreFileSelector: ActivityResultLauncher>? = null // Kinda hack, but I couldn't think of a better way - @Serializable data class BackupVars( - @JsonProperty("_Bool") @SerialName("_Bool") val bool: Map?, - @JsonProperty("_Int") @SerialName("_Int") val int: Map?, - @JsonProperty("_String") @SerialName("_String") val string: Map?, - @JsonProperty("_Float") @SerialName("_Float") val float: Map?, - @JsonProperty("_Long") @SerialName("_Long") val long: Map?, - @JsonProperty("_StringSet") @SerialName("_StringSet") val stringSet: Map?>?, + @JsonProperty("_Bool") val bool: Map?, + @JsonProperty("_Int") val int: Map?, + @JsonProperty("_String") val string: Map?, + @JsonProperty("_Float") val float: Map?, + @JsonProperty("_Long") val long: Map?, + @JsonProperty("_StringSet") val stringSet: Map?>?, ) - @Serializable data class BackupFile( - @JsonProperty("datastore") @SerialName("datastore") val datastore: BackupVars, - @JsonProperty("settings") @SerialName("settings") val settings: BackupVars, + @JsonProperty("datastore") val datastore: BackupVars, + @JsonProperty("settings") val settings: BackupVars ) @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() } @@ -147,7 +119,7 @@ object BackupUtils { allData.filter { it.value is String } as? Map, allData.filter { it.value is Float } as? Map, allData.filter { it.value is Long } as? Map, - allData.filter { it.value as? Set != null } as? Map>, + allData.filter { it.value as? Set != null } as? Map> ) val allSettingsSorted = BackupVars( @@ -156,12 +128,12 @@ object BackupUtils { allSettings.filter { it.value is String } as? Map, allSettings.filter { it.value is Float } as? Map, allSettings.filter { it.value is Long } as? Map, - allSettings.filter { it.value as? Set != null } as? Map>, + allSettings.filter { it.value as? Set != null } as? Map> ) return BackupFile( allDataSorted, - allSettingsSorted, + allSettingsSorted ) } @@ -170,7 +142,7 @@ object BackupUtils { context: Context?, backupFile: BackupFile, restoreSettings: Boolean, - restoreDataStore: Boolean, + restoreDataStore: Boolean ) { if (context == null) return if (restoreSettings) { @@ -190,18 +162,14 @@ 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 + var fileStream: OutputStream? = null var printStream: PrintWriter? = null - try { if (!context.checkWrite()) { showToast(R.string.backup_failed, Toast.LENGTH_LONG) @@ -209,21 +177,25 @@ object BackupUtils { return@ioSafe } - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) val displayName = "CS3_Backup_${date}" val backupFile = getBackup(context) val stream = setupBackupStream(context, displayName) fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(backupFile.toJson()) - showToast(R.string.backup_success, Toast.LENGTH_LONG) + printStream.print(mapper.writeValueAsString(backupFile)) + + showToast( + R.string.backup_success, + Toast.LENGTH_LONG + ) } catch (e: Exception) { logError(e) try { showToast( txt(R.string.backup_failed_error_format, e.toString()), - Toast.LENGTH_LONG, + Toast.LENGTH_LONG ) } catch (e: Exception) { logError(e) @@ -235,14 +207,14 @@ object BackupUtils { } @Throws(IOException::class) - private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): DownloadObjects.StreamData { + private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): StreamData { return setupStream( baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) ?: throw IOException("Bad config"), name, folder = null, extension = ext, - tryResume = false, + tryResume = false ) } @@ -257,14 +229,14 @@ 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, restoredValue, restoreSettings = true, - restoreDataStore = true, + restoreDataStore = true ) activity.runOnUiThread { activity.recreate() } } catch (e: Exception) { @@ -305,7 +277,7 @@ object BackupUtils { private fun Context.restoreMap( map: Map?, - isEditingAppSettings: Boolean = false, + isEditingAppSettings: Boolean = false ) { val editor = DataStore.editor(this, isEditingAppSettings) map?.forEach { @@ -317,27 +289,20 @@ object BackupUtils { } /** - * Copy of [com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getDefaultDir], - * modified for backup-specific paths. - */ + * Copy of [VideoDownloadManager.basePathToFile], [VideoDownloadManager.getDefaultDir] and [VideoDownloadManager.getBasePath] + * modded for backup specific paths + * */ + fun getDefaultBackupDir(context: Context): SafeFile? { return SafeFile.fromMedia(context, MediaFileContentType.Downloads) } - /** - * Copy of [com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath], - * modified for backup-specific paths. - */ 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 } - /** - * Copy of [com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile], - * modified for backup-specific paths. - */ private fun baseBackupPathToFile(context: Context, path: String?): SafeFile? { return when { path.isNullOrBlank() -> getDefaultBackupDir(context) 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 68f262d8d..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,35 +141,28 @@ 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? { - return try { + fun Context.getKey(path: String, valueType: Class): T? { + try { val json: String = getSharedPrefs().getString(path, null) ?: return null - parseJson(json, valueType.kotlin) - } catch (_: Exception) { - null + return json.toKotlinObject(valueType) + } catch (e: Exception) { + return null } } @@ -193,37 +170,21 @@ object DataStore { setKey(getFolderName(folder, path), value) } - @Deprecated( - message = "Use parseJson(this) directly instead.", - level = DeprecationLevel.WARNING, - replaceWith = ReplaceWith( - expression = "parseJson(this)", - imports = ["com.lagradost.cloudstream3.utils.AppUtils.parseJson"], - ), - ) inline fun String.toKotlinObject(): T { - return parseJson(this) + return mapper.readValue(this, T::class.java) } - @Deprecated( - message = "Use parseJson(this) directly instead.", - level = DeprecationLevel.WARNING, - replaceWith = ReplaceWith( - expression = "parseJson(this)", - imports = ["com.lagradost.cloudstream3.utils.AppUtils.parseJson"], - ), - ) - 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 inline fun Context.getKey(path: String, defVal: T?): T? { - return try { + try { val json: String = getSharedPrefs().getString(path, null) ?: return defVal - parseJson(json) - } catch (_: Exception) { - null + return json.toKotlinObject() + } catch (e: Exception) { + return null } } @@ -238,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 340266f6c..2e16fc8c7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,23 +1,20 @@ package com.lagradost.cloudstream3.utils import android.content.Context -import com.fasterxml.jackson.annotation.JsonIgnore 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 @@ -25,19 +22,8 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.player.ExtractorUri -import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE -import com.lagradost.cloudstream3.ui.result.EpisodeSortType -import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.VideoWatchState import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects -import com.lagradost.cloudstream3.utils.serializers.WriteOnlySerializer -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KeepGeneratedSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient import java.util.Calendar import java.util.Date import java.util.GregorianCalendar @@ -50,33 +36,31 @@ const val RESULT_WATCH_STATE = "result_watch_state" const val RESULT_WATCH_STATE_DATA = "result_watch_state_data" const val RESULT_SUBSCRIBED_STATE_DATA = "result_subscribed_state_data" const val RESULT_FAVORITES_STATE_DATA = "result_favorites_state_data" -const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // Changed due to id changes +const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // changed due to id changes const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching" 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 key: String, private val default: T //, private val klass: KClass ) { 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?, property: KProperty<*>, - t: T?, + t: T? ) { if (t == null) { removeKey(realKey) } else { - setKeyClass(realKey, t) + AcraApplication.setKeyClass(realKey, t) } } } @@ -90,89 +74,65 @@ object DataStoreHelper { R.drawable.profile_bg_pink, R.drawable.profile_bg_purple, R.drawable.profile_bg_red, - R.drawable.profile_bg_teal, + 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 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) - 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 - } - - @Serializable data class Account( - @JsonProperty("keyIndex") @SerialName("keyIndex") val keyIndex: Int, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("customImage") @SerialName("customImage") val customImage: String? = null, - @JsonProperty("defaultImageIndex") @SerialName("defaultImageIndex") val defaultImageIndex: Int, - @JsonProperty("lockPin") @SerialName("lockPin") val lockPin: String? = null, + @JsonProperty("keyIndex") + val keyIndex: Int, + @JsonProperty("name") + val name: String, + @JsonProperty("customImage") + val customImage: String? = null, + @JsonProperty("defaultImageIndex") + val defaultImageIndex: Int, + @JsonProperty("lockPin") + val lockPin: String? = null, ) { - @get:JsonIgnore - val image get() = customImage?.let { UiImage.Image(it) } ?: - profileImages.getOrNull(defaultImageIndex)?.let { - UiImage.Drawable(it) - } ?: UiImage.Drawable(profileImages.first()) + val image get() = customImage?.let { UiImage.Image(it) } ?: profileImages.getOrNull(defaultImageIndex)?.let { UiImage.Drawable(it) } ?: UiImage.Drawable(profileImages.first()) } const val TAG = "data_store_helper" @@ -185,7 +145,7 @@ object DataStoreHelper { * Setting this does not automatically reload the homepage. */ var currentHomePage: String? - get() = getKey("$currentAccount/$USER_SELECTED_HOMEPAGE_API") + get() = getKey("$currentAccount/$USER_SELECTED_HOMEPAGE_API") set(value) { val key = "$currentAccount/$USER_SELECTED_HOMEPAGE_API" if (value == null) { @@ -197,8 +157,8 @@ object DataStoreHelper { fun setAccount(account: Account) { 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) @@ -214,7 +174,7 @@ object DataStoreHelper { currentAccounts.getOrNull(currentAccounts.indexOfFirst { it.keyIndex == 0 }) ?: Account( keyIndex = 0, name = context.getString(R.string.default_account), - defaultImageIndex = 0, + defaultImageIndex = 0 ) } } @@ -240,111 +200,60 @@ object DataStoreHelper { } } - @Serializable data class PosDur( - @JsonProperty("position") @SerialName("position") val position: Long, - @JsonProperty("duration") @SerialName("duration") val duration: Long, + @JsonProperty("position") val position: Long, + @JsonProperty("duration") val duration: Long ) fun PosDur.fixVisual(): PosDur { if (duration <= 0) return PosDur(0, duration) val percentage = position * 100 / duration - return when { - percentage <= 1 -> PosDur(0, duration) - percentage <= 5 -> PosDur(5 * duration / 100, duration) - percentage >= 95 -> PosDur(duration, duration) - else -> this - } + if (percentage <= 1) return PosDur(0, duration) + if (percentage <= 5) return PosDur(5 * duration / 100, duration) + if (percentage >= 95) return PosDur(duration, duration) + 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. - */ - @Serializable + **/ abstract class LibrarySearchResponse( - /** - * These fields are marked @Transient because this class is only ever serialized through - * through its subclasses, which redeclare each property with their own @SerialName - * annotations. Without @Transient here, kotlinx.serialization would try to - * generate a serializer for the abstract base class itself (or double-serialize - * these fields), which fails/conflicts since these are meant to be overridden, - * not serialized directly from the parent. - */ - @Transient override var id: Int? = null, - @Transient open val latestUpdatedTime: Long = 0L, - @Transient override val name: String = "", - @Transient override val url: String = "", - @Transient override val apiName: String = "", - @Transient override var type: TvType? = null, - @Transient override var posterUrl: String? = null, - @Transient open val year: Int? = null, - @Transient open val syncData: Map? = null, - @Transient override var quality: SearchQuality? = null, - @Transient override var posterHeaders: Map? = null, - @Transient open val plot: String? = null, - @Transient override var score: Score? = null, - @Transient open val tags: List? = null, - ) : SearchResponse { - @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) - @SerialName("rating") - @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("id") override var id: Int?, + @JsonProperty("latestUpdatedTime") open val latestUpdatedTime: Long, + @JsonProperty("name") override val name: String, + @JsonProperty("url") override val url: String, + @JsonProperty("apiName") override val apiName: String, + @JsonProperty("type") override var type: TvType?, + @JsonProperty("posterUrl") override var posterUrl: String?, + @JsonProperty("year") open val year: Int?, + @JsonProperty("syncData") open val syncData: Map?, + @JsonProperty("quality") override var quality: SearchQuality?, + @JsonProperty("posterHeaders") override var posterHeaders: Map?, + @JsonProperty("plot") open val plot : String? = null, + @JsonProperty("rating") open val rating : Int? = null, + @JsonProperty("tags") open val tags : List? = null, + ) : SearchResponse - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = SubscribedData.Serializer::class) data class SubscribedData( - @JsonProperty("subscribedTime") @SerialName("subscribedTime") val subscribedTime: Long, - @JsonProperty("lastSeenEpisodeCount") @SerialName("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map, - @JsonProperty("id") @SerialName("id") override var id: Int?, - @JsonProperty("latestUpdatedTime") @SerialName("latestUpdatedTime") override val latestUpdatedTime: Long, - @JsonProperty("name") @SerialName("name") override val name: String, - @JsonProperty("url") @SerialName("url") override val url: String, - @JsonProperty("apiName") @SerialName("apiName") override val apiName: String, - @JsonProperty("type") @SerialName("type") override var type: TvType?, - @JsonProperty("posterUrl") @SerialName("posterUrl") override var posterUrl: String?, - @JsonProperty("year") @SerialName("year") override val year: Int?, - @JsonProperty("syncData") @SerialName("syncData") override val syncData: Map? = null, - @JsonProperty("quality") @SerialName("quality") override var quality: SearchQuality? = null, - @JsonProperty("posterHeaders") @SerialName("posterHeaders") override var posterHeaders: Map? = null, - @JsonProperty("plot") @SerialName("plot") override val plot: String? = null, - @JsonProperty("score") @SerialName("score") override var score: Score? = null, - @JsonProperty("tags") @SerialName("tags") override val tags: List? = null, - ) : LibrarySearchResponse( - id, - latestUpdatedTime, - name, - url, - apiName, - type, - posterUrl, - year, - syncData, - quality, - posterHeaders, - plot, - score, - tags, - ) { - object Serializer : WriteOnlySerializer( - SubscribedData.generatedSerializer(), - setOf("rating"), - ) - + @JsonProperty("subscribedTime") val subscribedTime: Long, + @JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map, + override var id: Int?, + override val latestUpdatedTime: Long, + override val name: String, + override val url: String, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override val year: Int?, + override val syncData: Map? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override val plot: String? = null, + override val rating: Int? = null, + override val tags: List? = null, + ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot,rating,tags) { fun toLibraryItem(): SyncAPI.LibraryItem? { return SyncAPI.LibraryItem( name, @@ -354,58 +263,28 @@ 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 ) } } - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = BookmarkedData.Serializer::class) data class BookmarkedData( - @JsonProperty("bookmarkedTime") @SerialName("bookmarkedTime") val bookmarkedTime: Long, - @JsonProperty("id") @SerialName("id") override var id: Int?, - @JsonProperty("latestUpdatedTime") @SerialName("latestUpdatedTime") override val latestUpdatedTime: Long, - @JsonProperty("name") @SerialName("name") override val name: String, - @JsonProperty("url") @SerialName("url") override val url: String, - @JsonProperty("apiName") @SerialName("apiName") override val apiName: String, - @JsonProperty("type") @SerialName("type") override var type: TvType?, - @JsonProperty("posterUrl") @SerialName("posterUrl") override var posterUrl: String?, - @JsonProperty("year") @SerialName("year") override val year: Int?, - @JsonProperty("syncData") @SerialName("syncData") override val syncData: Map? = null, - @JsonProperty("quality") @SerialName("quality") override var quality: SearchQuality? = null, - @JsonProperty("posterHeaders") @SerialName("posterHeaders") override var posterHeaders: Map? = null, - @JsonProperty("plot") @SerialName("plot") override val plot: String? = null, - @JsonProperty("score") @SerialName("score") override var score: Score? = null, - @JsonProperty("tags") @SerialName("tags") override val tags: List? = null, - ) : LibrarySearchResponse( - id, - latestUpdatedTime, - name, - url, - apiName, - type, - posterUrl, - year, - syncData, - quality, - posterHeaders, - plot, - ) { - object Serializer : WriteOnlySerializer( - BookmarkedData.generatedSerializer(), - setOf("rating"), - ) - + @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, + override var id: Int?, + override val latestUpdatedTime: Long, + override val name: String, + override val url: String, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override val year: Int?, + override val syncData: Map? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override val plot: String? = null, + override val rating: Int? = null, + override val tags: List? = null, + ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot) { fun toLibraryItem(id: String): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( name, @@ -415,58 +294,28 @@ 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 ) } } - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = FavoritesData.Serializer::class) data class FavoritesData( - @JsonProperty("favoritesTime") @SerialName("favoritesTime") val favoritesTime: Long, - @JsonProperty("id") @SerialName("id") override var id: Int?, - @JsonProperty("latestUpdatedTime") @SerialName("latestUpdatedTime") override val latestUpdatedTime: Long, - @JsonProperty("name") @SerialName("name") override val name: String, - @JsonProperty("url") @SerialName("url") override val url: String, - @JsonProperty("apiName") @SerialName("apiName") override val apiName: String, - @JsonProperty("type") @SerialName("type") override var type: TvType?, - @JsonProperty("posterUrl") @SerialName("posterUrl") override var posterUrl: String?, - @JsonProperty("year") @SerialName("year") override val year: Int?, - @JsonProperty("syncData") @SerialName("syncData") override val syncData: Map? = null, - @JsonProperty("quality") @SerialName("quality") override var quality: SearchQuality? = null, - @JsonProperty("posterHeaders") @SerialName("posterHeaders") override var posterHeaders: Map? = null, - @JsonProperty("plot") @SerialName("plot") override val plot: String? = null, - @JsonProperty("score") @SerialName("score") override var score: Score? = null, - @JsonProperty("tags") @SerialName("tags") override val tags: List? = null, - ) : LibrarySearchResponse( - id, - latestUpdatedTime, - name, - url, - apiName, - type, - posterUrl, - year, - syncData, - quality, - posterHeaders, - plot, - ) { - object Serializer : WriteOnlySerializer( - FavoritesData.generatedSerializer(), - setOf("rating"), - ) - + @JsonProperty("favoritesTime") val favoritesTime: Long, + override var id: Int?, + override val latestUpdatedTime: Long, + override val name: String, + override val url: String, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override val year: Int?, + override val syncData: Map? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override val plot: String? = null, + override val rating: Int? = null, + override val tags: List? = null, + ) : LibrarySearchResponse(id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders,plot) { fun toLibraryItem(): SyncAPI.LibraryItem? { return SyncAPI.LibraryItem( name, @@ -476,41 +325,30 @@ 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 ) } } - @Serializable data class ResumeWatchingResult( - @JsonProperty("name") @SerialName("name") override val name: String, - @JsonProperty("url") @SerialName("url") override val url: String, - @JsonProperty("apiName") @SerialName("apiName") override val apiName: String, - @JsonProperty("type") @SerialName("type") override var type: TvType? = null, - @JsonProperty("posterUrl") @SerialName("posterUrl") override var posterUrl: String?, - @JsonProperty("watchPos") @SerialName("watchPos") val watchPos: PosDur?, - @JsonProperty("id") @SerialName("id") override var id: Int?, - @JsonProperty("parentId") @SerialName("parentId") val parentId: Int?, - @JsonProperty("episode") @SerialName("episode") val episode: Int?, - @JsonProperty("season") @SerialName("season") val season: Int?, - @JsonProperty("isFromDownload") @SerialName("isFromDownload") val isFromDownload: Boolean, - @JsonProperty("quality") @SerialName("quality") override var quality: SearchQuality? = null, - @JsonProperty("posterHeaders") @SerialName("posterHeaders") override var posterHeaders: Map? = null, - @JsonProperty("score") @SerialName("score") override var score: Score? = null, + @JsonProperty("name") override val name: String, + @JsonProperty("url") override val url: String, + @JsonProperty("apiName") override val apiName: String, + @JsonProperty("type") override var type: TvType? = null, + @JsonProperty("posterUrl") override var posterUrl: String?, + @JsonProperty("watchPos") val watchPos: PosDur?, + @JsonProperty("id") override var id: Int?, + @JsonProperty("parentId") val parentId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("isFromDownload") val isFromDownload: Boolean, + @JsonProperty("quality") override var quality: SearchQuality? = null, + @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, ) : SearchResponse /** * A datastore wide account for future implementations of a multiple account system - */ + **/ fun getAllWatchStateIds(): List? { val folder = "$currentAccount/$RESULT_WATCH_STATE" @@ -546,7 +384,7 @@ object DataStoreHelper { } fun migrateResumeWatching() { - // if (getKey(RESULT_RESUME_WATCHING_HAS_MIGRATED, false) != true) { + // if (getKey(RESULT_RESUME_WATCHING_HAS_MIGRATED, false) != true) { setKey(RESULT_RESUME_WATCHING_HAS_MIGRATED, true) getAllResumeStateIdsOld()?.forEach { id -> getLastWatchedOld(id)?.let { @@ -556,12 +394,12 @@ object DataStoreHelper { it.episode, it.season, it.isFromDownload, - it.updateTime, + it.updateTime ) removeLastWatchedOld(it.parentId) } } - // } + //} } fun setLastWatched( @@ -576,13 +414,13 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - DownloadObjects.ResumeWatching( + VideoDownloadHelper.ResumeWatching( parentId, episodeId, episode, season, updateTime ?: System.currentTimeMillis(), - isFromDownload, + isFromDownload ) ) } @@ -597,17 +435,17 @@ 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( + return getKey( "$currentAccount/$RESULT_RESUME_WATCHING", id.toString(), ) } - private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { + private fun getLastWatchedOld(id: Int?): VideoDownloadHelper.ResumeWatching? { if (id == null) return null - return getKey( + return getKey( "$currentAccount/$RESULT_RESUME_WATCHING_OLD", id.toString(), ) @@ -621,18 +459,18 @@ object DataStoreHelper { fun getBookmarkedData(id: Int?): BookmarkedData? { if (id == null) return null - return getKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) + return getKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) } fun getAllBookmarkedData(): List { return getKeys("$currentAccount/$RESULT_WATCH_STATE_DATA")?.mapNotNull { - getKey(it) + getKey(it) } ?: emptyList() } fun getAllSubscriptions(): List { return getKeys("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA")?.mapNotNull { - getKey(it) + getKey(it) } ?: emptyList() } @@ -644,12 +482,12 @@ object DataStoreHelper { /** * Set new seen episodes and update time - */ + **/ fun updateSubscribedData(id: Int?, data: SubscribedData?, episodeResponse: EpisodeResponse?) { if (id == null || data == null || episodeResponse == null) return val newData = data.copy( latestUpdatedTime = unixTimeMS, - lastSeenEpisodeCount = episodeResponse.getLatestEpisodes(), + lastSeenEpisodeCount = episodeResponse.getLatestEpisodes() ) setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), newData) } @@ -662,12 +500,12 @@ object DataStoreHelper { fun getSubscribedData(id: Int?): SubscribedData? { if (id == null) return null - return getKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) + return getKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) } fun getAllFavorites(): List { return getKeys("$currentAccount/$RESULT_FAVORITES_STATE_DATA")?.mapNotNull { - getKey(it) + getKey(it) } ?: emptyList() } @@ -685,7 +523,7 @@ object DataStoreHelper { fun getFavoritesData(id: Int?): FavoritesData? { if (id == null) return null - return getKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString()) + return getKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString()) } fun setViewPos(id: Int?, pos: Long, dur: Long) { @@ -694,74 +532,19 @@ 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) + return getKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), null) } fun getVideoWatchState(id: Int?): VideoWatchState? { if (id == null) return null - return getKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString(), null) + return getKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString(), null) } fun setVideoWatchState(id: Int?, watchState: VideoWatchState) { if (id == null) return + // None == No key if (watchState == VideoWatchState.None) { removeKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString()) @@ -771,8 +554,8 @@ object DataStoreHelper { } fun getDub(id: Int): DubStatus? { - return DubStatus.entries - .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) + return DubStatus.values() + .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) } fun setDub(id: Int, status: DubStatus) { @@ -793,13 +576,13 @@ object DataStoreHelper { getKey( "$currentAccount/$RESULT_WATCH_STATE", id.toString(), - null, + null ) ) } fun getResultSeason(id: Int): Int? { - return getKey("$currentAccount/$RESULT_SEASON", id.toString(), null) + return getKey("$currentAccount/$RESULT_SEASON", id.toString(), null) } fun setResultSeason(id: Int, value: Int?) { @@ -807,7 +590,7 @@ object DataStoreHelper { } fun getResultEpisode(id: Int): Int? { - return getKey("$currentAccount/$RESULT_EPISODE", id.toString(), null) + return getKey("$currentAccount/$RESULT_EPISODE", id.toString(), null) } fun setResultEpisode(id: Int, value: Int?) { @@ -820,11 +603,7 @@ object DataStoreHelper { fun getSync(id: Int, idPrefixes: List): List { return idPrefixes.map { idPrefix -> - getKey("${idPrefix}_sync", id.toString()) + 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..4eeb4e5da --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -0,0 +1,104 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Notification +import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO +import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_PACKAGE +import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck +import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadEpisode +import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadFromResume +import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadResumePackage +import kotlinx.coroutines.delay + +const val DOWNLOAD_CHECK = "DownloadCheck" + +class DownloadFileWorkManager(val context: Context, private val workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + val key = workerParams.inputData.getString("key") + try { + if (key == DOWNLOAD_CHECK) { + downloadCheck(applicationContext, ::handleNotification) + } else if (key != null) { + val info = + applicationContext.getKey(WORK_KEY_INFO, key) + val pkg = + applicationContext.getKey( + WORK_KEY_PACKAGE, + key + ) + + if (info != null) { + getDownloadResumePackage(applicationContext, info.ep.id)?.let { dpkg -> + downloadFromResume(applicationContext, dpkg, ::handleNotification) + } ?: run { + downloadEpisode( + applicationContext, + info.source, + info.folder, + info.ep, + info.links, + ::handleNotification + ) + } + } else if (pkg != null) { + downloadFromResume(applicationContext, pkg, ::handleNotification) + } + removeKeys(key) + } + return Result.success() + } catch (e: Exception) { + logError(e) + if (key != null) { + removeKeys(key) + } + return Result.failure() + } + } + + private fun removeKeys(key: String) { + removeKey(WORK_KEY_INFO, key) + removeKey(WORK_KEY_PACKAGE, key) + } + + private suspend fun awaitDownload(id: Int) { + var isDone = false + val listener = { (localId, localType): Pair -> + if (id == localId) { + when (localType) { + VideoDownloadManager.DownloadType.IsDone, VideoDownloadManager.DownloadType.IsFailed, VideoDownloadManager.DownloadType.IsStopped -> { + isDone = true + } + + else -> Unit + } + } + } + downloadStatusEvent += listener + while (!isDone) { + println("AWAITING $id") + delay(1000) + } + downloadStatusEvent -= listener + } + + private fun handleNotification(id: Int, notification: Notification) { + main { + if (SDK_INT >= 29) + setForegroundAsync(ForegroundInfo(id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)) + else setForegroundAsync(ForegroundInfo(id, notification)) + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt index f66da4e5f..a0dfe734e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -24,28 +24,3 @@ class Event { } } } - -class EmptyEvent { - private val observers = mutableSetOf() - - val size: Int get() = observers.size - - operator fun plusAssign(observer: Runnable) { - synchronized(observers) { - observers.add(observer) - } - } - - operator fun minusAssign(observer: Runnable) { - synchronized(observers) { - observers.remove(observer) - } - } - - operator fun invoke() { - synchronized(observers) { - for (observer in observers) - observer.run() - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt index 8ec46cbe5..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,147 +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.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.Coroutines.main -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import java.io.InputStream +import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread -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 } - @Serializable - data class Show( - @JsonProperty("slug") @SerialName("slug") val slug: String, - @JsonProperty("title") @SerialName("title") val title: String, - @JsonProperty("filler") @SerialName("filler") val filler: ArrayList, - @JsonProperty("mixedCanon") @SerialName("mixedCanon") val mixedCanon: ArrayList, - @JsonProperty("mangaCanon") @SerialName("mangaCanon") val mangaCanon: ArrayList, - @JsonProperty("animeCanon") @SerialName("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 - @Serializable - data class MappingRoot( - @JsonProperty("type") @SerialName("type") val type: String?, - @JsonProperty("anidb_id") @SerialName("anidb_id") val anidbId: Long?, - @JsonProperty("anilist_id") @SerialName("anilist_id") val anilistId: Long?, - @JsonProperty("animecountdown_id") @SerialName("animecountdown_id") val animecountdownId: Long?, - @JsonProperty("animenewsnetwork_id") @SerialName("animenewsnetwork_id") val animenewsnetworkId: Long?, - @JsonProperty("anime-planet_id") @SerialName("anime-planet_id") val animePlanetId: String?, - @JsonProperty("anisearch_id") @SerialName("anisearch_id") val anisearchId: Long?, - @JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String?, - @JsonProperty("kitsu_id") @SerialName("kitsu_id") val kitsuId: Long?, - @JsonProperty("livechart_id") @SerialName("livechart_id") val livechartId: Long?, - @JsonProperty("mal_id") @SerialName("mal_id") val malId: Long?, - @JsonProperty("simkl_id") @SerialName("simkl_id") val simklId: Long?, - @JsonProperty("themoviedb_id") @SerialName("themoviedb_id") val themoviedbId: Long?, - @JsonProperty("tvdb_id") @SerialName("tvdb_id") val tvdbId: Long?, - @JsonProperty("season") @SerialName("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(")", "\\)") + })""" + ) - @Serializable - data class Season( - @JsonProperty("tvdb") @SerialName("tvdb") val tvdb: Long?, - @JsonProperty("tmdb") @SerialName("tmdb") val tmdb: Long?, - ) - - @Serializable - data class CombinedMedia( - @JsonProperty("mapping") @SerialName("mapping") val mapping: MappingRoot?, - @JsonProperty("show") @SerialName("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 { @@ -161,4 +126,4 @@ object FillerEpisodeCheck { return counter } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt deleted file mode 100644 index 58ff44bb2..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.content.Context - -/** - * Simple helper to get the short commit hash from assets. - * The hash is generated at build and stored as an asset - * that can be accessed at runtime for Gradle - * configuration cache support. - */ -object GitInfo { - fun Context.currentCommitHash(): String = try { - assets.open("git-hash.txt") - .bufferedReader() - .readText() - .trim() - } catch (_: Exception) { - "" - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt index 96193fe45..827487944 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt @@ -3,18 +3,16 @@ 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.Extras 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 @@ -23,121 +21,95 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.CachePolicy import coil3.request.ErrorResult import coil3.request.ImageRequest +import coil3.request.SuccessResult import coil3.request.allowHardware -import coil3.request.bitmapConfig import coil3.request.crossfade -import coil3.util.DebugLogger -import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.network.buildDefaultClient +import okhttp3.HttpUrl import okio.Path.Companion.toOkioPath import java.io.File import java.nio.ByteBuffer object ImageLoader { + private const val TAG = "CoilImgLoader" + internal fun buildImageLoader(context: PlatformContext): ImageLoader { - val isBrokenHardware = hasPotentialBrokenHardware() + val okHttpClient = buildDefaultClient(context) + return ImageLoader.Builder(context) - .crossfade(200) - .allowHardware(SDK_INT >= 28 && !isBrokenHardware) - .diskCachePolicy(CachePolicy.ENABLED) - .networkCachePolicy(CachePolicy.ENABLED) + .crossfade(250) + /** !Only use default placeholders and errors, if not using this instance for local + * image buttons because when animating this will appear or in more cases **/ + //.placeholder { getImageFromDrawable(context, R.drawable.x) } + //.error { getImageFromDrawable(context, R.drawable.x) } + .allowHardware(false) // SDK_INT >= 28, cant use hardware bitmaps for Palette Builder .memoryCache { - MemoryCache.Builder().maxSizePercent(context, 0.1)//10 % of heap for mem-cache - .strongReferencesEnabled(false) + MemoryCache.Builder() + .maxSizePercent(context, 0.1) // Use 10 % of the app's available memory for caching .build() } .diskCache { DiskCache.Builder() .directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath()) - .maxSizeBytes(512L * 1024 * 1024) // 512 MB - .maxSizePercent(0.04) // max 4% of storage for disk caching + .maxSizeBytes(256 * 1024 * 1024) // 256 MB + .maxSizePercent(0.04) // Use 4% of the device's storage space for disk caching .build() } + .diskCachePolicy(CachePolicy.ENABLED) + .networkCachePolicy(CachePolicy.ENABLED) /** 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) + .components { add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient })) } + .eventListener(object : EventListener() { + override fun onStart(request: ImageRequest) { + super.onStart(request) + Log.i(TAG, "Loading Image ${request.data}") } - setupCoilLogger() - } + + override fun onSuccess(request: ImageRequest, result: SuccessResult) { + super.onSuccess(request, result) + Log.d(TAG, "Image Loading successful") + } + + override fun onError(request: ImageRequest, result: ErrorResult) { + super.onError(request, result) + Log.e(TAG, "Error loading image: ${result.throwable}") + } + }) .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 **/ + /** we use coil's built in loader with our global synchronized instance, this way we achieve + latest and complete functionality as well as stability **/ private fun ImageView.loadImageInternal( imageData: Any?, headers: Map? = null, builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations ) { - // clear image to avoid loading & flickering issue at fast scrolling (~recycler view/lazy column) - this.dispose() - if (imageData == null) return - // setImageResource is better than coil3 on resources due to attr - if (imageData is Int) { - this.setImageResource(imageData); return - } - // headers can be overridden by extensions. + // clear image to avoid loading & flickering issue at fast scrolling (e.g, an image recycler) + this.load(null) + + // Use Coil's built-in load method but with our custom module this.load(imageData, SingletonImageLoader.get(context)) { this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder -> - headerBuilder["User-Agent"] = USER_AGENT + headerBuilder.add("User-Agent", USER_AGENT) headers?.forEach { (key, value) -> - headerBuilder[key] = value + headerBuilder.add(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 - ) - + ) = 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) @@ -153,6 +125,12 @@ object ImageLoader { imageData: Uri?, headers: Map? = null, builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData,headers = headers, builder = builder) + + fun ImageView.loadImage( + imageData: HttpUrl?, + headers: Map? = null, + builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) fun ImageView.loadImage( @@ -184,4 +162,4 @@ object ImageLoader { imageData: ByteBuffer?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt index 6ed4d4afa..1f90b920d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt @@ -7,9 +7,9 @@ import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat -import androidx.core.graphics.createBitmap import coil3.Image import coil3.asImage +import coil3.request.ImageRequest /// Type safe any image, because THIS IS NOT PYTHON sealed class UiImage { @@ -30,7 +30,11 @@ fun drawableToBitmap(drawable: Drawable): Bitmap? { return when (drawable) { is BitmapDrawable -> drawable.bitmap else -> { - val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) val canvas = Canvas(bitmap) drawable.setBounds(0, 0, canvas.width, canvas.height) drawable.draw(canvas) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index f4d6b4271..8bce8f639 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -3,370 +3,392 @@ package com.lagradost.cloudstream3.utils import android.app.Activity import android.content.Context import android.content.Intent -import android.content.pm.PackageManager.NameNotFoundException import android.net.Uri import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import androidx.core.content.edit import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.safe -import com.lagradost.cloudstream3.services.PackageInstallerService -import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import okio.BufferedSink import okio.buffer import okio.sink -import java.io.BufferedReader import java.io.File +import android.text.TextUtils +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.services.PackageInstallerService +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader -object InAppUpdater { - private const val GITHUB_USER_NAME = "recloudstream" - private const val GITHUB_REPO = "cloudstream" - private const val PRERELEASE_PACKAGE_NAME = "com.lagradost.cloudstream3.prerelease" - private const val LOG_TAG = "InAppUpdater" +class InAppUpdater { + companion object { + private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_REPO = "cloudstream" - @Serializable - private data class GithubAsset( - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("size") @SerialName("size") val size: Int, // Size in bytes - @JsonProperty("browser_download_url") @SerialName("browser_download_url") val browserDownloadUrl: String, - @JsonProperty("content_type") @SerialName("content_type") val contentType: String, // application/vnd.android.package-archive - ) + private const val LOG_TAG = "InAppUpdater" - @Serializable - private data class GithubRelease( - @JsonProperty("tag_name") @SerialName("tag_name") val tagName: String, // Version code - @JsonProperty("body") @SerialName("body") val body: String, // Description - @JsonProperty("assets") @SerialName("assets") val assets: List, - @JsonProperty("target_commitish") @SerialName("target_commitish") val targetCommitish: String, // Branch - @JsonProperty("prerelease") @SerialName("prerelease") val prerelease: Boolean, - @JsonProperty("node_id") @SerialName("node_id") val nodeId: String, - ) - - @Serializable - private data class GithubObject( - @JsonProperty("sha") @SerialName("sha") val sha: String, // SHA-256 hash - @JsonProperty("type") @SerialName("type") val type: String, - @JsonProperty("url") @SerialName("url") val url: String, - ) - - @Serializable - private data class GithubTag( - @JsonProperty("object") @SerialName("object") val githubObject: GithubObject, - ) - - @Serializable - private data class Update( - @JsonProperty("shouldUpdate") @SerialName("shouldUpdate") val shouldUpdate: Boolean, - @JsonProperty("updateURL") @SerialName("updateURL") val updateURL: String?, - @JsonProperty("updateVersion") @SerialName("updateVersion") val updateVersion: String?, - @JsonProperty("changelog") @SerialName("changelog") val changelog: String?, - @JsonProperty("updateNodeId") @SerialName("updateNodeId") val updateNodeId: String?, - ) - - private suspend fun Activity.getAppUpdate(installPrerelease: Boolean): Update { - return try { - when { - // No updates on debug version - BuildConfig.DEBUG -> Update(false, null, null, null, null) - BuildConfig.FLAVOR == "prerelease" || installPrerelease -> getPreReleaseUpdate() - else -> getReleaseUpdate() - } - } catch (e: Exception) { - Log.e(LOG_TAG, Log.getStackTraceString(e)) - Update(false, null, null, null, null) - } - } - - private suspend fun Activity.getReleaseUpdate(): Update { - val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = parseJson>( - app.get(url, headers = headers).text - ).toList() - - val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") - val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") - val foundList = response.filter { rel -> - !rel.prerelease - }.sortedWith(compareBy { release -> - release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> - versionRegex.find(it1)?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - } - }).toList() - - val found = foundList.lastOrNull() - val foundAsset = found?.assets?.getOrNull(0) - val foundVersion = foundAsset?.name?.let { versionRegex.find(it) } - - if (foundVersion == null) { - return Update(false, null, null, null, null) - } - - val currentVersion = packageName?.let { - packageManager.getPackageInfo(it, 0) - } - - val shouldUpdate = if (foundAsset.browserDownloadUrl.isBlank()) { - false - } else { - currentVersion?.versionName?.let { versionName -> - versionRegexLocal.find(versionName)?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - }?.compareTo( - foundVersion.groupValues.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - })!! < 0 - } - - return Update( - shouldUpdate, - foundAsset.browserDownloadUrl, - foundVersion.groupValues[2], - found.body, - found.nodeId - ) - } - - private suspend fun Activity.getPreReleaseUpdate(): Update { - val tagUrl = - "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" - val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = parseJson>( - app.get(releaseUrl, headers = headers).text - ).toList() - - val found = response.lastOrNull { rel -> - rel.prerelease || rel.tagName == "pre-release" - } - - val foundAsset = found?.assets?.filter { it -> - it.contentType == "application/vnd.android.package-archive" - }?.getOrNull(0) - - if (foundAsset == null) { - return Update(false, null, null, null, null) - } - - val tagResponse = parseJson(app.get(tagUrl, headers = headers).text) - val updateCommitHash = tagResponse.githubObject.sha.trim().take(7) - Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash") - - return Update( - currentCommitHash() != updateCommitHash, - foundAsset.browserDownloadUrl, - updateCommitHash, - found.body, - found.nodeId - ) - } - - private val updateLock = Mutex() - - private suspend fun Activity.downloadUpdate(url: String): Boolean { - try { - Log.d(LOG_TAG, "Downloading update: $url") - val appUpdateName = "CloudStream" - val appUpdateSuffix = "apk" - - // Delete all old updates - this.cacheDir.listFiles()?.filter { - it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix - }?.forEach { deleteFileOnExit(it) } - - val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") - val sink: BufferedSink = downloadedFile.sink().buffer() - - updateLock.withLock { - sink.writeAll(app.get(url).body.source()) - sink.close() - openApk(this, Uri.fromFile(downloadedFile)) - } - - return true - } catch (e: Exception) { - logError(e) - return false - } - } - - private fun openApk(context: Context, uri: Uri) = safe { - val path = uri.path ?: return@safe - val contentUri = FileProvider.getUriForFile( - context, BuildConfig.APPLICATION_ID + ".provider", File(path) - ) - val installIntent = Intent(Intent.ACTION_VIEW).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) - data = contentUri - } - context.startActivity(installIntent) - } - - fun Activity.installPreReleaseIfNeeded() = ioSafe { - val isInstalled = try { - packageManager.getPackageInfo(PRERELEASE_PACKAGE_NAME, 0) - true - } catch (_: NameNotFoundException) { - false - } - - if (isInstalled) { - showToast(R.string.prerelease_already_installed) - } else if (!runAutoUpdate(checkAutoUpdate = false, installPrerelease = true)) { - showToast(R.string.prerelease_install_failed) - } - } - - - /** - * @param checkAutoUpdate if the update check was launched automatically - * @param installPrerelease if we want to install the pre-release version - */ - suspend fun Activity.runAutoUpdate( - checkAutoUpdate: Boolean = true, installPrerelease: Boolean = false - ): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val autoUpdateEnabled = - settingsManager.getBoolean(getString(R.string.auto_update_key), true) - if (checkAutoUpdate && !autoUpdateEnabled) { - return false - } - - val update = getAppUpdate(installPrerelease) - if (!update.shouldUpdate || update.updateURL == null) { - return false - } - - // Check if update should be skipped - val updateNodeId = settingsManager.getString( - getString(R.string.skip_update_key), "" + // === IN APP UPDATER === + data class GithubAsset( + @JsonProperty("name") val name: String, + @JsonProperty("size") val size: Int, // Size bytes + @JsonProperty("browser_download_url") val browserDownloadUrl: String, // download link + @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive ) - // Skips the update if its an automatic update and the update is skipped - // This allows updating manually - if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { - return false - } + data class GithubRelease( + @JsonProperty("tag_name") val tagName: String, // Version code + @JsonProperty("body") val body: String, // Desc + @JsonProperty("assets") val assets: List, + @JsonProperty("target_commitish") val targetCommitish: String, // branch + @JsonProperty("prerelease") val prerelease: Boolean, + @JsonProperty("node_id") val nodeId: String //Node Id + ) - runOnUiThread { - safe { - val currentVersion = packageName?.let { - packageManager.getPackageInfo(it, 0) - } + data class GithubObject( + @JsonProperty("sha") val sha: String, // sha 256 hash + @JsonProperty("type") val type: String, // object type + @JsonProperty("url") val url: String, + ) - val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - builder.setTitle( - getString(R.string.new_update_format).format( - currentVersion?.versionName, update.updateVersion + data class GithubTag( + @JsonProperty("object") val githubObject: GithubObject, + ) + + data class Update( + @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, + @JsonProperty("updateURL") val updateURL: String?, + @JsonProperty("updateVersion") val updateVersion: String?, + @JsonProperty("changelog") val changelog: String?, + @JsonProperty("updateNodeId") val updateNodeId: String? + ) + + private suspend fun Activity.getAppUpdate(): Update { + return try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + if (settingsManager.getBoolean( + getString(R.string.prerelease_update_key), + resources.getBoolean(R.bool.is_prerelease) ) + ) { + getPreReleaseUpdate() + } else { + getReleaseUpdate() + } + } catch (e: Exception) { + Log.e(LOG_TAG, Log.getStackTraceString(e)) + Update(false, null, null, null, null) + } + } + + private suspend fun Activity.getReleaseUpdate(): Update { + val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = + parseJson>( + app.get( + url, + headers = headers + ).text ) - val logRegex = Regex("\\[(.*?)]\\((.*?)\\)") - val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> - matchResult.groupValues[1] - } // Sanitized because it looks cluttered - - builder.setMessage(sanitizedChangelog) - builder.apply { - setPositiveButton(R.string.update) { _, _ -> - // Forcefully start any delayed installations - if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton - - showToast(R.string.download_started, Toast.LENGTH_LONG) - - // Check if the setting hasn't been changed - if (settingsManager.getInt( - getString(R.string.apk_installer_key), -1 - ) == -1 - ) { - // Set to legacy installer if using MIUI - if (isMiUi()) { - settingsManager.edit { - putInt(getString(R.string.apk_installer_key), 1) - } + val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") + val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") + /* + val releases = response.map { it.assets }.flatten() + .filter { it.content_type == "application/vnd.android.package-archive" } + val found = + releases.sortedWith(compareBy { + versionRegex.find(it.name)?.groupValues?.get(2) + }).toList().lastOrNull()*/ + val foundList = + response.filter { rel -> + !rel.prerelease + }.sortedWith(compareBy { release -> + release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> + versionRegex.find( + it1 + )?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } } + }).toList() + val found = foundList.lastOrNull() + val foundAsset = found?.assets?.getOrNull(0) + val currentVersion = packageName?.let { + packageManager.getPackageInfo( + it, + 0 + ) + } - val currentInstaller = settingsManager.getInt( - getString(R.string.apk_installer_key), 1 - ) + foundAsset?.name?.let { assetName -> + val foundVersion = versionRegex.find(assetName) + val shouldUpdate = + if (foundAsset.browserDownloadUrl != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> + versionRegexLocal.find(versionName)?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + } + }?.compareTo( + foundVersion.groupValues.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + } + )!! < 0 else false + return if (foundVersion != null) { + Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + foundVersion.groupValues[2], + found.body, + found.nodeId + ) + } else { + Update(false, null, null, null, null) + } + } + return Update(false, null, null, null, null) + } - when (currentInstaller) { - // New method - 0 -> { - val intent = PackageInstallerService.Companion.getIntent( - this@runAutoUpdate, update.updateURL - ) - ContextCompat.startForegroundService( - this@runAutoUpdate, intent + private suspend fun Activity.getPreReleaseUpdate(): Update { + val tagUrl = + "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" + val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = + parseJson>(app.get(releaseUrl, headers = headers).text) + + val found = + response.lastOrNull { rel -> + rel.prerelease || rel.tagName == "pre-release" + } + val foundAsset = found?.assets?.filter { it -> + it.contentType == "application/vnd.android.package-archive" + }?.getOrNull(0) + + val tagResponse = + parseJson(app.get(tagUrl, headers = headers).text) + + Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.githubObject.sha.take(7)}") + + val shouldUpdate = + (getString(R.string.commit_hash) + .trim { c -> c.isWhitespace() } + .take(7) + != + tagResponse.githubObject.sha + .trim { c -> c.isWhitespace() } + .take(7)) + + return if (foundAsset != null) { + Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + tagResponse.githubObject.sha.take(10), + found.body, + found.nodeId + ) + } else { + Update(false, null, null, null, null) + } + } + + + private val updateLock = Mutex() + + private suspend fun Activity.downloadUpdate(url: String): Boolean { + try { + Log.d(LOG_TAG, "Downloading update: $url") + val appUpdateName = "CloudStream" + val appUpdateSuffix = "apk" + + // Delete all old updates + this.cacheDir.listFiles()?.filter { + it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix + }?.forEach { + deleteFileOnExit(it) + } + + val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") + val sink: BufferedSink = downloadedFile.sink().buffer() + + updateLock.withLock { + sink.writeAll(app.get(url).body.source()) + sink.close() + openApk(this, Uri.fromFile(downloadedFile)) + } + return true + } catch (e: Exception) { + return false + } + } + + private fun openApk(context: Context, uri: Uri) { + try { + uri.path?.let { + val contentUri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".provider", + File(it) + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + data = contentUri + } + context.startActivity(installIntent) + } + } catch (e: Exception) { + logError(e) + } + } + + /** + * @param checkAutoUpdate if the update check was launched automatically + **/ + suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + + if (!checkAutoUpdate || settingsManager.getBoolean( + getString(R.string.auto_update_key), + true + ) + ) { + val update = getAppUpdate() + if ( + update.shouldUpdate && + update.updateURL != null) { + + // Check if update should be skipped + val updateNodeId = + settingsManager.getString(getString(R.string.skip_update_key), "") + + // Skips the update if its an automatic update and the update is skipped + // This allows updating manually + if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { + return false + } + + runOnUiThread { + try { + val currentVersion = packageName?.let { + packageManager.getPackageInfo( + it, + 0 ) } - // Legacy - 1 -> { - ioSafe { - if (!downloadUpdate(update.updateURL)) { - runOnUiThread { - showToast( - R.string.download_failed, Toast.LENGTH_LONG + + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + builder.setTitle( + getString(R.string.new_update_format).format( + currentVersion?.versionName, + update.updateVersion + ) + ) + + val logRegex = Regex("\\[(.*?)\\]\\((.*?)\\)") + val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> + matchResult.groupValues[1] + } // Sanitized because it looks cluttered + + builder.setMessage(sanitizedChangelog) + + val context = this + builder.apply { + setPositiveButton(R.string.update) { _, _ -> + // Forcefully start any delayed installations + if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton + + showToast(R.string.download_started, Toast.LENGTH_LONG) + + // Check if the setting hasn't been changed + if (settingsManager.getInt( + getString(R.string.apk_installer_key), + -1 + ) == -1 + ) { + if (isMiUi()) // Set to legacy if using miui + settingsManager.edit() + .putInt(getString(R.string.apk_installer_key), 1) + .apply() + } + + val currentInstaller = + settingsManager.getInt( + getString(R.string.apk_installer_key), + 0 + ) + + when (currentInstaller) { + // New method + 0 -> { + val intent = PackageInstallerService.Companion.getIntent( + context, + update.updateURL ) + ContextCompat.startForegroundService(context, intent) + } + // Legacy + 1 -> { + ioSafe { + if (!downloadUpdate(update.updateURL)) + runOnUiThread { + showToast( + R.string.download_failed, + Toast.LENGTH_LONG + ) + } + } } } } + + setNegativeButton(R.string.cancel) { _, _ -> } + + if (checkAutoUpdate) { + setNeutralButton(R.string.skip_update) { _, _ -> + settingsManager.edit().putString( + getString(R.string.skip_update_key), + update.updateNodeId ?: "" + ).apply() + } + } } + builder.show().setDefaultFocus() + } catch (e: Exception) { + logError(e) } } - - setNegativeButton(R.string.cancel) { _, _ -> } - - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit { - putString( - getString(R.string.skip_update_key), update.updateNodeId ?: "" - ) - } - } - } + return true } - builder.show().setDefaultFocus() + return false + } + return false + } + + private fun isMiUi(): Boolean { + return !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.name")) + } + + private fun getSystemProperty(propName: String): String? { + return try { + val p = Runtime.getRuntime().exec("getprop $propName") + BufferedReader(InputStreamReader(p.inputStream), 1024).use { + it.readLine() + } + } catch (ex: IOException) { + null } } - return true - } - - private fun isMiUi(): Boolean = !getSystemProperty("ro.miui.ui.version.name").isNullOrEmpty() - - private fun getSystemProperty(propName: String): String? = try { - val p = Runtime.getRuntime().exec("getprop $propName") - BufferedReader(InputStreamReader(p.inputStream), 1024).use { - it.readLine() - } - } catch (_: IOException) { - null } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt index 67851f629..4be0dd56c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -11,7 +11,7 @@ import android.content.pm.PackageInstaller import android.os.Build import android.util.Log import android.widget.Toast -import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.services.PackageInstallerService diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt index 6580182bb..1e572fb7c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -4,67 +4,16 @@ import android.content.Context import android.graphics.Matrix import android.graphics.drawable.Drawable import android.util.AttributeSet -import androidx.core.content.withStyledAttributes -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -/** - * A custom [AppCompatImageView] that allows precise control over the visible crop area - * of an image by adjusting its horizontal and vertical center offset percentages. - * - * ### Key Features: - * - Allows **manual vertical or horizontal cropping** via percentage offsets. - * - Works seamlessly with Coil, Glide, or any image loading library. - * - * ### Usage (XML): - * You can set the crop offset directly in XML using custom attributes: - * ```xml - * - * ``` - * - `app:cropYCenterOffsetPct` → controls how far vertically the image shifts - * `0.0` = top-aligned, `0.5` = centered, `1.0` = bottom-aligned. - * - `app:cropXCenterOffsetPct` → controls how far horizontally the image shifts - * `0.0` = left, `0.5` = center, `1.0` = right. - * - * ### Programmatic Example: - * ```kotlin - * imageView.cropYCenterOffsetPct = 0.15f // Show slightly more (15%) of the top area - * imageView.cropXCenterOffsetPct = 0.5f // Keep image centered horizontally - * imageView.redraw() //Only needed if you changed cropYCenterOffsetPct/cropXCenterOffsetPct at runtime - * ``` - * - * ### Notes: - * - Must use `android:scaleType="matrix"` to enable manual matrix transformations. - * - Reference: https://stackoverflow.com/a/29055283 - * - * @property cropYCenterOffsetPct the vertical crop percentage (0.0–1.0) - * @property cropXCenterOffsetPct the horizontal crop percentage (0.0–1.0) - * - * @see ImageView.ScaleType.MATRIX - */ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { private var mCropYCenterOffsetPct: Float? = null private var mCropXCenterOffsetPct: Float? = null - constructor(context: Context?) : super(context!!) - - constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) { - initAttrs(context, attrs) - } - + constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) constructor( context: Context?, attrs: AttributeSet?, defStyle: Int - ) : super(context!!, attrs, defStyle) { - initAttrs(context, attrs) - } + ) : super(context!!, attrs, defStyle) var cropYCenterOffsetPct: Float get() = mCropYCenterOffsetPct!! @@ -94,12 +43,12 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { var dy = 0f if (dWidth * vHeight > vWidth * dHeight) { val cropXCenterOffsetPct = - if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!! else 0.5f + if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!!.toFloat() else 0.5f scale = vHeight.toFloat() / dHeight.toFloat() dx = (vWidth - dWidth * scale) * cropXCenterOffsetPct } else { val cropYCenterOffsetPct = - if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!! else 0f + if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!!.toFloat() else 0f scale = vWidth.toFloat() / dWidth.toFloat() dy = (vHeight - dHeight * scale) * cropYCenterOffsetPct } @@ -131,7 +80,6 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { super.setImageResource(resId) myConfigureBounds() } - // In case you can change the ScaleType in code you have to call redraw() //fullsizeImageView.setScaleType(ScaleType.FIT_CENTER); //fullsizeImageView.redraw(); @@ -143,26 +91,4 @@ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { setImageDrawable(d) } } - - private fun initAttrs(context: Context, attrs: AttributeSet?) { - attrs ?: return - context.withStyledAttributes(attrs, R.styleable.PercentageCropImageView) { - try { - if (hasValue(R.styleable.PercentageCropImageView_cropYCenterOffsetPct)) { - mCropYCenterOffsetPct = getFloat( - R.styleable.PercentageCropImageView_cropYCenterOffsetPct, - 0.5f - ) - } - if (hasValue(R.styleable.PercentageCropImageView_cropXCenterOffsetPct)) { - mCropXCenterOffsetPct = getFloat( - R.styleable.PercentageCropImageView_cropXCenterOffsetPct, - 0.5f - ) - } - } catch (e: Exception) { - logError(e) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt index e3c7d68df..0d7a8abc4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -3,13 +3,12 @@ package com.lagradost.cloudstream3.utils import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.PowerManager import android.provider.Settings import android.util.Log import androidx.appcompat.app.AlertDialog -import androidx.core.content.edit -import androidx.core.net.toUri import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast @@ -39,6 +38,7 @@ object BatteryOptimizationChecker { fun Context.showBatteryOptimizationDialog() { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + try { AlertDialog.Builder(this) .setTitle(R.string.battery_dialog_title) @@ -46,9 +46,9 @@ object BatteryOptimizationChecker { .setMessage(R.string.battery_dialog_message) .setPositiveButton(R.string.ok) { _, _ -> showRequestIgnoreBatteryOptDialog() } .setNegativeButton(R.string.cancel) { _, _ -> - settingsManager.edit { - putBoolean(getString(R.string.battery_optimisation_key), false) - } + settingsManager.edit() + .putBoolean(getString(R.string.battery_optimisation_key), false) + .apply() } .show() } catch (t: Throwable) { @@ -67,7 +67,7 @@ object BatteryOptimizationChecker { try { val intent = Intent().apply { action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - data = "package:$PACKAGE_NAME".toUri() + data = Uri.parse("package:$PACKAGE_NAME") } startActivity(intent) } catch (t: Throwable) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt index 26c710103..180131473 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -22,7 +22,6 @@ import com.lagradost.cloudstream3.databinding.BottomSelectionDialogBinding import com.lagradost.cloudstream3.databinding.BottomTextDialogBinding import com.lagradost.cloudstream3.databinding.OptionsPopupTvBinding import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage @@ -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) @@ -83,16 +81,16 @@ object SingleSelectionHelper { isGone = poster.isNullOrEmpty() loadImage(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/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt index c0068f91a..66a6e156c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -2,8 +2,8 @@ package com.lagradost.cloudstream3.utils import android.content.Context import com.lagradost.api.Log -import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile -import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder +import com.lagradost.safefile.SafeFile object SubtitleUtils { @@ -13,21 +13,17 @@ object SubtitleUtils { ".ttml", ".sbv", ".dfxp" ) - fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { - val cleanDisplay = cleanDisplayName(info.displayName) + fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { + val relative = info.relativePath + val display = info.displayName + val cleanDisplay = cleanDisplayName(display) - val base = basePathToFile(context, info.basePath) - val folder = - base?.gotoDirectory(info.relativePath, createMissingDirectories = false) ?: return - val folderFiles = folder.listFiles() ?: return - - for (file in folderFiles) { - val name = file.name() ?: continue - if (!isMatchingSubtitle(name, info.displayName, cleanDisplay)) { - continue - } - if (file.delete() != true) { - Log.e("SubtitleDeletion", "Failed to delete subtitle file: $name") + getFolder(context, relative, info.basePath)?.forEach { (name, uri) -> + if (isMatchingSubtitle(name, display, cleanDisplay)) { + val subtitleFile = SafeFile.fromUri(context, uri) + if (subtitleFile == null || subtitleFile.delete() != true) { + Log.e("SubtitleDeletion", "Failed to delete subtitle file: ${subtitleFile?.name()}") + } } } } @@ -43,7 +39,7 @@ object SubtitleUtils { cleanDisplay: String ): Boolean { // Check if the file has a valid subtitle extension - val hasValidExtension = allowedExtensions.any { name.endsWith(it, ignoreCase = true) } + val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) } // We can't have the exact same file as a subtitle val isNotDisplayName = !name.equals(display, ignoreCase = true) @@ -57,4 +53,4 @@ object SubtitleUtils { fun cleanDisplayName(name: String): String { return name.substringBeforeLast('.').trim() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt index 43e61ad88..351e77c8d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -5,11 +5,10 @@ package com.lagradost.cloudstream3.utils import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty 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 kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable +import com.lagradost.cloudstream3.utils.AppUtils.parseJson import java.util.concurrent.TimeUnit object SyncUtil { @@ -25,16 +24,18 @@ object SyncUtil { private const val NINE_ANIME = "9anime" private const val TWIST_MOE = "Twistmoe" - private val matchList = mapOf( - "9anime" to NINE_ANIME, - "gogoanime" to GOGOANIME, - "gogoanimes" to GOGOANIME, - "twist.moe" to TWIST_MOE, - ) + private val matchList = + mapOf( + "9anime" to NINE_ANIME, + "gogoanime" to GOGOANIME, + "gogoanimes" to GOGOANIME, + "twist.moe" to TWIST_MOE + ) suspend fun getIdsFromUrl(url: String?): Pair? { if (url == null) return null Log.i(TAG, "getIdsFromUrl $url") + for (regex in regexs) { regex.find(url)?.let { match -> if (match.groupValues.size == 3) { @@ -55,120 +56,117 @@ object SyncUtil { } } } - return null } - /** - * first. Mal, second. Anilist, - * Valid sites are: Gogoanime, Twistmoe and 9anime - */ + /** first. Mal, second. Anilist, + * valid sites are: Gogoanime, Twistmoe and 9anime*/ private suspend fun getIdsFromSlug( slug: String, - site: String = "Gogoanime", + site: String = "Gogoanime" ): Pair? { Log.i(TAG, "getIdsFromSlug $slug $site") try { - // Gogoanime, Twistmoe and 9anime - val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json" + //Gogoanime, Twistmoe and 9anime + 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 + if (overrideMal != null) { return overrideMal.toString() to overrideAnilist?.toString() } - return null } catch (e: Exception) { logError(e) } - return null } suspend fun getUrlsFromId(id: String, type: String = "anilist"): List { - val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/$type/anime/$id.json" + val url = + "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/$type/anime/$id.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).parsed() val pages = response.pages ?: return emptyList() - val current = pages.gogoanime.values.union(pages.nineanime.values).union(pages.twistmoe.values) - .mapNotNull { it.url }.toMutableList() + val current = + pages.gogoanime.values.union(pages.nineanime.values).union(pages.twistmoe.values) + .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 } - @Serializable data class SyncPage( - @JsonProperty("Pages") @SerialName("Pages") val pages: SyncPages?, + @JsonProperty("Pages") val pages: SyncPages?, ) - @Serializable data class SyncPages( - @JsonProperty("9anime") @SerialName("9anime") val nineanime: Map = emptyMap(), - @JsonProperty("Gogoanime") @SerialName("Gogoanime") val gogoanime: Map = emptyMap(), - @JsonProperty("Twistmoe") @SerialName("Twistmoe") val twistmoe: Map = emptyMap(), + @JsonProperty("9anime") val nineanime: Map = emptyMap(), + @JsonProperty("Gogoanime") val gogoanime: Map = emptyMap(), + @JsonProperty("Twistmoe") val twistmoe: Map = emptyMap(), ) - @Serializable data class ProviderPage( - @JsonProperty("url") @SerialName("url") val url: String?, + @JsonProperty("url") val url: String?, ) - @Serializable data class MalSyncPage( - @JsonProperty("identifier") @SerialName("identifier") val identifier: String?, - @JsonProperty("type") @SerialName("type") val type: String?, - @JsonProperty("page") @SerialName("page") val page: String?, - @JsonProperty("title") @SerialName("title") val title: String?, - @JsonProperty("url") @SerialName("url") val url: String?, - @JsonProperty("image") @SerialName("image") val image: String?, - @JsonProperty("hentai") @SerialName("hentai") val hentai: Boolean?, - @JsonProperty("sticky") @SerialName("sticky") val sticky: Boolean?, - @JsonProperty("active") @SerialName("active") val active: Boolean?, - @JsonProperty("actor") @SerialName("actor") val actor: String?, - @JsonProperty("malId") @SerialName("malId") val malId: Int?, - @JsonProperty("aniId") @SerialName("aniId") val aniId: Int?, - @JsonProperty("createdAt") @SerialName("createdAt") val createdAt: String?, - @JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: String?, - @JsonProperty("deletedAt") @SerialName("deletedAt") val deletedAt: String?, - @JsonProperty("Mal") @SerialName("Mal") val mal: Mal?, - @JsonProperty("Anilist") @SerialName("Anilist") val anilist: Anilist?, - @JsonProperty("malUrl") @SerialName("malUrl") val malUrl: String?, + @JsonProperty("identifier") val identifier: String?, + @JsonProperty("type") val type: String?, + @JsonProperty("page") val page: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("url") val url: String?, + @JsonProperty("image") val image: String?, + @JsonProperty("hentai") val hentai: Boolean?, + @JsonProperty("sticky") val sticky: Boolean?, + @JsonProperty("active") val active: Boolean?, + @JsonProperty("actor") val actor: String?, + @JsonProperty("malId") val malId: Int?, + @JsonProperty("aniId") val aniId: Int?, + @JsonProperty("createdAt") val createdAt: String?, + @JsonProperty("updatedAt") val updatedAt: String?, + @JsonProperty("deletedAt") val deletedAt: String?, + @JsonProperty("Mal") val mal: Mal?, + @JsonProperty("Anilist") val anilist: Anilist?, + @JsonProperty("malUrl") val malUrl: String? ) - @Serializable data class Anilist( - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("malId") @SerialName("malId") val malId: Int?, - @JsonProperty("type") @SerialName("type") val type: String?, - @JsonProperty("title") @SerialName("title") val title: String?, - @JsonProperty("url") @SerialName("url") val url: String?, - @JsonProperty("image") @SerialName("image") val image: String?, - @JsonProperty("category") @SerialName("category") val category: String?, - @JsonProperty("hentai") @SerialName("hentai") val hentai: Boolean?, - @JsonProperty("createdAt") @SerialName("createdAt") val createdAt: String?, - @JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: String?, - @JsonProperty("deletedAt") @SerialName("deletedAt") val deletedAt: String?, +// @JsonProperty("altTitle") val altTitle: List?, +// @JsonProperty("externalLinks") val externalLinks: List?, + @JsonProperty("id") val id: Int?, + @JsonProperty("malId") val malId: Int?, + @JsonProperty("type") val type: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("url") val url: String?, + @JsonProperty("image") val image: String?, + @JsonProperty("category") val category: String?, + @JsonProperty("hentai") val hentai: Boolean?, + @JsonProperty("createdAt") val createdAt: String?, + @JsonProperty("updatedAt") val updatedAt: String?, + @JsonProperty("deletedAt") val deletedAt: String? ) - @Serializable data class Mal( - @JsonProperty("id") @SerialName("id") val id: Int?, - @JsonProperty("type") @SerialName("type") val type: String?, - @JsonProperty("title") @SerialName("title") val title: String?, - @JsonProperty("url") @SerialName("url") val url: String?, - @JsonProperty("image") @SerialName("image") val image: String?, - @JsonProperty("category") @SerialName("category") val category: String?, - @JsonProperty("hentai") @SerialName("hentai") val hentai: Boolean?, - @JsonProperty("createdAt") @SerialName("createdAt") val createdAt: String?, - @JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: String?, - @JsonProperty("deletedAt") @SerialName("deletedAt") val deletedAt: String?, +// @JsonProperty("altTitle") val altTitle: List?, + @JsonProperty("id") val id: Int?, + @JsonProperty("type") val type: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("url") val url: String?, + @JsonProperty("image") val image: String?, + @JsonProperty("category") val category: String?, + @JsonProperty("hentai") val hentai: Boolean?, + @JsonProperty("createdAt") val createdAt: String?, + @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 5b784e05e..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) @@ -200,7 +196,7 @@ object TestingUtils { } else -> { - logger.error("Unknown load response: ${loadResponse::class.qualifiedName}") + logger.error("Unknown load response: ${loadResponse.javaClass.name}") return TestResult.Fail } } ?: return TestResult.Fail @@ -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 b5b367262..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt +++ /dev/null @@ -1,166 +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 = runCatching { - context.contentResolver.insert( - TvContractCompat.Channels.CONTENT_URI, - channel.toContentValues() - ) - }.getOrNull() - - channelUri?.let { - val channelId = ContentUris.parseId(it) - TvContractCompat.requestChannelBrowsable(context, channelId) - Log.d("TvChannelUtils", "Channel Created: $channelId") - } - } - -} 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 a848bee26..056be1e71 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -8,19 +8,15 @@ 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 @@ -28,6 +24,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowInsets import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.ListAdapter @@ -35,7 +32,7 @@ import android.widget.ListView import android.widget.Toast.LENGTH_LONG import androidx.annotation.AttrRes import androidx.annotation.ColorInt -import androidx.annotation.DimenRes +import androidx.annotation.IdRes import androidx.annotation.StyleRes import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.menu.MenuBuilder @@ -43,7 +40,6 @@ import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService -import androidx.core.content.withStyledAttributes import androidx.core.graphics.alpha import androidx.core.graphics.blue import androidx.core.graphics.green @@ -53,39 +49,24 @@ 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.google.android.material.appbar.AppBarLayout -import com.google.android.material.button.MaterialButton import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup -import com.google.android.material.progressindicator.CircularProgressIndicatorSpec -import com.google.android.material.progressindicator.IndeterminateDrawable -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.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 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() @@ -104,12 +85,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 @@ -129,9 +105,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) } } @@ -207,15 +181,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() { @@ -226,7 +202,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 } @@ -240,93 +216,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() @@ -343,48 +255,72 @@ object UIHelper { } } + fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val alpha = (Color.alpha(color) * factor).roundToInt() + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } + + fun Context.colorFromAttribute(attribute: Int): Int { + val attributes = obtainStyledAttributes(intArrayOf(attribute)) + val color = attributes.getColor(0, 0) + attributes.recycle() + return color + } + fun Activity.hideSystemUI() { // Enables regular immersive mode. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val controller = WindowCompat.getInsetsController(window, window.decorView) - controller.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller.hide(WindowInsetsCompat.Type.systemBars()) - return - } - // For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE. // Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY - @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 + ) // FIXME this should be replaced + //} } - fun Activity.enableEdgeToEdgeCompat() { - // edge-to-edge is very buggy on earlier versions - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return - WindowCompat.enableEdgeToEdge(window) - } - - fun Activity.setNavigationBarColorCompat(@AttrRes resourceId: Int) { - // edge-to-edge handles this - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return - - @Suppress("DEPRECATION") - window?.navigationBarColor = colorFromAttribute(resourceId) + fun 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 } @@ -396,6 +332,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 @@ -420,84 +367,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") @@ -513,52 +382,84 @@ 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 { + @Suppress("DEPRECATION") + 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 - } + /*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + + WindowCompat.setDecorFitsSystemWindows(window, true) + WindowInsetsControllerCompat(window, View(this)).show(WindowInsetsCompat.Type.systemBars()) + + } else {*/ /** WINDOW COMPAT IS BUGGY DUE TO FU*KED UP PLAYER AND TRAILERS **/ @Suppress("DEPRECATION") window.decorView.systemUiVisibility = - (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) + (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) // FIXME this should be replaced + //} changeStatusBarState(isLayout(EMULATOR)) } + fun Context.shouldShowPIPMode(isInPlayer: Boolean): Boolean { + return try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + settingsManager?.getBoolean( + getString(R.string.pip_enabled_key), + true + ) ?: true && isInPlayer + } catch (e: Exception) { + logError(e) + false + } + } + + fun Context.hasPIPPermission(): Boolean { + val appOps = + getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + appOps.unsafeCheckOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) == AppOpsManager.MODE_ALLOWED + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + @Suppress("DEPRECATION") + appOps.checkOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) == AppOpsManager.MODE_ALLOWED + } else { + return true + } + } + fun hideKeyboard(view: View?) { if (view == null) return @@ -586,43 +487,6 @@ object UIHelper { } } - /** - * Source: https://stackoverflow.com/questions/70954321/circular-progress-indicator-inside-buttons-android-material-design - * - * Shows indeterminate progress bar on this button in place of where icon would be. - * By default the tint of progress bar is the same as iconTint. - * - * @param tintColor (@ColorInt Int) Sets custom progress bar tint color. - */ - fun MaterialButton.showProgress(@ColorInt tintColor: Int = this.iconTint.defaultColor) = - // Use runOnMainThreadNative to allow process on io threads, to make the code a bit cleaner - runOnMainThreadNative { - // No need to set it again, as then it will reset the animation - if(this.icon is IndeterminateDrawable<*>) { - return@runOnMainThreadNative - } - val spec = CircularProgressIndicatorSpec( - context, null, 0, - com.google.android.material.R.style.Widget_Material3_CircularProgressIndicator_ExtraSmall - ) - - spec.indicatorColors = intArrayOf(tintColor) - - val progressIndicatorDrawable = - IndeterminateDrawable.createCircularDrawable(context, spec) - - this.icon = progressIndicatorDrawable - if (this.getTag(R.id.text1) == null) - this.setTag(R.id.text1, this.text) - this.text = "" - } - - fun MaterialButton.hideProgress() = - runOnMainThreadNative { - this.text = this.getTag(R.id.text1) as? String - this.icon = null - } - /**id, stringRes */ @SuppressLint("RestrictedApi") fun View.popupMenuNoIcons( @@ -630,13 +494,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, androidx.appcompat.R.attr.actionOverflowMenuStyle, 0) items.forEach { (id, stringRes) -> popup.menu.add(0, id, 0, stringRes) @@ -660,13 +518,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, androidx.appcompat.R.attr.actionOverflowMenuStyle, 0) items.forEach { (id, string) -> popup.menu.add(0, id, 0, string) @@ -682,39 +534,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 63% 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 adefedd20..6b9139402 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,44 @@ -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.app.Service +import android.content.* import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.graphics.Bitmap +import android.net.Uri import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log -import android.widget.Toast import androidx.annotation.DrawableRes import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat +import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import androidx.preference.PreferenceManager +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import coil3.Extras +import coil3.ImageLoader +import coil3.SingletonImageLoader +import coil3.asDrawable +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +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 +46,15 @@ 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.PackageInstallerService.Companion.UPDATE_NOTIFICATION_ID 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 +65,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.runBlocking 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.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 +129,56 @@ object VideoDownloadManager { Stop, } + data class DownloadEpisodeMetadata( + @JsonProperty("id") val id: Int, + @JsonProperty("mainName") val mainName: String, + @JsonProperty("sourceApiName") val sourceApiName: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("type") val type: TvType?, + ) + + data class DownloadItem( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List, + ) + + data class DownloadResumePackage( + @JsonProperty("item") val item: DownloadItem, + @JsonProperty("linkIndex") val linkIndex: Int?, + ) + + data class DownloadedFileInfo( + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("relativePath") val relativePath: String, + @JsonProperty("displayName") val displayName: String, + @JsonProperty("extraInfo") val extraInfo: String? = null, + @JsonProperty("basePath") val basePath: String? = null // null is for legacy downloads. See getDefaultPath() + ) + + data class DownloadedFileInfoResult( + @JsonProperty("fileLength") val fileLength: Long, + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("path") val path: Uri, + ) + + data class DownloadQueueResumePackage( + @JsonProperty("index") val index: Int, + @JsonProperty("pkg") val pkg: DownloadResumePackage, + ) + + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) /** Invalid input, just skip to the next one as the same args will give the same error */ private val DOWNLOAD_INVALID_INPUT = @@ -183,60 +195,89 @@ object VideoDownloadManager { /** the process failed due to some reason, so we retry and also try the next mirror */ private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false) - /** The download only downloaded partial */ - private val DOWNLOAD_PARTIAL_SUCCESS = - DownloadStatus(retrySame = true, tryNext = false, success = true) - - /** 50MB minimum size */ - const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 50L - /** bad config, skip all mirrors as every call to download will have the same bad config */ private val DOWNLOAD_BAD_CONFIG = DownloadStatus(retrySame = false, tryNext = false, success = false) - const val KEY_RESUME_PACKAGES = "download_resume_2" + const val KEY_RESUME_PACKAGES = "download_resume" const val KEY_DOWNLOAD_INFO = "download_info" - - /** A key to save all the downloads which have not yet started and those currently running, using [DownloadQueueWrapper] - * [KEY_RESUME_PACKAGES] can store keys which should not be automatically queued, unlike this key. - */ - const val KEY_RESUME_IN_QUEUE = "download_resume_queue_key" -// private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" + private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" val downloadStatus = HashMap() val downloadStatusEvent = Event>() val downloadDeleteEvent = Event() val downloadEvent = Event>() val downloadProgressEvent = Event>() -// val downloadQueue = LinkedList() + val downloadQueue = LinkedList() - private var hasCreatedNotChannel = false + private var hasCreatedNotChanel = false private fun Context.createNotificationChannel() { - hasCreatedNotChannel = true - - this.createNotificationChannel( - DOWNLOAD_CHANNEL_ID, - DOWNLOAD_CHANNEL_NAME, - DOWNLOAD_CHANNEL_DESCRIPT - ) - } - - fun cancelAllDownloadNotifications(context: Context) { - val manager = NotificationManagerCompat.from(context) - manager.activeNotifications.forEach { notification -> - if (notification.tag == DOWNLOAD_NOTIFICATION_TAG) { - manager.cancel(DOWNLOAD_NOTIFICATION_TAG, notification.id) + hasCreatedNotChanel = true + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = DOWNLOAD_CHANNEL_NAME //getString(R.string.channel_name) + val descriptionText = DOWNLOAD_CHANNEL_DESCRIPT//getString(R.string.channel_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(DOWNLOAD_CHANNEL_ID, name, importance).apply { + description = descriptionText } + // Register the channel with the system + val notificationManager: NotificationManager = + this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) } } + ///** Will return IsDone if not found or error */ + //fun getDownloadState(id: Int): DownloadType { + // return try { + // downloadStatus[id] ?: DownloadType.IsDone + // } catch (e: Exception) { + // logError(e) + // DownloadType.IsDone + // } + //} + + private val cachedBitmaps = hashMapOf() + fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { + try { + if (cachedBitmaps.containsKey(url)) { + return cachedBitmaps[url] + } + + val imageLoader = SingletonImageLoader.get(this) + + val request = ImageRequest.Builder(this) + .data(url) + .apply { + headers?.forEach { (key, value) -> + extras[Extras.Key(key)] = value + } + } + .build() + + val bitmap = runBlocking { + val result = imageLoader.execute(request) + (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources)?.toBitmap() + } + + bitmap?.let { + cachedBitmaps[url] = it + } + + return bitmap + } catch (e: Exception) { + logError(e) + return null + } + } /** * @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 +293,7 @@ object VideoDownloadManager { try { if (total <= 0) return null// crash, invalid data +// main { // DON'T WANT TO SLOW IT DOWN val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setAutoCancel(true) .setColorized(true) @@ -318,7 +360,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 +376,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 +437,7 @@ object VideoDownloadManager { builder.setContentText(txt) } - if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused || state == DownloadType.IsPending) && SDK_INT >= Build.VERSION_CODES.O) { + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT if (state == DownloadType.IsDownloading) { @@ -412,9 +449,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 +487,7 @@ object VideoDownloadManager { } } - if (!hasCreatedNotChannel) { + if (!hasCreatedNotChanel) { context.createNotificationChannel() } @@ -468,7 +502,7 @@ object VideoDownloadManager { ) { return null } - notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) + notify(ep.id, notification) } return notification } catch (e: Exception) { @@ -477,6 +511,69 @@ object VideoDownloadManager { } } + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" + fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + var tempName = name + for (c in RESERVED_CHARS) { + tempName = tempName.replace(c, ' ') + } + if (removeSpaces) tempName = tempName.replace(" ", "") + return tempName.replace(" ", " ").trim(' ') + } + + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ + fun getFolder( + context: Context, + relativePath: String, + basePath: String? + ): List>? { + val base = basePathToFile(context, basePath) + val folder = + base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null + + //if (folder.isDirectory() != false) return null + + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } + } + + + data class CreateNotificationMetadata( + val type: DownloadType, + val bytesDownloaded: Long, + val bytesTotal: Long, + val hlsProgress: Long? = null, + val hlsTotal: Long? = null, + val bytesPerSecond: Long + ) + + data class StreamData( + private val fileLength: Long, + val file: SafeFile, + //val fileStream: OutputStream, + ) { + @Throws(IOException::class) + fun open(): OutputStream { + return file.openOutputStreamOrThrow(resume) + } + + @Throws(IOException::class) + fun openNew(): OutputStream { + return file.openOutputStreamOrThrow(false) + } + + fun delete(): Boolean { + return file.delete() == true + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + @Throws(IOException::class) fun setupStream( @@ -498,7 +595,7 @@ object VideoDownloadManager { /** * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads and backups. + * Used for initializing downloads. * */ @Throws(IOException::class) fun setupStream( @@ -531,7 +628,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 +639,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 +657,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 +684,8 @@ object VideoDownloadManager { DownloadActionType.Stop -> { type = DownloadType.IsStopped + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + saveQueue() stopListener?.invoke() stopListener = null } @@ -606,32 +700,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 +847,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 +882,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 +899,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 +917,7 @@ object VideoDownloadManager { ) val requestStream = request.body.byteStream() + val buffer = ByteArray(bufferSize) var read: Int try { @@ -853,7 +948,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 +956,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 +994,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 +1002,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 +1013,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 +1023,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 +1041,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 +1081,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 +1140,6 @@ object VideoDownloadManager { bytesDownloaded = 0, createNotificationCallback = createNotificationCallback, id = parentId, - linkHash = link.url.hashCode(), - isHLS = false ) try { // get the file path @@ -1040,7 +1161,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 +1287,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 +1304,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 +1329,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 +1359,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 +1385,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 +1408,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 +1422,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 +1461,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 +1484,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 +1547,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 +1563,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 +1574,75 @@ object VideoDownloadManager { return "$name.$extension" } + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * As of writing UniFile is used for everything but download directory on scoped storage. + * Special ContentResolver fuckery is needed for that as UniFile doesn't work. + * */ + fun getDefaultDir(context: Context): SafeFile? { + // See https://www.py4u.net/discuss/614761 + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads + ) + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + private fun basePathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { + return getFileName(context, metadata.name, metadata.episode, metadata.season) + } + + private fun getFileName( + context: Context, + epName: String?, + episode: Int?, + season: Int? + ): String { + // kinda ugly ik + return sanitizeFilename( + if (epName == null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" + } else { + "${context.getString(R.string.episode)} $episode" + } + } else { + if (episode != null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" + } else { + "${context.getString(R.string.episode)} $episode - $epName" + } + } else { + epName + } + } + ) + } + private suspend fun downloadSingleEpisode( context: Context, source: String?, @@ -1496,7 +1668,7 @@ object VideoDownloadManager { val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - createDownloadNotification( + createNotification( context, source, link.name, @@ -1530,7 +1702,7 @@ object VideoDownloadManager { folder ?: "", ep.id, startIndex, - callback, parallelConnections = maxConcurrentConnections(context) + callback, parallelConnections = maxConcurrentConnections ) } @@ -1544,23 +1716,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.removeAt(0) + val item = pkg.item + val id = item.ep.id + if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT + downloadEvent.invoke(id to DownloadActionType.Resume) + return + } + + currentDownloads.add(id) + try { + for (index in (pkg.linkIndex ?: 0) until item.links.size) { + val link = item.links[index] + val resume = pkg.linkIndex == index + + setKey( + KEY_RESUME_PACKAGES, + id.toString(), + DownloadResumePackage(item, index) + ) + + var connectionResult = + downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + resume + ) + + if (connectionResult.retrySame) { + connectionResult = downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + true + ) + } + + if (connectionResult.success) { // SUCCESS + removeKey(KEY_RESUME_PACKAGES, id.toString()) + break + } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { + downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) + break + } + } + } catch (e: Exception) { + logError(e) + } finally { + currentDownloads.remove(id) + // Because otherwise notifications will not get caught by the work manager + downloadCheckUsingWorker(context) + } + + // return id + } + + /* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { + val res = getDownloadFileInfo(context, id) + if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) + return res + } + */ + fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? = + getDownloadFileInfo(context, id) + + private fun DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory(relativePath, createMissingDirectories = false) + ?.findFile(displayName) + } + + private fun getDownloadFileInfo( context: Context, id: Int, ): DownloadedFileInfoResult? { @@ -1570,7 +1822,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,475 +1874,156 @@ 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 } fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { - return context.getKey(KEY_RESUME_PACKAGES, id.toString()) + return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? { - return context.getKey(KEY_RESUME_IN_QUEUE, id.toString()) + suspend fun downloadFromResume( + context: Context, + pkg: DownloadResumePackage, + notificationCallback: (Int, Notification) -> Unit, + setKey: Boolean = true + ) { + if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { + downloadQueue.addLast(pkg) + downloadCheck(context, notificationCallback) + if (setKey) saveQueue() + //ret + } else { + downloadEvent( + pkg.item.ep.id to DownloadActionType.Resume + ) + //null + } } - fun getDownloadEpisodeMetadata( - episode: ResultEpisode, - titleName: String, - apiName: String, - currentPoster: String?, - currentIsMovie: Boolean, - tvType: TvType, - ): DownloadEpisodeMetadata { - return DownloadEpisodeMetadata( - episode.id, - episode.parentId, - sanitizeFilename(titleName), - apiName, - episode.poster ?: currentPoster, - episode.name, - if (currentIsMovie) null else episode.season, - if (currentIsMovie) null else episode.episode, - tvType, + private fun saveQueue() { + try { + val dQueue = + downloadQueue.toList() + .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } + .toTypedArray() + setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) + } catch (t: Throwable) { + logError(t) + } + } + + /*fun isMyServiceRunning(context: Context, serviceClass: Class<*>): Boolean { + val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + for (service in manager!!.getRunningServices(Int.MAX_VALUE)) { + if (serviceClass.name == service.service.className) { + return true + } + } + return false + }*/ + + suspend fun downloadEpisode( + context: Context?, + source: String?, + folder: String?, + ep: DownloadEpisodeMetadata, + links: List, + notificationCallback: (Int, Notification) -> Unit, + ) { + if (context == null) return + if (links.isEmpty()) return + downloadFromResume( + context, + DownloadResumePackage(DownloadItem(source, folder, ep, links), null), + notificationCallback ) } - class EpisodeDownloadInstance( - val context: Context, - val downloadQueueWrapper: DownloadQueueWrapper - ) { - private val TAG = "EpisodeDownloadInstance" - private var subtitleDownloadJob: Job? = null - private var downloadJob: Job? = null - private var linkLoadingJob: Job? = null - - /** isCompleted just means the download should not be retried. - * It includes stopped by user AND completion of file download. - * */ - var isCompleted = false - set(value) { - field = value - if (value) { - removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) - // Do not emit events when completed as it may also trigger on cancellation. - - - // Force refresh the queue when completed. - // May lead to some redundant calls, but ensures that the queue is always up to date. - DownloadQueueManager.forceRefreshQueue() - } - } - - /** Cancels all active jobs and sets instance to failed. */ - fun cancelDownload() { - val cause = "Cancel call from cancelDownload" - this.subtitleDownloadJob?.cancel(cause) - this.linkLoadingJob?.cancel(cause) - - // Should not cancel the download job, it may need to clean up itself. - // Better to send a status event using isStopped and let it cancel itself. - isCancelled = true - } - - // Run to cancel ongoing work, delete partial work and refresh queue - private fun cleanup(status: DownloadType) { - removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) - val id = downloadQueueWrapper.id - - // Delete subtitles on cancel - safe { - val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) - if (info != null) { - deleteMatchingSubtitles(context, info) - } - } - - downloadStatusEvent.invoke(Pair(id, status)) - downloadStatus[id] = status - downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) - - // Force refresh the queue when failed. - // May lead to some redundant calls, but ensures that the queue is always up to date. - DownloadQueueManager.forceRefreshQueue() - } - - var isCancelled = false - set(value) { - val oldField = field - field = value - - // Clean up cancelled work, but only once - if (value && !oldField) { - cleanup(DownloadType.IsStopped) - } - } - - - /** This failure can be both downloader and user initiated. - * Do not automatically retry in case of failure. */ - var isFailed = false - set(value) { - val oldField = field - field = value - - // Clean up failed work, but only once - if (value && !oldField) { - cleanup(DownloadType.IsFailed) - } - } - - companion object { - private fun displayNotification(context: Context, id: Int, notification: Notification) { - safe { - if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) return@safe - - NotificationManagerCompat.from(context) - .notify(DOWNLOAD_NOTIFICATION_TAG, id, notification) - } - } - } - - private suspend fun downloadFromResume( - downloadResumePackage: DownloadResumePackage, - notificationCallback: (Int, Notification) -> Unit, - ) { - val item = downloadResumePackage.item - val id = item.ep.id - if (currentDownloads.value.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(id to DownloadActionType.Resume) - return - } - - _currentDownloads.update { downloads -> - downloads + id - } - - try { - for (index in (downloadResumePackage.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = downloadResumePackage.linkIndex == index - - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) - ) - - var connectionResult = - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ) - - if (connectionResult.retrySame) { - connectionResult = downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - true - ) - } - - if (connectionResult.success) { // SUCCESS - isCompleted = true - break - } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { - isFailed = true - break - } - } - } catch (e: Exception) { - isFailed = true - logError(e) - } finally { - isFailed = !isCompleted - _currentDownloads.update { downloads -> - downloads - id - } - } - } - - private suspend fun startDownload( - info: DownloadItem?, - pkg: DownloadResumePackage? - ) { - try { - if (info != null) { - getDownloadResumePackage(context, info.ep.id)?.let { dpkg -> - downloadFromResume(dpkg) { id, notification -> - displayNotification(context, id, notification) - } - } ?: run { - if (info.links.isEmpty()) return - downloadFromResume( - DownloadResumePackage(info, null) - ) { id, notification -> - displayNotification(context, id, notification) - } - } - } else if (pkg != null) { - downloadFromResume(pkg) { id, notification -> - displayNotification(context, id, notification) - } - } - return - } catch (e: Exception) { - isFailed = true - logError(e) - return - } - } - - private suspend fun downloadFromResume() { - val resumePackage = downloadQueueWrapper.resumePackage ?: return - downloadFromResume(resumePackage) { id, notification -> - displayNotification(context, id, notification) - } - } - - fun startDownload() { - Log.d(TAG, "Starting download ${downloadQueueWrapper.id}") - setKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString(), downloadQueueWrapper) - - ioSafe { - if (downloadQueueWrapper.resumePackage != null) { - downloadFromResume() - // Load links if they are not already loaded - } else if (downloadQueueWrapper.downloadItem != null && downloadQueueWrapper.downloadItem.links.isNullOrEmpty()) { - downloadEpisodeWithoutLinks() - } else if (downloadQueueWrapper.downloadItem?.links != null) { - downloadEpisodeWithLinks( - sortUrls(downloadQueueWrapper.downloadItem.links.toSet()), - downloadQueueWrapper.downloadItem.subs - ) - } - } - } - - private fun downloadEpisodeWithLinks( - links: List, - subs: List? - ) { - val downloadItem = downloadQueueWrapper.downloadItem ?: return - try { - // Prepare visual keys - setKey( - DOWNLOAD_HEADER_CACHE, - downloadItem.resultId.toString(), - DownloadObjects.DownloadHeaderCached( - apiName = downloadItem.apiName, - url = downloadItem.resultUrl, - type = downloadItem.resultType, - name = downloadItem.resultName, - poster = downloadItem.resultPoster, - id = downloadItem.resultId, - cacheTime = System.currentTimeMillis(), - ) - ) - setKey( - getFolderName( - DOWNLOAD_EPISODE_CACHE, - downloadItem.resultId.toString() - ), // 3 deep folder for faster access - downloadItem.episode.id.toString(), - DownloadObjects.DownloadEpisodeCached( - name = downloadItem.episode.name, - poster = downloadItem.episode.poster, - episode = downloadItem.episode.episode, - season = downloadItem.episode.season, - id = downloadItem.episode.id, - parentId = downloadItem.resultId, - score = downloadItem.episode.score, - description = downloadItem.episode.description, - cacheTime = System.currentTimeMillis(), - ) - ) - - val meta = - getDownloadEpisodeMetadata( - downloadItem.episode, - downloadItem.resultName, - downloadItem.apiName, - downloadItem.resultPoster, - downloadItem.isMovie, - downloadItem.resultType - ) - - val folder = - getFolder(downloadItem.resultType, downloadItem.resultName) - val src = "$DOWNLOAD_NAVIGATE_TO/${downloadItem.resultId}" - - // DOWNLOAD VIDEO - val info = DownloadItem(src, folder, meta, links) - - this.downloadJob = ioSafe { - startDownload(info, null) - } - - // 1. Checks if the lang should be downloaded - // 2. Makes it into the download format - // 3. Downloads it as a .vtt file - this.subtitleDownloadJob = ioSafe { - try { - val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() - - subs?.filter { subtitle -> - downloadList.any { langTagIETF -> - subtitle.languageCode == langTagIETF || - subtitle.originalName.contains( - fromTagToEnglishLanguageName( - langTagIETF - ) ?: langTagIETF - ) - } - } - ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } - ?.take(3) // max subtitles download hardcoded (?_?) - ?.forEach { link -> - val fileName = getFileName(context, meta) - downloadSubtitle(context, link, fileName, folder) - } - - } catch (_: CancellationException) { - val fileName = getFileName(context, meta) - - val info = DownloadedFileInfo( - totalBytes = 0, - relativePath = folder, - displayName = fileName, - basePath = context.getBasePath().second - ) - - deleteMatchingSubtitles(context, info) - } - } - } catch (e: Exception) { - // The work is only failed if the job did not get started - if (this.downloadJob == null) { - isFailed = true - } - logError(e) - } - } - - private suspend fun downloadEpisodeWithoutLinks() { - val downloadItem = downloadQueueWrapper.downloadItem ?: return - - val generator = RepoLinkGenerator(listOf(downloadItem.episode)) - val currentLinks = mutableSetOf() - val currentSubs = mutableSetOf() - val meta = - getDownloadEpisodeMetadata( - downloadItem.episode, - downloadItem.resultName, - downloadItem.apiName, - downloadItem.resultPoster, - downloadItem.isMovie, - downloadItem.resultType - ) - - createDownloadNotification( - context, - downloadItem.apiName, - txt(R.string.loading).asString(context), - meta, - DownloadType.IsPending, - 0, - 1, - { _, _ -> }, - null, - null, - 0 - )?.let { linkLoadingNotification -> - displayNotification(context, downloadItem.episode.id, linkLoadingNotification) - } - - linkLoadingJob = ioSafe { - generator.generateLinks( - offset = 0, - isCasting = false, - clearCache = false, - sourceTypes = LOADTYPE_INAPP_DOWNLOAD, - callback = { - it.first?.let { link -> - currentLinks.add(link) - } - }, - subtitleCallback = { sub -> - currentSubs.add(sub) - }) - } - - // Wait for link loading completion - linkLoadingJob?.join() - - // Remove link loading notification - NotificationManagerCompat.from(context) - .cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) - - if (linkLoadingJob?.isCancelled == true) { - // Same as if no links, but no toast. - // Cancelled link loading is presumed to be user initiated - isCancelled = true - return - } else if (currentLinks.isEmpty()) { - main { - showToast( - R.string.no_links_found_toast, - Toast.LENGTH_SHORT - ) - } - isFailed = true - return - } else { - main { - showToast( - R.string.download_started, - Toast.LENGTH_SHORT - ) - } - } - - // Profiles should always contain a download type - val profile = QualityDataHelper.getProfiles().first { - it.types.contains( - QualityDataHelper.QualityProfileType.Download - ) - } - - val sortedLinks = currentLinks.sortedBy { link -> - // Negative, because the highest priority should be first - -getLinkPriority(profile.id, link) - } - - downloadEpisodeWithLinks( - sortedLinks, - sortSubs(currentSubs), + /** Worker stuff */ + private fun startWork(context: Context, key: String) { + val req = OneTimeWorkRequest.Builder(DownloadFileWorkManager::class.java) + .setInputData( + Data.Builder() + .putString("key", key) + .build() ) - } + .build() + (WorkManager.getInstance(context)).enqueueUniqueWork( + key, + ExistingWorkPolicy.KEEP, + req + ) } + + fun downloadCheckUsingWorker( + context: Context, + ) { + startWork(context, DOWNLOAD_CHECK) + } + + fun downloadFromResumeUsingWorker( + context: Context, + pkg: DownloadResumePackage, + ) { + val key = pkg.item.ep.id.toString() + setKey(WORK_KEY_PACKAGE, key, pkg) + startWork(context, key) + } + + // Keys are needed to transfer the data to the worker reliably and without exceeding the data limit + const val WORK_KEY_PACKAGE = "work_key_package" + const val WORK_KEY_INFO = "work_key_info" + + fun downloadEpisodeUsingWorker( + context: Context, + source: String?, + folder: String?, + ep: DownloadEpisodeMetadata, + links: List, + ) { + val info = DownloadInfo( + source, folder, ep, links + ) + + val key = info.ep.id.toString() + setKey(WORK_KEY_INFO, key, info) + startWork(context, key) + } + + data class DownloadInfo( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List + ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt deleted file mode 100644 index 898c30a1c..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.content.Context -import android.net.Uri -import androidx.core.net.toUri -import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.getFolderPrefix -import com.lagradost.cloudstream3.isEpisodeBased -import com.lagradost.safefile.MediaFileContentType -import com.lagradost.safefile.SafeFile - -object DownloadFileManagement { - private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" - internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { - var tempName = name - for (c in RESERVED_CHARS) { - tempName = tempName.replace(c, ' ') - } - if (removeSpaces) tempName = tempName.replace(" ", "") - return tempName.replace(" ", " ").trim(' ') - } - - /** - * Used for getting video player subs. - * @return List of pairs for the files in this format: - * */ - internal fun getFolder( - context: Context, - relativePath: String, - basePath: String? - ): List>? { - val base = basePathToFile(context, basePath) - val folder = - base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null - - //if (folder.isDirectory() != false) return null - - return folder.listFiles() - ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } - } - - /** - * Turns a string to an UniFile. Used for stored string paths such as settings. - * Should only be used to get a download path. - * */ - internal fun basePathToFile(context: Context, path: String?): SafeFile? { - return when { - path.isNullOrBlank() -> getDefaultDir(context) - path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) - else -> SafeFile.fromFilePath(context, path) - } - } - - /** - * Base path where downloaded things should be stored, changes depending on settings. - * Returns the file and a string to be stored for future file retrieval. - * UniFile.filePath is not sufficient for storage. - * */ - internal fun Context.getBasePath(): Pair { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) - return basePathToFile(this, basePathSetting) to basePathSetting - } - - internal fun getFileName( - context: Context, - metadata: DownloadObjects.DownloadEpisodeMetadata - ): String { - return getFileName(context, metadata.name, metadata.episode, metadata.season) - } - - internal fun getFileName( - context: Context, - epName: String?, - episode: Int?, - season: Int? - ): String { - // kinda ugly ik - return sanitizeFilename( - if (epName == null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" - } else { - "${context.getString(R.string.episode)} $episode" - } - } else { - if (episode != null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" - } else { - "${context.getString(R.string.episode)} $episode - $epName" - } - } else { - epName - } - } - ) - } - - - internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { - return basePathToFile(context, this.basePath)?.gotoDirectory( - relativePath, - createMissingDirectories = false - ) - ?.findFile(displayName) - } - - internal fun getFolder(currentType: TvType, titleName: String): String { - return if (currentType.isEpisodeBased()) { - val sanitizedFileName = sanitizeFilename(titleName) - "${currentType.getFolderPrefix()}/$sanitizedFileName" - } else currentType.getFolderPrefix() - } - - /** - * Gets the default download path as an UniFile. - * Vital for legacy downloads, be careful about changing anything here. - * - * As of writing UniFile is used for everything but download directory on scoped storage. - * Special ContentResolver fuckery is needed for that as UniFile doesn't work. - * */ - fun getDefaultDir(context: Context): SafeFile? { - // See https://www.py4u.net/discuss/614761 - return SafeFile.fromMedia( - context, MediaFileContentType.Downloads - ) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt deleted file mode 100644 index 946e7ecf9..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt +++ /dev/null @@ -1,251 +0,0 @@ -package com.lagradost.cloudstream3.utils.downloader - -import android.net.Uri -import com.fasterxml.jackson.annotation.JsonIgnore -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.Score -import com.lagradost.cloudstream3.SkipSerializationTest -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.cloudstream3.utils.serializers.UriSerializer -import com.lagradost.cloudstream3.utils.serializers.WriteOnlySerializer -import com.lagradost.safefile.SafeFile -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KeepGeneratedSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -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 */ - @Serializable - data class DownloadQueueWrapper( - @JsonProperty("resumePackage") @SerialName("resumePackage") val resumePackage: DownloadResumePackage?, - @JsonProperty("downloadItem") @SerialName("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. */ - @JsonIgnore - fun isCurrentlyDownloading(): Boolean { - return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } - } - - @JsonProperty("id") @SerialName("id") - val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id - - @JsonProperty("parentId") @SerialName("parentId") - val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId - } - - /** General data about the episode and show to start a download from. */ - @Serializable - data class DownloadQueueItem( - @JsonProperty("episode") @SerialName("episode") val episode: ResultEpisode, - @JsonProperty("isMovie") @SerialName("isMovie") val isMovie: Boolean, - @JsonProperty("resultName") @SerialName("resultName") val resultName: String, - @JsonProperty("resultType") @SerialName("resultType") val resultType: TvType, - @JsonProperty("resultPoster") @SerialName("resultPoster") val resultPoster: String?, - @JsonProperty("apiName") @SerialName("apiName") val apiName: String, - @JsonProperty("resultId") @SerialName("resultId") val resultId: Int, - @JsonProperty("resultUrl") @SerialName("resultUrl") val resultUrl: String, - @JsonProperty("links") @SerialName("links") val links: List? = null, - @JsonProperty("subs") @SerialName("subs") val subs: List? = null, - ) { - fun toWrapper(): DownloadQueueWrapper { - return DownloadQueueWrapper(null, this) - } - } - - interface DownloadCached { - val id: Int - } - - @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now - @KeepGeneratedSerializer - @Serializable(with = DownloadEpisodeCached.Serializer::class) - data class DownloadEpisodeCached( - @JsonProperty("name") @SerialName("name") val name: String?, - @JsonProperty("poster") @SerialName("poster") val poster: String?, - @JsonProperty("episode") @SerialName("episode") val episode: Int, - @JsonProperty("season") @SerialName("season") val season: Int?, - @JsonProperty("parentId") @SerialName("parentId") val parentId: Int, - @JsonProperty("score") @SerialName("score") var score: Score? = null, - @JsonProperty("description") @SerialName("description") val description: String?, - @JsonProperty("cacheTime") @SerialName("cacheTime") val cacheTime: Long, - @JsonProperty("id") @SerialName("id") override val id: Int, - ) : DownloadCached { - object Serializer : WriteOnlySerializer( - DownloadEpisodeCached.generatedSerializer(), - setOf("rating"), - ) - - @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) - @SerialName("rating") - @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 */ - @Serializable - data class DownloadHeaderCached( - @JsonProperty("apiName") @SerialName("apiName") val apiName: String, - @JsonProperty("url") @SerialName("url") val url: String, - @JsonProperty("type") @SerialName("type") val type: TvType, - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("poster") @SerialName("poster") val poster: String?, - @JsonProperty("cacheTime") @SerialName("cacheTime") val cacheTime: Long, - @JsonProperty("id") @SerialName("id") override val id: Int, - ) : DownloadCached - - @Serializable - data class DownloadResumePackage( - @JsonProperty("item") @SerialName("item") val item: DownloadItem, - /** Tills which link should get resumed */ - @JsonProperty("linkIndex") @SerialName("linkIndex") val linkIndex: Int?, - ) { - fun toWrapper(): DownloadQueueWrapper { - return DownloadQueueWrapper(this, null) - } - } - - @Serializable - data class DownloadItem( - @JsonProperty("source") @SerialName("source") val source: String?, - @JsonProperty("folder") @SerialName("folder") val folder: String?, - @JsonProperty("ep") @SerialName("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") @SerialName("links") val links: List, - ) - - /** Metadata for a specific episode and how to display it. */ - @Serializable - data class DownloadEpisodeMetadata( - @JsonProperty("id") @SerialName("id") val id: Int, - @JsonProperty("parentId") @SerialName("parentId") val parentId: Int, - @JsonProperty("mainName") @SerialName("mainName") val mainName: String, - @JsonProperty("sourceApiName") @SerialName("sourceApiName") val sourceApiName: String?, - @JsonProperty("poster") @SerialName("poster") val poster: String?, - @JsonProperty("name") @SerialName("name") val name: String?, - @JsonProperty("season") @SerialName("season") val season: Int?, - @JsonProperty("episode") @SerialName("episode") val episode: Int?, - @JsonProperty("type") @SerialName("type") val type: TvType?, - ) - - @Serializable - data class DownloadedFileInfo( - @JsonProperty("totalBytes") @SerialName("totalBytes") val totalBytes: Long, - @JsonProperty("relativePath") @SerialName("relativePath") val relativePath: String, - @JsonProperty("displayName") @SerialName("displayName") val displayName: String, - @JsonProperty("extraInfo") @SerialName("extraInfo") val extraInfo: String? = null, - @JsonProperty("basePath") @SerialName("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") @SerialName("linkHash") val linkHash: Int? = null, - ) - - @Serializable - @SkipSerializationTest // Uri has issues with Jackson - data class DownloadedFileInfoResult( - @JsonProperty("fileLength") @SerialName("fileLength") val fileLength: Long, - @JsonProperty("totalBytes") @SerialName("totalBytes") val totalBytes: Long, - @JsonProperty("path") @SerialName("path") - @Serializable(with = UriSerializer::class) - val path: Uri, - ) - - @Serializable - data class ResumeWatching( - @JsonProperty("parentId") @SerialName("parentId") val parentId: Int, - @JsonProperty("episodeId") @SerialName("episodeId") val episodeId: Int?, - @JsonProperty("episode") @SerialName("episode") val episode: Int?, - @JsonProperty("season") @SerialName("season") val season: Int?, - @JsonProperty("updateTime") @SerialName("updateTime") val updateTime: Long, - @JsonProperty("isFromDownload") @SerialName("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 can't 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) - } - } -} 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 adceede42..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.utils.serializers - -import android.net.Uri -import com.lagradost.cloudstream3.InternalAPI -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, - * ) - */ -@InternalAPI -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 4bb78a1b0..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.lagradost.cloudstream3.utils.videoskip - -import com.fasterxml.jackson.annotation.JsonProperty -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 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -// 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, - ) - } - } - - @Serializable - data class AniSkipResponse( - @JsonProperty("found") @SerialName("found") val found: Boolean, - @JsonProperty("results") @SerialName("results") val results: List?, - @JsonProperty("message") @SerialName("message") val message: String?, - @JsonProperty("statusCode") @SerialName("statusCode") val statusCode: Int, - ) - - @Serializable - data class Stamp( - @JsonProperty("interval") @SerialName("interval") val interval: AniSkipInterval, - @JsonProperty("skipType") @SerialName("skipType") val skipType: String, - @JsonProperty("skipId") @SerialName("skipId") val skipId: String, - @JsonProperty("episodeLength") @SerialName("episodeLength") val episodeLength: Double, - ) - - @Serializable - data class AniSkipInterval( - @JsonProperty("startTime") @SerialName("startTime") val startTime: Double, - @JsonProperty("endTime") @SerialName("endTime") val endTime: Double, - ) -} 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 df9d56217..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt +++ /dev/null @@ -1,355 +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 kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -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') - } - - @Serializable - data class LoginRoot( - @JsonProperty("data") @SerialName("data") val data: LoginData, - ) - - @Serializable - data class LoginData( - @JsonProperty("login") @SerialName("login") val login: Login, - ) - - @Serializable - data class Login( - @JsonProperty("authToken") @SerialName("authToken") val authToken: String, - @JsonProperty("refreshToken") @SerialName("refreshToken") val refreshToken: String, - @JsonProperty("account") @SerialName("account") val account: Account, - ) - - @Serializable - data class ApiRoot( - @JsonProperty("data") @SerialName("data") val data: ApiData, - ) - - @Serializable - data class ApiData( - @JsonProperty("myApiClients") @SerialName("myApiClients") val myApiClients: List, - ) - - @Serializable - data class MyApiClient( - @JsonProperty("id") @SerialName("id") val id: String, - ) - - @Serializable - data class Account( - @JsonProperty("profileUrl") @SerialName("profileUrl") val profileUrl: String, - @JsonProperty("username") @SerialName("username") val username: String, - @JsonProperty("email") @SerialName("email") val email: String, - ) - - @Serializable - data class Payload( - @JsonProperty("profileUrl") @SerialName("profileUrl") val profileUrl: String, - @JsonProperty("username") @SerialName("username") val username: String, - @JsonProperty("email") @SerialName("email") val email: String, - @JsonProperty("clientId") @SerialName("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() - } - - @Serializable - data class Root( - @JsonProperty("data") @SerialName("data") val data: Data, - ) - - @Serializable - data class Data( - @JsonProperty("searchShows") @SerialName("searchShows") val searchShows: List, - ) - - @Serializable - data class SearchShow( - @JsonProperty("name") @SerialName("name") val name: String, - @JsonProperty("originalName") @SerialName("originalName") val originalName: String?, - @JsonProperty("seasonCount") @SerialName("seasonCount") val seasonCount: Long, - @JsonProperty("episodeCount") @SerialName("episodeCount") val episodeCount: Long, - @JsonProperty("baseDuration") @SerialName("baseDuration") val baseDuration: Double, - @JsonProperty("episodes") @SerialName("episodes") val episodes: List, - ) - - @Serializable - data class Episode( - @JsonProperty("number") @SerialName("number") val number: String?, - @JsonProperty("absoluteNumber") @SerialName("absoluteNumber") val absoluteNumber: String?, - @JsonProperty("season") @SerialName("season") val season: String?, - @JsonProperty("timestamps") @SerialName("timestamps") val timestamps: List, - ) - - @Serializable - data class Timestamp( - @JsonProperty("at") @SerialName("at") val at: Double, - @JsonProperty("type") @SerialName("type") val type: Type, - ) - - @Serializable - data class Type( - @JsonProperty("name") @SerialName("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 75e22a15f..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt +++ /dev/null @@ -1,80 +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 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -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 - ) - } - ) - } - - @Serializable - data class IntroDbResponse( - @JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String?, - @JsonProperty("season") @SerialName("season") val season: Int?, - @JsonProperty("episode") @SerialName("episode") val episode: Int?, - @JsonProperty("intro") @SerialName("intro") val intro: Segment?, - @JsonProperty("recap") @SerialName("recap") val recap: Segment?, - @JsonProperty("outro") @SerialName("outro") val outro: Segment?, - ) - - @Serializable - data class Segment( - @JsonProperty("start_sec") @SerialName("start_sec") val startSec: Double?, - @JsonProperty("end_sec") @SerialName("end_sec") val endSec: Double?, - @JsonProperty("start_ms") @SerialName("start_ms") val startMs: Long?, - @JsonProperty("end_ms") @SerialName("end_ms") val endMs: Long?, - @JsonProperty("confidence") @SerialName("confidence") val confidence: Double?, - @JsonProperty("submission_count") @SerialName("submission_count") val submissionCount: Int?, - @JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: String?, - ) -} 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 3fd050f1a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt +++ /dev/null @@ -1,72 +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 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** 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() - } - - @Serializable - data class Root( - @JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long, - @JsonProperty("type") @SerialName("type") val type: String, - @JsonProperty("intro") @SerialName("intro") val intro: List = emptyList(), - @JsonProperty("recap") @SerialName("recap") val recap: List = emptyList(), - @JsonProperty("credits") @SerialName("credits") val credits: List = emptyList(), - @JsonProperty("preview") @SerialName("preview") val preview: List = emptyList(), - ) - - @Serializable - data class Stamp( - @JsonProperty("start_ms") @SerialName("start_ms") val startMs: Long?, - @JsonProperty("end_ms") @SerialName("end_ms") val endMs: Long?, - ) -} 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/java/go/Seq.java b/app/src/main/java/go/Seq.java new file mode 100644 index 000000000..2a510bdca --- /dev/null +++ b/app/src/main/java/go/Seq.java @@ -0,0 +1,413 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package go; + +import android.content.Context; + +import java.lang.ref.PhantomReference; +import java.lang.ref.ReferenceQueue; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.HashSet; +import java.util.logging.Logger; + +import torrServer.TorrServer; + +// Seq is a sequence of machine-dependent encoded values. +// Used by automatically generated language bindings to talk to Go. +public class Seq { + private static Logger log = Logger.getLogger("GoSeq"); + + // also known to bind/seq/ref.go and bind/objc/seq_darwin.m + private static final int NULL_REFNUM = 41; + + // use single Ref for null Object + public static final Ref nullRef = new Ref(NULL_REFNUM, null); + + // The singleton GoRefQueue + private static GoRefQueue goRefQueue = null; + + static Boolean hasLoaded = false; + + // We want to lazy loadLibrary in case of crashes + public static synchronized void load() { + if (hasLoaded) return; + hasLoaded = true; + log.info("Starting loading gojni"); + System.loadLibrary("gojni"); + log.info("Starting loading gojni init"); + init(); + log.info("Starting loading gojni goRefQueue"); + goRefQueue = new GoRefQueue(); + log.info("Starting loading gojni Universe"); + Universe.touch(); + Universe._init(); + log.info("Starting loading gojni TorrServer"); + TorrServer.touch(); + TorrServer._init(); + log.info("Loaded torrent server"); + } + + // setContext sets the context in the go-library to be used in RunOnJvm. + public static void setContext(Context context) { + setContext((java.lang.Object)context); + } + + private static native void init(); + + // Empty method to run class initializer + public static void touch() {} + + private Seq() { + } + + // ctx is an android.context.Context. + static native void setContext(java.lang.Object ctx); + + public static void incRefnum(int refnum) { + tracker.incRefnum(refnum); + } + + // incRef increments the reference count of Java objects. + // For proxies for Go objects, it calls into the Proxy method + // incRefnum() to make sure the Go reference count is positive + // even if the Proxy is garbage collected and its Ref is finalized. + public static int incRef(Object o) { + return tracker.inc(o); + } + + public static int incGoObjectRef(GoObject o) { + return o.incRefnum(); + } + + // trackGoRef tracks a Go reference and decrements its refcount + // when the given GoObject wrapper is garbage collected. + // + // TODO(crawshaw): We could cut down allocations for frequently + // sent Go objects by maintaining a map to weak references. This + // however, would require allocating two objects per reference + // instead of one. It also introduces weak references, the bane + // of any Java debugging session. + // + // When we have real code, examine the tradeoffs. + public static void trackGoRef(int refnum, GoObject obj) { + if (refnum > 0) { + throw new RuntimeException("trackGoRef called with Java refnum " + refnum); + } + goRefQueue.track(refnum, obj); + } + + public static Ref getRef(int refnum) { + return tracker.get(refnum); + } + + // Increment the Go reference count before sending over a refnum. + // The ref parameter is only used to make sure the referenced + // object is not garbage collected before Go increments the + // count. It's the equivalent of Go's runtime.KeepAlive. + public static native void incGoRef(int refnum, GoObject ref); + + // Informs the Go ref tracker that Java is done with this refnum. + static native void destroyRef(int refnum); + + // decRef is called from seq.FinalizeRef + static void decRef(int refnum) { + tracker.dec(refnum); + } + + // A GoObject is a Java class implemented in Go. When a GoObject + // is passed to Go, it is wrapped in a Go proxy, to make it behave + // the same as passing a regular Java class. + public interface GoObject { + // Increment refcount and return the refnum of the proxy. + // + // The Go reference count need to be bumped while the + // refnum is passed to Go, to avoid finalizing and + // invalidating it before being translated on the Go side. + int incRefnum(); + } + // A Proxy is a Java object that proxies a Go object. Proxies, unlike + // GoObjects, are unwrapped to their Go counterpart when deserialized + // in Go. + public interface Proxy extends GoObject {} + + // A Ref represents an instance of a Java object passed back and forth + // across the language boundary. + public static final class Ref { + public final int refnum; + + private int refcnt; // Track how many times sent to Go. + + public final Object obj; // The referenced Java obj. + + Ref(int refnum, Object o) { + if (refnum < 0) { + throw new RuntimeException("Ref instantiated with a Go refnum " + refnum); + } + this.refnum = refnum; + this.refcnt = 0; + this.obj = o; + } + + void inc() { + // Count how many times this ref's Java object is passed to Go. + if (refcnt == Integer.MAX_VALUE) { + throw new RuntimeException("refnum " + refnum + " overflow"); + } + refcnt++; + } + } + + static final RefTracker tracker = new RefTracker(); + + static final class RefTracker { + private static final int REF_OFFSET = 42; + + // Next Java object reference number. + // + // Reference numbers are positive for Java objects, + // and start, arbitrarily at a different offset to Go + // to make debugging by reading Seq hex a little easier. + private int next = REF_OFFSET; // next Java object ref + + // Java objects that have been passed to Go. refnum -> Ref + // The Ref obj field is non-null. + // This map pins Java objects so they don't get GCed while the + // only reference to them is held by Go code. + private final RefMap javaObjs = new RefMap(); + + // Java objects to refnum + private final IdentityHashMap javaRefs = new IdentityHashMap<>(); + + // inc increments the reference count of a Java object when it + // is sent to Go. inc returns the refnum for the object. + synchronized int inc(Object o) { + if (o == null) { + return NULL_REFNUM; + } + if (o instanceof Proxy) { + return ((Proxy)o).incRefnum(); + } + Integer refnumObj = javaRefs.get(o); + if (refnumObj == null) { + if (next == Integer.MAX_VALUE) { + throw new RuntimeException("createRef overflow for " + o); + } + refnumObj = next++; + javaRefs.put(o, refnumObj); + } + int refnum = refnumObj; + Ref ref = javaObjs.get(refnum); + if (ref == null) { + ref = new Ref(refnum, o); + javaObjs.put(refnum, ref); + } + ref.inc(); + return refnum; + } + + synchronized void incRefnum(int refnum) { + Ref ref = javaObjs.get(refnum); + if (ref == null) { + throw new RuntimeException("referenced Java object is not found: refnum="+refnum); + } + ref.inc(); + } + + // dec decrements the reference count of a Java object when + // Go signals a corresponding proxy object is finalized. + // If the count reaches zero, the Java object is removed + // from the javaObjs map. + synchronized void dec(int refnum) { + if (refnum <= 0) { + // We don't keep track of the Go object. + // This must not happen. + log.severe("dec request for Go object "+ refnum); + return; + } + if (refnum == Seq.nullRef.refnum) { + return; + } + // Java objects are removed on request of Go. + Ref obj = javaObjs.get(refnum); + if (obj == null) { + throw new RuntimeException("referenced Java object is not found: refnum="+refnum); + } + obj.refcnt--; + if (obj.refcnt <= 0) { + javaObjs.remove(refnum); + javaRefs.remove(obj.obj); + } + } + + // get returns an existing Ref to a Java object. + synchronized Ref get(int refnum) { + if (refnum < 0) { + throw new RuntimeException("ref called with Go refnum " + refnum); + } + if (refnum == NULL_REFNUM) { + return nullRef; + } + Ref ref = javaObjs.get(refnum); + if (ref == null) { + throw new RuntimeException("unknown java Ref: "+refnum); + } + return ref; + } + } + + // GoRefQueue is a queue of GoRefs that are no longer live. An internal thread + // processes the queue and decrement the reference count on the Go side. + static class GoRefQueue extends ReferenceQueue { + // The set of tracked GoRefs. If we don't hold on to the GoRef instances, the Java GC + // will not add them to the queue when their referents are reclaimed. + private final Collection refs = Collections.synchronizedCollection(new HashSet()); + + void track(int refnum, GoObject obj) { + refs.add(new GoRef(refnum, obj, this)); + } + + GoRefQueue() { + Thread daemon = new Thread(new Runnable() { + @Override public void run() { + while (true) { + try { + GoRef ref = (GoRef)remove(); + refs.remove(ref); + destroyRef(ref.refnum); + ref.clear(); + } catch (InterruptedException e) { + // Ignore + } + } + } + }); + daemon.setDaemon(true); + daemon.setName("GoRefQueue Finalizer Thread"); + daemon.start(); + } + } + + // A GoRef is a PhantomReference to a Java proxy for a Go object. + // GoRefs are enqueued to the singleton GoRefQueue when no longer live, + // so the corresponding reference count can be decremented. + static class GoRef extends PhantomReference { + final int refnum; + + GoRef(int refnum, GoObject obj, GoRefQueue q) { + super(obj, q); + if (refnum > 0) { + throw new RuntimeException("GoRef instantiated with a Java refnum " + refnum); + } + this.refnum = refnum; + } + } + + // RefMap is a mapping of integers to Ref objects. + // + // The integers can be sparse. In Go this would be a map[int]*Ref. + static final class RefMap { + private int next = 0; + private int live = 0; + private int[] keys = new int[16]; + private Ref[] objs = new Ref[16]; + + RefMap() {} + + Ref get(int key) { + int i = Arrays.binarySearch(keys, 0, next, key); + if (i >= 0) { + return objs[i]; + } + return null; + } + + void remove(int key) { + int i = Arrays.binarySearch(keys, 0, next, key); + if (i >= 0) { + if (objs[i] != null) { + objs[i] = null; + live--; + } + } + } + + void put(int key, Ref obj) { + if (obj == null) { + throw new RuntimeException("put a null ref (with key "+key+")"); + } + int i = Arrays.binarySearch(keys, 0, next, key); + if (i >= 0) { + if (objs[i] == null) { + objs[i] = obj; + live++; + } + if (objs[i] != obj) { + throw new RuntimeException("replacing an existing ref (with key "+key+")"); + } + return; + } + if (next >= keys.length) { + grow(); + i = Arrays.binarySearch(keys, 0, next, key); + } + i = ~i; + if (i < next) { + // Insert, shift everything afterwards down. + System.arraycopy(keys, i, keys, i+1, next-i); + System.arraycopy(objs, i, objs, i+1, next-i); + } + keys[i] = key; + objs[i] = obj; + live++; + next++; + } + + private void grow() { + // Compact and (if necessary) grow backing store. + int[] newKeys; + Ref[] newObjs; + int len = 2*roundPow2(live); + if (len > keys.length) { + newKeys = new int[keys.length*2]; + newObjs = new Ref[objs.length*2]; + } else { + newKeys = keys; + newObjs = objs; + } + + int j = 0; + for (int i = 0; i < keys.length; i++) { + if (objs[i] != null) { + newKeys[j] = keys[i]; + newObjs[j] = objs[i]; + j++; + } + } + for (int i = j; i < newKeys.length; i++) { + newKeys[i] = 0; + newObjs[i] = null; + } + + keys = newKeys; + objs = newObjs; + next = j; + + if (live != next) { + throw new RuntimeException("bad state: live="+live+", next="+next); + } + } + + private static int roundPow2(int x) { + int p = 1; + while (p < x) { + p *= 2; + } + return p; + } + } +} diff --git a/app/src/main/java/go/Universe.java b/app/src/main/java/go/Universe.java new file mode 100644 index 000000000..364d7ef64 --- /dev/null +++ b/app/src/main/java/go/Universe.java @@ -0,0 +1,31 @@ +package go; + +public abstract class Universe { + private Universe() { + } + + public static void touch() { + } + + public static native void _init(); + + private static final class proxyerror extends Exception implements Seq.Proxy, error { + private final int refnum; + + public final int incRefnum() { + Seq.incGoRef(this.refnum, this); + return this.refnum; + } + + proxyerror(int var1) { + this.refnum = var1; + Seq.trackGoRef(var1, this); + } + + public String getMessage() { + return this.error(); + } + + public native String error(); + } +} diff --git a/app/src/main/java/go/error.java b/app/src/main/java/go/error.java new file mode 100644 index 000000000..39ba8e767 --- /dev/null +++ b/app/src/main/java/go/error.java @@ -0,0 +1,5 @@ +package go; + +public interface error { + String error(); +} \ No newline at end of file diff --git a/app/src/main/java/torrServer/TorrServer.java b/app/src/main/java/torrServer/TorrServer.java new file mode 100644 index 000000000..085e2732c --- /dev/null +++ b/app/src/main/java/torrServer/TorrServer.java @@ -0,0 +1,18 @@ +package torrServer; + +public abstract class TorrServer { + private TorrServer() { + } + + public static void touch() { + } + public static native void _init(); + + public static native void addTrackers(String var0); + + public static native long startTorrentServer(String var0, long var1); + + public static native void stopTorrentServer(); + + public static native void waitTorrentServer(); +} diff --git a/app/src/main/jniLibs/arm64-v8a/libgojni.so b/app/src/main/jniLibs/arm64-v8a/libgojni.so new file mode 100644 index 000000000..7284f5cde Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libgojni.so differ diff --git a/app/src/main/jniLibs/armeabi-v7a/libgojni.so b/app/src/main/jniLibs/armeabi-v7a/libgojni.so new file mode 100644 index 000000000..f1067546c Binary files /dev/null and b/app/src/main/jniLibs/armeabi-v7a/libgojni.so differ diff --git a/app/src/main/jniLibs/x86/libgojni.so b/app/src/main/jniLibs/x86/libgojni.so new file mode 100644 index 000000000..fcb10e9a9 Binary files /dev/null and b/app/src/main/jniLibs/x86/libgojni.so differ diff --git a/app/src/main/jniLibs/x86_64/libgojni.so b/app/src/main/jniLibs/x86_64/libgojni.so new file mode 100644 index 000000000..f3dcfeb2e Binary files /dev/null and b/app/src/main/jniLibs/x86_64/libgojni.so differ 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/item_select_color_tv.xml b/app/src/main/res/color/item_select_color_tv.xml deleted file mode 100644 index 3042fd588..000000000 --- a/app/src/main/res/color/item_select_color_tv.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/animeskip.xml b/app/src/main/res/drawable/animeskip.xml deleted file mode 100644 index 8f1bb3105..000000000 --- a/app/src/main/res/drawable/animeskip.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable/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_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_clock_24.xml b/app/src/main/res/drawable/ic_baseline_clock_24.xml deleted file mode 100644 index 3436c6208..000000000 --- a/app/src/main/res/drawable/ic_baseline_clock_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml index 7dea8241e..70db409b3 100644 --- a/app/src/main/res/drawable/ic_baseline_close_24.xml +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -1,4 +1,4 @@ - diff --git a/app/src/main/res/drawable/ic_baseline_exit_24.xml b/app/src/main/res/drawable/ic_baseline_exit_24.xml index 6aebfabdc..bf421c227 100644 --- a/app/src/main/res/drawable/ic_baseline_exit_24.xml +++ b/app/src/main/res/drawable/ic_baseline_exit_24.xml @@ -1,13 +1,5 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/ic_baseline_folder_open_24.xml b/app/src/main/res/drawable/ic_baseline_folder_open_24.xml index 66afaed2c..6e130c3c9 100644 --- a/app/src/main/res/drawable/ic_baseline_folder_open_24.xml +++ b/app/src/main/res/drawable/ic_baseline_folder_open_24.xml @@ -1,12 +1,5 @@ - - - - + + + + diff --git a/app/src/main/res/drawable/ic_baseline_north_west_24.xml b/app/src/main/res/drawable/ic_baseline_north_west_24.xml deleted file mode 100644 index c46eb4b0c..000000000 --- a/app/src/main/res/drawable/ic_baseline_north_west_24.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_people_24.xml b/app/src/main/res/drawable/ic_baseline_people_24.xml deleted file mode 100644 index 2e7c9b070..000000000 --- a/app/src/main/res/drawable/ic_baseline_people_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_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 926227c58..000000000 --- a/app/src/main/res/layout-port/player_select_source_priority.xml +++ /dev/null @@ -1,132 +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 610b4e3e4..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,43 +25,38 @@ android:layout_gravity="center_horizontal"> + + + + + + - - - - - - diff --git a/app/src/main/res/layout/chromecast_subtitle_settings.xml b/app/src/main/res/layout/chromecast_subtitle_settings.xml index 92d0bd350..4d3b50dfe 100644 --- a/app/src/main/res/layout/chromecast_subtitle_settings.xml +++ b/app/src/main/res/layout/chromecast_subtitle_settings.xml @@ -1,21 +1,16 @@ - - - + android:layout_height="match_parent" + android:id="@+id/subs_root" + android:background="?attr/primaryBlackBackground"> - + android:layout_height="wrap_content"> - - - - - + - - - - - - - - - - - - + + - - - - + + \ No newline at end of file diff --git a/app/src/main/res/layout/confirm_exit_dialog.xml b/app/src/main/res/layout/confirm_exit_dialog.xml index c312e64e3..518aaa477 100644 --- a/app/src/main/res/layout/confirm_exit_dialog.xml +++ b/app/src/main/res/layout/confirm_exit_dialog.xml @@ -5,11 +5,9 @@ android:orientation="vertical" android:paddingHorizontal="16dp" android:paddingVertical="8dp"> - + android:text="@string/dont_show_again" /> diff --git a/app/src/main/res/layout/custom_preference_category_material.xml b/app/src/main/res/layout/custom_preference_category_material.xml 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" />