diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml index 931db3bd9..f35900673 100644 --- a/.github/ISSUE_TEMPLATE/application-bug.yml +++ b/.github/ISSUE_TEMPLATE/application-bug.yml @@ -80,13 +80,13 @@ body: label: Acknowledgements description: Your issue will be closed if you haven't done these steps. options: + - label: I am sure my issue is related to the app and **NOT some extension**. + required: true - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. required: true - label: I have written a short but informative title. required: true - label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**. required: true - - label: If related to a provider, I have checked the site and it works, but not the app. - required: true - label: I will fill out all of the requested information in this form. required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 250734cdd..b56cdf8ed 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,7 +2,7 @@ blank_issues_enabled: false contact_links: - name: Request a new provider or report bug with an existing provider url: https://github.com/recloudstream - about: Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord. + about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord. - name: Discord url: https://discord.gg/5Hus6fM about: Join our discord for faster support on smaller issues. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 9c35ba56f..e18daebb3 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -27,9 +27,7 @@ body: label: Acknowledgements description: Your issue will be closed if you haven't done these steps. options: + - label: My suggestion is **NOT** about adding a new provider + required: true - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - required: true - - label: I have written a short but informative title. - required: true - - label: I will fill out all of the requested information in this form. - required: true + required: true \ No newline at end of file diff --git a/.github/locales.py b/.github/locales.py index 7d6d6b90d..6127d9d80 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -7,7 +7,7 @@ import lxml.etree as ET # builtin library doesn't preserve comments SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt" START_MARKER = "/* begin language list */" END_MARKER = "/* end language list */" -XML_NAME = "app/src/main/res/values-" +XML_NAME = "app/src/main/res/values-b+" ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" INDENT = " "*4 @@ -20,29 +20,29 @@ rest, after_src = rest.split(END_MARKER) # Load already added langs languages = {} -for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest): - flag, name, iso = lang.groups() - languages[iso] = (flag, name) +for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): + name, iso = lang.groups() + languages[iso] = name # Add not yet added langs for folder in glob.glob(f"{XML_NAME}*"): - iso = folder[len(XML_NAME):] + iso = folder[len(XML_NAME):].replace("+", "-") if iso not in languages.keys(): - entry = iso_map.get(iso.lower(),{'nativeName':iso}) - languages[iso] = ("", entry['nativeName'].split(',')[0]) + entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found + languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple -# Create triples -triples = [] -for iso in sorted(languages.keys()): - flag, name = languages[iso] - triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),') +# Create pairs +pairs = [] +for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name + name = languages[iso] + pairs.append(f'{INDENT}Pair("{name}", "{iso}"),') # Update settings file open(SETTINGS_PATH, "w+",encoding='utf-8').write( before_src + START_MARKER + "\n" + - "\n".join(triples) + + "\n".join(pairs) + "\n" + END_MARKER + after_src @@ -53,6 +53,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"): try: tree = ET.parse(file) for child in tree.getroot(): + if not child.text: + continue if child.text.startswith("\\@string/"): print(f"[{file}] fixing {child.attrib['name']}") child.text = child.text.replace("\\@string/", "@string/") diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index 3b7aa9aec..30bedcc1b 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -1,78 +1,93 @@ -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@v1 - 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@v1 - with: - app_id: ${{ secrets.GH_APP_ID }} - private_key: ${{ secrets.GH_APP_KEY }} - repository: "recloudstream/cloudstream-archive" - - uses: actions/checkout@v2 - - name: Set up JDK 17 - uses: actions/setup-java@v2 - 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@v3 - 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 +name: Archive build + +on: + push: + branches: [ master ] + paths-ignore: + - '*.md' + - '*.json' + - '**/wcokey.txt' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: "Archive-build" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Generate access token + id: generate_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.GH_APP_ID }} + private_key: ${{ secrets.GH_APP_KEY }} + repository: "recloudstream/secrets" + + - name: Generate access token (archive) + id: generate_archive_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.GH_APP_ID }} + private_key: ${{ secrets.GH_APP_KEY }} + repository: "recloudstream/cloudstream-archive" + + - uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Fetch keystore + id: fetch_keystore + run: | + TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore + mkdir -p "${TMP_KEYSTORE_FILE_PATH}" + curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks" + curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt" + KEY_PWD="$(cat keystore_password.txt)" + echo "::add-mask::${KEY_PWD}" + echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + + - name: Run Gradle + run: ./gradlew assemblePrereleaseRelease + env: + SIGNING_KEY_ALIAS: "key0" + SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} + SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} + MDL_API_KEY: ${{ secrets.MDL_API_KEY }} + + - uses: actions/checkout@v6 + with: + repository: "recloudstream/cloudstream-archive" + token: ${{ steps.generate_archive_token.outputs.token }} + path: "archive" + + - name: Move build + run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk" + + - name: Push archive + run: | + cd $GITHUB_WORKSPACE/archive + git config --local user.email "actions@github.com" + git config --local user.name "GitHub Actions" + git add . + git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit + git push --force diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index abeee0b29..d67b8a519 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -1,64 +1,67 @@ name: Dokka -# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency -concurrency: - group: "dokka" - cancel-in-progress: true - on: push: - branches: - # choose your default branch - - master - - main + branches: [ master ] paths-ignore: - '*.md' +permissions: + contents: read + +concurrency: + group: "dokka" + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/dokka" + - name: Checkout - uses: actions/checkout@master + uses: actions/checkout@v6 with: path: "src" - name: Checkout dokka - uses: actions/checkout@master + uses: actions/checkout@v6 with: repository: "recloudstream/dokka" path: "dokka" token: ${{ steps.generate_token.outputs.token }} - + - name: Clean old builds run: | cd $GITHUB_WORKSPACE/dokka/ - rm -rf "./-cloudstream" + rm -rf "./app" + rm -rf "./library" - - name: Setup JDK 17 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: + distribution: temurin java-version: 17 - - name: Setup Android SDK - uses: android-actions/setup-android@v2 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Generate Dokka run: | cd $GITHUB_WORKSPACE/src/ chmod +x gradlew - ./gradlew app:dokkaHtml + ./gradlew docs:dokkaGeneratePublicationHtml - name: Copy Dokka - run: | - cp -r $GITHUB_WORKSPACE/src/app/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ + run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ - name: Push builds run: | diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml deleted file mode 100644 index 108cec82e..000000000 --- a/.github/workflows/issue_action.yml +++ /dev/null @@ -1,88 +0,0 @@ -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@v1 - 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@v6 - 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@v2 - - 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@v6 - 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 58009a7a7..b5b17ba6a 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -8,29 +8,36 @@ on: - '*.json' - '**/wcokey.txt' -concurrency: +concurrency: group: "pre-release" cancel-in-progress: true +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - - uses: actions/checkout@v2 + + - uses: actions/checkout@v6 + - name: Set up JDK 17 - uses: actions/setup-java@v2 + uses: actions/setup-java@v5 with: - java-version: '17' - distribution: 'adopt' + distribution: temurin + java-version: 17 + - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Fetch keystore id: fetch_keystore run: | @@ -41,17 +48,25 @@ jobs: KEY_PWD="$(cat keystore_password.txt)" echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Run Gradle - run: | - ./gradlew assemblePrerelease makeJar androidSourcesJar + run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} + MDL_API_KEY: ${{ secrets.MDL_API_KEY }} + - name: Create pre-release - 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 b6177710d..8f5c62866 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,22 +2,35 @@ name: Artifact Build on: [pull_request] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + - name: Set up JDK 17 - uses: actions/setup-java@v2 + uses: actions/setup-java@v5 with: - java-version: '17' - distribution: 'adopt' + distribution: temurin + java-version: 17 + - name: Grant execute permission for gradlew run: chmod +x gradlew + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + cache-read-only: false + - name: Run Gradle - run: ./gradlew assemblePrereleaseDebug + run: ./gradlew assemblePrereleaseDebug lint check + - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v7 with: name: pull-request-build path: "app/build/outputs/apk/prerelease/debug/*.apk" diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml index 628e9bc92..0a538d5d4 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -1,37 +1,41 @@ name: Fix locale issues on: - workflow_dispatch: push: + branches: [ master ] paths: - '**.xml' - branches: - - master + workflow_dispatch: -concurrency: +concurrency: group: "locale" cancel-in-progress: true +permissions: + contents: read + jobs: create: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream" - - uses: actions/checkout@v2 + + - uses: actions/checkout@v6 with: token: ${{ steps.generate_token.outputs.token }} + - name: Install dependencies - run: | - pip3 install lxml + run: pip3 install lxml requests + - name: Edit files - run: | - python3 .github/locales.py + run: python3 .github/locales.py + - name: Commit to the repo run: | git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" diff --git a/.gitignore b/.gitignore index 2ac6c9695..5fc9f0870 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -*.iml -.gradle /local.properties /.idea/caches /.idea/misc.xml @@ -11,6 +9,220 @@ .DS_Store /build /captures -.externalNativeBuild .cxx +.kotlin/* + +# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode + +### Android ### +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. + +### Java ### +# Compiled class file +*.class + +# Log file + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Kotlin ### +# Compiled class file + +# Log file + +# BlueJ files + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml + +### VisualStudioCode ### +.vscode/* + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +.navigation/ +*.ipr +*~ +*.swp + +# Keystore files + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# NDK +obj/ + +# IntelliJ IDEA +*.iws +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/workspace.xml +.idea/tasks.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml +.idea/assetWizardSettings.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/navEditor.xml + +# Legacy Eclipse project files +.classpath +.project +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 1eb497a93..000000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -CloudStream \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 7643783a8..000000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123c2..000000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b589d56e9..000000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml deleted file mode 100644 index d8e956166..000000000 --- a/.idea/discord.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index a8a2961a1..000000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index 333d49373..000000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfb..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7282979ad..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "githubPullRequests.ignoredPullRequestBranches": [ - "master" - ], - "java.configuration.updateBuildConfiguration": "interactive" -} \ No newline at end of file diff --git a/AI-POLICY.md b/AI-POLICY.md new file mode 100644 index 000000000..5409393fb --- /dev/null +++ b/AI-POLICY.md @@ -0,0 +1,11 @@ +# AI Policy + +AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions. + +1. Always state any AI usage in pull requests and issues. + +2. Always test code before making a pull request. We do not want to test your AI generated code. + +3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI. + +4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions. diff --git a/README.md b/README.md index 8949304e9..c2492c5d8 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,46 @@ # CloudStream -**⚠️ Warning: By default this app doesn't provide any video sources, you have to install extensions in order to add functionality to the app.** - +**⚠️ Warning: By default, this app doesn't provide any video sources; you have to install extensions to add functionality to the app.** [![Discord](https://invidget.switchblade.xyz/5Hus6fM)](https://discord.gg/5Hus6fM) -### Features: + +## Table of Contents: ++ [About Us:](#about_us) ++ [Installation Steps:](#install_rules) ++ [Contributing:](#contributing) ++ [Issues:](#issues) + + [Bugs Reports:](#bug_report) + + [Enhancement:](#enhancment) ++ [Extension Development:](#extensions) ++ [Language Support:](#languages) ++ [Further Sources](#contact_and_sources) + + + + +## About us: + +**CloudStream is a media center that prioritizes and emphasizes complete freedom and flexibility for users and developers.** + +CloudStream is an extension-based multimedia player with tracking support. There are extensions to view videos from: + ++ [Librevox (audio-books)](https://librivox.org/) ++ [Youtube](https://www.youtube.com/) ++ [Twitch](https://www.twitch.tv/) ++ [iptv-org (A collection of publicly available IPTV (Internet Protocol television) channels from all over the world.)](https://github.com/iptv-org/iptv) ++ [nginx](https://nginx.org/) ++ And more... + + +**Please don't create illegal extensions or use any that host any copyrighted media.** For more details about our stance on the DMCA and EUCD, you can read about it on our organization: [reCloudStream](https://github.com/recloudstream) + +#### Important Copyright Note: + +Our documentation is unmaintained and open to contributions; therefore, apps and sources, extensions in recommended sources, and recommended apps are not officially moderated or endorsed by CloudStream; if you or another copyright owner identify an extension that breaches your copyright, please let us know. + + +#### Features: + **AdFree**, No ads whatsoever + No tracking/analytics + Bookmarks @@ -13,7 +48,64 @@ + Chromecast + Extension system for personal customization + + + +## Installation: + +Our documentation provides the steps to install and configure CloudStream for your streaming needs. + +[Getting Started With CloudStream:](https://recloudstream.github.io/csdocs/) + + + +## Contributing: +We **happily** accept any contributions to our project. To find out where you can start contributing towards the project, please look [at our issues tab](/cloudstream/issues) + + + + + +### Issues: +While we **actively** accept issues and pull requests, we do require you fill out an [template](https://github.com/recloudstream/cloudstream/issues/new/choose) for issues. These include the following: + + + +- [Bug Report Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=bug&projects=&template=application-bug.yml) + - For bug reports, we want as much info as possible, including your downloaded version of CloudeStream, device and updated version (if possible, current API), + expected behavior of the program, and the actual behavior that the program did, most importantly we require clear, reproducible steps of the bug. If your bug can't be reproduced, it is unlikely we'll work on your issue. + + + +- [Feature Request Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yml) + - Before adding a feature request, please check to see if a feature request already has been requested. + + +### Extensions: + +**Further details on creating extensions for CloudStream are found in our documentation.** + +[Guide: For Extension Developers](https://recloudstream.github.io/csdocs/devs/gettingstarted/) + + + +## Further Sources: + +As well as providing clear install steps, our [website](https://dweb.link/ipns/cloudstream.on.fleek.co/) includes a wide variety of other tools, such as: +- [Troubleshooting](https://recloudstream.github.io/csdocs/troubleshooting/) +- [Further CloudStream Repositories](https://recloudstream.github.io/csdocs/repositories/) +- Set-Up for other devices, such as: + - [Android TV](https://recloudstream.github.io/csdocs/other-devices/tv/) + - [Windows](https://recloudstream.github.io/csdocs/other-devices/windows/) + - [Linux](https://recloudstream.github.io/csdocs/other-devices/linux/) +- And more... + + + ### Supported languages: + +Even if you can't contribute to the code or documentation, we always look for those who can contribute to translation and language support. Your contribution is exceptionally appreciated; you can check our translation from the figure below. + Translation status diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt deleted file mode 100644 index 7f7fd14c1..000000000 --- a/app/CMakeLists.txt +++ /dev/null @@ -1,6 +0,0 @@ -# Set this to the minimum version your project supports. -cmake_minimum_required(VERSION 3.18) -project(CrashHandler) -find_library(log-lib log) -add_library(native-lib SHARED src/main/cpp/native-lib.cpp) -target_link_libraries(native-lib ${log-lib}) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dfd2c1734..6c784f3ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,47 +1,96 @@ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties -import org.jetbrains.dokka.gradle.DokkaTask -import java.io.ByteArrayOutputStream -import java.net.URL +import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform +import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier +import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { - id("com.android.application") - id("kotlin-android") - id("kotlin-kapt") - id("org.jetbrains.dokka") + alias(libs.plugins.android.application) + alias(libs.plugins.dokka) + alias(libs.plugins.kotlin.serialization) } -val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" -val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() +val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) -fun String.execute() = ByteArrayOutputStream().use { baot -> - if (project.exec { - workingDir = projectDir - commandLine = this@execute.split(Regex("\\s")) - standardOutput = baot - }.exitValue == 0) - String(baot.toByteArray()).trim() - else null +abstract class GenerateGitHashTask : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val headFile: RegularFileProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val headsDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val head = headFile.get().asFile + + val hash = try { + if (head.exists()) { + // Read the commit hash from .git/HEAD + val headContent = head.readText().trim() + if (headContent.startsWith("ref:")) { + val refPath = headContent.substring(5) // e.g., refs/heads/main + val commitFile = File(head.parentFile, refPath) + if (commitFile.exists()) commitFile.readText().trim() else "" + } else headContent // If it's a detached HEAD (commit hash directly) + } else "" // If .git/HEAD doesn't exist + } catch (_: Throwable) { + "" // Just set to an empty string if any exception occurs + }.take(7) // Get the short commit hash + + val outFile = outputDir.file("git-hash.txt").get().asFile + outFile.parentFile.mkdirs() + outFile.writeText(hash) + } +} + +val generateGitHash = tasks.register("generateGitHash") { + val gitDir = layout.projectDirectory.dir("../.git") + + headFile.set(gitDir.file("HEAD")) + headsDir.set(gitDir.dir("refs/heads")) + + outputDir.set(layout.buildDirectory.dir("generated/git")) } android { + @Suppress("UnstableApiUsage") testOptions { unitTests.isReturnDefaultValues = true } - viewBinding { - enable = true + // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491 + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false } - externalNativeBuild { - cmake { - path("CMakeLists.txt") + androidComponents { + onVariants { variant -> + variant.sources.assets?.addGeneratedSourceDirectory( + generateGitHash, + GenerateGitHashTask::outputDir + ) } } signingConfigs { - create("prerelease") { - if (prereleaseStoreFile != null) { - storeFile = file(prereleaseStoreFile) + // We just use SIGNING_KEY_ALIAS here since it won't change + // so won't kill the configuration cache. + if (System.getenv("SIGNING_KEY_ALIAS") != null) { + create("prerelease") { + val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" + val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() + + storeFile = prereleaseStoreFile?.let { file(it) } storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") @@ -49,28 +98,24 @@ android { } } - compileSdk = 33 - buildToolsVersion = "30.0.3" + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.lagradost.cloudstream3" - minSdk = 21 - targetSdk = 29 + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = libs.versions.versionCode.get().toInt() + versionName = libs.versions.versionName.get() - versionCode = 59 - versionName = "4.1.7" - - resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") - resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") - resValue("bool", "is_prerelease", "false") + manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() // Reads local.properties - val localProperties = gradleLocalProperties(rootDir) + val localProperties = gradleLocalProperties(rootDir, project.providers) buildConfigField( - "String", - "BUILDDATE", - "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));" + "long", + "BUILD_DATE", + "${System.currentTimeMillis()}" ) buildConfigField( "String", @@ -83,10 +128,6 @@ android { "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - kapt { - includeCompileClasspath = true - } } buildTypes { @@ -108,184 +149,195 @@ android { ) } } + flavorDimensions.add("state") 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" - signingConfig = signingConfigs.getByName("prerelease") + if (signingConfigs.names.contains("prerelease")) { + signingConfig = signingConfigs.getByName("prerelease") + } else { + logger.warn("No prerelease signing config!") + } versionNameSuffix = "-PRE" versionCode = (System.currentTimeMillis() / 60000).toInt() } } - //toolchain { - // languageVersion.set(JavaLanguageVersion.of(17)) - // } - // jvmToolchain(17) compileOptions { isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.toVersion(javaTarget.target) + targetCompatibility = JavaVersion.toVersion(javaTarget.target) + } - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - freeCompilerArgs = listOf("-Xjvm-default=compatibility") + 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" } -repositories { - maven("https://jitpack.io") -} - dependencies { - implementation("com.google.android.mediahome:video:1.0.0") - implementation("androidx.test.ext:junit-ktx:1.1.5") - testImplementation("org.json:json:20180813") + // Testing + testImplementation(libs.junit) + testImplementation(libs.json) + androidTestImplementation(libs.core) + androidTestImplementation(libs.classgraph) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.instancio.core) + androidTestImplementation(libs.junit.ktx) + androidTestImplementation(libs.kotlin.test) - implementation("androidx.core:core-ktx:1.10.1") - implementation("androidx.appcompat:appcompat:1.6.1") // need target 32 for 1.5.0 + // 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 - // dont change this to 1.6.0 it looks ugly af - implementation("com.google.android.material:material:1.5.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.navigation:navigation-fragment-ktx:2.6.0") - implementation("androidx.navigation:navigation-ui-ktx:2.6.0") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation("androidx.test:core") + // Design & UI + implementation(libs.preference.ktx) + implementation(libs.material) + implementation(libs.constraintlayout) - //implementation("io.karn:khttp-android:0.1.2") //okhttp instead - // implementation("org.jsoup:jsoup:1.13.1") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") + // Coil Image Loading + implementation(libs.bundles.coil) - implementation("androidx.preference:preference-ktx:1.2.0") + // Media 3 (ExoPlayer) + implementation(libs.bundles.media3) + implementation(libs.video) - implementation("com.github.bumptech.glide:glide:4.13.1") - kapt("com.github.bumptech.glide:compiler:4.13.1") - implementation("com.github.bumptech.glide:okhttp3-integration:4.13.0") + // FFmpeg Decoding + implementation(libs.bundles.nextlib) - implementation("jp.wasabeef:glide-transformations:4.3.0") + // Anime-db for filler + implementation(libs.anime.db) - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + // PlayBack + implementation(libs.colorpicker) // Subtitle Color Picker + implementation(libs.newpipeextractor) // For Trailers + implementation(libs.juniversalchardet) // Subtitle Decoding - // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09") + // UI Stuff + implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton) + implementation(libs.palette.ktx) // Palette for Images -> Colors + implementation(libs.tvprovider) + implementation(libs.overlappingpanels) // Gestures + implementation(libs.biometric) // Fingerprint Authentication + implementation(libs.previewseekbar.media3) // SeekBar Preview + implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV - // Media 3 - implementation("androidx.media3:media3-common:1.1.1") - implementation("androidx.media3:media3-exoplayer:1.1.1") - implementation("androidx.media3:media3-datasource-okhttp:1.1.1") - implementation("androidx.media3:media3-ui:1.1.1") - implementation("androidx.media3:media3-session:1.1.1") - implementation("androidx.media3:media3-cast:1.1.1") - implementation("androidx.media3:media3-exoplayer-hls:1.1.1") - implementation("androidx.media3:media3-exoplayer-dash:1.1.1") - // Custom ffmpeg extension for audio codecs - implementation("com.github.recloudstream:media-ffmpeg:1.1.0") + // Extensions & Other Libs + implementation(libs.jsoup) // HTML Parser + implementation(libs.rhino) // Run JavaScript + implementation(libs.safefile) // To Prevent the URI File Fu*kery + coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor + implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 + implementation(libs.jackson.module.kotlin) // JSON Parser + implementation(libs.zipline) - //implementation("com.google.android.exoplayer:extension-leanback:2.14.0") - - // Bug reports - implementation("ch.acra:acra-core:5.11.0") - implementation("ch.acra:acra-toast:5.11.0") - - compileOnly("com.google.auto.service:auto-service-annotations:1.0") - //either for java sources: - annotationProcessor("com.google.auto.service:auto-service:1.0") - //or for kotlin sources (requires kapt gradle plugin): - kapt("com.google.auto.service:auto-service:1.0") - - // subtitle color picker - implementation("com.jaredrummler:colorpicker:1.1.0") - - //run JS - // do not upgrade to 1.7.14, since in 1.7.14 Rhino uses the `SourceVersion` class, which is not - // available on Android (even when using desugaring), and `NoClassDefFoundError` is thrown - implementation("org.mozilla:rhino:1.7.13") - - // TorrentStream - //implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0") - - // Downloading - implementation("androidx.work:work-runtime:2.8.1") - implementation("androidx.work:work-runtime-ktx:2.8.1") - - // Networking - // implementation("com.squareup.okhttp3:okhttp:4.9.2") - // implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1") - implementation("com.github.Blatzar:NiceHttp:0.4.3") - // To fix SSL fuckery on android 9 - implementation("org.conscrypt:conscrypt-android:2.2.1") - // Util to skip the URI file fuckery 🙏 - implementation("com.github.tachiyomiorg:unifile:17bec43") - - // API because cba maintaining it myself - implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0") - - implementation("com.github.discord:OverlappingPanels:0.1.5") - // debugImplementation because LeakCanary should only run in debug builds. - //debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12") - - // for shimmer when loading - implementation("com.facebook.shimmer:shimmer:0.5.0") - - implementation("androidx.tvprovider:tvprovider:1.0.0") - - // used for subtitle decoding https://github.com/albfernandez/juniversalchardet - implementation("com.github.albfernandez:juniversalchardet:2.4.0") - - // newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L204 - // this should be updated frequently to avoid trailer fu*kery - implementation("com.github.TeamNewPipe:NewPipeExtractor:1f08d28") - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") - - // Library/extensions searching with Levenshtein distance + // Deprecated; will be removed once extensions have time to migrate from using it implementation("me.xdrop:fuzzywuzzy:1.4.0") - // color palette for images -> colors - implementation("androidx.palette:palette-ktx:1.0.0") + // Torrent Support + implementation(libs.torrentserver) + + // Downloading & Networking + implementation(libs.work.runtime.ktx) + implementation(libs.nicehttp) // HTTP Lib + + implementation(project(":library")) } -tasks.register("androidSourcesJar", Jar::class) { +tasks.register("androidSourcesJar") { archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.srcDirs) //full sources + from(android.sourceSets.getByName("main").java.directories) // Full Sources } -// this is used by the gradlew plugin -tasks.register("makeJar", Copy::class) { - from("build/intermediates/compile_app_classes_jar/prereleaseDebug") - into("build") - include("classes.jar") - dependsOn("build") +tasks.register("copyJar") { + dependsOn("build", ":library:jvmJar") + from( + "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", + "../library/build/libs" + ) + into("build/app-classes") + include("classes.jar", "library-jvm*.jar") + // Remove the version + rename("library-jvm.*.jar", "library-jvm.jar") } -tasks.withType().configureEach { - moduleName.set("Cloudstream") +// Merge the app classes and the library classes into classes.jar +tasks.register("makeJar") { + // Duplicates cause hard to catch errors, better to fail at compile time. + duplicatesStrategy = DuplicatesStrategy.FAIL + dependsOn(tasks.getByName("copyJar")) + from( + zipTree("build/app-classes/classes.jar"), + zipTree("build/app-classes/library-jvm.jar") + ) + destinationDirectory.set(layout.buildDirectory) + archiveBaseName = "classes" +} + +tasks.withType { + compilerOptions { + jvmTarget.set(javaTarget) + jvmDefault.set(JvmDefaultMode.ENABLE) + freeCompilerArgs.add("-Xannotation-default-target=param-property") + optIn.addAll( + "com.lagradost.cloudstream3.InternalAPI", + "com.lagradost.cloudstream3.Prerelease", + "kotlin.uuid.ExperimentalUuidApi", + ) + } +} + +dokka { + moduleName = "App" dokkaSourceSets { - named("main") { - sourceLink { - // Unix based directory relative path to the root of the project (where you execute gradle respectively). - localDirectory.set(file("src/main/java")) + configureEach { + suppress = name != "prereleaseDebug" + analysisPlatform = KotlinPlatform.JVM + displayName = "JVM" + documentedVisibilities( + VisibilityModifier.Public, + VisibilityModifier.Protected + ) - // URL showing where the source code can be accessed through the web browser - remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java")) - // Suffix which is used to append the line number to the URL. Use #L for GitHub - remoteLineSuffix.set("#L") + sourceLink { + localDirectory = file("..") + remoteUrl("https://github.com/recloudstream/cloudstream/tree/master") + remoteLineSuffix = "#L" } } } diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 000000000..b2f5e8f2b --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index df41ef91f..4c5cdea5b 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -7,8 +7,11 @@ 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 +import com.lagradost.cloudstream3.databinding.FragmentLibraryTvBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding import com.lagradost.cloudstream3.databinding.FragmentResultBinding @@ -17,6 +20,7 @@ import com.lagradost.cloudstream3.databinding.FragmentSearchBinding import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding import com.lagradost.cloudstream3.databinding.HomeResultGridBinding import com.lagradost.cloudstream3.databinding.HomepageParentBinding +import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding @@ -85,6 +89,8 @@ 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) @@ -117,9 +123,12 @@ class ExampleInstrumentedTest { // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv) // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv) - testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent) - testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent) + testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent) + testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent) + testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent) + testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library) + testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library) } } } @@ -127,14 +136,14 @@ class ExampleInstrumentedTest { @Test @Throws(AssertionError::class) fun providerCorrectData() { - val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 } - Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty()) + val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag } + Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty()) for (api in getAllProviders()) { Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE") Assert.assertTrue("Api does not contain a name", api.name != "NONE") Assert.assertTrue( "Api ${api.name} does not contain a valid language code", - isoNames.contains(api.lang) + langTagsIETF.contains(api.lang) ) Assert.assertTrue( "Api ${api.name} does not contain any supported types", @@ -148,7 +157,7 @@ class ExampleInstrumentedTest { fun providerCorrectHomepage() { runBlocking { getAllProviders().toList().amap { api -> - TestingUtils.testHomepage(api, ::println) + TestingUtils.testHomepage(api, TestingUtils.Logger()) } } println("Done providerCorrectHomepage") @@ -160,7 +169,6 @@ class ExampleInstrumentedTest { TestingUtils.getDeferredProviderTests( this, getAllProviders(), - ::println ) { _, _ -> } } } diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt new file mode 100644 index 000000000..d1a11e003 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt @@ -0,0 +1,135 @@ +package com.lagradost.cloudstream3 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import dalvik.system.DexFile +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import kotlinx.serialization.serializerOrNull +import org.instancio.Instancio +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmName +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +class SerializationClassTester { + // Same as app, or using app reference + val jacksonMapper = mapper + val kotlinxMapper = json + + @Test + fun isIdenticalSerialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + + val jacksonJson = jacksonMapper.writeValueAsString(instance) + val kotlinxJson = serializeWithKotlinx(kClass, instance) + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical serialization for: ${kClass.jvmName}") + } + } + + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + @Test + fun isIdenticalDeserialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + // Convert to JSON to get example JSON object + // We prefer jackson here because the app may have many jackson JSON strings in local storage + val originalJson = jacksonMapper.writeValueAsString(instance) + + // Create an object from the JSON using kotlinx + val serializer = + kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass) + assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!") + val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson) + + // Create an object from the JSON using jackson + val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java) + + + // Deep inspect both object using the mapper toJson function. + // This deep equality check can be performed using other methods, but this just works. + val jacksonJson = mapperDecoded.toJson() + val kotlinxJson = kotlinxDecoded.toJson() + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical deserialization for: ${kClass.jvmName}") + } + } + + // DEX files are the best solution to read all our classes dynamically. + // ClassGraph() can be used instead, but it only gives results on the JVM, not Android. + @Suppress("DEPRECATION") + private fun findSerializableClasses(packageName: String): List> { + val context = InstrumentationRegistry + .getInstrumentation() + .targetContext + + val dexFile = DexFile(context.packageCodePath) + + return dexFile.entries() + .toList() + .filter { it.startsWith(packageName) } + .mapNotNull { + runCatching { Class.forName(it).kotlin }.getOrNull() + }.filter { kClass -> + // Not possible to use .hasAnnotation() on newer Android versions. + kClass.java.annotations.any { + it is Serializable + } + } + } + + @OptIn(InternalSerializationApi::class) + @Suppress("UNCHECKED_CAST") + private fun serializeWithKotlinx( + kClass: KClass<*>, + value: Any + ): String { + val serializer = kClass.serializer() as KSerializer + return kotlinxMapper.encodeToString(serializer, value) + } +} diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt new file mode 100644 index 000000000..15ad532f8 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt @@ -0,0 +1,157 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KeepGeneratedSerializer +import kotlinx.serialization.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = NonEmptyData.Serializer::class) +data class NonEmptyData( + val title: String = "", + val tags: List = emptyList(), + val meta: Map = emptyMap(), + val name: String = "hello", +) { + object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) +} + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = WriteOnlyData.Serializer::class) +data class WriteOnlyData( + val fieldA: String = "", + val fieldB: String = "", +) { + object Serializer : WriteOnlySerializer( + WriteOnlyData.generatedSerializer(), + setOf("fieldB"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = MultiWriteOnly.Serializer::class) +data class MultiWriteOnly( + val fieldA: String = "", + val fieldB: String = "", + val fieldC: String = "", +) { + object Serializer : WriteOnlySerializer( + MultiWriteOnly.generatedSerializer(), + setOf("fieldB", "fieldC"), + ) +} + +@Serializable +data class UriData( + @Serializable(with = UriSerializer::class) + val uri: Uri = Uri.EMPTY, +) + +class SerializerTest { + + @Test + fun nonEmptySerializerOmitsEmptyStrings() { + val data = NonEmptyData(title = "", name = "hello") + val result = data.toJson() + assertFalse(result.contains("title")) + assertTrue(result.contains("name")) + } + + @Test + fun nonEmptySerializerOmitsEmptyLists() { + val data = NonEmptyData(tags = emptyList(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("tags")) + } + + @Test + fun nonEmptySerializerOmitsEmptyMaps() { + val data = NonEmptyData(meta = emptyMap(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("meta")) + } + + @Test + fun nonEmptySerializerKeepsNonEmptyFields() { + val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) + val result = data.toJson() + assertTrue(result.contains("title")) + assertTrue(result.contains("tags")) + assertTrue(result.contains("meta")) + } + + @Test + fun nonEmptySerializerDoesNotAffectDeserialization() { + val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + assertEquals(listOf("a"), result.tags) + assertEquals(mapOf("k" to "v"), result.meta) + assertEquals("world", result.name) + } + + @Test + fun writeOnlySerializerOmitsFieldOnSerialize() { + val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + } + + @Test + fun writeOnlySerializerDeserializesNormally() { + val input = """{"fieldA":"hello","fieldB":"secret"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("secret", result.fieldB) + } + + @Test + fun writeOnlySerializerDeserializesMissingAsDefault() { + val input = """{"fieldA":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("", result.fieldB) + } + + @Test + fun writeOnlySerializerHandlesMultipleKeys() { + val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + assertFalse(result.contains("fieldC")) + } + + @Test + fun uriSerializerSerializesUriToString() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val result = data.toJson() + assertTrue(result.contains("https://example.com/path?query=1")) + } + + @Test + fun uriSerializerDeserializesStringToUri() { + val input = """{"uri":"https://example.com/path?query=1"}""" + val result = parseJson(input) + assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) + } + + @Test + fun uriSerializerRoundtripsCorrectly() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data.uri, decoded.uri) + } +} diff --git a/app/src/debug/res/drawable-v24/ic_banner_background.xml b/app/src/debug/res/drawable-v24/ic_banner_background.xml index 7b05b7111..caed023d5 100644 --- a/app/src/debug/res/drawable-v24/ic_banner_background.xml +++ b/app/src/debug/res/drawable-v24/ic_banner_background.xml @@ -25,9 +25,8 @@ android:endY="245.72" android:endX="292.58" android:type="linear"> - - - + + @@ -40,9 +39,8 @@ android:endY="245.72" android:endX="248.76" android:type="linear"> - - - + + @@ -55,46 +53,45 @@ android:endY="245.69" android:endX="210.03" android:type="linear"> - - - + + + android:fillColor="#39A11D"/> + android:fillColor="#39A11D"/> + android:fillColor="#39A11D"/> + android:fillColor="#39A11D"/> + android:fillColor="#39A11D"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> @@ -104,9 +101,9 @@ android:endY="252.3" android:endX="373.57" android:type="linear"> - - - + + + @@ -117,9 +114,9 @@ android:startX="400.11" android:endX="900" android:type="linear"> - - - + + + @@ -132,9 +129,9 @@ android:endY="252.3" android:endX="373.57" android:type="linear"> - - - + + + @@ -145,9 +142,9 @@ android:startX="700.11" android:endX="900.57" android:type="linear"> - - - + + + @@ -158,9 +155,9 @@ android:startX="400.11" android:endX="800.57" android:type="linear"> - - - + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 15767d7b6..ee4c978f2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,16 +6,63 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + tools:targetApi="${target_sdk_version}"> + android:supportsPictureInPicture="true" + android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer" + android:launchMode="singleTask" + tools:ignore="DiscouragedApi"> @@ -79,25 +125,55 @@ + + + + + + + + + + + + android:supportsPictureInPicture="true" /> + + + + + + @@ -124,7 +200,14 @@ + + + + + + + @@ -148,7 +231,7 @@ - + @@ -161,15 +244,11 @@ - - - + android:exported="false"> + @@ -177,14 +256,28 @@ + + + + + - - \ No newline at end of file + + diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp deleted file mode 100644 index f4cb531fa..000000000 --- a/app/src/main/cpp/native-lib.cpp +++ /dev/null @@ -1,28 +0,0 @@ -#include -#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 5f3162b49..bbe7d97de 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -1,222 +1,78 @@ package com.lagradost.cloudstream3 -import android.app.Activity -import android.app.Application -import android.content.Context -import android.content.ContextWrapper -import android.content.Intent -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import com.google.auto.service.AutoService -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.openBrowser -import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.DataStore.removeKeys -import com.lagradost.cloudstream3.utils.DataStore.setKey -import kotlinx.coroutines.runBlocking -import org.acra.ACRA -import org.acra.ReportField -import org.acra.config.CoreConfiguration -import org.acra.data.CrashReportData -import org.acra.data.StringFormat -import org.acra.ktx.initAcra -import org.acra.sender.ReportSender -import org.acra.sender.ReportSenderFactory -import java.io.File -import java.io.FileNotFoundException -import java.io.PrintStream -import java.lang.Exception -import java.lang.ref.WeakReference -import kotlin.concurrent.thread -import kotlin.system.exitProcess +/** + * Deprecated alias for CloudStreamApp for backwards compatibility with plugins. + * Use CloudStreamApp instead. + */ +@Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), + level = DeprecationLevel.WARNING +) +class AcraApplication { + companion object { + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), + level = DeprecationLevel.WARNING + ) + val context get() = CloudStreamApp.context -class CustomReportSender : ReportSender { - // Sends all your crashes to google forms - override fun send(context: Context, errorContent: CrashReportData) { - println("Sending report") - val url = - "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse" - val data = mapOf( - "entry.1993829403" to errorContent.toJSON() - ) + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), + level = DeprecationLevel.WARNING + ) + fun removeKeys(folder: String): Int? = + CloudStreamApp.removeKeys(folder) - thread { // to not run it on main thread - runBlocking { - suspendSafeApiCall { - app.post(url, data = data) - //println("Report response: $post") - } - } - } + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), + level = DeprecationLevel.WARNING + ) + fun setKey(path: String, value: T) = + CloudStreamApp.setKey(path, value) - runOnMainThread { // to run it on main looper - normalSafeApiCall { - Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show() - } - } - } -} - -@AutoService(ReportSenderFactory::class) -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(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")) - ps.println( - String.format( - "Fatal exception on thread %s (%d)", - thread.name, - thread.id - ) - ) - error.printStackTrace(ps) - } - } catch (ignored: FileNotFoundException) { - } - try { - onError.invoke() - } catch (ignored: Exception) { - } - exitProcess(1) - } - -} - -class AcraApplication : Application() { - - override fun onCreate() { - super.onCreate() - //NativeCrashHandler.initCrashHandler() - ExceptionHandler(filesDir.resolve("last_error")) { - val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) - startActivity(Intent.makeRestartActivityTask(intent!!.component)) - }.also { - exceptionHandler = it - Thread.setDefaultUncaughtExceptionHandler(it) - } - } - - override fun attachBaseContext(base: Context?) { - super.attachBaseContext(base) - context = base - - initAcra { - //core configuration: - buildConfigClass = BuildConfig::class.java - reportFormat = StringFormat.JSON - - reportContent = listOf( - ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE, - ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, - ReportField.STACK_TRACE, - ) - - // removed this due to bug when starting the app, moved it to when it actually crashes - //each plugin you chose above can be configured in a block like this: - /*toast { - text = getString(R.string.acra_report_toast) - //opening this block automatically enables the plugin. - }*/ - } - } - - companion object { - var exceptionHandler: ExceptionHandler? = null - - /** Use to get activity from Context */ - tailrec fun Context.getActivity(): Activity? = this as? Activity - ?: (this as? ContextWrapper)?.baseContext?.getActivity() - - private var _context: WeakReference? = null - var context - get() = _context?.get() - private set(value) { - _context = WeakReference(value) - } - - 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, - isTvSettings(), - activity?.supportFragmentManager?.fragments?.lastOrNull() - ) - } - } + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"), + level = DeprecationLevel.WARNING + ) + fun setKey(folder: String, path: String, value: T) = + CloudStreamApp.setKey(folder, path, value) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(path: String, defVal: T?): T? = + CloudStreamApp.getKey(path, defVal) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(path: String): T? = + CloudStreamApp.getKey(path) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(folder: String, path: String): T? = + CloudStreamApp.getKey(folder, path) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(folder: String, path: String, defVal: T?): T? = + CloudStreamApp.getKey(folder, path, defVal) + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt new file mode 100644 index 000000000..a9cd9c01e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -0,0 +1,181 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import com.lagradost.api.setContext +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser +import com.lagradost.cloudstream3.utils.AppDebug +import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.DataStore.removeKeys +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader +import kotlinx.coroutines.runBlocking +import java.io.File +import java.io.FileNotFoundException +import java.io.PrintStream +import java.lang.ref.WeakReference +import java.util.Locale +import kotlin.concurrent.thread +import kotlin.system.exitProcess + +class ExceptionHandler( + val errorFile: File, + val onError: (() -> Unit) +) : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(thread: Thread, error: Throwable) { + try { + val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + thread.threadId() + } else { + @Suppress("DEPRECATION") + thread.id + } + + PrintStream(errorFile).use { ps -> + ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") + ps.println("Fatal exception on thread ${thread.name} ($threadId)") + error.printStackTrace(ps) + } + } catch (_: FileNotFoundException) { + } + try { + onError() + } catch (_: Exception) { + } + exitProcess(1) + } +} + +class CloudStreamApp : Application(), SingletonImageLoader.Factory { + + override fun onCreate() { + super.onCreate() + // If we want to initialize Coil as early as possible, maybe when + // loading an image or GIF in a splash screen activity. + // buildImageLoader(applicationContext) + + ExceptionHandler(filesDir.resolve("last_error")) { + val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) + startActivity(Intent.makeRestartActivityTask(intent!!.component)) + }.also { + exceptionHandler = it + Thread.setDefaultUncaughtExceptionHandler(it) + } + + AppDebug.isDebug = BuildConfig.DEBUG + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + context = base + } + + override fun newImageLoader(context: PlatformContext): ImageLoader { + // Coil module will be initialized globally when first loadImage() is invoked. + return buildImageLoader(applicationContext) + } + + companion object { + var exceptionHandler: ExceptionHandler? = null + + /** Use to get Activity from Context. */ + tailrec fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + } + + private var _context: WeakReference? = null + var context + get() = _context?.get() + private set(value) { + _context = WeakReference(value) + setContext(WeakReference(value)) + } + + fun getKeyClass(path: String, valueType: Class): T? { + return context?.getKey(path, valueType) + } + + fun setKeyClass(path: String, value: T) { + context?.setKey(path, value) + } + + fun removeKeys(folder: String): Int? { + return context?.removeKeys(folder) + } + + fun setKey(path: String, value: T) { + context?.setKey(path, value) + } + + fun setKey(folder: String, path: String, value: T) { + context?.setKey(folder, path, value) + } + + inline fun getKey(path: String, defVal: T?): T? { + return context?.getKey(path, defVal) + } + + inline fun getKey(path: String): T? { + return context?.getKey(path) + } + + inline fun getKey(folder: String, path: String): T? { + return context?.getKey(folder, path) + } + + inline fun getKey(folder: String, path: String, defVal: T?): T? { + return context?.getKey(folder, path, defVal) + } + + fun getKeys(folder: String): List? { + return context?.getKeys(folder) + } + + fun removeKey(folder: String, path: String) { + context?.removeKey(folder, path) + } + + fun removeKey(path: String) { + context?.removeKey(path) + } + + /** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */ + fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) { + context?.openBrowser(url, fallbackWebView, fragment) + } + + /** Will fall back to WebView if in TV or emulator layout. */ + fun openBrowser(url: String, activity: FragmentActivity?) { + openBrowser( + url, + isLayout(TV or EMULATOR), + activity?.supportFragmentManager?.fragments?.lastOrNull() + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 0bcd41523..4ce09bd44 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -1,16 +1,23 @@ package com.lagradost.cloudstream3 -import android.Manifest +import android.annotation.SuppressLint import android.app.Activity import android.app.PictureInPictureParams import android.content.Context import android.content.pm.PackageManager +import android.content.res.Configuration import android.content.res.Resources +import android.Manifest import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.DisplayMetrics import android.util.Log -import android.view.* +import android.view.Gravity +import android.view.KeyEvent +import android.view.View import android.view.View.NO_ID -import android.widget.TextView +import android.view.ViewGroup import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts @@ -20,27 +27,41 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.children +import androidx.core.view.isNotEmpty import androidx.preference.PreferenceManager import com.google.android.gms.cast.framework.CastSession import com.google.android.material.chip.ChipGroup import com.google.android.material.navigationrail.NavigationRailView -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import com.lagradost.cloudstream3.databinding.ToastBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.player.PlayerEventType -import com.lagradost.cloudstream3.ui.result.ResultFragment -import com.lagradost.cloudstream3.ui.result.UiText -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv -import com.lagradost.cloudstream3.utils.AppUtils.isRtl -import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter +import com.lagradost.cloudstream3.ui.home.ParentItemAdapter +import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible +import com.lagradost.cloudstream3.ui.player.Torrent +import com.lagradost.cloudstream3.ui.result.ActorAdaptor +import com.lagradost.cloudstream3.ui.result.EpisodeAdapter +import com.lagradost.cloudstream3.ui.result.ImageAdapter +import com.lagradost.cloudstream3.ui.search.SearchAdapter +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.updateTv +import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter +import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission -import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode +import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod import com.lagradost.cloudstream3.utils.UIHelper.toPx -import org.schabi.newpipe.extractor.NewPipe +import com.lagradost.cloudstream3.utils.UiText import java.lang.ref.WeakReference -import java.util.* +import java.util.Locale +import kotlin.math.max +import kotlin.math.min +import org.schabi.newpipe.extractor.NewPipe enum class FocusDirection { Start, @@ -58,24 +79,48 @@ object CommonActivity { _activity = WeakReference(value) } + @MainThread + fun setActivityInstance(newActivity: Activity?) { + activity = newActivity + } + @MainThread fun Activity?.getCastSession(): CastSession? { return (this as MainActivity?)?.mSessionManager?.currentCastSession } + val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics - var canEnterPipMode: Boolean = false - var canShowPipMode: Boolean = false + // screenWidth and screenHeight does always + // refer to the screen while in landscape mode + val screenWidth: Int + get() { + return max(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + val screenHeight: Int + 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 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 - - var currentToast: Toast? = null + private var currentToast: Toast? = null fun showToast(@StringRes message: Int, duration: Int? = null) { val act = activity ?: return @@ -131,42 +176,50 @@ object CommonActivity { } catch (e: Exception) { logError(e) } + try { - val inflater = - act.getSystemService(AppCompatActivity.LAYOUT_INFLATER_SERVICE) as LayoutInflater - - val layout: View = inflater.inflate( - R.layout.toast, - act.findViewById(R.id.toast_layout_root) as ViewGroup? - ) - - val text = layout.findViewById(R.id.text) as TextView - text.text = message.trim() + val binding = ToastBinding.inflate(act.layoutInflater) + binding.text.text = message.trim() + // custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11) val toast = Toast(act) - toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) toast.duration = duration ?: Toast.LENGTH_SHORT - toast.view = layout - //https://github.com/PureWriter/ToastCompat - toast.show() + 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. currentToast = toast + toast.show() + + val handler = Handler(Looper.getMainLooper()) + val ref = WeakReference(toast) + + /* Clean up activity leak */ + handler.postDelayed({ + if (ref.get() == currentToast) { + currentToast = null + } + }, 10_000) + } catch (e: Exception) { logError(e) } } /** - * Not all languages can be fetched from locale with a code. - * This map allows sidestepping the default Locale(languageCode) - * when setting the app language. - **/ - val appLanguageExceptions = hashMapOf( - "zh-rTW" to Locale.TRADITIONAL_CHINESE - ) - - fun setLocale(context: Context?, languageCode: String?) { - if (context == null || languageCode == null) return - val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode) + * Set locale + * @param languageTag shall a IETF BCP 47 conformant tag. + * Check [com.lagradost.cloudstream3.utils.SubtitleHelper]. + * + * See locales on: + * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json + * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry + * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml + * https://iso639-3.sil.org/code_tables/639/data/all + */ + fun setLocale(context: Context?, languageTag: String?) { + if (context == null || languageTag == null) return + val locale = Locale.forLanguageTag(languageTag) val resources: Resources = context.resources val config = resources.configuration Locale.setDefault(locale) @@ -174,7 +227,12 @@ object CommonActivity { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) context.createConfigurationContext(config) - resources.updateConfiguration(config, resources.displayMetrics) + + @Suppress("DEPRECATION") + resources.updateConfiguration( + config, + resources.displayMetrics + ) // FIXME this should be replaced } fun Context.updateLocale() { @@ -183,44 +241,38 @@ object CommonActivity { setLocale(this, localeCode) } - fun init(act: ComponentActivity?) { - if (act == null) return - activity = act - //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 - act.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN - act.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS + fun init(act: Activity) { + setActivityInstance(act) + ioSafe { Torrent.deleteAllFiles() } + val componentActivity = activity as? ComponentActivity ?: return - act.updateLocale() - act.updateTv() + componentActivity.updateLocale() + componentActivity.updateTv() + AccountManager.initMainAPI() NewPipe.init(DownloaderTestImpl.getInstance()) - for (resumeApp in resumeApps) { - resumeApp.launcher = - act.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val resultCode = result.resultCode - val data = result.data - if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) { - val pos = resumeApp.getPosition(data) - val dur = resumeApp.getDuration(data) - if (dur > 0L && pos > 0L) - DataStoreHelper.setViewPos(getKey(resumeApp.lastId), pos, dur) - removeKey(resumeApp.lastId) - ResultFragment.updateUI() - } + 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") } - } + } // Ask for notification permissions on Android 13 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( - act, + componentActivity, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { - val requestPermissionLauncher = act.registerForActivityResult( + val requestPermissionLauncher = componentActivity.registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> Log.d(TAG, "Notification permission: $isGranted") @@ -231,17 +283,22 @@ object CommonActivity { } } + /** Enters pip mode if it is both possible and desired to do so*/ private fun Activity.enterPIPMode() { - if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return + if (!isPipDesired || !this.isPIPPossible()) return + try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try { enterPictureInPictureMode(PictureInPictureParams.Builder().build()) - } catch (e: Exception) { + } catch (_: Exception) { + // Use fallback just in case + @Suppress("DEPRECATION") enterPictureInPictureMode() } } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + @Suppress("DEPRECATION") enterPictureInPictureMode() } } @@ -250,9 +307,32 @@ object CommonActivity { } } - fun onUserLeaveHint(act: Activity?) { - if (canEnterPipMode && canShowPipMode) { - act?.enterPIPMode() + fun onUserLeaveHint(act: Activity) { + // On Android 12 and later we use setAutoEnterEnabled() instead. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return + act.enterPIPMode() + } + + fun updateTheme(act: Activity) { + 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 + ) { + loadThemes(act) + } + } + + private fun mapSystemTheme(act: Activity): Int { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val currentNightMode = + act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return when (currentNightMode) { + Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme + else -> R.style.AppTheme // Night mode is active, we're using dark theme + } + } else { + return R.style.AppTheme } } @@ -262,6 +342,7 @@ object CommonActivity { val currentTheme = when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) { + "System" -> mapSystemTheme(act) "Black" -> R.style.AppTheme "Light" -> R.style.LightMode "Amoled" -> R.style.AmoledMode @@ -269,18 +350,25 @@ 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 } val currentOverlayTheme = when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) { "Normal" -> R.style.OverlayPrimaryColorNormal + "DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow "CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink + "Orange" -> R.style.OverlayPrimaryColorOrange "DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen "Maroon" -> R.style.OverlayPrimaryColorMaroon "NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue "Grey" -> R.style.OverlayPrimaryColorGrey "White" -> R.style.OverlayPrimaryColorWhite + "CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue "Brown" -> R.style.OverlayPrimaryColorBrown "Purple" -> R.style.OverlayPrimaryColorPurple "Green" -> R.style.OverlayPrimaryColorGreen @@ -289,6 +377,7 @@ object CommonActivity { "Banana" -> R.style.OverlayPrimaryColorBanana "Party" -> R.style.OverlayPrimaryColorParty "Pink" -> R.style.OverlayPrimaryColorPink + "Lavender" -> R.style.OverlayPrimaryColorLavender "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal @@ -297,9 +386,13 @@ 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 @@ -328,6 +421,13 @@ object CommonActivity { currentLook = currentLook.parent as? View ?: break }*/ + private fun View.hasContent(): Boolean { + return isShown && when (this) { + is ViewGroup -> this.isNotEmpty() + else -> true + } + } + /** skips the initial stage of searching for an id using the view, see getNextFocus for specification */ fun continueGetNextFocus( root: Any?, @@ -348,16 +448,17 @@ object CommonActivity { } ?: return null next = localLook(view, nextId) ?: next + val shown = next.hasContent() // if cant focus but visible then break and let android decide // the exception if is the view is a parent and has children that wants focus val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent -> - parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0 + parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty() } ?: false - if (!next.isFocusable && next.isShown && !hasChildrenThatWantsFocus) return null + if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null // if not shown then continue because we will "skip" over views to get to a replacement - if (!next.isShown) { + if (!shown) { // we don't want a while true loop, so we let android decide if we find a recursive view if (next == view) return null return getNextFocus(root, next, direction, depth + 1) @@ -430,98 +531,8 @@ object CommonActivity { } - fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) { - //println("Keycode: $keyCode") - //showToast( - // this, - // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", - // Toast.LENGTH_LONG - //) - - // Tested keycodes on remote: - // KeyEvent.KEYCODE_MEDIA_FAST_FORWARD - // KeyEvent.KEYCODE_MEDIA_REWIND - // KeyEvent.KEYCODE_MENU - // KeyEvent.KEYCODE_MEDIA_NEXT - // KeyEvent.KEYCODE_MEDIA_PREVIOUS - // KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE - - // 149 keycode_numpad 5 - when (keyCode) { - KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { - PlayerEventType.SeekForward - } - - KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { - PlayerEventType.SeekBack - } - - KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> { - PlayerEventType.NextEpisode - } - - KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> { - PlayerEventType.PrevEpisode - } - - KeyEvent.KEYCODE_MEDIA_PAUSE -> { - PlayerEventType.Pause - } - - KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { - PlayerEventType.Play - } - - KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { - PlayerEventType.Lock - } - - KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> { - PlayerEventType.ToggleHide - } - - KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { - PlayerEventType.ToggleMute - } - - KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { - PlayerEventType.ShowMirrors - } - // OpenSubtitles shortcut - KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { - PlayerEventType.SearchSubtitlesOnline - } - - KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { - PlayerEventType.ShowSpeed - } - - KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { - PlayerEventType.Resize - } - - KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { - PlayerEventType.SkipOp - } - - KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { - PlayerEventType.SkipCurrentChapter - } - - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation - PlayerEventType.PlayPauseToggle - } - - else -> null - }?.let { playerEvent -> - playerEventListener?.invoke(playerEvent) - } - - //when (keyCode) { - // KeyEvent.KEYCODE_DPAD_CENTER -> { - // println("DPAD PRESSED") - // } - //} + fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? { + return null } /** overrides focus and custom key events */ @@ -558,6 +569,7 @@ object CommonActivity { else -> null } + // println("NEXT FOCUS : $nextView") if (nextView != null) { nextView.requestFocus() @@ -565,10 +577,15 @@ object CommonActivity { return true } - if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && + // 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) && (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) ) { - UIHelper.showInputMethod(act.currentFocus?.findFocus()) + showInputMethod(act.currentFocus?.findFocus()) } //println("Keycode: $keyCode") @@ -577,7 +594,6 @@ object CommonActivity { // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", // Toast.LENGTH_LONG //) - } // if someone else want to override the focus then don't handle the event as it is already diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt index 0a2db2bd4..8da7ca384 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3 import okhttp3.OkHttpClient import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Request import org.schabi.newpipe.extractor.downloader.Response @@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() { - private val client: OkHttpClient + private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build() override fun execute(request: Request): Response { val httpMethod: String = request.httpMethod() val url: String = request.url() @@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do val dataToSend: ByteArray? = request.dataToSend() var requestBody: RequestBody? = null if (dataToSend != null) { - requestBody = RequestBody.create(null, dataToSend) + requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size) } val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() .method(httpMethod, requestBody).url(url) @@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do return instance } } - - init { - client = builder.readTimeout(30, TimeUnit.SECONDS).build() - } } \ 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 deleted file mode 100644 index 045a7963a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.lagradost.cloudstream3 - -import android.view.LayoutInflater -import androidx.annotation.LayoutRes -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.ui.HeaderViewDecoration - -fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) { - val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null) - view.addItemDecoration(HeaderViewDecoration(headerView)) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt deleted file mode 100644 index 803324454..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt +++ /dev/null @@ -1,1782 +0,0 @@ -package com.lagradost.cloudstream3 - -import android.annotation.SuppressLint -import android.content.Context -import android.net.Uri -import android.util.Base64.encodeToString -import androidx.annotation.WorkerThread -import androidx.preference.PreferenceManager -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.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi -import com.lagradost.cloudstream3.syncproviders.SyncIdName -import com.lagradost.cloudstream3.syncproviders.providers.SimklApi -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.Coroutines.mainWork -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf -import okhttp3.Interceptor -import org.mozilla.javascript.Scriptable -import java.text.SimpleDateFormat -import java.util.* -import kotlin.math.absoluteValue - -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" - -//val baseHeader = mapOf("User-Agent" to USER_AGENT) -val mapper = JsonMapper.builder().addModule(KotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!! - -/** - * Defines the constant for the all languages preference, if this is set then it is - * the equivalent of all languages being set - **/ -const val AllLanguagesName = "universal" - -object APIHolder { - val unixTime: Long - get() = System.currentTimeMillis() / 1000L - val unixTimeMS: Long - get() = System.currentTimeMillis() - - private const val defProvider = 0 - - // ConcurrentModificationException is possible!!! - val allProviders = threadSafeListOf() - - fun initAll() { - synchronized(allProviders) { - for (api in allProviders) { - api.init() - } - } - apiMap = null - } - - fun String.capitalize(): String { - return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } - - var apis: List = threadSafeListOf() - var apiMap: Map? = null - - fun addPluginMapping(plugin: MainAPI) { - synchronized(apis) { - apis = apis + plugin - } - initMap(true) - } - - fun removePluginMapping(plugin: MainAPI) { - synchronized(apis) { - apis = apis.filter { it != plugin } - } - initMap(true) - } - - private fun initMap(forcedUpdate: Boolean = false) { - synchronized(apis) { - if (apiMap == null || forcedUpdate) - apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap() - } - } - - fun getApiFromNameNull(apiName: String?): MainAPI? { - if (apiName == null) return null - synchronized(allProviders) { - initMap() - synchronized(apis) { - return apiMap?.get(apiName)?.let { apis.getOrNull(it) } - // Leave the ?. null check, it can crash regardless - ?: allProviders.firstOrNull { it.name == apiName } - } - } - } - - fun getApiFromUrlNull(url: String?): MainAPI? { - if (url == null) return null - synchronized(allProviders) { - allProviders.forEach { api -> - if (url.startsWith(api.mainUrl)) return api - } - } - return null - } - - private fun getLoadResponseIdFromUrl(url: String, apiName: String): Int { - return url.replace(getApiFromNameNull(apiName)?.mainUrl ?: "", "").replace("/", "") - .hashCode() - } - - fun LoadResponse.getId(): Int { - return getLoadResponseIdFromUrl(url, apiName) - } - - /** - * Gets the website captcha token - * discovered originally by https://github.com/ahmedgamal17 - * optimized by https://github.com/justfoolingaround - * - * @param url the main url, likely the same website you found the key from. - * @param key used to fill https://www.google.com/recaptcha/api.js?render=.... - * - * @param referer the referer for the google.com/recaptcha/api.js... request, optional. - * */ - - // Try document.select("script[src*=https://www.google.com/recaptcha/api.js?render=]").attr("src").substringAfter("render=") - // To get the key - suspend fun getCaptchaToken(url: String, key: String, referer: String? = null): String? { - try { - val uri = Uri.parse(url) - val domain = encodeToString( - (uri.scheme + "://" + uri.host + ":443").encodeToByteArray(), - 0 - ).replace("\n", "").replace("=", ".") - - val vToken = - app.get( - "https://www.google.com/recaptcha/api.js?render=$key", - referer = referer, - cacheTime = 0 - ) - .text - .substringAfter("releases/") - .substringBefore("/") - val recapToken = - app.get("https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=cs3&k=$key&co=$domain&v=$vToken") - .document - .selectFirst("#recaptcha-token")?.attr("value") - if (recapToken != null) { - return app.post( - "https://www.google.com/recaptcha/api2/reload?k=$key", - data = mapOf( - "v" to vToken, - "k" to key, - "c" to recapToken, - "co" to domain, - "sa" to "", - "reason" to "q" - ), cacheTime = 0 - ).text - .substringAfter("rresp\",\"") - .substringBefore("\"") - } - } catch (e: Exception) { - logError(e) - } - return null - } - - private var trackerCache: HashMap = hashMapOf() - - /** - * Get anime tracker information based on title, year and type. - * Both titles are attempted to be matched with both Romaji and English title. - * Uses the consumet api. - * - * @param titles uses first index to search, but if you have multiple titles and want extra guarantee to match you can also have that - * @param types Optional parameter to narrow down the scope to Movies, TV, etc. See TrackerType.getTypes() - * @param year Optional parameter to only get anime with a specific year - **/ - suspend fun getTracker( - titles: List, - types: Set?, - year: Int? - ): Tracker? { - return try { - require(titles.isNotEmpty()) { "titles must no be empty when calling getTracker" } - - val mainTitle = titles[0] - val search = - trackerCache[mainTitle] - ?: app.get("https://api.consumet.org/meta/anilist/$mainTitle") - .parsedSafe()?.also { - trackerCache[mainTitle] = it - } ?: return null - - val res = search.results?.find { media -> - val matchingYears = year == null || media.releaseDate == year - val matchingTitles = media.title?.let { title -> - titles.any { userTitle -> - title.isMatchingTitles(userTitle) - } - } ?: false - - val matchingTypes = types?.any { it.name.equals(media.type, true) } == true - matchingTitles && matchingTypes && matchingYears - } ?: return null - - Tracker(res.malId, res.aniId, res.image, res.cover) - } catch (t: Throwable) { - logError(t) - null - } - } - - - fun Context.getApiSettings(): HashSet { - //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - - val hashSet = HashSet() - val activeLangs = getApiProviderLangSettings() - val hasUniversal = activeLangs.contains(AllLanguagesName) - 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 - } - - fun Context.getApiDubstatusSettings(): HashSet { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val hashSet = HashSet() - hashSet.addAll(DubStatus.values()) - val list = settingsManager.getStringSet( - this.getString(R.string.display_sub_key), - hashSet.map { it.name }.toMutableSet() - ) ?: return hashSet - - val names = DubStatus.values().map { it.name }.toHashSet() - //if(realSet.isEmpty()) return hashSet - - return list.filter { names.contains(it) }.map { DubStatus.valueOf(it) }.toHashSet() - } - - fun Context.getApiProviderLangSettings(): HashSet { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val hashSet = hashSetOf(AllLanguagesName) // def is all languages -// hashSet.add("en") // def is only en - val list = settingsManager.getStringSet( - this.getString(R.string.provider_lang_key), - hashSet - ) - - if (list.isNullOrEmpty()) return hashSet - return list.toHashSet() - } - - fun Context.getApiTypeSettings(): HashSet { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val hashSet = HashSet() - hashSet.addAll(TvType.values()) - val list = settingsManager.getStringSet( - this.getString(R.string.search_types_list_key), - hashSet.map { it.name }.toMutableSet() - ) - - if (list.isNullOrEmpty()) return hashSet - - val names = TvType.values().map { it.name }.toHashSet() - val realSet = list.filter { names.contains(it) }.map { TvType.valueOf(it) }.toHashSet() - if (realSet.isEmpty()) return hashSet - - return realSet - } - - fun Context.updateHasTrailers() { - LoadResponse.isTrailersEnabled = getHasTrailers() - } - - private fun Context.getHasTrailers(): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - return settingsManager.getBoolean(this.getString(R.string.show_trailers_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 - // Trying fixing using classloader fuckery - val oldLoader = Thread.currentThread().contextClassLoader - Thread.currentThread().contextClassLoader = TvType::class.java.classLoader - - val default = TvType.values() - .sorted() - .filter { it != TvType.NSFW } - .map { it.ordinal } - - Thread.currentThread().contextClassLoader = oldLoader - - val defaultSet = default.map { it.toString() }.toSet() - val currentPrefMedia = try { - PreferenceManager.getDefaultSharedPreferences(this) - .getStringSet(this.getString(R.string.prefer_media_type_key), defaultSet) - ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } - } catch (e: Throwable) { - null - } ?: default - val langs = this.getApiProviderLangSettings() - val hasUniversal = langs.contains(AllLanguagesName) - val allApis = synchronized(apis) { - apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } - } - return if (currentPrefMedia.isEmpty()) { - allApis - } else { - // Filter API depending on preferred media type - allApis.filter { api -> api.supportedTypes.any { currentPrefMedia.contains(it.ordinal) } } - } - } - - fun Context.filterSearchResultByFilmQuality(data: List): List { - // Filter results omitting entries with certain quality - if (data.isNotEmpty()) { - val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) - ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) - ?.mapNotNull { entry -> - entry.toIntOrNull() ?: return@mapNotNull null - } ?: listOf() - if (filteredSearchQuality.isNotEmpty()) { - return data.filter { item -> - val searchQualVal = item.quality?.ordinal ?: -1 - //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") - !filteredSearchQuality.contains(searchQualVal) - } - } - } - return data - } - - fun Context.filterHomePageListByFilmQuality(data: HomePageList): HomePageList { - // Filter results omitting entries with certain quality - if (data.list.isNotEmpty()) { - val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) - ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) - ?.mapNotNull { entry -> - entry.toIntOrNull() ?: return@mapNotNull null - } ?: listOf() - if (filteredSearchQuality.isNotEmpty()) { - return HomePageList( - name = data.name, - isHorizontalImages = data.isHorizontalImages, - list = data.list.filter { item -> - val searchQualVal = item.quality?.ordinal ?: -1 - //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") - !filteredSearchQuality.contains(searchQualVal) - } - ) - } - } - return data - } -} - -/* -// THIS IS WORK IN PROGRESS API -interface ITag { - val name: UiText -} - -data class SimpleTag(override val name: UiText, val data: String) : ITag - -enum class SelectType { - SingleSelect, - MultiSelect, - MultiSelectAndExclude, -} - -enum class SelectValue { - Selected, - Excluded, -} - -interface GenreSelector { - val title: UiText - val id : Int -} - -data class TagSelector( - override val title: UiText, - override val id : Int, - val tags: Set, - val defaultTags : Set = setOf(), - val selectType: SelectType = SelectType.SingleSelect, -) : GenreSelector - -data class BoolSelector( - override val title: UiText, - override val id : Int, - - val defaultValue : Boolean = false, -) : GenreSelector - -data class InputField( - override val title: UiText, - override val id : Int, - - val hint : UiText? = null, -) : GenreSelector - -// This response describes how a user might filter the homepage or search results -data class GenreResponse( - val searchSelectors : List, - val filterSelectors: List = searchSelectors -) */ - -/* -0 = Site not good -1 = All good -2 = Slow, heavy traffic -3 = restricted, must donate 30 benenes to use - */ -const val PROVIDER_STATUS_KEY = "PROVIDER_STATUS_KEY" -const val PROVIDER_STATUS_BETA_ONLY = 3 -const val PROVIDER_STATUS_SLOW = 2 -const val PROVIDER_STATUS_OK = 1 -const val PROVIDER_STATUS_DOWN = 0 - -data class ProvidersInfoJson( - @JsonProperty("name") var name: String, - @JsonProperty("url") var url: String, - @JsonProperty("credentials") var credentials: String? = null, - @JsonProperty("status") var status: Int, -) - -data class SettingsJson( - @JsonProperty("enableAdult") var enableAdult: Boolean = false, -) - - -data class MainPageData( - val name: String, - val data: String, - val horizontalImages: Boolean = false -) - -data class MainPageRequest( - val name: String, - val data: String, - val horizontalImages: Boolean, - //TODO genre selection or smth -) - -fun mainPage(url: String, name: String, horizontalImages: Boolean = false): MainPageData { - return MainPageData(name = name, data = url, horizontalImages = horizontalImages) -} - -fun mainPageOf(vararg elements: MainPageData): List { - return elements.toList() -} - -/** return list of MainPageData with url to name, make for more readable code */ -fun mainPageOf(vararg elements: Pair): List { - return elements.map { (url, name) -> MainPageData(name = name, data = url) } -} - -fun newHomePageResponse( - name: String, - list: List, - hasNext: Boolean? = null, -): HomePageResponse { - return HomePageResponse( - listOf(HomePageList(name, list)), - hasNext = hasNext ?: list.isNotEmpty() - ) -} - -fun newHomePageResponse( - data: MainPageRequest, - list: List, - hasNext: Boolean? = null, -): HomePageResponse { - return HomePageResponse( - listOf(HomePageList(data.name, list, data.horizontalImages)), - hasNext = hasNext ?: list.isNotEmpty() - ) -} - -fun newHomePageResponse(list: HomePageList, hasNext: Boolean? = null): HomePageResponse { - return HomePageResponse(listOf(list), hasNext = hasNext ?: list.list.isNotEmpty()) -} - -fun newHomePageResponse(list: List, hasNext: Boolean? = null): HomePageResponse { - return HomePageResponse(list, hasNext = hasNext ?: list.any { it.list.isNotEmpty() }) -} - -/**Every provider will **not** have try catch built in, so handle exceptions when calling these functions*/ -abstract class MainAPI { - companion object { - var overrideData: HashMap? = null - var settingsForProvider: SettingsJson = SettingsJson() - } - - fun init() { - overrideData?.get(this.javaClass.simpleName)?.let { data -> - overrideWithNewData(data) - } - } - - fun overrideWithNewData(data: ProvidersInfoJson) { - if (!canBeOverridden) return - this.name = data.name - if (data.url.isNotBlank() && data.url != "NONE") - this.mainUrl = data.url - this.storedCredentials = data.credentials - } - - open var name = "NONE" - open var mainUrl = "NONE" - open var storedCredentials: String? = null - open var canBeOverridden: Boolean = true - - /** if this is turned on then it will request the homepage one after the other, - used to delay if they block many request at the same time*/ - open var sequentialMainPage: Boolean = false - - /** in milliseconds, this can be used to add more delay between homepage requests - * on first load if sequentialMainPage is turned on */ - open var sequentialMainPageDelay: Long = 0L - - /** in milliseconds, this can be used to add more delay between homepage requests when scrolling */ - open var sequentialMainPageScrollDelay: Long = 0L - - /** used to keep track when last homepage request was in unixtime ms */ - var lastHomepageRequest: Long = 0L - - open var lang = "en" // ISO_639_1 check SubtitleHelper - - /**If link is stored in the "data" string, so links can be instantly loaded*/ - open val instantLinkLoading = false - - /**Set false if links require referer or for some reason cant be played on a chromecast*/ - open val hasChromecastSupport = true - - /**If all links are encrypted then set this to false*/ - open val hasDownloadSupport = true - - /**Used for testing and can be used to disable the providers if WebView is not available*/ - open val usesWebView = false - - /** Determines which plugin a given provider is from */ - var sourcePlugin: String? = null - - open val hasMainPage = false - open val hasQuickSearch = false - - /** - * A set of which ids the provider can open with getLoadUrl() - * If the set contains SyncIdName.Imdb then getLoadUrl() can be started with - * an Imdb class which inherits from SyncId. - * - * getLoadUrl() is then used to get page url based on that ID. - * - * Example: - * "tt6723592" -> getLoadUrl(ImdbSyncId("tt6723592")) -> "mainUrl/imdb/tt6723592" -> load("mainUrl/imdb/tt6723592") - * - * This is used to launch pages from personal lists or recommendations using IDs. - **/ - open val supportedSyncNames = setOf() - - open val supportedTypes = setOf( - TvType.Movie, - TvType.TvSeries, - TvType.Cartoon, - TvType.Anime, - TvType.OVA, - ) - - open val vpnStatus = VPNStatus.None - open val providerType = ProviderType.DirectProvider - - //emptyList() // - open val mainPage = listOf(MainPageData("", "", false)) - - @WorkerThread - open suspend fun getMainPage( - page: Int, - request: MainPageRequest, - ): HomePageResponse? { - throw NotImplementedError() - } - - @WorkerThread - open suspend fun search(query: String): List? { - throw NotImplementedError() - } - - @WorkerThread - open suspend fun quickSearch(query: String): List? { - throw NotImplementedError() - } - - @WorkerThread - /** - * Based on data from search() or getMainPage() it generates a LoadResponse, - * basically opening the info page from a link. - * */ - open suspend fun load(url: String): LoadResponse? { - throw NotImplementedError() - } - - /** - * Largely redundant feature for most providers. - * - * This job runs in the background when a link is playing in exoplayer. - * First implemented to do polling for sflix to keep the link from getting expired. - * - * This function might be updated to include exoplayer timestamps etc in the future - * if the need arises. - * */ - @WorkerThread - open suspend fun extractorVerifierJob(extractorData: String?) { - throw NotImplementedError() - } - - /**Callback is fired once a link is found, will return true if method is executed successfully*/ - @WorkerThread - open suspend fun loadLinks( - data: String, - isCasting: Boolean, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ): Boolean { - throw NotImplementedError() - } - - /** An okhttp interceptor for used in OkHttpDataSource */ - open fun getVideoInterceptor(extractorLink: ExtractorLink): Interceptor? { - return null - } - - /** - * Get the load() url based on a sync ID like IMDb or MAL. - * Only contains SyncIds based on supportedSyncUrls. - **/ - open suspend fun getLoadUrl(name: SyncIdName, id: String): String? { - return null - } -} - -/** Might need a different implementation for desktop*/ -@SuppressLint("NewApi") -fun base64Decode(string: String): String { - return String(base64DecodeArray(string), Charsets.ISO_8859_1) -} - -@SuppressLint("NewApi") -fun base64DecodeArray(string: String): ByteArray { - return try { - android.util.Base64.decode(string, android.util.Base64.DEFAULT) - } catch (e: Exception) { - Base64.getDecoder().decode(string) - } -} - -@SuppressLint("NewApi") -fun base64Encode(array: ByteArray): String { - return try { - String(android.util.Base64.encode(array, android.util.Base64.NO_WRAP), Charsets.ISO_8859_1) - } catch (e: Exception) { - String(Base64.getEncoder().encode(array)) - } -} - -class ErrorLoadingException(message: String? = null) : Exception(message) - -fun MainAPI.fixUrlNull(url: String?): String? { - if (url.isNullOrEmpty()) { - return null - } - return fixUrl(url) -} - -fun MainAPI.fixUrl(url: String): String { - if (url.startsWith("http") || - // Do not fix JSON objects when passed as urls. - url.startsWith("{\"") - ) { - return url - } - if (url.isEmpty()) { - return "" - } - - val startsWithNoHttp = url.startsWith("//") - if (startsWithNoHttp) { - return "https:$url" - } else { - if (url.startsWith('/')) { - return mainUrl + url - } - return "$mainUrl/$url" - } -} - -fun sortUrls(urls: Set): List { - return urls.sortedBy { t -> -t.quality } -} - -fun sortSubs(subs: Set): List { - return subs.sortedBy { it.name } -} - -fun capitalizeString(str: String): String { - return capitalizeStringNullable(str) ?: str -} - -fun capitalizeStringNullable(str: String?): String? { - if (str == null) - return null - return try { - str.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } catch (e: Exception) { - str - } -} - -fun fixTitle(str: String): String { - return str.split(" ").joinToString(" ") { - it.lowercase() - .replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it } - } -} - -/** - * Get rhino context in a safe way as it needs to be initialized on the main thread. - * Make sure you get the scope using: val scope: Scriptable = rhino.initSafeStandardObjects() - * Use like the following: rhino.evaluateString(scope, js, "JavaScript", 1, null) - **/ -suspend fun getRhinoContext(): org.mozilla.javascript.Context { - return Coroutines.mainWork { - val rhino = org.mozilla.javascript.Context.enter() - rhino.initSafeStandardObjects() - rhino.optimizationLevel = -1 - rhino - } -} - -/** https://www.imdb.com/title/tt2861424/ -> tt2861424 */ -fun imdbUrlToId(url: String): String? { - return Regex("/title/(tt[0-9]*)").find(url)?.groupValues?.get(1) - ?: Regex("tt[0-9]{5,}").find(url)?.groupValues?.get(0) -} - -fun imdbUrlToIdNullable(url: String?): String? { - if (url == null) return null - return imdbUrlToId(url) -} - -enum class ProviderType { - // When data is fetched from a 3rd party site like imdb - MetaProvider, - - // When all data is from the site - DirectProvider, -} - -enum class VPNStatus { - None, - MightBeNeeded, - Torrent, -} - -enum class ShowStatus { - Completed, - Ongoing, -} - -enum class DubStatus(val id: Int) { - None(-1), - Dubbed(1), - Subbed(0), -} - -enum class TvType(value: Int?) { - Movie(1), - AnimeMovie(2), - TvSeries(3), - Cartoon(4), - Anime(5), - OVA(6), - Torrent(7), - Documentary(8), - AsianDrama(9), - Live(10), - NSFW(11), - Others(12) -} - -public enum class AutoDownloadMode(val value: Int) { - Disable(0), - FilterByLang(1), - All(2), - NsfwOnly(3) - ; - - companion object { - infix fun getEnum(value: Int): AutoDownloadMode? = - AutoDownloadMode.values().firstOrNull { it.value == value } - } -} - -// IN CASE OF FUTURE ANIME MOVIE OR SMTH -fun TvType.isMovieType(): Boolean { - return this == TvType.Movie || this == TvType.AnimeMovie || this == TvType.Torrent || this == TvType.Live -} - -fun TvType.isLiveStream(): Boolean { - return this == TvType.Live -} - -// returns if the type has an anime opening -fun TvType.isAnimeOp(): Boolean { - return this == TvType.Anime || this == TvType.OVA -} - -data class SubtitleFile(val lang: String, val url: String) - -data class HomePageResponse( - val items: List, - val hasNext: Boolean = false -) - -data class HomePageList( - val name: String, - var list: List, - val isHorizontalImages: Boolean = false -) - -enum class SearchQuality(value: Int?) { - //https://en.wikipedia.org/wiki/Pirated_movie_release_types - Cam(1), - CamRip(2), - HdCam(3), - Telesync(4), // TS - WorkPrint(5), - Telecine(6), // TC - HQ(7), - HD(8), - HDR(9), // high dynamic range - BlueRay(10), - DVD(11), - SD(12), - FourK(13), - UHD(14), - SDR(15), // standard dynamic range - WebRip(16) -} - -/**Add anything to here if you find a site that uses some specific naming convention*/ -fun getQualityFromString(string: String?): SearchQuality? { - val check = (string ?: return null).trim().lowercase().replace(" ", "") - - return when (check) { - "cam" -> SearchQuality.Cam - "camrip" -> SearchQuality.CamRip - "hdcam" -> SearchQuality.HdCam - "hdtc" -> SearchQuality.HdCam - "hdts" -> SearchQuality.HdCam - "highquality" -> SearchQuality.HQ - "hq" -> SearchQuality.HQ - "highdefinition" -> SearchQuality.HD - "hdrip" -> SearchQuality.HD - "hd" -> SearchQuality.HD - "hdtv" -> SearchQuality.HD - "rip" -> SearchQuality.CamRip - "telecine" -> SearchQuality.Telecine - "tc" -> SearchQuality.Telecine - "telesync" -> SearchQuality.Telesync - "ts" -> SearchQuality.Telesync - "dvd" -> SearchQuality.DVD - "dvdrip" -> SearchQuality.DVD - "dvdscr" -> SearchQuality.DVD - "blueray" -> SearchQuality.BlueRay - "bluray" -> SearchQuality.BlueRay - "blu" -> SearchQuality.BlueRay - "fhd" -> SearchQuality.HD - "br" -> SearchQuality.BlueRay - "standard" -> SearchQuality.SD - "sd" -> SearchQuality.SD - "4k" -> SearchQuality.FourK - "uhd" -> SearchQuality.UHD // may also be 4k or 8k - "blue" -> SearchQuality.BlueRay - "wp" -> SearchQuality.WorkPrint - "workprint" -> SearchQuality.WorkPrint - "webrip" -> SearchQuality.WebRip - "webdl" -> SearchQuality.WebRip - "web" -> SearchQuality.WebRip - "hdr" -> SearchQuality.HDR - "sdr" -> SearchQuality.SDR - else -> null - } -} - -interface SearchResponse { - val name: String - val url: String - val apiName: String - var type: TvType? - var posterUrl: String? - var posterHeaders: Map? - var id: Int? - var quality: SearchQuality? -} - -fun MainAPI.newMovieSearchResponse( - name: String, - url: String, - type: TvType = TvType.Movie, - fix: Boolean = true, - initializer: MovieSearchResponse.() -> Unit = { }, -): MovieSearchResponse { - val builder = MovieSearchResponse(name, if (fix) fixUrl(url) else url, this.name, type) - builder.initializer() - - return builder -} - -fun MainAPI.newTvSeriesSearchResponse( - name: String, - url: String, - type: TvType = TvType.TvSeries, - fix: Boolean = true, - initializer: TvSeriesSearchResponse.() -> Unit = { }, -): TvSeriesSearchResponse { - val builder = TvSeriesSearchResponse(name, if (fix) fixUrl(url) else url, this.name, type) - builder.initializer() - - return builder -} - - -fun MainAPI.newAnimeSearchResponse( - name: String, - url: String, - type: TvType = TvType.Anime, - fix: Boolean = true, - initializer: AnimeSearchResponse.() -> Unit = { }, -): AnimeSearchResponse { - val builder = AnimeSearchResponse(name, if (fix) fixUrl(url) else url, this.name, type) - builder.initializer() - - return builder -} - -fun SearchResponse.addQuality(quality: String) { - this.quality = getQualityFromString(quality) -} - -fun SearchResponse.addPoster(url: String?, headers: Map? = null) { - this.posterUrl = url - this.posterHeaders = headers -} - -fun LoadResponse.addPoster(url: String?, headers: Map? = null) { - this.posterUrl = url - this.posterHeaders = headers -} - -enum class ActorRole { - Main, - Supporting, - Background, -} - -data class Actor( - val name: String, - val image: String? = null, -) - -data class ActorData( - val actor: Actor, - val role: ActorRole? = null, - val roleString: String? = null, - val voiceActor: Actor? = null, -) - -data class AnimeSearchResponse( - override val name: String, - override val url: String, - override val apiName: String, - override var type: TvType? = null, - - override var posterUrl: String? = null, - var year: Int? = null, - var dubStatus: EnumSet? = null, - - var otherName: String? = null, - var episodes: MutableMap = mutableMapOf(), - - override var id: Int? = null, - override var quality: SearchQuality? = null, - override var posterHeaders: Map? = null, -) : SearchResponse - -fun AnimeSearchResponse.addDubStatus(status: DubStatus, episodes: Int? = null) { - this.dubStatus = dubStatus?.also { it.add(status) } ?: EnumSet.of(status) - if (this.type?.isMovieType() != true) - if (episodes != null && episodes > 0) - this.episodes[status] = episodes -} - -fun AnimeSearchResponse.addDubStatus(isDub: Boolean, episodes: Int? = null) { - addDubStatus(if (isDub) DubStatus.Dubbed else DubStatus.Subbed, episodes) -} - -fun AnimeSearchResponse.addDub(episodes: Int?) { - if (episodes == null || episodes <= 0) return - addDubStatus(DubStatus.Dubbed, episodes) -} - -fun AnimeSearchResponse.addSub(episodes: Int?) { - if (episodes == null || episodes <= 0) return - addDubStatus(DubStatus.Subbed, episodes) -} - -fun AnimeSearchResponse.addDubStatus( - dubExist: Boolean, - subExist: Boolean, - dubEpisodes: Int? = null, - subEpisodes: Int? = null -) { - if (dubExist) - addDubStatus(DubStatus.Dubbed, dubEpisodes) - - if (subExist) - addDubStatus(DubStatus.Subbed, subEpisodes) -} - -fun AnimeSearchResponse.addDubStatus(status: String, episodes: Int? = null) { - if (status.contains("(dub)", ignoreCase = true)) { - addDubStatus(DubStatus.Dubbed, episodes) - } else if (status.contains("(sub)", ignoreCase = true)) { - addDubStatus(DubStatus.Subbed, episodes) - } -} - -data class TorrentSearchResponse( - override val name: String, - override val url: String, - override val apiName: String, - override var type: TvType?, - - override var posterUrl: String?, - override var id: Int? = null, - override var quality: SearchQuality? = null, - override var posterHeaders: Map? = null, -) : SearchResponse - -data class MovieSearchResponse( - override val name: String, - override val url: String, - override val apiName: String, - override var type: TvType? = null, - - override var posterUrl: String? = null, - var year: Int? = null, - override var id: Int? = null, - override var quality: SearchQuality? = null, - override var posterHeaders: Map? = null, -) : SearchResponse - -data class LiveSearchResponse( - override val name: String, - override val url: String, - override val apiName: String, - override var type: TvType? = null, - - override var posterUrl: String? = null, - override var id: Int? = null, - override var quality: SearchQuality? = null, - override var posterHeaders: Map? = null, - val lang: String? = null, -) : SearchResponse - -data class TvSeriesSearchResponse( - override val name: String, - override val url: String, - override val apiName: String, - override var type: TvType? = null, - - override var posterUrl: String? = null, - val year: Int? = null, - val episodes: Int? = null, - override var id: Int? = null, - override var quality: SearchQuality? = null, - override var posterHeaders: Map? = null, -) : SearchResponse - -data class TrailerData( - val extractorUrl: String, - val referer: String?, - val raw: Boolean, - //var mirros: List, - //var subtitles: List = emptyList(), -) - -interface LoadResponse { - var name: String - var url: String - var apiName: String - var type: TvType - var posterUrl: String? - var year: Int? - var plot: String? - var rating: Int? // 0-10000 - var tags: List? - var duration: Int? // in minutes - var trailers: MutableList - - var recommendations: List? - var actors: List? - var comingSoon: Boolean - var syncData: MutableMap - var posterHeaders: Map? - var backgroundPosterUrl: String? - - companion object { - private val malIdPrefix = malApi.idPrefix - private val aniListIdPrefix = aniListApi.idPrefix - private val simklIdPrefix = simklApi.idPrefix - var isTrailersEnabled = true - - fun LoadResponse.isMovie(): Boolean { - return this.type.isMovieType() || this is MovieLoadResponse - } - - @JvmName("addActorNames") - fun LoadResponse.addActors(actors: List?) { - this.actors = actors?.map { ActorData(Actor(it)) } - } - - @JvmName("addActors") - fun LoadResponse.addActors(actors: List>?) { - this.actors = actors?.map { (actor, role) -> ActorData(actor, roleString = role) } - } - - @JvmName("addActorsRole") - fun LoadResponse.addActors(actors: List>?) { - this.actors = actors?.map { (actor, role) -> ActorData(actor, role = role) } - } - - /** - * Internal helper function to add simkl ids from other databases. - */ - private fun LoadResponse.addSimklId( - database: SimklApi.Companion.SyncServices, - id: String? - ) { - normalSafeApiCall { - this.syncData[simklIdPrefix] = - SimklApi.addIdToString(this.syncData[simklIdPrefix], database, id.toString()) - ?: return@normalSafeApiCall - } - } - - @JvmName("addActorsOnly") - fun LoadResponse.addActors(actors: List?) { - this.actors = actors?.map { actor -> ActorData(actor) } - } - - fun LoadResponse.getMalId(): String? { - return this.syncData[malIdPrefix] - } - - fun LoadResponse.getAniListId(): String? { - return this.syncData[aniListIdPrefix] - } - - fun LoadResponse.addMalId(id: Int?) { - this.syncData[malIdPrefix] = (id ?: return).toString() - this.addSimklId(SimklApi.Companion.SyncServices.Mal, id.toString()) - } - - fun LoadResponse.addAniListId(id: Int?) { - this.syncData[aniListIdPrefix] = (id ?: return).toString() - this.addSimklId(SimklApi.Companion.SyncServices.AniList, id.toString()) - } - - fun LoadResponse.addSimklId(id: Int?) { - this.addSimklId(SimklApi.Companion.SyncServices.Simkl, id.toString()) - } - - fun LoadResponse.addImdbUrl(url: String?) { - addImdbId(imdbUrlToIdNullable(url)) - } - - /**better to call addTrailer with mutible trailers directly instead of calling this multiple times*/ - suspend fun LoadResponse.addTrailer( - trailerUrl: String?, - referer: String? = null, - addRaw: Boolean = false - ) { - if (!isTrailersEnabled || trailerUrl.isNullOrBlank()) return - this.trailers.add(TrailerData(trailerUrl, referer, addRaw)) - /*val links = arrayListOf() - val subs = arrayListOf() - if (!loadExtractor( - trailerUrl, - referer, - { subs.add(it) }, - { links.add(it) }) && addRaw - ) { - this.trailers.add( - TrailerData( - listOf( - ExtractorLink( - "", - "Trailer", - trailerUrl, - referer ?: "", - Qualities.Unknown.value, - trailerUrl.contains(".m3u8") - ) - ), listOf() - ) - ) - } else { - this.trailers.add(TrailerData(links, subs)) - }*/ - } - - /* - fun LoadResponse.addTrailer(newTrailers: List) { - trailers.addAll(newTrailers.map { TrailerData(listOf(it)) }) - }*/ - - suspend fun LoadResponse.addTrailer( - trailerUrls: List?, - referer: String? = null, - addRaw: Boolean = false - ) { - if (!isTrailersEnabled || trailerUrls == null) return - trailers.addAll(trailerUrls.map { TrailerData(it, referer, addRaw) }) - /*val trailers = trailerUrls.filter { it.isNotBlank() }.amap { trailerUrl -> - val links = arrayListOf() - val subs = arrayListOf() - if (!loadExtractor( - trailerUrl, - referer, - { subs.add(it) }, - { links.add(it) }) && addRaw - ) { - arrayListOf( - ExtractorLink( - "", - "Trailer", - trailerUrl, - referer ?: "", - Qualities.Unknown.value, - trailerUrl.contains(".m3u8") - ) - ) to arrayListOf() - } else { - links to subs - } - }.map { (links, subs) -> TrailerData(links, subs) } - this.trailers.addAll(trailers)*/ - } - - fun LoadResponse.addImdbId(id: String?) { - // TODO add imdb sync - this.addSimklId(SimklApi.Companion.SyncServices.Imdb, id) - } - - fun LoadResponse.addTrackId(id: String?) { - // TODO add trackt sync - } - - fun LoadResponse.addkitsuId(id: String?) { - // TODO add kitsu sync - } - - fun LoadResponse.addTMDbId(id: String?) { - // TODO add TMDb sync - this.addSimklId(SimklApi.Companion.SyncServices.Tmdb, id) - } - - fun LoadResponse.addRating(text: String?) { - addRating(text.toRatingInt()) - } - - fun LoadResponse.addRating(value: Int?) { - if ((value ?: return) < 0 || value > 10000) { - return - } - this.rating = value - } - - fun LoadResponse.addDuration(input: String?) { - this.duration = getDurationFromString(input) ?: this.duration - } - } -} - -fun getDurationFromString(input: String?): Int? { - val cleanInput = input?.trim()?.replace(" ", "") ?: return null - //Use first as sometimes the text passes on the 2 other Regex, but failed to provide accurate return value - Regex("(\\d+\\shr)|(\\d+\\shour)|(\\d+\\smin)|(\\d+\\ssec)").findAll(input).let { values -> - var seconds = 0 - values.forEach { - val time_text = it.value - if (time_text.isNotBlank()) { - val time = time_text.filter { s -> s.isDigit() }.trim().toInt() - val scale = time_text.filter { s -> !s.isDigit() }.trim() - //println("Scale: $scale") - val timeval = when (scale) { - "hr", "hour" -> time * 60 * 60 - "min" -> time * 60 - "sec" -> time - else -> 0 - } - seconds += timeval - } - } - if (seconds > 0) { - return seconds / 60 - } - } - Regex("([0-9]*)h.*?([0-9]*)m").find(cleanInput)?.groupValues?.let { values -> - if (values.size == 3) { - val hours = values[1].toIntOrNull() - val minutes = values[2].toIntOrNull() - if (minutes != null && hours != null) { - return hours * 60 + minutes - } - } - } - Regex("([0-9]*)m").find(cleanInput)?.groupValues?.let { values -> - if (values.size == 2) { - val return_value = values[1].toIntOrNull() - if (return_value != null) { - return return_value - } - } - } - return null -} - -fun LoadResponse?.isEpisodeBased(): Boolean { - if (this == null) return false - return this is EpisodeResponse && this.type.isEpisodeBased() -} - -fun LoadResponse?.isAnimeBased(): Boolean { - if (this == null) return false - return (this.type == TvType.Anime || this.type == TvType.OVA) // && (this is AnimeLoadResponse) -} - -fun TvType?.isEpisodeBased(): Boolean { - if (this == null) return false - return (this == TvType.TvSeries || this == TvType.Anime || this == TvType.AsianDrama) -} - - -data class NextAiring( - val episode: Int, - val unixTime: Long, -) - -/** - * @param season To be mapped with episode season, not shown in UI if displaySeason is defined - * @param name To be shown next to the season like "Season $displaySeason $name" but if displaySeason is null then "$name" - * @param displaySeason What to be displayed next to the season name, if null then the name is the only thing shown. - * */ -data class SeasonData( - val season: Int, - val name: String? = null, - val displaySeason: Int? = null, // will use season if null -) - -interface EpisodeResponse { - var showStatus: ShowStatus? - var nextAiring: NextAiring? - var seasonNames: List? - fun getLatestEpisodes(): Map -} - -@JvmName("addSeasonNamesString") -fun EpisodeResponse.addSeasonNames(names: List) { - this.seasonNames = if (names.isEmpty()) null else names.mapIndexed { index, s -> - SeasonData( - season = index + 1, - s - ) - } -} - -@JvmName("addSeasonNamesSeasonData") -fun EpisodeResponse.addSeasonNames(names: List) { - this.seasonNames = names.ifEmpty { null } -} - -data class TorrentLoadResponse( - override var name: String, - override var url: String, - override var apiName: String, - var magnet: String?, - var torrent: String?, - override var plot: String?, - override var type: TvType = TvType.Torrent, - override var posterUrl: String? = null, - override var year: Int? = null, - override var rating: Int? = null, - override var tags: List? = null, - override var duration: Int? = null, - override var trailers: MutableList = mutableListOf(), - override var recommendations: List? = null, - override var actors: List? = null, - override var comingSoon: Boolean = false, - override var syncData: MutableMap = mutableMapOf(), - override var posterHeaders: Map? = null, - override var backgroundPosterUrl: String? = null, -) : LoadResponse - -data class AnimeLoadResponse( - var engName: String? = null, - var japName: String? = null, - override var name: String, - override var url: String, - override var apiName: String, - override var type: TvType, - - override var posterUrl: String? = null, - override var year: Int? = null, - - var episodes: MutableMap> = mutableMapOf(), - override var showStatus: ShowStatus? = null, - - override var plot: String? = null, - override var tags: List? = null, - var synonyms: List? = null, - - override var rating: Int? = null, - override var duration: Int? = null, - override var trailers: MutableList = mutableListOf(), - override var recommendations: List? = null, - override var actors: List? = null, - override var comingSoon: Boolean = false, - override var syncData: MutableMap = mutableMapOf(), - override var posterHeaders: Map? = null, - override var nextAiring: NextAiring? = null, - override var seasonNames: List? = null, - override var backgroundPosterUrl: String? = null, -) : LoadResponse, EpisodeResponse { - override fun getLatestEpisodes(): Map { - return episodes.map { (status, episodes) -> - val maxSeason = episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE } - .takeUnless { it == Int.MIN_VALUE } - status to episodes - .filter { it.season == maxSeason } - .maxOfOrNull { it.episode ?: Int.MIN_VALUE } - .takeUnless { it == Int.MIN_VALUE } - }.toMap() - } -} - -/** - * If episodes already exist appends the list. - * */ -fun AnimeLoadResponse.addEpisodes(status: DubStatus, episodes: List?) { - if (episodes.isNullOrEmpty()) return - this.episodes[status] = (this.episodes[status] ?: emptyList()) + episodes -} - -suspend fun MainAPI.newAnimeLoadResponse( - name: String, - url: String, - type: TvType, - comingSoonIfNone: Boolean = true, - initializer: suspend AnimeLoadResponse.() -> Unit = { }, -): AnimeLoadResponse { - val builder = AnimeLoadResponse(name = name, url = url, apiName = this.name, type = type) - builder.initializer() - if (comingSoonIfNone) { - builder.comingSoon = true - for (key in builder.episodes.keys) - if (!builder.episodes[key].isNullOrEmpty()) { - builder.comingSoon = false - break - } - } - return builder -} - -data class LiveStreamLoadResponse( - override var name: String, - override var url: String, - override var apiName: String, - var dataUrl: String, - - override var posterUrl: String? = null, - override var year: Int? = null, - override var plot: String? = null, - - override var type: TvType = TvType.Live, - override var rating: Int? = null, - override var tags: List? = null, - override var duration: Int? = null, - override var trailers: MutableList = mutableListOf(), - override var recommendations: List? = null, - override var actors: List? = null, - override var comingSoon: Boolean = false, - override var syncData: MutableMap = mutableMapOf(), - override var posterHeaders: Map? = null, - override var backgroundPosterUrl: String? = null, -) : LoadResponse - -data class MovieLoadResponse( - override var name: String, - override var url: String, - override var apiName: String, - override var type: TvType, - var dataUrl: String, - - override var posterUrl: String? = null, - override var year: Int? = null, - override var plot: String? = null, - - override var rating: Int? = null, - override var tags: List? = null, - override var duration: Int? = null, - override var trailers: MutableList = mutableListOf(), - override var recommendations: List? = null, - override var actors: List? = null, - override var comingSoon: Boolean = false, - override var syncData: MutableMap = mutableMapOf(), - override var posterHeaders: Map? = null, - override var backgroundPosterUrl: String? = null, -) : LoadResponse - -suspend fun MainAPI.newMovieLoadResponse( - name: String, - url: String, - type: TvType, - data: T?, - initializer: suspend MovieLoadResponse.() -> Unit = { } -): MovieLoadResponse { - // just in case - if (data is String) return newMovieLoadResponse( - name, - url, - type, - dataUrl = data, - initializer = initializer - ) - val dataUrl = data?.toJson() ?: "" - val builder = MovieLoadResponse( - name = name, - url = url, - apiName = this.name, - type = type, - dataUrl = dataUrl, - comingSoon = dataUrl.isBlank() - ) - builder.initializer() - return builder -} - -suspend fun MainAPI.newMovieLoadResponse( - name: String, - url: String, - type: TvType, - dataUrl: String, - initializer: suspend MovieLoadResponse.() -> Unit = { } -): MovieLoadResponse { - val builder = MovieLoadResponse( - name = name, - url = url, - apiName = this.name, - type = type, - dataUrl = dataUrl, - comingSoon = dataUrl.isBlank() - ) - builder.initializer() - return builder -} - -data class Episode( - var data: String, - var name: String? = null, - var season: Int? = null, - var episode: Int? = null, - var posterUrl: String? = null, - var rating: Int? = null, - var description: String? = null, - var date: Long? = null, -) - -fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") { - try { - this.date = SimpleDateFormat(format)?.parse(date ?: return)?.time - } catch (e: Exception) { - logError(e) - } -} - -fun Episode.addDate(date: Date?) { - this.date = date?.time -} - -fun MainAPI.newEpisode( - url: String, - initializer: Episode.() -> Unit = { }, - fix: Boolean = true, -): Episode { - val builder = Episode( - data = if (fix) fixUrl(url) else url - ) - builder.initializer() - return builder -} - -fun MainAPI.newEpisode( - data: T, - initializer: Episode.() -> Unit = { } -): Episode { - if (data is String) return newEpisode( - url = data, - initializer = initializer - ) // just in case java is wack - - val builder = Episode( - data = data?.toJson() ?: throw ErrorLoadingException("invalid newEpisode") - ) - builder.initializer() - return builder -} - -data class TvSeriesLoadResponse( - override var name: String, - override var url: String, - override var apiName: String, - override var type: TvType, - var episodes: List, - - override var posterUrl: String? = null, - override var year: Int? = null, - override var plot: String? = null, - - override var showStatus: ShowStatus? = null, - override var rating: Int? = null, - override var tags: List? = null, - override var duration: Int? = null, - override var trailers: MutableList = mutableListOf(), - override var recommendations: List? = null, - override var actors: List? = null, - override var comingSoon: Boolean = false, - override var syncData: MutableMap = mutableMapOf(), - override var posterHeaders: Map? = null, - override var nextAiring: NextAiring? = null, - override var seasonNames: List? = null, - override var backgroundPosterUrl: String? = null, -) : LoadResponse, EpisodeResponse { - override fun getLatestEpisodes(): Map { - val maxSeason = - episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE }.takeUnless { it == Int.MIN_VALUE } - val max = episodes - .filter { it.season == maxSeason } - .maxOfOrNull { it.episode ?: Int.MIN_VALUE } - .takeUnless { it == Int.MIN_VALUE } - return mapOf(DubStatus.None to max) - } -} - -suspend fun MainAPI.newTvSeriesLoadResponse( - name: String, - url: String, - type: TvType, - episodes: List, - initializer: suspend TvSeriesLoadResponse.() -> Unit = { } -): TvSeriesLoadResponse { - val builder = TvSeriesLoadResponse( - name = name, - url = url, - apiName = this.name, - type = type, - episodes = episodes, - comingSoon = episodes.isEmpty(), - ) - builder.initializer() - return builder -} - -fun fetchUrls(text: String?): List { - if (text.isNullOrEmpty()) { - return listOf() - } - val linkRegex = - Regex("""(https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))""") - return linkRegex.findAll(text).map { it.value.trim().removeSurrounding("\"") }.toList() -} - -fun String?.toRatingInt(): Int? = - this?.replace(" ", "")?.trim()?.toDoubleOrNull()?.absoluteValue?.times(1000f)?.toInt() - -data class Tracker( - val malId: Int? = null, - val aniId: String? = null, - val image: String? = null, - val cover: String? = null, -) - -data class Title( - @JsonProperty("romaji") val romaji: String? = null, - @JsonProperty("english") val english: String? = null, -) { - fun isMatchingTitles(title: String?): Boolean { - if (title == null) return false - return english.equals(title, true) || romaji.equals(title, true) - } -} - -data class Results( - @JsonProperty("id") val aniId: String? = null, - @JsonProperty("malId") val malId: Int? = null, - @JsonProperty("title") val title: Title? = null, - @JsonProperty("releaseDate") val releaseDate: Int? = null, - @JsonProperty("type") val type: String? = null, - @JsonProperty("image") val image: String? = null, - @JsonProperty("cover") val cover: String? = null, -) - -data class AniSearch( - @JsonProperty("results") val results: ArrayList? = arrayListOf() -) - -/** - * used for the getTracker() method - **/ -enum class TrackerType { - MOVIE, - TV, - TV_SHORT, - ONA, - OVA, - SPECIAL, - MUSIC; - - companion object { - fun getTypes(type: TvType): Set { - return when (type) { - TvType.Movie -> setOf(MOVIE) - TvType.AnimeMovie -> setOf(MOVIE) - TvType.TvSeries -> setOf(TV, TV_SHORT) - TvType.Anime -> setOf(TV, TV_SHORT, ONA, OVA) - TvType.OVA -> setOf(OVA, SPECIAL, ONA) - TvType.Others -> setOf(MUSIC) - else -> emptySet() - } - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 15b160786..90583011d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1,31 +1,40 @@ package com.lagradost.cloudstream3 import android.animation.ValueAnimator -import android.content.ComponentName +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.net.Uri -import android.os.Build +import android.graphics.Rect import android.os.Bundle import android.util.AttributeSet import android.util.Log +import android.view.Gravity import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.WindowManager +import android.widget.CheckBox +import android.widget.ImageView +import android.widget.LinearLayout import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IdRes import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.constraintlayout.widget.ConstraintLayout +import androidx.cardview.widget.CardView +import androidx.core.content.edit +import androidx.core.net.toUri import androidx.core.view.children +import androidx.core.view.get import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.marginStart import androidx.fragment.app.FragmentActivity @@ -41,9 +50,7 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSnapHelper import androidx.recyclerview.widget.RecyclerView -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +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 @@ -52,98 +59,125 @@ import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.navigationrail.NavigationRailView import com.google.android.material.snackbar.Snackbar +import com.google.common.collect.Comparators.min import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.initAll -import com.lagradost.cloudstream3.APIHolder.updateHasTrailers -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint +import com.lagradost.cloudstream3.CommonActivity.screenHeight +import com.lagradost.cloudstream3.CommonActivity.setActivityInstance import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.updateLocale +import com.lagradost.cloudstream3.CommonActivity.updateTheme +import com.lagradost.cloudstream3.actions.temp.fcast.FcastManager import com.lagradost.cloudstream3.databinding.ActivityMainBinding import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe +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.loadAllOnlinePlugins +import com.lagradost.cloudstream3.plugins.PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringPlayer -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths +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.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.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.home.HomeViewModel +import com.lagradost.cloudstream3.ui.library.LibraryViewModel 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.result.LinearListLayout import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST -import com.lagradost.cloudstream3.ui.result.setImage -import com.lagradost.cloudstream3.ui.result.setText -import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.search.SearchFragment import com.lagradost.cloudstream3.ui.search.SearchResultBuilder -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv +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 import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions import com.lagradost.cloudstream3.utils.ApkInstaller -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable -import com.lagradost.cloudstream3.utils.AppUtils.isLtr -import com.lagradost.cloudstream3.utils.AppUtils.isNetworkAvailable -import com.lagradost.cloudstream3.utils.AppUtils.isRtl -import com.lagradost.cloudstream3.utils.AppUtils.loadCache -import com.lagradost.cloudstream3.utils.AppUtils.loadRepository -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable +import com.lagradost.cloudstream3.utils.AppContextUtils.isLtr +import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable +import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl +import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache +import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository +import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +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.BackupUtils.backup import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.IOnBackPressed -import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar +import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState import com.lagradost.cloudstream3.utils.UIHelper.checkWrite -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.requestRW +import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API -import com.lagradost.nicehttp.Requests -import com.lagradost.nicehttp.ResponseParser +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.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File @@ -153,132 +187,53 @@ import java.net.URLDecoder import java.nio.charset.Charset import kotlin.math.abs import kotlin.math.absoluteValue -import kotlin.reflect.KClass import kotlin.system.exitProcess +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel - -//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/ - -//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://www.webvideocaster.com/integrations - -//https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225 - -const val VLC_PACKAGE = "org.videolan.vlc" -const val MPV_PACKAGE = "is.xyz.mpv" -const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo" - -val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity") -val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity") - -//TODO REFACTOR AF -open class ResultResume( - val packageString: String, - val action: String = Intent.ACTION_VIEW, - val position: String? = null, - val duration: String? = null, - var launcher: ActivityResultLauncher? = null, -) { - val defaultTime = -1L - - val lastId get() = "${packageString}_last_open_id" - suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) { - val intent = Intent(action) - - if (id != null) - setKey(lastId, id) - else - removeKey(lastId) - - intent.setPackage(packageString) - callback.invoke(intent) - launcher?.launch(intent) - } - - open fun getPosition(intent: Intent?): Long { - return defaultTime - } - - open fun getDuration(intent: Intent?): Long { - return defaultTime - } -} - -val VLC = object : ResultResume( - VLC_PACKAGE, - // Android 13 intent restrictions fucks up specifically launching the VLC player - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - "org.videolan.vlc.player.result" - } else { - Intent.ACTION_VIEW - }, - "extra_position", - "extra_duration", -) { - override fun getPosition(intent: Intent?): Long { - return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime - } - - override fun getDuration(intent: Intent?): Long { - return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime - } -} - -val MPV = object : ResultResume( - MPV_PACKAGE, - //"is.xyz.mpv.MPVActivity.result", // resume not working :pensive: - position = "position", - duration = "duration", -) { - override fun getPosition(intent: Intent?): Long { - return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong() ?: defaultTime - } - - override fun getDuration(intent: Intent?): Long { - return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong() ?: defaultTime - } -} - -val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE) - -val resumeApps = arrayOf( - VLC, MPV, WEB_VIDEO -) - -// Short name for requests client to make it nicer to use - -var app = Requests(responseParser = object : ResponseParser { - val mapper: ObjectMapper = jacksonObjectMapper().configure( - DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, - false - ) - - override fun parse(text: String, kClass: KClass): T { - return mapper.readValue(text, kClass.java) - } - - override fun parseSafe(text: String, kClass: KClass): T? { - return try { - mapper.readValue(text, kClass.java) - } catch (e: Exception) { - null - } - } - - override fun writeValueAsString(obj: Any): String { - return mapper.writeValueAsString(obj) - } -}).apply { - defaultHeaders = mapOf("user-agent" to USER_AGENT) -} - -class MainActivity : AppCompatActivity(), ColorPickerDialogListener { +class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { + var activityResultLauncher: ActivityResultLauncher? = null + const val TAG = "MAINACT" + const val ANIMATED_OUTLINE: Boolean = false var lastError: String? = null + + /** Update lastError variable based on error file, to check if app crashed. + * Can be called multiple times without changing the lastError variable changing. + **/ + fun setLastError(context: Context) { + if (lastError != null) return + + val errorFile = context.filesDir.resolve("last_error") + if (errorFile.exists() && errorFile.isFile) { + lastError = errorFile.readText(Charset.defaultCharset()) + errorFile.delete() + } else { + lastError = null + } + } + + private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" + const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" + + /** + * Transient files to delete on application exit. + * Deletes files on onDestroy(). + */ + private var filesToDelete: Set + // This needs to be persistent because the application may exit without calling onDestroy. + get() = getKey>(FILE_DELETE_KEY) ?: setOf() + private set(value) = setKey(FILE_DELETE_KEY, value) + + /** + * Add file to delete on Exit. + */ + fun deleteFileOnExit(file: File) { + filesToDelete = filesToDelete + file.path + } + /** * Setting this will automatically enter the query in the search * next time the search fragment is opened. @@ -302,6 +257,20 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // kinda shitty solution, but cant com main->home otherwise for popups val bookmarksUpdatedEvent = Event() + /** + * Used by DataStoreHelper to fully reload home when switching accounts + */ + val reloadHomeEvent = Event() + + /** + * Used by DataStoreHelper to fully reload library when switching accounts + */ + val reloadLibraryEvent = Event() + + /** + * Used by DataStoreHelper to fully reload Navigation Rail header picture + */ + val reloadAccountEvent = Event() /** * @return true if the str has launched an app task (be it successful or not) @@ -310,13 +279,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { fun handleAppIntentUrl( activity: FragmentActivity?, str: String?, - isWebview: Boolean + isWebview: Boolean, + extraArgs: Bundle? = null ): Boolean = with(activity) { // TODO MUCH BETTER HANDLING // Invalid URIs can crash - fun safeURI(uri: String) = normalSafeApiCall { URI(uri) } + fun safeURI(uri: String) = safe { URI(uri) } if (str != null && this != null) { if (str.startsWith("https://cs.repo")) { @@ -324,29 +294,30 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { println("Repository url: $realUrl") loadRepository(realUrl) return true - } else if (str.contains(appString)) { - for (api in OAuth2Apis) { - if (str.contains("/${api.redirectUrl}")) { + } else if (str.contains(APP_STRING)) { + for (api in AccountManager.allApis) { + if (api.isValidRedirectUrl(str)) { ioSafe { Log.i(TAG, "handleAppIntent $str") - val isSuccessful = api.handleRedirect(str) - - if (isSuccessful) { - Log.i(TAG, "authenticated ${api.name}") - } else { - Log.i(TAG, "failed to authenticate ${api.name}") - } - - this@with.runOnUiThread { - try { - showToast( - getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail).format( - api.name - ) - ) - } catch (e: Exception) { - logError(e) // format might fail + try { + val isSuccessful = api.login(str) + if (isSuccessful) { + Log.i(TAG, "authenticated ${api.name}") + } else { + Log.i(TAG, "failed to authenticate ${api.name}") } + showToast( + if (isSuccessful) { + txt(R.string.authenticated_user, api.name) + } else { + txt(R.string.authenticated_user_fail, api.name) + } + ) + } catch (t: Throwable) { + logError(t) + showToast( + txt(R.string.authenticated_user_fail, api.name) + ) } } return true @@ -354,19 +325,23 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } // 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 == "$appString:") { - PluginManager.hotReloadAllLocalPlugins(activity) + if (str == "$APP_STRING:") { + ioSafe { + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins( + activity + ) + } } - } else if (safeURI(str)?.scheme == appStringRepo) { - val url = str.replaceFirst(appStringRepo, "https") + } else if (safeURI(str)?.scheme == APP_STRING_REPO) { + val url = str.replaceFirst(APP_STRING_REPO, "https") loadRepository(url) return true - } else if (safeURI(str)?.scheme == appStringSearch) { - val query = str.substringAfter("$appStringSearch://") + } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) { + val query = str.substringAfter("$APP_STRING_SEARCH://") nextSearchQuery = try { URLDecoder.decode(query, "UTF-8") - } catch (t : Throwable) { + } catch (t: Throwable) { logError(t) query } @@ -376,8 +351,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { R.id.navigation_search activity?.findViewById(R.id.nav_rail_view)?.selectedItemId = R.id.navigation_search - } else if (safeURI(str)?.scheme == appStringPlayer) { - val uri = Uri.parse(str) + } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) { + val uri = str.toUri() val name = uri.getQueryParameter("name") val url = URLDecoder.decode(uri.authority, "UTF-8") @@ -387,12 +362,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { LinkGenerator( listOf(BasicLink(url, name)), extract = true, - ) + id = url.hashCode() + ), 0 ) ) - } else if (safeURI(str)?.scheme == appStringResumeWatching) { + } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { val id = - str.substringAfter("$appStringResumeWatching://").toIntOrNull() + str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull() ?: return false ioSafe { val resumeWatchingCard = @@ -403,34 +379,93 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { START_ACTION_RESUME_LATEST ) } + } else if (str.startsWith(APP_STRING_SHARE)) { + try { + val data = str.substringAfter("$APP_STRING_SHARE:") + val parts = data.split("?", limit = 2) + loadResult( + String(base64DecodeArray(parts[1]), Charsets.UTF_8), + String(base64DecodeArray(parts[0]), Charsets.UTF_8), + "" + ) + return true + } catch (e: Exception) { + showToast("Invalid Uri", Toast.LENGTH_SHORT) + return false + } } else if (!isWebview) { if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) { this.navigate(R.id.navigation_downloads) return true } else { - synchronized(apis) { - for (api in apis) { - if (str.startsWith(api.mainUrl)) { - loadResult(str, api.name) - return true - } - } + val apiName = extraArgs?.getString(API_NAME_EXTRA_KEY) + ?.takeIf { it.isNotBlank() } + // if provided, try to match the api name instead of the api url + // this is in order to also support providers that use JSON dataUrls + // for example + if (apiName != null) { + loadResult(str, apiName, "") + return true + } + + val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull() + if (matchedApi != null) { + loadResult(str, matchedApi.name, "") + return true } } } } 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 - fun loadPopup(result: SearchResponse) { + var lastPopupJob: Job? = null + fun loadPopup(result: SearchResponse, load: Boolean = true) { lastPopup = result - viewModel.load( - this, result.url, result.apiName, false, if (getApiDubstatusSettings() - .contains(DubStatus.Dubbed) - ) DubStatus.Dubbed else DubStatus.Subbed, null - ) + val syncName = syncViewModel.syncName(result.apiName) + + // based on apiName we decide on if it is a local list or not, this is because + // we want to show a bit of extra UI to sync apis + if (result is SyncAPI.LibraryItem && syncName != null) { + isLocalList = false + syncViewModel.setSync(syncName, result.syncId) + syncViewModel.updateMetaAndUser() + } else { + isLocalList = true + syncViewModel.clear() + } + + lastPopupJob?.cancel() + lastPopupJob = if (load) { + viewModel.load( + this, result.url, result.apiName, false, if (getApiDubstatusSettings() + .contains(DubStatus.Dubbed) + ) DubStatus.Dubbed else DubStatus.Subbed, null + ) + } else { + viewModel.loadSmall(result) + } } override fun onColorSelected(dialogId: Int, color: Int) { @@ -444,6 +479,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) updateLocale() // android fucks me by chaining lang when rotating the phone + updateTheme(this) // Update if system theme val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment @@ -468,6 +504,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { 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, @@ -482,18 +519,19 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { ).contains(destination.id) - val dontPush = listOf( + /*val dontPush = listOf( R.id.navigation_home, R.id.navigation_search, R.id.navigation_results_phone, R.id.navigation_results_tv, R.id.navigation_player, + R.id.navigation_quick_search, ).contains(destination.id) binding?.navHostFragment?.apply { val params = layoutParams as ConstraintLayout.LayoutParams val push = - if (!dontPush && isTvSettings()) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0 + if (!dontPush && isLayout(TV or EMULATOR)) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0 if (!this.isLtr()) { params.setMargins( @@ -512,34 +550,58 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } layoutParams = params - } + }*/ - val landscape = when (resources.configuration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> { - true - } - - Configuration.ORIENTATION_PORTRAIT -> { - isTvSettings() - } - - else -> { - false - } - } binding?.apply { - navView.isVisible = isNavVisible && !landscape - navRailView.isVisible = isNavVisible && landscape + navRailView.isVisible = isNavVisible && isLandscape() + navView.isVisible = isNavVisible && !isLandscape() + navHostFragment.apply { + val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width) + layoutParams = + (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { + marginStart = + if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 + } + } - // Hide library on TV since it is not supported yet :( - val isTrueTv = isTrueTvSettings() - navView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv - navRailView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv + /** + * We need to make sure if we return to a sub-fragment, + * the correct navigation item is selected so that it does not + * 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 + ) -> { + 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, + R.id.navigation_chrome_subtitles, + R.id.navigation_settings_player, + R.id.navigation_settings_updates, + R.id.navigation_settings_ui, + R.id.navigation_settings_account, + R.id.navigation_settings_providers, + R.id.navigation_settings_general, + R.id.navigation_settings_extensions, + R.id.navigation_settings_plugins, + R.id.navigation_test_providers + ) -> { + navRailView.menu.findItem(R.id.navigation_settings).isChecked = true + navView.menu.findItem(R.id.navigation_settings).isChecked = true + } + } } } //private var mCastSession: CastSession? = null - lateinit var mSessionManager: SessionManager + var mSessionManager: SessionManager? = null private val mSessionManagerListener: SessionManagerListener by lazy { SessionManagerListenerImpl() } private inner class SessionManagerListenerImpl : SessionManagerListener { @@ -576,10 +638,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { override fun onResume() { super.onResume() afterPluginsLoadedEvent += ::onAllPluginsLoaded + setActivityInstance(this) try { if (isCastApiAvailable()) { - //mCastSession = mSessionManager.currentCastSession - mSessionManager.addSessionManagerListener(mSessionManagerListener) + mSessionManager?.addSessionManagerListener(mSessionManagerListener) } } catch (e: Exception) { logError(e) @@ -595,7 +657,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } try { if (isCastApiAvailable()) { - mSessionManager.removeSessionManagerListener(mSessionManagerListener) + mSessionManager?.removeSessionManagerListener(mSessionManagerListener) //mCastSession = null } } catch (e: Exception) { @@ -603,18 +665,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - val response = CommonActivity.dispatchKeyEvent(this, event) - if (response != null) - return response - 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) - - return super.onKeyDown(keyCode, event) - } + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = + CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) override fun onUserLeaveHint() { @@ -622,55 +677,57 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { onUserLeaveHint(this) } - private fun showConfirmExitDialog() { - val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setTitle(R.string.confirm_exit_dialog) - builder.apply { - // Forceful exit since back button can actually go back to setup - setPositiveButton(R.string.yes) { _, _ -> exitProcess(0) } - setNegativeButton(R.string.no) { _, _ -> } + @SuppressLint("ApplySharedPref") // commit since the op needs to be synchronous + private fun showConfirmExitDialog(settingsManager: SharedPreferences) { + val confirmBeforeExit = settingsManager.getInt(getString(R.string.confirm_exit_key), -1) + + if (confirmBeforeExit == 1 || (confirmBeforeExit == -1 && isLayout(PHONE))) { + // finish() causes a bug on some TVs where player + // may keep playing after closing the app. + if (isLayout(TV)) exitProcess(0) else finish() + return } + + val dialogView = layoutInflater.inflate(R.layout.confirm_exit_dialog, null) + val dontShowAgainCheck: CheckBox = dialogView.findViewById(R.id.checkboxDontShowAgain) + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + builder.setView(dialogView) + .setTitle(R.string.confirm_exit_dialog) + .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ } + .setPositiveButton(R.string.yes) { _, _ -> + if (dontShowAgainCheck.isChecked) { + settingsManager.edit(commit = true) { + putInt(getString(R.string.confirm_exit_key), 1) + } + } + // finish() causes a bug on some TVs where player + // may keep playing after closing the app. + if (isLayout(TV)) exitProcess(0) else finish() + } + builder.show().setDefaultFocus() } - private fun backPressed() { - this.window?.navigationBarColor = - this.colorFromAttribute(R.attr.primaryGrayBackground) - this.updateLocale() - this.updateLocale() - - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment - val navController = navHostFragment?.navController - val isAtHome = - navController?.currentDestination?.matchDestination(R.id.navigation_home) == true - - if (isAtHome && isTrueTvSettings()) { - showConfirmExitDialog() - } else { - super.onBackPressed() - } - } - - override fun onBackPressed() { - ((supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.childFragmentManager?.primaryNavigationFragment as? IOnBackPressed)?.onBackPressed() - ?.let { runNormal -> - if (runNormal) backPressed() - } ?: run { - backPressed() - } - } - override fun onDestroy() { + filesToDelete.forEach { path -> + val result = File(path).deleteRecursively() + if (result) { + Log.d(TAG, "Deleted temporary file: $path") + } else { + Log.d(TAG, "Failed to delete temporary file: $path") + } + } + filesToDelete = setOf() val broadcastIntent = Intent() broadcastIntent.action = "restart_service" 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) } @@ -679,13 +736,55 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (intent == null) return val str = intent.dataString loadCache() - handleAppIntentUrl(this, str, false) + + 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) @@ -698,11 +797,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { saveState = true ) } - val options = builder.build() return try { - navController.navigate(item.itemId, null, options) - navController.currentDestination?.matchDestination(item.itemId) == true + navController.navigate(destinationId, null, builder.build()) + navController.currentDestination?.matchDestination(destinationId) == true } catch (e: IllegalArgumentException) { + Log.e("NavigationError", "Failed to navigate: ${e.message}") false } } @@ -711,19 +810,21 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { pluginsLock.withLock { - synchronized(allProviders) { + allProviders.withLock { // Load cloned sites after plugins have been loaded since clones depend on plugins. try { getKey>(USER_PROVIDER_API)?.let { list -> list.forEach { custom -> allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass } ?.let { - allProviders.add(it.javaClass.newInstance().apply { - name = custom.name - lang = custom.lang - mainUrl = custom.url.trimEnd('/') - canBeOverridden = false - }) + allProviders.add( + it.javaClass.getDeclaredConstructor().newInstance() + .apply { + name = custom.name + lang = custom.lang + mainUrl = custom.url.trimEnd('/') + canBeOverridden = false + }) } } } @@ -740,30 +841,52 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } lateinit var viewModel: ResultViewModel2 + lateinit var syncViewModel: SyncViewModel + private var libraryViewModel: LibraryViewModel? = null + /** kinda dirty, however it signals that we should use the watch status as sync or not*/ + var isLocalList: Boolean = false override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? { - viewModel = - ViewModelProvider(this)[ResultViewModel2::class.java] + + viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] + syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java] return super.onCreateView(name, context, attrs) } private fun hidePreviewPopupDialog() { bottomPreviewPopup.dismissSafe(this) + lastPopupJob?.cancel() + lastPopupJob = null bottomPreviewPopup = null bottomPreviewBinding = null } - private var bottomPreviewPopup: BottomSheetDialog? = null + private var bottomPreviewPopup: Dialog? = null private var bottomPreviewBinding: BottomResultviewPreviewBinding? = null private fun showPreviewPopupDialog(): BottomResultviewPreviewBinding { val ret = (bottomPreviewBinding ?: run { - val builder = - BottomSheetDialog(this) - val binding: BottomResultviewPreviewBinding = - BottomResultviewPreviewBinding.inflate(builder.layoutInflater, null, false) + + 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) + bottomPreviewBinding = binding - builder.setContentView(binding.root) + builder.setContentView(root) builder.setOnDismissListener { bottomPreviewPopup = null bottomPreviewBinding = null @@ -830,6 +953,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { focusOutline.get()?.isVisible = false } } + /*private val scrollListener = object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + current = current.copy(x = current.x + dx, y = current.y + dy) + setTargetPosition(current) + } + }*/ private fun setTargetPosition(target: FocusTarget) { focusOutline.get()?.apply { @@ -859,7 +989,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { RecyclerView::class.java.declaredMethods.firstOrNull { it.name == "scrollStep" }?.also { it.isAccessible = true } - } catch (t : Throwable) { + } catch (t: Throwable) { null } } @@ -872,7 +1002,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (!exactlyTheSame) { lastView?.removeOnLayoutChangeListener(layoutListener) lastView?.removeOnAttachStateChangeListener(attachListener) - (lastView?.parent as? RecyclerView)?.removeOnLayoutChangeListener(layoutListener) + (lastView?.parent as? RecyclerView)?.apply { + removeOnLayoutChangeListener(layoutListener) + //removeOnScrollListener(scrollListener) + } } val wasGone = focusOutline.isGone @@ -906,11 +1039,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (dx > 0) dx else 0 } - if(!NO_MOVE_LIST) { + if (!NO_MOVE_LIST) { parent.smoothScrollBy(rdx, 0) - }else { + } else { val smoothScroll = reflectedScroll - if(smoothScroll == null) { + if (smoothScroll == null) { parent.smoothScrollBy(rdx, 0) } else { try { @@ -920,12 +1053,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { val out = IntArray(2) smoothScroll.invoke(parent, rdx, 0, out) val scrolledX = out[0] - if(abs(scrolledX) <= 0) { // newFocus.measuredWidth*2 + if (abs(scrolledX) <= 0) { // newFocus.measuredWidth*2 smoothScroll.invoke(parent, -rdx, 0, out) parent.smoothScrollBy(scrolledX, 0) if (NO_MOVE_LIST) targetDx = scrolledX } - } catch (t : Throwable) { + } catch (t: Throwable) { parent.smoothScrollBy(rdx, 0) } } @@ -950,7 +1083,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { focusOutline.isVisible = false } if (!exactlyTheSame) { - (newFocus.parent as? RecyclerView)?.addOnLayoutChangeListener(layoutListener) + (newFocus.parent as? RecyclerView)?.apply { + addOnLayoutChangeListener(layoutListener) + //addOnScrollListener(scrollListener) + } newFocus.addOnLayoutChangeListener(layoutListener) newFocus.addOnAttachStateChangeListener(attachListener) } @@ -968,8 +1104,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { ) // if they are the same within then snap, aka scrolling - val deltaMin = 50.toPx - if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMin && (start.y - end.y).absoluteValue < deltaMin) { + val deltaMinX = min(end.width / 2, 60.toPx) + val deltaMinY = min(end.height / 2, 60.toPx) + if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMinX && (start.y - end.y).absoluteValue < deltaMinY) { animator?.cancel() last = start current = end @@ -998,7 +1135,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // animate between a and b animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply { startDelay = 0 - duration = 100 + duration = 200 addUpdateListener { animation -> val animatedValue = animation.animatedValue as Float val target = FocusTarget.lerp(last, current, minOf(animatedValue, 1.0f)) @@ -1040,18 +1177,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } - override fun onCreate(savedInstanceState: Bundle?) { - app.initClient(this) + app.initClient(this, ignoreSSL = false) + @OptIn(UnsafeSSL::class) + insecureApp.initClient(this, ignoreSSL = true) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val errorFile = filesDir.resolve("last_error") - if (errorFile.exists() && errorFile.isFile) { - lastError = errorFile.readText(Charset.defaultCharset()) - errorFile.delete() - } else { - lastError = null - } + setLastError(this) val settingsForProvider = SettingsJson() settingsForProvider.enableAdult = @@ -1060,11 +1193,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { MainAPI.settingsForProvider = settingsForProvider loadThemes(this) + enableEdgeToEdgeCompat() + setNavigationBarColorCompat(R.attr.primaryGrayBackground) updateLocale() super.onCreate(savedInstanceState) try { if (isCastApiAvailable()) { - mSessionManager = CastContext.getSharedInstance(this).sessionManager + CastContext.getSharedInstance(this) { it.run() } + .addOnSuccessListener { mSessionManager = it.sessionManager } } } catch (t: Throwable) { logError(t) @@ -1074,29 +1210,63 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { updateTv() // backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting? - try { + safe { val appVer = BuildConfig.VERSION_NAME val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: "" if (appVer != lastAppAutoBackup) { setKey("VERSION_NAME", BuildConfig.VERSION_NAME) - backup() + if (lastAppAutoBackup.isEmpty()) return@safe + + safe { + backup(this) + } + safe { + // Recompile oat on new version + PluginManager.deleteAllOatFiles(this) + } } - } catch (t: Throwable) { - logError(t) } // just in case, MAIN SHOULD *NEVER* BOOT LOOP CRASH binding = try { - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { val newLocalBinding = ActivityMainTvBinding.inflate(layoutInflater, null, false) setContentView(newLocalBinding.root) - TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline) - newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> - // println("refocus $oldFocus -> $newFocus") - TvFocus.updateFocusView(newFocus) + + if (isLayout(TV) && ANIMATED_OUTLINE) { + TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline) + newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener { + TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true) + } + newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> + TvFocus.updateFocusView(newFocus) + } + } else { + newLocalBinding.focusOutline.isVisible = false } - newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener { - TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true) + + 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_info_btt, + R.id.home_preview_hidden_next_focus, + R.id.home_preview_hidden_prev_focus, + R.id.result_play_movie_button, + R.id.result_play_series_button, + R.id.result_resume_series_button, + R.id.result_play_trailer_button, + R.id.result_bookmark_Button, + R.id.result_favorite_Button, + R.id.result_subscribe_Button, + R.id.result_search_Button, + R.id.result_episodes_show_button, + ) + + newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> + if (exceptionButtons.contains(newFocus?.id)) return@addOnGlobalFocusChangeListener + centerView(newFocus) + } } ActivityMainBinding.bind(newLocalBinding.root) // this may crash @@ -1110,7 +1280,46 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { null } - changeStatusBarState(isEmulatorSettings()) + 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 **/ + val noAccounts = settingsManager.getBoolean( + getString(R.string.skip_startup_account_select_key), + false + ) || accounts.count() <= 1 + + if (isLayout(PHONE) && isAuthEnabled(this) && noAccounts) { + if (deviceHasPasswordPinLock(this)) { + startBiometricAuthentication(this, R.string.biometric_authentication_title, false) + + promptInfo?.let { prompt -> + biometricPrompt?.authenticate(prompt) + } + + // hide background while authenticating, Sorry moms & dads 🙏 + binding?.navHostFragment?.isInvisible = true + } + } // Automatically enable jsdelivr if cant connect to raw.githubusercontent.com if (this.getKey(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) { @@ -1119,30 +1328,25 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { this.setKey(getString(R.string.jsdelivr_proxy_key), false) } else { this.setKey(getString(R.string.jsdelivr_proxy_key), true) - val parentView: View = findViewById(android.R.id.content) - Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG) - .let { snackbar -> - snackbar.setAction(R.string.revert) { - setKey(getString(R.string.jsdelivr_proxy_key), false) - } - snackbar.setBackgroundTint(colorFromAttribute(R.attr.primaryGrayBackground)) - snackbar.setTextColor(colorFromAttribute(R.attr.textColor)) - snackbar.setActionTextColor(colorFromAttribute(R.attr.colorPrimary)) - snackbar.show() - } + showSnackbar( + this@MainActivity, + R.string.jsdelivr_enabled, + Snackbar.LENGTH_LONG, + R.string.revert + ) { setKey(getString(R.string.jsdelivr_proxy_key), false) } } - } } + ioSafe { SafeFile.check(this@MainActivity) } if (PluginManager.checkSafeModeFile()) { - normalSafeApiCall { + safe { showToast(R.string.safe_mode_file, Toast.LENGTH_LONG) } } else if (lastError == null) { ioSafe { - getKey(USER_SELECTED_HOMEPAGE_API)?.let { homeApi -> + DataStoreHelper.currentHomePage?.let { homeApi -> mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi)) } ?: run { mainPluginsLoadedEvent.invoke(false) @@ -1154,9 +1358,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { true ) ) { - PluginManager.updateAllOnlinePluginsAndLoadThem(this@MainActivity) + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem( + this@MainActivity + ) } else { - loadAllOnlinePlugins(this@MainActivity) + ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(this@MainActivity) } //Automatically download not existing plugins, using mode specified. @@ -1167,7 +1373,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { ) ) ?: AutoDownloadMode.Disable if (autoDownloadPlugin != AutoDownloadMode.Disable) { - PluginManager.downloadNotExistingPluginsAndLoad( + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( this@MainActivity, autoDownloadPlugin ) @@ -1175,8 +1381,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } ioSafe { - PluginManager.loadAllLocalPlugins(this@MainActivity, false) + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins( + this@MainActivity, + false + ) } + +// Add your channel creation here + } } else { val builder: AlertDialog.Builder = AlertDialog.Builder(this) @@ -1195,6 +1407,77 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { builder.show().setDefaultFocus() } + + fun setUserData(status: Resource?) { + if (isLocalList) return + bottomPreviewBinding?.apply { + when (status) { + is Resource.Success -> { + resultviewPreviewBookmark.isEnabled = true + resultviewPreviewBookmark.setText(status.value.status.stringRes) + resultviewPreviewBookmark.setIconResource(status.value.status.iconRes) + } + + is Resource.Failure -> { + resultviewPreviewBookmark.isEnabled = false + resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) + resultviewPreviewBookmark.text = status.errorString + } + + else -> { + resultviewPreviewBookmark.isEnabled = false + resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) + resultviewPreviewBookmark.setText(R.string.loading) + } + } + } + } + + fun setWatchStatus(state: WatchType?) { + if (!isLocalList || state == null) return + + bottomPreviewBinding?.resultviewPreviewBookmark?.apply { + setIconResource(state.iconRes) + setText(state.stringRes) + } + } + + fun setSubscribeStatus(state: Boolean?) { + bottomPreviewBinding?.resultviewPreviewSubscribe?.apply { + if (state != null) { + val drawable = if (state) { + R.drawable.ic_baseline_notifications_active_24 + } else { + R.drawable.baseline_notifications_none_24 + } + setImageResource(drawable) + } + isVisible = state != null + + setOnClickListener { + viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleSubscriptionStatus + + val message = if (newStatus) { + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else { + R.string.subscription_deleted + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) + } + } + } + } + + observe(viewModel.watchStatus, ::setWatchStatus) + observe(syncViewModel.userData, ::setUserData) + observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus) + observeNullable(viewModel.page) { resource -> if (resource == null) { hidePreviewPopupDialog() @@ -1229,26 +1512,86 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { resultviewPreviewMetaDuration.setText(d.durationText) resultviewPreviewMetaRating.setText(d.ratingText) - resultviewPreviewDescription.setText(d.plotText) - resultviewPreviewPoster.setImage( - d.posterImage ?: d.posterBackgroundImage - ) + 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.setOnClickListener { + setUserData(syncViewModel.userData.value) + setWatchStatus(viewModel.watchStatus.value) + setSubscribeStatus(viewModel.subscribeStatus.value) + + resultviewPreviewBookmark.setOnClickListener { //viewModel.updateWatchStatus(WatchType.PLANTOWATCH) - val value = viewModel.watchStatus.value ?: WatchType.NONE + if (isLocalList) { + val value = viewModel.watchStatus.value ?: WatchType.NONE - this@MainActivity.showBottomDialog( - WatchType.values().map { getString(it.stringRes) }.toList(), - value.ordinal, - this@MainActivity.getString(R.string.action_add_to_bookmarks), - showApply = false, - {}) { - viewModel.updateWatchStatus(WatchType.values()[it]) + this@MainActivity.showBottomDialog( + WatchType.entries.map { getString(it.stringRes) }.toList(), + value.ordinal, + this@MainActivity.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + viewModel.updateWatchStatus( + WatchType.entries[it], + this@MainActivity + ) + } + } else { + val value = + (syncViewModel.userData.value as? Resource.Success)?.value?.status + ?: SyncWatchType.NONE + + this@MainActivity.showBottomDialog( + SyncWatchType.entries.map { getString(it.stringRes) }.toList(), + value.ordinal, + this@MainActivity.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + syncViewModel.setStatus(SyncWatchType.entries[it].internalId) + syncViewModel.publishUserData() + } } } - if (!isTvSettings()) // dont want this clickable on tv layout + observeNullable(viewModel.favoriteStatus) observeFavoriteStatus@{ isFavorite -> + resultviewPreviewFavorite.isVisible = isFavorite != null + if (isFavorite == null) return@observeFavoriteStatus + + val drawable = if (isFavorite) { + R.drawable.ic_baseline_favorite_24 + } else { + R.drawable.ic_baseline_favorite_border_24 + } + + resultviewPreviewFavorite.setImageResource(drawable) + } + + resultviewPreviewFavorite.setOnClickListener { + viewModel.toggleFavoriteStatus(this@MainActivity) { newStatus: Boolean? -> + if (newStatus == null) return@toggleFavoriteStatus + + val message = if (newStatus) { + R.string.favorite_added + } else { + R.string.favorite_removed + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(this@MainActivity) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) + } + } + + if (isLayout(PHONE)) // dont want this clickable on tv layout resultviewPreviewDescription.setOnClickListener { view -> view.context?.let { ctx -> val builder: AlertDialog.Builder = @@ -1283,15 +1626,24 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // init accounts ioSafe { - for (api in accountManagers) { - api.init() - } + // 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 + libraryViewModel = + ViewModelProvider(this@MainActivity)[LibraryViewModel::class.java] + libraryViewModel?.currentApiName?.observe(this@MainActivity) { + val syncAPI = libraryViewModel?.currentSyncApi + Log.i("SYNC_API", "${syncAPI?.name}, ${syncAPI?.idPrefix}") + val icon = if (syncAPI?.idPrefix == localListApi.idPrefix) { + R.drawable.library_icon_selector + } else { + syncAPI?.icon ?: R.drawable.library_icon_selector + } - inAppAuths.amap { api -> - try { - api.initialize() - } catch (e: Exception) { - logError(e) + binding?.apply { + navRailView.menu.findItem(R.id.navigation_library)?.setIcon(icon) + navView.menu.findItem(R.id.navigation_library)?.setIcon(icon) + } } } } @@ -1301,9 +1653,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { ioSafe { initAll() // No duplicates (which can happen by registerMainAPI) - apis = synchronized(allProviders) { - allProviders.distinctBy { it } - } + apis = allProviders.distinctBy { it } } // val navView: BottomNavigationView = findViewById(R.id.nav_view) @@ -1322,6 +1672,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery) } } + + if (navDestination.matchDestination(R.id.navigation_home)) { + attachBackPressedCallback("MainActivity") { + showConfirmExitDialog(settingsManager) + } + } else detachBackPressedCallback("MainActivity") } //val navController = findNavController(R.id.nav_host_fragment) @@ -1347,17 +1703,27 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { navController ) } + } binding?.navRailView?.apply { - itemRippleColor = rippleColor - itemActiveIndicatorColor = rippleColor + if (isLayout(PHONE)) { + itemRippleColor = rippleColor + itemActiveIndicatorColor = rippleColor + } else { + val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f)) + val rippleColorTransparent = + ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f)) + itemSpacing = 12.toPx // expandedItemSpacing does not have an attr + itemRippleColor = rippleColorTransparent + itemActiveIndicatorColor = rippleColor + } setupWithNavController(navController) - if (isTvSettings()) { + /*if (isLayout(TV or EMULATOR)) { background?.alpha = 200 } else { background?.alpha = 255 - } + }*/ setOnItemSelectedListener { item -> onNavDestinationSelected( @@ -1366,6 +1732,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { ) } + fun noFocus(view: View) { view.tag = view.context.getString(R.string.tv_no_focus_tag) (view as? ViewGroup)?.let { @@ -1374,7 +1741,132 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } } - noFocus(this) + //noFocus(this) + + val navProfileRoot = findViewById(R.id.nav_footer_root) + + if (isLayout(TV or EMULATOR)) { + val navProfilePic = findViewById(R.id.nav_footer_profile_pic) + val navProfileCard = findViewById(R.id.nav_footer_profile_card) + + navProfileCard?.setOnClickListener { + showAccountSelectLinear() + } + + val homeViewModel = + ViewModelProvider(this@MainActivity)[HomeViewModel::class.java] + + observe(homeViewModel.currentAccount) { currentAccount -> + if (currentAccount != null) { + navProfilePic?.loadImage( + currentAccount.image + ) + navProfileRoot.isVisible = true + } else { + navProfileRoot.isGone = true + } + } + } else { + navProfileRoot.isGone = true + } + } + + 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() @@ -1443,7 +1935,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { fun buildMediaQueueItem(video: String): MediaQueueItem { // val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO) //movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream") - val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString()) + val mediaInfo = MediaInfo.Builder(video.toUri().toString()) .setStreamType(MediaInfo.STREAM_TYPE_NONE) .setContentType(MimeTypes.IMAGE_JPEG) // .setMetadata(movieMetadata).build() @@ -1469,7 +1961,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" - synchronized(allProviders) { + allProviders.withLock { 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) { navController.navigate(R.id.navigation_setup_language) @@ -1518,8 +2028,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } catch (e: Exception) { logError(e) - } finally { - setKey(HAS_DONE_SETUP_KEY, true) } // Used to check current focus for TV @@ -1531,6 +2039,24 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // } // } + attachBackPressedCallback("MainActivityDefault") { + setNavigationBarColorCompat(R.attr.primaryGrayBackground) + updateLocale() + runDefault() + } + + // Start the download queue + DownloadQueueManager.init(this) + } + + /** Biometric stuff **/ + override fun onAuthenticationSuccess() { + // make background (nav host fragment) visible again + binding?.navHostFragment?.isInvisible = false + } + + override fun onAuthenticationError() { + finish() } suspend fun checkGithubConnectivity(): Boolean { diff --git a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt deleted file mode 100644 index 1fe007485..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.lagradost.cloudstream3 - -import com.lagradost.cloudstream3.MainActivity.Companion.lastError -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.plugins.PluginManager.checkSafeModeFile -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -object NativeCrashHandler { - // external fun triggerNativeCrash() - private external fun initNativeCrashHandler() - private external fun getSignalStatus(): Int - - private fun initSignalPolling() = CoroutineScope(Dispatchers.IO).launch { - - //launch { - // delay(10000) - // triggerNativeCrash() - //} - - while (true) { - delay(10_000) - val signal = getSignalStatus() - // Signal is initialized to zero - if (signal == 0) continue - - // Do not crash in safe mode! - if (lastError != null) continue - if (checkSafeModeFile()) continue - - AcraApplication.exceptionHandler?.uncaughtException( - Thread.currentThread(), - RuntimeException("Native crash with code: $signal. Try uninstalling extensions.\n") - ) - } - } - - fun initCrashHandler() { - try { - System.loadLibrary("native-lib") - initNativeCrashHandler() - } catch (t: Throwable) { - // Make debug crash. - if (BuildConfig.DEBUG) throw t - logError(t) - return - } - - initSignalPolling() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt b/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt deleted file mode 100644 index 469554275..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.lagradost.cloudstream3 - -import com.lagradost.cloudstream3.mvvm.logError -import kotlinx.coroutines.* - -//https://stackoverflow.com/questions/34697828/parallel-operations-on-kotlin-collections -/* -fun Iterable.pmap( - numThreads: Int = maxOf(Runtime.getRuntime().availableProcessors() - 2, 1), - exec: ExecutorService = Executors.newFixedThreadPool(numThreads), - transform: (T) -> R, -): List { - - // default size is just an inlined version of kotlin.collections.collectionSizeOrDefault - val defaultSize = if (this is Collection<*>) this.size else 10 - val destination = Collections.synchronizedList(ArrayList(defaultSize)) - - for (item in this) { - exec.submit { destination.add(transform(item)) } - } - - exec.shutdown() - exec.awaitTermination(1, TimeUnit.DAYS) - - return ArrayList(destination) -}*/ - - -@OptIn(DelicateCoroutinesApi::class) -suspend fun Map.amap(f: suspend (Map.Entry) -> R): List = - with(CoroutineScope(GlobalScope.coroutineContext)) { - map { async { f(it) } }.map { it.await() } - } - -fun Map.apmap(f: suspend (Map.Entry) -> R): List = runBlocking { - map { async { f(it) } }.map { it.await() } -} - - -@OptIn(DelicateCoroutinesApi::class) -suspend fun List.amap(f: suspend (A) -> B): List = - with(CoroutineScope(GlobalScope.coroutineContext)) { - map { async { f(it) } }.map { it.await() } - } - - -fun List.apmap(f: suspend (A) -> B): List = runBlocking { - map { async { f(it) } }.map { it.await() } -} - -fun List.apmapIndexed(f: suspend (index: Int, A) -> B): List = runBlocking { - mapIndexed { index, a -> async { f(index, a) } }.map { it.await() } -} - -@OptIn(DelicateCoroutinesApi::class) -suspend fun List.amapIndexed(f: suspend (index: Int, A) -> B): List = - with(CoroutineScope(GlobalScope.coroutineContext)) { - mapIndexed { index, a -> async { f(index, a) } }.map { it.await() } - } - -// run code in parallel -/*fun argpmap( - vararg transforms: () -> R, - numThreads: Int = maxOf(Runtime.getRuntime().availableProcessors() - 2, 1), - exec: ExecutorService = Executors.newFixedThreadPool(numThreads) -) { - for (item in transforms) { - exec.submit { item.invoke() } - } - - exec.shutdown() - exec.awaitTermination(1, TimeUnit.DAYS) -}*/ - -// built in try catch -fun argamap( - vararg transforms: suspend () -> R, -) = runBlocking { - transforms.map { - async { - try { - it.invoke() - } catch (e: Exception) { - logError(e) - } - } - }.map { it.await() } -} \ 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 new file mode 100644 index 000000000..a3c4040b5 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt @@ -0,0 +1,26 @@ +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 new file mode 100644 index 000000000..ac912cbeb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt @@ -0,0 +1,135 @@ +package com.lagradost.cloudstream3.actions + +import android.app.Activity +import android.content.ComponentName +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.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.ResultFragment +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled +import com.lagradost.cloudstream3.utils.DataStoreHelper +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) + ResultFragment.updateUI() +} + +/** + * Util method that may be helpful for creating intents for apps that support m3u8 files. + * All sources are written to a temporary m3u8 file, which is then sent to the app. + */ +fun makeTempM3U8Intent( + context: Context, + intent: Intent, + result: LinkLoadingResult +) { + if (result.links.size == 1) { + intent.setDataAndType(result.links.first().url.toUri(), "video/*") + return + } + + intent.apply { + addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + + val outputFile = File.createTempFile("mirrorlist", ".m3u8", context.cacheDir) + var text = "#EXTM3U\n#EXT-X-VERSION:3" + + result.links.forEach { link -> + text += "\n#EXTINF:0,${link.name}\n${link.url}" + } + + //With subtitles it doesn't work for no reason :( + /*for (sub in result.subs) { + val normalizedName = sub.name.replace("[^a-zA-Z0-9 ]".toRegex(), "") + text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${normalizedName}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.languageCode}\",URI=\"${sub.url}\"" + }*/ + + text += "\n#EXT-X-ENDLIST" + outputFile.writeText(text) + + intent.setDataAndType( + FileProvider.getUriForFile( + context, + context.applicationContext.packageName + ".provider", + outputFile + ), "application/x-mpegURL" + ) +} + +abstract class OpenInAppAction( + open val appName: UiText, + open val packageName: String, + private val intentClass: String? = null, + private val action: String = Intent.ACTION_VIEW +) : VideoClickAction() { + override val name: UiText + get() = txt(R.string.episode_action_play_in_format, appName) + + override val isPlayer = true + + override fun shouldShow(context: Context?, video: ResultEpisode?) = + context?.isAppInstalled(packageName) != false + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + if (context == null) return + val intent = Intent(action) + intent.setPackage(packageName) + if (intentClass != null) { + intent.component = ComponentName(packageName, intentClass) + } + putExtra(context, intent, video, result, index) + setKey("last_opened", video) + launchResult(intent) + } + + /** + * Before intent is sent, this function is called to put extra data into the intent. + * @see VideoClickAction.runAction + * */ + @Throws + abstract suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) + + /** + * This function is called when the app is opened again after the intent was sent. + * You can use it to for example update duration and position. + * @see updateDurationAndPosition + */ + @Throws + abstract fun onResult(activity: Activity, intent: Intent?) + + /** Safe version of onResult, we don't trust extension devs to not crash the app */ + fun onResultSafe(activity: Activity, intent: Intent?) { + try { + onResult(activity, intent) + } catch (t: Throwable) { + logError(t) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt new file mode 100644 index 000000000..a864b5fb7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -0,0 +1,205 @@ +package com.lagradost.cloudstream3.actions + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.core.app.ActivityOptionsCompat +import com.lagradost.api.Log +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage +import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction +import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage +import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage +import com.lagradost.cloudstream3.actions.temp.MpvExPackage +import com.lagradost.cloudstream3.actions.temp.MpvKtPackage +import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage +import com.lagradost.cloudstream3.actions.temp.MpvPackage +import com.lagradost.cloudstream3.actions.temp.MpvRxPackage +import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage +import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage +import com.lagradost.cloudstream3.actions.temp.OnlyPlayer +import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction +import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction +import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action +import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage +import com.lagradost.cloudstream3.actions.temp.VlcPackage +import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage +import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.UiText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.Callable +import java.util.concurrent.FutureTask +import kotlin.reflect.jvm.jvmName + +object VideoClickActionHolder { + val allVideoClickActions = atomicListOf( + // Default + PlayInBrowserAction(), + CopyClipboardAction(), + ViewM3U8Action(), + PlayMirrorAction(), + // main support external apps + VlcPackage(), + MpvPackage(), + MpvExPackage(), + NextPlayerPackage(), + JustPlayerPackage(), + FcastAction(), + LibreTorrentPackage(), + BiglyBTPackage(), + // forks/backup apps + VlcNightlyPackage(), + WebVideoCastPackage(), + MpvYTDLPackage(), + MpvKtPackage(), + MpvKtPreviewPackage(), + OnlyPlayer(), + MpvRxPackage(), + // Always Ask option + AlwaysAskAction(), + // added by plugins + // ... + ) + + init { + Log.d("VideoClickActionHolder", "allVideoClickActions: ${allVideoClickActions.map { it.uniqueId() }}") + } + + private const val ACTION_ID_OFFSET = 1000 + + fun makeOptionMap(activity: Activity?, video: ResultEpisode) = allVideoClickActions + // We need to have index before filtering + .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET } + .filter { it.first.shouldShowSafe(activity, video) } + .map { it.first.name to it.second } + + + fun getActionById(id: Int): VideoClickAction? = allVideoClickActions.getOrNull(id - ACTION_ID_OFFSET) + + fun getByUniqueId(uniqueId: String): VideoClickAction? = allVideoClickActions.firstOrNull { it.uniqueId() == uniqueId } + + fun uniqueIdToId(uniqueId: String?): Int? { + if (uniqueId == null) return null + return allVideoClickActions + .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET } + .firstOrNull { it.first.uniqueId() == uniqueId } + ?.second + } + + fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShowSafe(activity, null) } +} + +abstract class VideoClickAction { + abstract val name: UiText + + /** if true, the app will show dialog to select source - result.links[index] */ + open val oneSource : Boolean = false + + /** if true, this action could be selected as default player (one press action) in settings */ + open val isPlayer: Boolean = false + + /** Which type of sources this action can handle. */ + open val sourceTypes: Set = ExtractorLinkType.entries.toSet() + + /** Determines which plugin a given provider is from. This is the full path to the plugin. */ + var sourcePlugin: String? = null + + /** Even if VideoClickAction should not run any UI code, startActivity requires it, + * this is a wrapper for runOnUiThread in a suspended safe context that bubble up exceptions */ + @Throws + suspend fun uiThread(callable : Callable) : T? { + val future = FutureTask{ + try { + Result.success(callable.call()) + } catch (t : Throwable) { + Result.failure(t) + } + } + CommonActivity.activity?.runOnUiThread(future) ?: throw ErrorLoadingException("No UI Activity, this should never happened") + val result = withContext(Dispatchers.IO) { + return@withContext future.get() + } + return result.getOrThrow() + } + + /** Internally uses activityResultLauncher, + * use this when the activity has a result like watched position */ + @Throws + suspend fun launchResult(intent : Intent?, options : ActivityOptionsCompat? = null) { + if (intent == null) { + return + } + + uiThread { + MainActivity.activityResultLauncher?.launch(intent,options) + } + } + + /** Internally uses startActivity, use this when you don't + * have any result that needs to be stored when exiting the activity */ + @Throws + suspend fun launch(intent : Intent?, bundle : Bundle? = null) { + if (intent == null) { + return + } + + uiThread { + CommonActivity.activity?.startActivity(intent, bundle) + } + } + + fun uniqueId() = "$sourcePlugin:${this::class.jvmName}" + + @Throws + abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean + + /** Safe version of shouldShow, as we don't trust extension devs to handle exceptions, + * however no dev *should* throw in shouldShow */ + fun shouldShowSafe(context: Context?, video: ResultEpisode?): Boolean { + return try { + shouldShow(context,video) + } catch (t : Throwable) { + logError(t) + false + } + } + + /** + * This function is called when the action is clicked. + * @param context The current activity + * @param video The episode/movie that was clicked + * @param result The result of the link loading, contains video & subtitle links + * @param index if oneSource is true, this is the index of the selected source + */ + @Throws + abstract suspend fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) + + /** Safe version of runAction, as we don't trust extension devs to handle exceptions */ + fun runActionSafe(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) = ioSafe { + try { + runAction(context, video, result, index) + } catch (_ : NotImplementedError) { + CommonActivity.showToast("runAction has not been implemented for ${name.asStringNull(context)}, please contact the extension developer of $sourcePlugin", Toast.LENGTH_LONG) + } catch (error : ErrorLoadingException) { + CommonActivity.showToast(error.message, Toast.LENGTH_LONG) + } catch (_: ActivityNotFoundException) { + CommonActivity.showToast(R.string.app_not_found_error, Toast.LENGTH_LONG) + } catch (t : Throwable) { + logError(t) + CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG) + } + } +} 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 new file mode 100644 index 000000000..a7401c2ff --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..3959bb9d3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..d414b6117 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt @@ -0,0 +1,162 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.ui.player.ExtractorUri +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.SubtitleOrigin +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF +import com.lagradost.cloudstream3.utils.txt + +/** + * If you want to support CloudStream 3 as an external player, then this shows how to play any video link + * For basic interactions, just `intent.data = uri` works + * + * However for more advanced use, CloudStream 3 also supports playlists of MinimalVideoLink and MinimalSubtitleLink with a `String[]` of JSON + * These are passed as LINKS_EXTRA and SUBTITLE_EXTRA respectively + */ +@Suppress("Unused") +class CloudStreamPackage : OpenInAppAction( + appName = txt("CloudStream"), + packageName = BuildConfig.APPLICATION_ID, //"com.lagradost.cloudstream3" or "com.lagradost.cloudstream3.prerelease" + intentClass = "com.lagradost.cloudstream3.ui.player.DownloadedPlayerActivity" +) { + override val oneSource: Boolean = false + + companion object { + const val SUBTITLE_EXTRA: String = "subs" // Json of an array of MinimalVideoLink + const val LINKS_EXTRA: String = "links" // Json of an array of MinimalSubtitleLink + const val TITLE_EXTRA: String = "title" // Unused (String) + const val ID_EXTRA: String = + "id" // Identification number for the video(s), used to store start time (Int) + const val POSITION_EXTRA: String = "pos" // Start time in MS (Long) + const val DURATION_EXTRA: String = "dur" // Duration time in MS (Long) + } + + data class MinimalVideoLink( + @JsonProperty("uri") + val uri: Uri?, + @JsonProperty("url") + val url: String?, + @JsonProperty("mimeType") + val mimeType: String = "video/mp4", + @JsonProperty("name") + val name: String?, + @JsonProperty("headers") + var headers: Map = mapOf(), + @JsonProperty("quality") + val quality: Int?, + ) { + companion object { + fun fromExtractor(link: ExtractorLink): MinimalVideoLink = MinimalVideoLink( + uri = null, + url = link.url, + name = link.name, + mimeType = link.type.getMimeType(), + headers = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) + link.headers, + quality = link.quality + ) + } + + suspend fun toExtractorLink(): Pair = + url?.let { url -> + newExtractorLink( + source = "NONE", + name = name ?: "Unknown", + url = url, + type = ExtractorLinkType.entries.firstOrNull { ty -> ty.getMimeType() == mimeType } + ?: ExtractorLinkType.VIDEO) { + + this@newExtractorLink.headers = + this@MinimalVideoLink.headers + + this@newExtractorLink.quality = + this@MinimalVideoLink.quality ?: Qualities.Unknown.value + } + } to uri?.let { uri -> + ExtractorUri( + uri = uri, + name = name ?: "Unknown", + ) + } + } + + + data class MinimalSubtitleLink( + @JsonProperty("url") + val url: String, + @JsonProperty("mimeType") + val mimeType: String = "text/vtt", + @JsonProperty("name") + val name: String?, + @JsonProperty("headers") + var headers: Map = mapOf(), + ) { + companion object { + fun fromSubtitle(sub: SubtitleData): MinimalSubtitleLink = MinimalSubtitleLink( + url = sub.url, + mimeType = sub.mimeType, + name = sub.originalName, + headers = sub.headers, + ) + } + + fun toSubtitleData(): SubtitleData = SubtitleData( + url = url, + nameSuffix = "", + mimeType = mimeType, + originalName = name ?: "Unknown", + headers = headers, + origin = SubtitleOrigin.URL, + languageCode = fromCodeToLangTagIETF(name) ?: + fromLanguageToTagIETF(name, true) ?: + name, + ) + } + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.apply { + val position = getViewPos(video.id)?.position + if (position != null) + putExtra(POSITION_EXTRA, position) + + putExtra(ID_EXTRA, video.id) + putExtra(TITLE_EXTRA, video.name) + putExtra( + SUBTITLE_EXTRA, + result.subs.map { MinimalSubtitleLink.fromSubtitle(it).toJson() }.toTypedArray() + ) + putExtra( + LINKS_EXTRA, + result.links.filter { it !is ExtractorLinkPlayList && it !is DrmExtractorLink } + .map { MinimalVideoLink.fromExtractor(it).toJson() }.toTypedArray() + ) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + // No results yet + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt new file mode 100644 index 000000000..7e89d7c8c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt @@ -0,0 +1,27 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.content.Context +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper + +class CopyClipboardAction: VideoClickAction() { + override val name = txt("Copy to clipboard") + + override val oneSource = true + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + if (index == null) return + val link = result.links.getOrNull(index) ?: return + clipboardHelper(txt(link.name), link.url) + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..20eb843c7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt @@ -0,0 +1,37 @@ +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 new file mode 100644 index 000000000..11d1858c6 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt @@ -0,0 +1,36 @@ +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 new file mode 100644 index 000000000..faae39212 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt @@ -0,0 +1,68 @@ +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.actions.updateDurationAndPosition +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +class MpvKtPreviewPackage: MpvKtPackage( + appName = "mpvKt Preview", + packageName = "live.mehiz.mpvkt.preview", +) + +open class MpvKtPackage( + appName: String = "mpvKt", + packageName: String = "live.mehiz.mpvkt", +): OpenInAppAction( + appName = txt(appName), + packageName = packageName, + intentClass = "live.mehiz.mpvkt.ui.player.PlayerActivity" +) { + override val oneSource = true + + override val sourceTypes = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + val link = result.links.getOrNull(index ?: 0) ?: return + + intent.apply { + putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) + setDataAndType(link.url.toUri(), "video/*") + + // m3u8 plays, but changing sources feature is not available + // makeTempM3U8Intent(activity, this, result) + + //putExtra("headers", link.headers.flatMap { listOf(it.key, it.value) }.toTypedArray()) + + val position = getViewPos(video.id)?.position + if (position != null) + putExtra("position", position.toInt()) + + putExtra("secure_uri", true) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + val position = intent?.getIntExtra("position", -1)?.toLong() ?: -1 + val duration = intent?.getIntExtra("duration", -1)?.toLong() ?: -1 + updateDurationAndPosition(position, duration) + } + +} \ No newline at end of file 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 new file mode 100644 index 000000000..cd49eb994 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt @@ -0,0 +1,68 @@ +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.makeTempM3U8Intent +import com.lagradost.cloudstream3.actions.updateDurationAndPosition +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +// https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904 +// https://mpv-android.github.io/mpv-android/intent.html + +//https://github.com/marlboro-advance/mpvEx +class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity") + +class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { + override val sourceTypes = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) +} + +open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( + txt(appName), + packageName, + intentClass +) { + override val oneSource = true // mpv has poor playlist support on TV + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.apply { + putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) + putExtra("title", video.name) + + if (index != null) { + setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") + } else { + makeTempM3U8Intent(context, this, result) + } + + val position = getViewPos(video.id)?.position + if (position != null) + putExtra("position", position.toInt()) + + putExtra("secure_uri", true) + } + } + + 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/MpvRxPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt new file mode 100644 index 000000000..e8bb93a99 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt @@ -0,0 +1,75 @@ +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 new file mode 100644 index 000000000..5d0923b81 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt @@ -0,0 +1,35 @@ +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 new file mode 100644 index 000000000..348be440a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt @@ -0,0 +1,44 @@ +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 new file mode 100644 index 000000000..bfd2926bf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt @@ -0,0 +1,39 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +class PlayInBrowserAction: VideoClickAction() { + override val name = txt(R.string.episode_action_play_in_format, "Browser") + + override val oneSource = true + + override val isPlayer = true + + override val sourceTypes: Set = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + val link = result.links.getOrNull(index ?: 0) ?: return + val i = Intent(Intent.ACTION_VIEW) + i.data = link.url.toUri() + launch(i) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt new file mode 100644 index 000000000..56512377b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt @@ -0,0 +1,65 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.player.ExtractorUri +import com.lagradost.cloudstream3.ui.player.GeneratorPlayer +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.VideoGenerator +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.txt + +class PlayMirrorAction : VideoClickAction() { + override val name = txt(R.string.episode_action_play_mirror) + + override val oneSource = true + + override val isPlayer = true + + override val sourceTypes: Set = LOADTYPE_INAPP + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + //Implemented a generator to handle the single + val activity = context as? Activity ?: return + val link = index?.let { result.links[it] } + val generatorMirror = object : VideoGenerator(listOf(video)) { + override val hasCache: Boolean = false + override val canSkipLoading: Boolean = false + override fun getId(index: Int): Int = video.id + + override suspend fun generateLinks( + clearCache: Boolean, + sourceTypes: Set, + callback: (Pair) -> Unit, + subtitleCallback: (SubtitleData) -> Unit, + offset: Int, + isCasting: Boolean + ): Boolean { + index?.let { callback(link to null) } + result.subs.forEach { subtitle -> subtitleCallback(subtitle) } + return true + } + } + + activity.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + generatorMirror, 0, result.syncData + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt new file mode 100644 index 000000000..791566862 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.content.Context +import android.content.Intent +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.actions.makeTempM3U8Intent +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt + +class ViewM3U8Action: VideoClickAction() { + override val name = txt(R.string.episode_action_play_in_format, "m3u8 player") + + override val isPlayer = true + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + if (context == null) return + val i = Intent(Intent.ACTION_VIEW) + makeTempM3U8Intent(context, i, result) + launch(i) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt new file mode 100644 index 000000000..46b46a2c2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.net.toUri +import com.lagradost.api.Log +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.actions.makeTempM3U8Intent +import com.lagradost.cloudstream3.actions.updateDurationAndPosition +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos + +// https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898 +// https://wiki.videolan.org/Android_Player_Intents/ + +class VlcNightlyPackage : VlcPackage() { + override val packageName = "org.videolan.vlc.debug" + override val appName = txt("VLC Nightly") +} + +open class VlcPackage: OpenInAppAction( + appName = txt("VLC"), + packageName = "org.videolan.vlc", + intentClass = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + "org.videolan.vlc.gui.video.VideoPlayerActivity" + } else { + null + }, + action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + "org.videolan.vlc.player.result" + } else { + Intent.ACTION_VIEW + } +) { + // while VLC supports multi links, it has poor support, so we disable it for now + override val oneSource = true + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + if (index != null) { + intent.setDataAndType(result.links[index].url.toUri(), "video/*") + } else { + makeTempM3U8Intent(context, intent, result) + } + val position = getViewPos(video.id)?.position ?: 0L + + intent.putExtra("from_start", false) + intent.putExtra("position", position) + intent.putExtra("secure_uri", true) + intent.putExtra("title", video.name) + + val subsLang = getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" + result.subs.firstOrNull { + subsLang == it.languageCode + }?.let { + intent.putExtra("subtitles_location", it.url) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + val position = intent?.getLongExtra("extra_position", -1) ?: -1 + val duration = intent?.getLongExtra("extra_duration", -1) ?: -1 + 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 new file mode 100644 index 000000000..963221bb3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt @@ -0,0 +1,61 @@ +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.USER_AGENT +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +// https://www.webvideocaster.com/integrations + +class WebVideoCastPackage: OpenInAppAction( + txt("Web Video Cast"), + "com.instantbits.cast.webvideo" +) { + + override val oneSource = true + + override val sourceTypes = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + val link = result.links[index ?: 0] + + intent.apply { + setDataAndType(link.url.toUri(), "video/*") + + val title = video.name ?: video.headerName + + putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) + putExtra("title", title) + video.poster?.let { putExtra("poster", it) } + val headers = Bundle().apply { + if (link.referer.isNotBlank()) + putString("Referer", link.referer) + putString("User-Agent", USER_AGENT) + for ((key, value) in link.headers) { + putString(key, value) + } + } + putExtra("android.media.intent.extra.HTTP_HEADERS", headers) + putExtra("secure_uri", true) + } + } + + 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/fcast/FcastAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt new file mode 100644 index 000000000..1036a7055 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt @@ -0,0 +1,69 @@ +package com.lagradost.cloudstream3.actions.temp.fcast + +import android.content.Context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog + +class FcastAction: VideoClickAction() { + override val name = txt("Fcast to device") + + override val oneSource = true + + override val sourceTypes = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + + override fun shouldShow(context: Context?, video: ResultEpisode?) = FcastManager.currentDevices.isNotEmpty() + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + val link = result.links.getOrNull(index ?: 0) ?: return + val devices = FcastManager.currentDevices.toList() + uiThread { + context?.getActivity()?.showBottomDialog( + devices.map { it.name }, + -1, + txt(R.string.player_settings_select_cast_device).asString(context), + false, + {}) { + val position = getViewPos(video.id)?.position + castTo(devices.getOrNull(it), link, position) + } + } + } + + + private fun castTo(device: PublicDeviceInfo?, link: ExtractorLink, position: Long?) { + val host = device?.host ?: return + + FcastSession(host).use { session -> + session.sendMessage( + Opcode.Play, + PlayMessage( + link.type.getMimeType(), + link.url, + time = position?.let { it / 1000.0 }, + headers = mapOf( + "referer" to link.referer, + "user-agent" to USER_AGENT + ) + link.headers + ) + ) + } + } +} 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 new file mode 100644 index 000000000..e2cf4f002 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt @@ -0,0 +1,195 @@ +package com.lagradost.cloudstream3.actions.temp.fcast + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdManager.ResolveListener +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.os.ext.SdkExtensions +import android.util.Log +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe + +class FcastManager { + private var nsdManager: NsdManager? = null + + // Used for receiver + private val registrationListenerTcp = DefaultRegistrationListener() + private fun getDeviceName(): String { + return "${Build.MANUFACTURER}-${Build.MODEL}" + } + + /** + * Start the fcast service + * @param registerReceiver If true will register the app as a compatible fcast receiver for discovery in other app + */ + fun init(context: Context, registerReceiver: Boolean) = ioSafe { + nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + val serviceType = "_fcast._tcp" + + if (registerReceiver) { + val serviceName = "$APP_PREFIX-${getDeviceName()}" + + val serviceInfo = NsdServiceInfo().apply { + this.serviceName = serviceName + this.serviceType = serviceType + this.port = TCP_PORT + } + + nsdManager?.registerService( + serviceInfo, + NsdManager.PROTOCOL_DNS_SD, + registrationListenerTcp + ) + } + + nsdManager?.discoverServices( + serviceType, + NsdManager.PROTOCOL_DNS_SD, + DefaultDiscoveryListener() + ) + } + + fun stop() { + nsdManager?.unregisterService(registrationListenerTcp) + } + + inner class DefaultDiscoveryListener : NsdManager.DiscoveryListener { + val tag = "DiscoveryListener" + override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { + Log.d(tag, "Discovery failed: $serviceType, error code: $errorCode") + } + + override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) { + Log.d(tag, "Stop discovery failed: $serviceType, error code: $errorCode") + } + + override fun onDiscoveryStarted(serviceType: String?) { + Log.d(tag, "Discovery started: $serviceType") + } + + override fun onDiscoveryStopped(serviceType: String?) { + Log.d(tag, "Discovery stopped: $serviceType") + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo?) { + // Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback + safe { + if (serviceInfo == null) return@safe + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( + Build.VERSION_CODES.TIRAMISU + ) >= 7 + ) { + nsdManager?.registerServiceInfoCallback( + serviceInfo, + Runnable::run, + object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.e(tag, "Service registration failed: $errorCode") + } + + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.d( + tag, + "Service updated: ${serviceInfo.serviceName}," + + "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" + ) + synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } + } + + override fun onServiceLost() { + Log.d(tag, "Service lost: ${serviceInfo.serviceName},") + synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } + } + } + + override fun onServiceInfoCallbackUnregistered() {} + }) + } else { + @Suppress("DEPRECATION") + nsdManager?.resolveService(serviceInfo, object : ResolveListener { + override fun onResolveFailed( + serviceInfo: NsdServiceInfo?, + errorCode: Int + ) { + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + + synchronized(_currentDevices) { + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } + + Log.d( + tag, + "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" + ) + } + }) + } + } + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + + // May remove duplicates, but net and port is null here, preventing device specific identification + synchronized(_currentDevices) { + _currentDevices.removeAll { + it.rawName == serviceInfo.serviceName + } + } + + Log.d(tag, "Service lost: ${serviceInfo.serviceName}") + } + } + + companion object { + const val APP_PREFIX = "CloudStream" + private val _currentDevices: MutableList = mutableListOf() + val currentDevices: List = _currentDevices + + class DefaultRegistrationListener : NsdManager.RegistrationListener { + val tag = "DiscoveryService" + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { + Log.d(tag, "Service registered: ${serviceInfo.serviceName}") + } + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(tag, "Service registration failed: errorCode=$errorCode") + } + + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) { + Log.d(tag, "Service unregistered: ${serviceInfo.serviceName}") + } + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(tag, "Service unregistration failed: errorCode=$errorCode") + } + } + + const val TCP_PORT = 46899 + } +} + +class PublicDeviceInfo(serviceInfo: NsdServiceInfo) { + val rawName: String = serviceInfo.serviceName + val host: String? = if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + SdkExtensions.getExtensionVersion( + Build.VERSION_CODES.TIRAMISU + ) >= 7 + ) { + serviceInfo.hostAddresses.firstOrNull()?.hostAddress + } else { + @Suppress("DEPRECATION") + serviceInfo.host.hostAddress + } + val name = rawName.replace("-", " ") + host?.let { " $it" } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastSession.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastSession.kt new file mode 100644 index 000000000..326d11191 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastSession.kt @@ -0,0 +1,60 @@ +package com.lagradost.cloudstream3.actions.temp.fcast + +import android.util.Log +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.safefile.closeQuietly +import java.io.DataOutputStream +import java.net.Socket +import kotlin.jvm.Throws + +class FcastSession(private val hostAddress: String): AutoCloseable { + val tag = "FcastSession" + + private var socket: Socket? = null + @Throws + @WorkerThread + fun open(): Socket { + val socket = Socket(hostAddress, FcastManager.TCP_PORT) + this.socket = socket + return socket + } + + override fun close() { + socket?.closeQuietly() + socket = null + } + + @Throws + private fun acquireSocket(): Socket { + return socket ?: open() + } + + fun ping() { + sendMessage(Opcode.Ping, null) + } + + fun sendMessage(opcode: Opcode, message: T) { + ioSafe { + val socket = acquireSocket() + val outputStream = DataOutputStream(socket.getOutputStream()) + + val json = message?.toJson() + val content = json?.toByteArray() ?: ByteArray(0) + + // Little endian starting from 1 + // https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 + val size = content.size + 1 + + val sizeArray = ByteArray(4) { num -> + (size shr 8 * num and 0xff).toByte() + } + + Log.d(tag, "Sending message with size: $size, opcode: $opcode") + outputStream.write(sizeArray) + outputStream.write(ByteArray(1) { opcode.value }) + outputStream.write(content) + } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..26f5cec53 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/Packets.kt @@ -0,0 +1,62 @@ +package com.lagradost.cloudstream3.actions.temp.fcast + +// See https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 +enum class Opcode(val value: Byte) { + None(0), + Play(1), + Pause(2), + Resume(3), + Stop(4), + Seek(5), + PlaybackUpdate(6), + VolumeUpdate(7), + SetVolume(8), + PlaybackError(9), + SetSpeed(10), + Version(11), + Ping(12), + Pong(13); +} + + +data class PlayMessage( + 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 +) + +data class PlaybackUpdateMessage( + val generationTime: Long, + val time: Double, + val duration: Double, + val state: Int, + val speed: Double +) + +data class VolumeUpdateMessage( + val generationTime: Long, + val volume: Double +) + +data class PlaybackErrorMessage( + val message: String +) + +data class SetSpeedMessage( + val speed: Double +) + +data class SetVolumeMessage( + val volume: Double +) + +data class VersionMessage( + val version: Long +) diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/AStreamHub.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/AStreamHub.kt deleted file mode 100644 index b0051ba76..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/AStreamHub.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import android.util.Log -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -open class AStreamHub : ExtractorApi() { - override val name = "AStreamHub" - override val mainUrl = "https://astreamhub.com" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - app.get(url).document.selectFirst("body > script").let { script -> - val text = script?.html() ?: "" - Log.i("Dev", "text => $text") - if (text.isNotBlank()) { - val m3link = "(?<=file:)(.*)(?=,)".toRegex().find(text) - ?.groupValues?.get(0)?.trim()?.trim('"') ?: "" - Log.i("Dev", "m3link => $m3link") - if (m3link.isNotBlank()) { - sources.add( - ExtractorLink( - name = name, - source = name, - url = m3link, - isM3u8 = true, - quality = Qualities.Unknown.value, - referer = referer ?: url - ) - ) - } - } - } - return sources - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt deleted file mode 100644 index c782b29df..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.base64Decode -import com.lagradost.cloudstream3.utils.* - -open class Acefile : ExtractorApi() { - override val name = "Acefile" - override val mainUrl = "https://acefile.co" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - app.get(url).document.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val data = getAndUnpack(script.data()) - val id = data.substringAfter("{\"id\":\"").substringBefore("\",") - val key = data.substringAfter("var nfck=\"").substringBefore("\";") - app.get("https://acefile.co/local/$id?key=$key").text.let { - base64Decode( - it.substringAfter("JSON.parse(atob(\"").substringBefore("\"))") - ).let { res -> - sources.add( - ExtractorLink( - name, - name, - res.substringAfter("\"file\":\"").substringBefore("\","), - "$mainUrl/", - Qualities.Unknown.value, - ) - ) - } - } - } - } - return sources - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/AsianLoad.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/AsianLoad.kt deleted file mode 100644 index 4bed3169d..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/AsianLoad.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper -import com.lagradost.cloudstream3.utils.getQualityFromName -import java.net.URI - -open class AsianLoad : ExtractorApi() { - override var name = "AsianLoad" - override var mainUrl = "https://asianhdplay.pro" - override val requiresReferer = true - - private val sourceRegex = Regex("""sources:[\W\w]*?file:\s*?["'](.*?)["']""") - override suspend fun getUrl(url: String, referer: String?): List { - val extractedLinksList: MutableList = mutableListOf() - with(app.get(url, referer = referer)) { - sourceRegex.findAll(this.text).forEach { sourceMatch -> - val extractedUrl = sourceMatch.groupValues[1] - // Trusting this isn't mp4, may fuck up stuff - if (URI(extractedUrl).path.endsWith(".m3u8")) { - M3u8Helper.generateM3u8( - name, - extractedUrl, - url, - headers = mapOf("referer" to this.url) - ).forEach { link -> - extractedLinksList.add(link) - } - } else if (extractedUrl.endsWith(".mp4")) { - extractedLinksList.add( - ExtractorLink( - name, - name, - extractedUrl, - url.replace(" ", "%20"), - getQualityFromName(sourceMatch.groupValues[2]), - ) - ) - } - } - return extractedLinksList - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/BullStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/BullStream.kt deleted file mode 100644 index 71fa7066b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/BullStream.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class BullStream : ExtractorApi() { - override val name = "BullStream" - override val mainUrl = "https://bullstream.xyz" - override val requiresReferer = false - val regex = Regex("(?<=sniff\\()(.*)(?=\\)\\);)") - - override suspend fun getUrl(url: String, referer: String?): List? { - val data = regex.find(app.get(url).text)?.value - ?.replace("\"", "") - ?.split(",") - ?: return null - - val m3u8 = "$mainUrl/m3u8/${data[1]}/${data[2]}/master.txt?s=1&cache=${data[4]}" - //println("shiv : $m3u8") - return M3u8Helper.generateM3u8( - name, - m3u8, - url, - headers = mapOf("referer" to url, "accept" to "*/*") - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/ByteShare.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/ByteShare.kt deleted file mode 100644 index 2d56fe1f5..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/ByteShare.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.utils.* - -open class ByteShare : ExtractorApi() { - override val name = "ByteShare" - override val mainUrl = "https://byteshare.to" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - sources.add( - ExtractorLink( - name, - name, - url.replace("/embed/", "/download/"), - "", - Qualities.Unknown.value, - ) - ) - return sources - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Chillx.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Chillx.kt deleted file mode 100644 index b4f3d8975..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Chillx.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.utils.AppUtils -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import javax.crypto.Cipher -import javax.crypto.SecretKeyFactory -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.PBEKeySpec -import javax.crypto.spec.SecretKeySpec - -class Moviesapi : Chillx() { - override val name = "Moviesapi" - override val mainUrl = "https://w1.moviesapi.club" -} - -class Bestx : Chillx() { - override val name = "Bestx" - override val mainUrl = "https://bestx.stream" -} - -class Watchx : Chillx() { - override val name = "Watchx" - override val mainUrl = "https://watchx.top" -} -open class Chillx : ExtractorApi() { - override val name = "Chillx" - override val mainUrl = "https://chillx.top" - override val requiresReferer = true - - companion object { - private const val KEY = "11x&W5UBrcqn\$9Yl" - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val master = Regex("MasterJS\\s*=\\s*'([^']+)").find( - app.get( - url, - referer = referer - ).text - )?.groupValues?.get(1) - val encData = AppUtils.tryParseJson(base64Decode(master ?: return)) - val decrypt = cryptoAESHandler(encData ?: return, KEY, false) - - val source = Regex(""""?file"?:\s*"([^"]+)""").find(decrypt)?.groupValues?.get(1) - val tracks = Regex("""tracks:\s*\[(.+)]""").find(decrypt)?.groupValues?.get(1) - - // required - val headers = mapOf( - "Accept" to "*/*", - "Connection" to "keep-alive", - "Sec-Fetch-Dest" to "empty", - "Sec-Fetch-Mode" to "cors", - "Sec-Fetch-Site" to "cross-site", - "Origin" to mainUrl, - ) - - callback.invoke( - ExtractorLink( - name, - name, - source ?: return, - "$mainUrl/", - Qualities.P1080.value, - headers = headers, - isM3u8 = true - ) - ) - - AppUtils.tryParseJson>("[$tracks]") - ?.filter { it.kind == "captions" }?.map { track -> - subtitleCallback.invoke( - SubtitleFile( - track.label ?: "", - track.file ?: return@map null - ) - ) - } - } - - private fun cryptoAESHandler( - data: AESData, - pass: String, - encrypt: Boolean = true - ): String { - val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512") - val spec = PBEKeySpec( - pass.toCharArray(), - data.salt?.hexToByteArray(), - data.iterations?.toIntOrNull() ?: 1, - 256 - ) - val key = factory.generateSecret(spec) - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - return if (!encrypt) { - cipher.init( - Cipher.DECRYPT_MODE, - SecretKeySpec(key.encoded, "AES"), - IvParameterSpec(data.iv?.hexToByteArray()) - ) - String(cipher.doFinal(base64DecodeArray(data.ciphertext.toString()))) - } else { - cipher.init( - Cipher.ENCRYPT_MODE, - SecretKeySpec(key.encoded, "AES"), - IvParameterSpec(data.iv?.hexToByteArray()) - ) - base64Encode(cipher.doFinal(data.ciphertext?.toByteArray())) - } - } - - private fun String.hexToByteArray(): ByteArray { - check(length % 2 == 0) { "Must have an even length" } - return chunked(2) - .map { it.toInt(16).toByte() } - - .toByteArray() - } - - data class AESData( - @JsonProperty("ciphertext") val ciphertext: String? = null, - @JsonProperty("iv") val iv: String? = null, - @JsonProperty("salt") val salt: String? = null, - @JsonProperty("iterations") val iterations: String? = null, - ) - - data class Tracks( - @JsonProperty("file") val file: String? = null, - @JsonProperty("label") val label: String? = null, - @JsonProperty("kind") val kind: String? = null, - ) -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Dailymotion.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Dailymotion.kt deleted file mode 100644 index 4b7cb19f0..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Dailymotion.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 -import com.lagradost.cloudstream3.utils.Qualities -import java.net.URL - -open class Dailymotion : ExtractorApi() { - override val mainUrl = "https://www.dailymotion.com" - override val name = "Dailymotion" - override val requiresReferer = false - - @Suppress("RegExpSimplifiable") - private val videoIdRegex = "^[kx][a-zA-Z0-9]+\$".toRegex() - - // https://www.dailymotion.com/video/k3JAHfletwk94ayCVIu - // https://www.dailymotion.com/embed/video/k3JAHfletwk94ayCVIu - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val embedUrl = getEmbedUrl(url) ?: return - val doc = app.get(embedUrl).document - val prefix = "window.__PLAYER_CONFIG__ = " - val configStr = doc.selectFirst("script:containsData($prefix)")?.data() ?: return - val config = tryParseJson(configStr.substringAfter(prefix)) ?: return - val id = getVideoId(embedUrl) ?: return - val dmV1st = config.dmInternalData.v1st - val dmTs = config.dmInternalData.ts - val metaDataUrl = - "$mainUrl/player/metadata/video/$id?locale=en&dmV1st=$dmV1st&dmTs=$dmTs&is_native_app=0" - val cookies = mapOf( - "v1st" to dmV1st, - "dmvk" to config.context.dmvk, - "ts" to dmTs.toString() - ) - val metaData = app.get(metaDataUrl, referer = embedUrl, cookies = cookies) - .parsedSafe() ?: return - metaData.qualities.forEach { (_, video) -> - video.forEach { - getStream(it.url, this.name, callback) - } - } - } - - private fun getEmbedUrl(url: String): String? { - if (url.contains("/embed/")) { - return url - } - val vid = getVideoId(url) ?: return null - return "$mainUrl/embed/video/$vid" - } - - private fun getVideoId(url: String): String? { - val path = URL(url).path - val id = path.substringAfter("video/") - if (id.matches(videoIdRegex)) { - return id - } - return null - } - - private suspend fun getStream( - streamLink: String, - name: String, - callback: (ExtractorLink) -> Unit - ) { - return generateM3u8( - name, - streamLink, - "", - ).forEach(callback) - } - data class Config( - val context: Context, - val dmInternalData: InternalData - ) - - data class InternalData( - val ts: Int, - val v1st: String - ) - - data class Context( - @JsonProperty("access_token") val accessToken: String?, - val dmvk: String, - ) - - data class MetaData( - val qualities: Map> - ) - - data class VideoLink( - val type: String, - val url: String - ) - -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt deleted file mode 100644 index 8dcfb8596..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName -import kotlinx.coroutines.delay - -class Dooood : DoodLaExtractor() { - override var mainUrl = "https://dooood.com" -} - -class DoodWfExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.wf" -} - -class DoodCxExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.cx" -} - -class DoodShExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.sh" -} -class DoodWatchExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.watch" -} - -class DoodPmExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.pm" -} - -class DoodToExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.to" -} - -class DoodSoExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.so" -} - -class DoodWsExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.ws" -} - -class DoodYtExtractor : DoodLaExtractor() { - override var mainUrl = "https://dood.yt" -} - -open class DoodLaExtractor : ExtractorApi() { - override var name = "DoodStream" - override var mainUrl = "https://dood.la" - override val requiresReferer = false - - override fun getExtractorUrl(id: String): String { - return "$mainUrl/d/$id" - } - - override suspend fun getUrl(url: String, referer: String?): List? { - val response0 = app.get(url).text // html of DoodStream page to look for /pass_md5/... - val md5 =mainUrl+(Regex("/pass_md5/[^']*").find(response0)?.value ?: return null) // get https://dood.ws/pass_md5/... - val trueUrl = app.get(md5, referer = url).text + "zUEJeL3mUN?token=" + md5.substringAfterLast("/") //direct link to extract (zUEJeL3mUN is random) - val quality = Regex("\\d{3,4}p").find(response0.substringAfter("").substringBefore(""))?.groupValues?.get(0) - return listOf( - ExtractorLink( - this.name, - this.name, - trueUrl, - mainUrl, - getQualityFromName(quality), - false - ) - ) // links are valid in 8h - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt deleted file mode 100644 index e8f8c49ac..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 -import com.lagradost.cloudstream3.utils.getAndUnpack -import org.jsoup.nodes.Document - -open class Fastream: ExtractorApi() { - override var mainUrl = "https://fastream.to" - override var name = "Fastream" - override val requiresReferer = false - suspend fun getstream( - response: Document, - sources: ArrayList): Boolean{ - response.select("script").amap { script -> - if (script.data().contains(Regex("eval\\(function\\(p,a,c,k,e,[rd]"))) { - val unpacked = getAndUnpack(script.data()) - //val m3u8regex = Regex("((https:|http:)\\/\\/.*\\.m3u8)") - val newm3u8link = unpacked.substringAfter("file:\"").substringBefore("\"") - //val m3u8link = m3u8regex.find(unpacked)?.value ?: return@forEach - generateM3u8( - name, - newm3u8link, - mainUrl - ).forEach { link -> - sources.add(link) - } - } - } - return true - } - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = ArrayList() - val idregex = Regex("emb.html\\?(.*)=") - if (url.contains(Regex("(emb.html.*fastream)"))) { - val id = idregex.find(url)?.destructured?.component1() ?: "" - val response = app.post("https://fastream.to/dl", allowRedirects = false, - data = mapOf( - "op" to "embed", - "file_code" to id, - "auto" to "1" - ) - ).document - getstream(response, sources) - } - val response = app.get(url, referer = url).document - getstream(response, sources) - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt deleted file mode 100644 index a1148bb81..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 - -class Guccihide : Filesim() { - override val name = "Guccihide" - override var mainUrl = "https://guccihide.com" -} - -class Ahvsh : Filesim() { - override val name = "Ahvsh" - override var mainUrl = "https://ahvsh.com" -} - -class Moviesm4u : Filesim() { - override val mainUrl = "https://moviesm4u.com" - override val name = "Moviesm4u" -} - -class FileMoonIn : Filesim() { - override val mainUrl = "https://filemoon.in" - override val name = "FileMoon" -} - -class StreamhideTo : Filesim() { - override val mainUrl = "https://streamhide.to" - override val name = "Streamhide" -} - -class StreamhideCom : Filesim() { - override var name: String = "Streamhide" - override var mainUrl: String = "https://streamhide.com" -} - -class Movhide : Filesim() { - override var name: String = "Movhide" - override var mainUrl: String = "https://movhide.pro" -} - -class Ztreamhub : Filesim() { - override val mainUrl: String = "https://ztreamhub.com" //Here 'cause works - override val name = "Zstreamhub" -} -class FileMoon : Filesim() { - override val mainUrl = "https://filemoon.to" - override val name = "FileMoon" -} - -class FileMoonSx : Filesim() { - override val mainUrl = "https://filemoon.sx" - override val name = "FileMoonSx" -} - -open class Filesim : ExtractorApi() { - override val name = "Filesim" - override val mainUrl = "https://files.im" - override val requiresReferer = true - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val response = app.get(url, referer = referer) - val script = if (!getPacked(response.text).isNullOrEmpty()) { - getAndUnpack(response.text) - } else { - response.document.selectFirst("script:containsData(sources:)")?.data() - } - val m3u8 = - Regex("file:\\s*\"(.*?m3u8.*?)\"").find(script ?: return)?.groupValues?.getOrNull(1) - generateM3u8( - name, - m3u8 ?: return, - mainUrl - ).forEach(callback) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/GMPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/GMPlayer.kt deleted file mode 100644 index 52c450968..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/GMPlayer.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -open class GMPlayer : ExtractorApi() { - override val name = "GM Player" - override val mainUrl = "https://gmplayer.xyz" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List? { - val ref = referer ?: return null - val id = url.substringAfter("/video/").substringBefore("/") - - val m3u8 = app.post( - "$mainUrl/player/index.php?data=$id&do=getVideo", - mapOf( - "accept" to "*/*", - "referer" to ref, - "x-requested-with" to "XMLHttpRequest", - "origin" to mainUrl - ), - data = mapOf("hash" to id, "r" to ref) - ).parsed().videoSource ?: return null - - return listOf( - ExtractorLink( - this.name, - this.name, - m3u8, - ref, - Qualities.Unknown.value, - headers = mapOf("accept" to "*/*"), - isM3u8 = true - ) - ) - } - - private data class GmResponse( - val videoSource: String? = null - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Gofile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Gofile.kt deleted file mode 100644 index d76b0e11b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Gofile.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -open class Gofile : ExtractorApi() { - override val name = "Gofile" - override val mainUrl = "https://gofile.io" - override val requiresReferer = false - private val mainApi = "https://api.gofile.io" - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val id = Regex("/(?:\\?c=|d/)([\\da-zA-Z]+)").find(url)?.groupValues?.get(1) - val token = app.get("$mainApi/createAccount").parsedSafe()?.data?.get("token") - val websiteToken = app.get("$mainUrl/dist/js/alljs.js").text.let { - Regex("websiteToken\\s*=\\s*\"([^\"]+)").find(it)?.groupValues?.get(1) - } - app.get("$mainApi/getContent?contentId=$id&token=$token&websiteToken=$websiteToken") - .parsedSafe()?.data?.contents?.forEach { - callback.invoke( - ExtractorLink( - this.name, - this.name, - it.value["link"] ?: return, - "", - getQuality(it.value["name"]), - headers = mapOf( - "Cookie" to "accountToken=$token" - ) - ) - ) - } - - } - - private fun getQuality(str: String?): Int { - return Regex("(\\d{3,4})[pP]").find(str ?: "")?.groupValues?.getOrNull(1)?.toIntOrNull() - ?: Qualities.Unknown.value - } - - data class Account( - @JsonProperty("data") val data: HashMap? = null, - ) - - data class Data( - @JsonProperty("contents") val contents: HashMap>? = null, - ) - - data class Source( - @JsonProperty("data") val data: Data? = null, - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt deleted file mode 100644 index 3d0462672..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* - -class Vanfem : GuardareStream() { - override var name = "Vanfem" - override var mainUrl = "https://vanfem.com/" -} - -class CineGrabber : GuardareStream() { - override var name = "CineGrabber" - override var mainUrl = "https://cinegrabber.com" -} - -open class GuardareStream : ExtractorApi() { - override var name = "Guardare" - override var mainUrl = "https://guardare.stream" - override val requiresReferer = false - - data class GuardareJsonData( - @JsonProperty("data") val data: List, - @JsonProperty("captions") val captions: List?, - ) - - data class GuardareData( - @JsonProperty("file") val file: String, - @JsonProperty("label") val label: String, - @JsonProperty("type") val type: String - ) - - - // https://cinegrabber.com/asset/userdata/224879/caption/gqdmzh-71ez76z8/876438.srt - data class GuardareCaptions( - @JsonProperty("id") val id: String, - @JsonProperty("hash") val hash: String, - @JsonProperty("language") val language: String?, - @JsonProperty("extension") val extension: String - ) { - fun getUrl(mainUrl: String, userId: String): String { - return "$mainUrl/asset/userdata/$userId/caption/$hash/$id.$extension" - } - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val response = - app.post(url.replace("/v/", "/api/source/"), data = mapOf("d" to mainUrl)).text - - val jsonVideoData = AppUtils.parseJson(response) - jsonVideoData.data.forEach { - callback.invoke( - ExtractorLink( - this.name, - this.name, - it.file + ".${it.type}", - mainUrl, - it.label.filter { it.isDigit() }.toInt(), - false - ) - ) - } - - if (!jsonVideoData.captions.isNullOrEmpty()){ - val iframe = app.get(url) - // var USER_ID = '224879'; - val userIdRegex = Regex("""USER_ID.*?(\d+)""") - val userId = userIdRegex.find(iframe.text)?.groupValues?.getOrNull(1) ?: return - jsonVideoData.captions.forEach { - if (it == null) return@forEach - val subUrl = it.getUrl(mainUrl, userId) - subtitleCallback.invoke( - SubtitleFile( - it.language ?: "", - subUrl - ) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Hxfile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Hxfile.kt deleted file mode 100644 index bfd7cae53..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Hxfile.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson - -class Neonime7n : Hxfile() { - override val name = "Neonime7n" - override val mainUrl = "https://neonime.fun" - override val redirect = false -} - -class Neonime8n : Hxfile() { - override val name = "Neonime8n" - override val mainUrl = "https://8njctn.neonime.net" - override val redirect = false -} - -class KotakAnimeid : Hxfile() { - override val name = "KotakAnimeid" - override val mainUrl = "https://nontonanimeid.bio" - override val requiresReferer = true -} - -class Yufiles : Hxfile() { - override val name = "Yufiles" - override val mainUrl = "https://yufiles.com" -} - -class Aico : Hxfile() { - override val name = "Aico" - override val mainUrl = "https://aico.pw" -} - -open class Hxfile : ExtractorApi() { - override val name = "Hxfile" - override val mainUrl = "https://hxfile.co" - override val requiresReferer = false - open val redirect = true - - override suspend fun getUrl(url: String, referer: String?): List? { - val sources = mutableListOf() - val document = app.get(url, allowRedirects = redirect, referer = referer).document - with(document) { - this.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val data = - getAndUnpack(script.data()).substringAfter("sources:[").substringBefore("]") - tryParseJson>("[$data]")?.map { - sources.add( - ExtractorLink( - name, - name, - it.file, - referer = mainUrl, - quality = when { - url.contains("hxfile.co") -> getQualityFromName( - Regex("\\d\\.(.*?).mp4").find( - document.select("title").text() - )?.groupValues?.get(1).toString() - ) - else -> getQualityFromName(it.label) - } - ) - ) - } - } else if (script.data().contains("\"sources\":[")) { - val data = script.data().substringAfter("\"sources\":[").substringBefore("]") - tryParseJson>("[$data]")?.map { - sources.add( - ExtractorLink( - name, - name, - it.file, - referer = mainUrl, - quality = when { - it.label?.contains("HD") == true -> Qualities.P720.value - it.label?.contains("SD") == true -> Qualities.P480.value - else -> getQualityFromName(it.label) - } - ) - ) - } - } - else { - null - } - } - } - return sources - } - - private data class ResponseSource( - @JsonProperty("file") val file: String, - @JsonProperty("type") val type: String?, - @JsonProperty("label") val label: String? - ) - -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/JWPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/JWPlayer.kt deleted file mode 100644 index 6e6f65167..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/JWPlayer.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName - -class Meownime : JWPlayer() { - override val name = "Meownime" - override val mainUrl = "https://meownime.ltd" -} - -class DesuOdchan : JWPlayer() { - override val name = "DesuOdchan" - override val mainUrl = "https://desustream.me/odchan/" -} - -class DesuArcg : JWPlayer() { - override val name = "DesuArcg" - override val mainUrl = "https://desustream.me/arcg/" -} - -class DesuDrive : JWPlayer() { - override val name = "DesuDrive" - override val mainUrl = "https://desustream.me/desudrive/" -} - -class DesuOdvip : JWPlayer() { - override val name = "DesuOdvip" - override val mainUrl = "https://desustream.me/odvip/" -} - -open class JWPlayer : ExtractorApi() { - override val name = "JWPlayer" - override val mainUrl = "https://www.jwplayer.com" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List? { - val sources = mutableListOf() - with(app.get(url).document) { - val data = this.select("script").mapNotNull { script -> - if (script.data().contains("sources: [")) { - script.data().substringAfter("sources: [") - .substringBefore("],").replace("'", "\"") - } else if (script.data().contains("otakudesu('")) { - script.data().substringAfter("otakudesu('") - .substringBefore("');") - } else { - null - } - } - - tryParseJson>("$data")?.map { - sources.add( - ExtractorLink( - name, - name, - it.file, - referer = url, - quality = getQualityFromName( - Regex("(\\d{3,4}p)").find(it.file)?.groupValues?.get( - 1 - ) - ) - ) - ) - } - } - return sources - } - - private data class ResponseSource( - @JsonProperty("file") val file: String, - @JsonProperty("type") val type: String?, - @JsonProperty("label") val label: String? - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Jawcloud.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Jawcloud.kt deleted file mode 100644 index 203a266c1..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Jawcloud.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - - -open class Jawcloud : ExtractorApi() { - override var name = "Jawcloud" - override var mainUrl = "https://jawcloud.co" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List? { - val doc = app.get(url).document - val urlString = doc.select("html body div source").attr("src") - val sources = mutableListOf() - if (urlString.contains("m3u8")) - M3u8Helper.generateM3u8( - name, - urlString, - url, - headers = app.get(url).headers.toMap() - ).forEach { link -> sources.add(link) } - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/MultiQuality.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/MultiQuality.kt deleted file mode 100644 index c7f4ac767..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/MultiQuality.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName -import java.net.URI - -open class MultiQuality : ExtractorApi() { - override var name = "MultiQuality" - override var mainUrl = "https://anihdplay.com" - private val sourceRegex = Regex("""file:\s*['"](.*?)['"],label:\s*['"](.*?)['"]""") - private val m3u8Regex = Regex(""".*?(\d*).m3u8""") - private val urlRegex = Regex("""(.*?)([^/]+$)""") - override val requiresReferer = false - - override fun getExtractorUrl(id: String): String { - return "$mainUrl/loadserver.php?id=$id" - } - - override suspend fun getUrl(url: String, referer: String?): List { - val extractedLinksList: MutableList = mutableListOf() - with(app.get(url)) { - sourceRegex.findAll(this.text).forEach { sourceMatch -> - val extractedUrl = sourceMatch.groupValues[1] - // Trusting this isn't mp4, may fuck up stuff - if (URI(extractedUrl).path.endsWith(".m3u8")) { - with(app.get(extractedUrl)) { - m3u8Regex.findAll(this.text).forEach { match -> - extractedLinksList.add( - ExtractorLink( - name, - name = name, - urlRegex.find(this.url)!!.groupValues[1] + match.groupValues[0], - url, - getQualityFromName(match.groupValues[1]), - isM3u8 = true - ) - ) - } - - } - } else if (extractedUrl.endsWith(".mp4")) { - extractedLinksList.add( - ExtractorLink( - name, - "$name ${sourceMatch.groupValues[2]}", - extractedUrl, - url.replace(" ", "%20"), - Qualities.Unknown.value, - ) - ) - } - } - return extractedLinksList - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt deleted file mode 100644 index 70e87fbf4..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - -data class DataOptionsJson ( - @JsonProperty("flashvars") var flashvars : Flashvars? = Flashvars(), -) -data class Flashvars ( - @JsonProperty("metadata") var metadata : String? = null, - @JsonProperty("hlsManifestUrl") var hlsManifestUrl : String? = null, //m3u8 -) - -data class MetadataOkru ( - @JsonProperty("videos") var videos: ArrayList = arrayListOf(), -) - -data class Videos ( - @JsonProperty("name") var name : String, - @JsonProperty("url") var url : String, - @JsonProperty("seekSchema") var seekSchema : Int? = null, - @JsonProperty("disallowed") var disallowed : Boolean? = null -) - -class OkRuHttps: OkRu(){ - override var mainUrl = "https://ok.ru" -} - -open class OkRu : ExtractorApi() { - override var name = "Okru" - override var mainUrl = "http://ok.ru" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List? { - val doc = app.get(url).document - val sources = ArrayList() - val datajson = doc.select("div[data-options]").attr("data-options") - if (datajson.isNotBlank()) { - val main = parseJson(datajson) - val metadatajson = parseJson(main.flashvars?.metadata!!) - val servers = metadatajson.videos - servers.forEach { - val quality = it.name.uppercase() - .replace("MOBILE","144p") - .replace("LOWEST","240p") - .replace("LOW","360p") - .replace("SD","480p") - .replace("HD","720p") - .replace("FULL","1080p") - .replace("QUAD","1440p") - .replace("ULTRA","4k") - val extractedurl = it.url.replace("\\\\u0026", "&") - sources.add(ExtractorLink( - name, - name = this.name, - extractedurl, - url, - getQualityFromName(quality), - isM3u8 = false - )) - } - } - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Okrulink.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Okrulink.kt deleted file mode 100644 index 37bb09e3c..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Okrulink.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -data class Okrulinkdata ( - @JsonProperty("status" ) var status : String? = null, - @JsonProperty("url" ) var url : String? = null -) - -open class Okrulink: ExtractorApi() { - override var mainUrl = "https://okru.link" - override var name = "Okrulink" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - val key = url.substringAfter("html?t=") - val request = app.post("https://apizz.okru.link/decoding", allowRedirects = false, - data = mapOf("video" to key) - ).parsedSafe() - if (request?.url != null) { - sources.add( - ExtractorLink( - name, - name, - request.url!!, - "", - Qualities.Unknown.value, - isM3u8 = false - ) - ) - } - return sources - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt deleted file mode 100644 index 45ec4c2f8..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.extractorApis -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.loadExtractor -import org.jsoup.Jsoup - -/** - * overrideMainUrl is necessary for for other vidstream clones like vidembed.cc - * If they diverge it'd be better to make them separate. - * */ -open class Pelisplus(val mainUrl: String) { - val name: String = "Vidstream" - - private fun getExtractorUrl(id: String): String { - return "$mainUrl/play?id=$id" - } - - private fun getDownloadUrl(id: String): String { - return "$mainUrl/download?id=$id" - } - - private val normalApis = arrayListOf(MultiQuality()) - - // https://gogo-stream.com/streaming.php?id=MTE3NDg5 - suspend fun getUrl( - id: String, - isCasting: Boolean = false, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ): Boolean { - try { - normalApis.amap { api -> - val url = api.getExtractorUrl(id) - api.getSafeUrl(url, subtitleCallback = subtitleCallback, callback = callback) - } - val extractorUrl = getExtractorUrl(id) - - /** Stolen from GogoanimeProvider.kt extractor */ - suspendSafeApiCall { - val link = getDownloadUrl(id) - println("Generated vidstream download link: $link") - val page = app.get(link, referer = extractorUrl) - - val pageDoc = Jsoup.parse(page.text) - val qualityRegex = Regex("(\\d+)P") - - //a[download] - pageDoc.select(".dowload > a")?.amap { element -> - val href = element.attr("href") ?: return@amap - val qual = if (element.text() - .contains("HDP") - ) "1080" else qualityRegex.find(element.text())?.destructured?.component1() - .toString() - - if (!loadExtractor(href, link, subtitleCallback, callback)) { - callback.invoke( - ExtractorLink( - this.name, - name = this.name, - href, - page.url, - getQualityFromName(qual), - element.attr("href").contains(".m3u8") - ) - ) - } - } - } - - with(app.get(extractorUrl)) { - val document = Jsoup.parse(this.text) - val primaryLinks = document.select("ul.list-server-items > li.linkserver") - //val extractedLinksList: MutableList = mutableListOf() - - // All vidstream links passed to extractors - primaryLinks.distinctBy { it.attr("data-video") }.forEach { element -> - val link = element.attr("data-video") - //val name = element.text() - - // Matches vidstream links with extractors - extractorApis.filter { !it.requiresReferer || !isCasting }.amap { api -> - if (link.startsWith(api.mainUrl)) { - api.getSafeUrl(link, extractorUrl, subtitleCallback, callback) - } - } - } - return true - } - } catch (e: Exception) { - return false - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Pixeldrain.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Pixeldrain.kt deleted file mode 100644 index 9b4812403..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Pixeldrain.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -open class Pixeldrain : ExtractorApi() { - override val name = "Pixeldrain" - override val mainUrl = "https://pixeldrain.com" - override val requiresReferer = false - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val mId = Regex("/([ul]/[\\da-zA-Z\\-]+)").find(url)?.groupValues?.get(1)?.split("/") - callback.invoke( - ExtractorLink( - this.name, - this.name, - "$mainUrl/api/file/${mId?.last() ?: return}?download", - url, - Qualities.Unknown.value, - ) - ) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Solidfiles.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Solidfiles.kt deleted file mode 100644 index cc34781cf..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Solidfiles.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.getQualityFromName - - -open class Solidfiles : ExtractorApi() { - override val name = "Solidfiles" - override val mainUrl = "https://www.solidfiles.com" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - with(app.get(url).document) { - this.select("script").map { script -> - if (script.data().contains("\"streamUrl\":")) { - val data = script.data().substringAfter("constant('viewerOptions', {").substringBefore("});") - val source = tryParseJson("{$data}") - val quality = Regex("\\d{3,4}p").find(source!!.nodeName)?.groupValues?.get(0) - sources.add( - ExtractorLink( - name, - name, - source.streamUrl, - referer = url, - quality = getQualityFromName(quality) - ) - ) - } - } - } - return sources - } - - - private data class ResponseSource( - @JsonProperty("streamUrl") val streamUrl: String, - @JsonProperty("nodeName") val nodeName: String - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/SpeedoStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/SpeedoStream.kt deleted file mode 100644 index 3f6fff2f8..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/SpeedoStream.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -class SpeedoStream1 : SpeedoStream() { - override val mainUrl = "https://speedostream.pm" -} - -open class SpeedoStream : ExtractorApi() { - override val name = "SpeedoStream" - override val mainUrl = "https://speedostream.mom" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - app.get(url, referer = referer).document.select("script").map { script -> - if (script.data().contains("jwplayer(\"vplayer\").setup(")) { - val data = script.data().substringAfter("sources: [") - .substringBefore("],").replace("file", "\"file\"").trim() - tryParseJson(data)?.let { - M3u8Helper.generateM3u8( - name, - it.file, - "$mainUrl/", - ).forEach { m3uData -> sources.add(m3uData) } - } - } - } - return sources - } - - private data class File( - @JsonProperty("file") val file: String, - ) - - -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamTape.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamTape.kt deleted file mode 100644 index ece8dc4bb..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamTape.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -class StreamTapeNet : StreamTape() { - override var mainUrl = "https://streamtape.net" -} - -class ShaveTape : StreamTape(){ - override var mainUrl = "https://shavetape.cash" -} - -open class StreamTape : ExtractorApi() { - override var name = "StreamTape" - override var mainUrl = "https://streamtape.com" - override val requiresReferer = false - - private val linkRegex = - Regex("""'robotlink'\)\.innerHTML = '(.+?)'\+ \('(.+?)'\)""") - - override suspend fun getUrl(url: String, referer: String?): List? { - with(app.get(url)) { - linkRegex.find(this.text)?.let { - val extractedUrl = - "https:${it.groups[1]!!.value + it.groups[2]!!.value.substring(3)}" - return listOf( - ExtractorLink( - name, - name, - extractedUrl, - url, - Qualities.Unknown.value, - ) - ) - } - } - return null - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamoUpload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamoUpload.kt deleted file mode 100644 index 7fafe05be..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamoUpload.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.getAndUnpack -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class StreamoUpload : ExtractorApi() { - override val name = "StreamoUpload" - override val mainUrl = "https://streamoupload.xyz" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - val response = app.get(url, referer = referer) - val scriptElements = response.document.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val data = getAndUnpack(script.data()) - .substringAfter("sources:[") - .substringBefore("],") - .replace("file", "\"file\"") - .trim() - tryParseJson(data)?.let { - M3u8Helper.generateM3u8( - name, - it.file, - "$mainUrl/", - ).forEach { m3uData -> sources.add(m3uData) } - } - } - } - return sources - } - - private data class File( - @JsonProperty("file") val file: String, - ) -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt deleted file mode 100644 index dd49d9945..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - -data class Files( - @JsonProperty("file") val id: String, - @JsonProperty("label") val label: String? = null, -) - -open class Supervideo : ExtractorApi() { - override var name = "Supervideo" - override var mainUrl = "https://supervideo.tv" - override val requiresReferer = false - override suspend fun getUrl(url: String, referer: String?): List? { - val extractedLinksList: MutableList = mutableListOf() - val response = app.get(url).text - val jstounpack = Regex("eval((.|\\n)*?)").find(response)?.groups?.get(1)?.value - val unpacjed = JsUnpacker(jstounpack).unpack() - val extractedUrl = - unpacjed?.let { Regex("""sources:((.|\n)*?)image""").find(it) }?.groups?.get(1)?.value.toString() - .replace("file", """"file"""").replace("label", """"label"""") - .substringBeforeLast(",") - val parsedlinks = parseJson>(extractedUrl) - parsedlinks.forEach { data -> - if (data.label.isNullOrBlank()) { // mp4 links (with labels) are slow. Use only m3u8 link. - M3u8Helper.generateM3u8( - name, - data.id, - url, - headers = mapOf("referer" to url) - ).forEach { link -> - extractedLinksList.add(link) - } - } - } - return extractedLinksList - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt deleted file mode 100644 index 28a2eb20d..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.app -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.readValue -import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.mapper -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - - -class Cinestart: Tomatomatela() { - override var name: String = "Cinestart" - override val mainUrl: String = "https://cinestart.net" - override val details = "vr.php?v=" -} - -class TomatomatelalClub: Tomatomatela() { - override var name: String = "Tomatomatela" - override val mainUrl: String = "https://tomatomatela.club" -} - -open class Tomatomatela : ExtractorApi() { - override var name = "Tomatomatela" - override val mainUrl = "https://tomatomatela.com" - override val requiresReferer = false - private data class Tomato ( - @JsonProperty("status") val status: Int, - @JsonProperty("file") val file: String? - ) - open val details = "details.php?v=" - open val embeddetails = "/embed.html#" - override suspend fun getUrl(url: String, referer: String?): List? { - val link = url.replace("$mainUrl$embeddetails","$mainUrl/$details") - val sources = ArrayList() - val server = app.get(link, allowRedirects = false, - headers = mapOf( - "User-Agent" to USER_AGENT, - "Accept" to "application/json, text/javascript, */*; q=0.01", - "Accept-Language" to "en-US,en;q=0.5", - "X-Requested-With" to "XMLHttpRequest", - "DNT" to "1", - "Connection" to "keep-alive", - "Sec-Fetch-Dest" to "empty", - "Sec-Fetch-Mode" to "cors", - "Sec-Fetch-Site" to "same-origin" - - ) - ).parsedSafe() - if (server?.file != null) { - sources.add( - ExtractorLink( - name, - name, - server.file, - "", - Qualities.Unknown.value, - isM3u8 = false - ) - ) - } - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Uqload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Uqload.kt deleted file mode 100644 index 86bd9e0bc..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Uqload.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.app - -class Uqload1 : Uqload() { - override var mainUrl = "https://uqload.com" -} - -class Uqload2 : Uqload() { - override var mainUrl = "https://uqload.co" -} - -open class Uqload : ExtractorApi() { - override val name: String = "Uqload" - override val mainUrl: String = "https://www.uqload.com" - private val srcRegex = Regex("""sources:.\[(.*?)\]""") // would be possible to use the parse and find src attribute - override val requiresReferer = true - - - override suspend fun getUrl(url: String, referer: String?): List? { - with(app.get(url)) { // raised error ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED (3003) is due to the response: "error_nofile" - srcRegex.find(this.text)?.groupValues?.get(1)?.replace("\"", "")?.let { link -> - return listOf( - ExtractorLink( - name, - name, - link, - url, - Qualities.Unknown.value, - ) - ) - } - } - return null - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt deleted file mode 100644 index a27bf1887..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import kotlinx.coroutines.delay -import java.net.URI - -class VidSrcExtractor2 : VidSrcExtractor() { - override val mainUrl = "https://vidsrc.me/embed" - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val newUrl = url.lowercase().replace(mainUrl, super.mainUrl) - super.getUrl(newUrl, referer, subtitleCallback, callback) - } -} - -open class VidSrcExtractor : ExtractorApi() { - override val name = "VidSrc" - private val absoluteUrl = "https://v2.vidsrc.me" - override val mainUrl = "$absoluteUrl/embed" - override val requiresReferer = false - - companion object { - /** Infinite function to validate the vidSrc pass */ - suspend fun validatePass(url: String) { - val uri = URI(url) - val host = uri.host - - // Basically turn https://tm3p.vidsrc.stream/ -> https://vidsrc.stream/ - val referer = host.split(".").let { - val size = it.size - "https://" + it.subList(maxOf(0, size - 2), size).joinToString(".") + "/" - } - - while (true) { - app.get(url, referer = referer) - delay(60_000) - } - } - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val iframedoc = app.get(url).document - - val serverslist = - iframedoc.select("div#sources.button_content div#content div#list div").map { - val datahash = it.attr("data-hash") - if (datahash.isNotBlank()) { - val links = try { - app.get( - "$absoluteUrl/srcrcp/$datahash", - referer = "https://rcp.vidsrc.me/" - ).url - } catch (e: Exception) { - "" - } - links - } else "" - } - - serverslist.amap { server -> - val linkfixed = server.replace("https://vidsrc.xyz/", "https://embedsito.com/") - if (linkfixed.contains("/prorcp")) { - val srcresponse = app.get(server, referer = absoluteUrl).text - val m3u8Regex = Regex("((https:|http:)//.*\\.m3u8)") - val srcm3u8 = m3u8Regex.find(srcresponse)?.value ?: return@amap - val passRegex = Regex("""['"](.*set_pass[^"']*)""") - val pass = passRegex.find(srcresponse)?.groupValues?.get(1)?.replace( - Regex("""^//"""), "https://" - ) - - callback.invoke( - ExtractorLink( - this.name, - this.name, - srcm3u8, - "https://vidsrc.stream/", - Qualities.Unknown.value, - extractorData = pass, - isM3u8 = true - ) - ) - } else { - loadExtractor(linkfixed, url, subtitleCallback, callback) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/VideoVard.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VideoVard.kt deleted file mode 100644 index 30a1d8fe6..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VideoVard.kt +++ /dev/null @@ -1,271 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 -import kotlinx.coroutines.delay -import java.math.BigInteger - -class VideovardSX : WcoStream() { - override var mainUrl = "https://videovard.sx" -} - -open class VideoVard : ExtractorApi() { - override var name = "Videovard" // Cause works for animekisa and wco - override var mainUrl = "https://videovard.to" - override val requiresReferer = false - - //The following code was extracted from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/parsers/anime/extractors/VideoVard.kt - override suspend fun getUrl(url: String, referer: String?): List { - val id = url.substringAfter("e/").substringBefore("/") - val sources = mutableListOf() - val hash = app.get("$mainUrl/api/make/download/$id").parsed() - delay(11_000) - val resm3u8 = app.post( - "$mainUrl/api/player/setup", - mapOf("Referer" to "$mainUrl/"), - data = mapOf( - "cmd" to "get_stream", - "file_code" to id, - "hash" to hash.hash!! - ) - ).parsed() - val m3u8 = decode(resm3u8.src!!, resm3u8.seed) - sources.addAll( - generateM3u8( - name, - m3u8, - mainUrl, - headers = mapOf("Referer" to mainUrl) - ) - ) - return sources - } - - companion object { - private val big0 = 0.toBigInteger() - private val big3 = 3.toBigInteger() - private val big4 = 4.toBigInteger() - private val big15 = 15.toBigInteger() - private val big16 = 16.toBigInteger() - private val big255 = 255.toBigInteger() - - private fun decode(dataFile: String, seed: String): String { - val dataSeed = replace(seed) - val newDataSeed = binaryDigest(dataSeed) - val newDataFile = bytes2blocks(ascii2bytes(dataFile)) - var list = listOf(1633837924, 1650680933).map { it.toBigInteger() } - val xorList = mutableListOf() - for (i in newDataFile.indices step 2) { - val temp = newDataFile.slice(i..i + 1) - xorList += xorBlocks(list, tearDecode(temp, newDataSeed)) - list = temp - } - - val result = replace(unPad(blocks2bytes(xorList)).map { it.toInt().toChar() }.joinToString("")) - return padLastChars(result) - } - - private fun binaryDigest(input: String): List { - val keys = listOf(1633837924, 1650680933, 1667523942, 1684366951).map { it.toBigInteger() } - var list1 = keys.slice(0..1) - var list2 = list1 - val blocks = bytes2blocks(digestPad(input)) - - for (i in blocks.indices step 4) { - list1 = tearCode(xorBlocks(blocks.slice(i..i + 1), list1), keys).toMutableList() - list2 = tearCode(xorBlocks(blocks.slice(i + 2..i + 3), list2), keys).toMutableList() - - val temp = list1[0] - list1[0] = list1[1] - list1[1] = list2[0] - list2[0] = list2[1] - list2[1] = temp - } - - return listOf(list1[0], list1[1], list2[0], list2[1]) - } - - private fun tearDecode(a90: List, a91: List): MutableList { - var (a95, a96) = a90 - - var a97 = (-957401312).toBigInteger() - for (_i in 0 until 32) { - a96 -= ((((a95 shl 4) xor rShift(a95, 5)) + a95) xor (a97 + a91[rShift(a97, 11).and(3.toBigInteger()).toInt()])) - a97 += 1640531527.toBigInteger() - a95 -= ((((a96 shl 4) xor rShift(a96, 5)) + a96) xor (a97 + a91[a97.and(3.toBigInteger()).toInt()])) - - } - - return mutableListOf(a95, a96) - } - - private fun digestPad(string: String): List { - val empList = mutableListOf() - val length = string.length - val extra = big15 - (length.toBigInteger() % big16) - empList.add(extra) - for (i in 0 until length) { - empList.add(string[i].code.toBigInteger()) - } - for (i in 0 until extra.toInt()) { - empList.add(big0) - } - - return empList - } - - private fun bytes2blocks(a22: List): List { - val empList = mutableListOf() - val length = a22.size - var listIndex = 0 - - for (i in 0 until length) { - val subIndex = i % 4 - val shiftedByte = a22[i] shl (3 - subIndex) * 8 - - if (subIndex == 0) { - empList.add(shiftedByte) - } else { - empList[listIndex] = empList[listIndex] or shiftedByte - } - - if (subIndex == 3) listIndex += 1 - } - - return empList - } - - private fun blocks2bytes(inp: List): List { - val tempList = mutableListOf() - inp.indices.forEach { i -> - tempList += (big255 and rShift(inp[i], 24)) - tempList += (big255 and rShift(inp[i], 16)) - tempList += (big255 and rShift(inp[i], 8)) - tempList += (big255 and inp[i]) - } - return tempList - } - - private fun unPad(a46: List): List { - val evenOdd = a46[0].toInt().mod(2) - return (1 until (a46.size - evenOdd)).map { - a46[it] - } - } - - private fun xorBlocks(a76: List, a77: List): List { - return listOf(a76[0] xor a77[0], a76[1] xor a77[1]) - } - - private fun rShift(input: BigInteger, by: Int): BigInteger { - return (input.mod(4294967296.toBigInteger()) shr by) - } - - private fun tearCode(list1: List, list2: List): MutableList { - var a1 = list1[0] - var a2 = list1[1] - var temp = big0 - - for (_i in 0 until 32) { - a1 += (a2 shl 4 xor rShift(a2, 5)) + a2 xor temp + list2[(temp and big3).toInt()] - temp -= 1640531527.toBigInteger() - a2 += (a1 shl 4 xor rShift(a1, 5)) + a1 xor temp + list2[(rShift(temp, 11) and big3).toInt()] - } - return mutableListOf(a1, a2) - } - - private fun ascii2bytes(input: String): List { - val abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" - val abcMap = abc.mapIndexed { i, c -> c to i.toBigInteger() }.toMap() - var index = -1 - val length = input.length - var listIndex = 0 - val bytes = mutableListOf() - - while (true) { - for (i in input) { - if (abc.contains(i)) { - index++ - break - } - } - - bytes.add((abcMap[input.getOrNull(index)?:return bytes]!! * big4)) - - while (true) { - index++ - if (abc.contains(input[index])) { - break - } - } - - var temp = abcMap[input[index]]!! - - bytes[listIndex] = bytes[listIndex] or rShift(temp, 4) - listIndex++ - temp = (big15.and(temp)) - - if ((temp == big0) && (index == (length - 1))) return bytes - - bytes.add((temp * big4 * big4)) - - while (true) { - index++ - if (index >= length) return bytes - if (abc.contains(input[index])) break - } - - temp = abcMap[input[index]]!! - bytes[listIndex] = bytes[listIndex] or rShift(temp, 2) - listIndex++ - temp = (big3 and temp) - if ((temp == big0) && (index == (length - 1))) { - return bytes - } - bytes.add((temp shl 6)) - for (i in input) { - index++ - if (abc.contains(input[index])) { - break - } - } - bytes[listIndex] = bytes[listIndex] or abcMap[input[index]]!! - listIndex++ - } - } - - private fun replace(a: String): String { - val map = mapOf( - '0' to '5', - '1' to '6', - '2' to '7', - '5' to '0', - '6' to '1', - '7' to '2' - ) - var b = "" - a.forEach { - b += if (map.containsKey(it)) map[it] else it - } - return b - } - - private fun padLastChars(input:String):String{ - return if(input.reversed()[3].isDigit()) input - else input.dropLast(4) - } - - private data class HashResponse( - val hash: String? = null, - val version:String? = null - ) - - private data class SetupResponse( - val seed: String, - val src: String?=null, - val link:String?=null - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidmoly.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidmoly.kt deleted file mode 100644 index 615cfd74f..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidmoly.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson - -class Vidmolyme : Vidmoly() { - override val mainUrl = "https://vidmoly.me" -} - -open class Vidmoly : ExtractorApi() { - override val name = "Vidmoly" - override val mainUrl = "https://vidmoly.to" - override val requiresReferer = true - - private fun String.addMarks(str: String): String { - return this.replace(Regex("\"?$str\"?"), "\"$str\"") - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - - val script = app.get( - url, - referer = referer, - ).document.select("script") - .find { it.data().contains("sources:") }?.data() - val videoData = script?.substringAfter("sources: [") - ?.substringBefore("],")?.addMarks("file") - val subData = script?.substringAfter("tracks: [")?.substringBefore("]")?.addMarks("file") - ?.addMarks("label")?.addMarks("kind") - - tryParseJson(videoData)?.file?.let { m3uLink -> - M3u8Helper.generateM3u8( - name, - m3uLink, - "$mainUrl/" - ).forEach(callback) - } - - tryParseJson>("[${subData}]") - ?.filter { it.kind == "captions" }?.map { - subtitleCallback.invoke( - SubtitleFile( - it.label.toString(), - fixUrl(it.file.toString()) - ) - ) - } - - } - - private data class Source( - @JsonProperty("file") val file: String? = null, - ) - - private data class SubSource( - @JsonProperty("file") val file: String? = null, - @JsonProperty("label") val label: String? = null, - @JsonProperty("kind") val kind: String? = null, - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidstream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidstream.kt deleted file mode 100644 index 7eb7fbacf..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidstream.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.argamap -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.extractorApis -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.loadExtractor -import org.jsoup.Jsoup - -/** - * overrideMainUrl is necessary for for other vidstream clones like vidembed.cc - * If they diverge it'd be better to make them separate. - * */ -class Vidstream(val mainUrl: String) { - val name: String = "Vidstream" - - private fun getExtractorUrl(id: String): String { - return "$mainUrl/streaming.php?id=$id" - } - - private fun getDownloadUrl(id: String): String { - return "$mainUrl/download?id=$id" - } - - private val normalApis = arrayListOf(MultiQuality()) - - // https://gogo-stream.com/streaming.php?id=MTE3NDg5 - suspend fun getUrl( - id: String, - isCasting: Boolean = false, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit, - ): Boolean { - val extractorUrl = getExtractorUrl(id) - argamap( - { - normalApis.amap { api -> - val url = api.getExtractorUrl(id) - api.getSafeUrl( - url, - callback = callback, - subtitleCallback = subtitleCallback - ) - } - }, { - /** Stolen from GogoanimeProvider.kt extractor */ - val link = getDownloadUrl(id) - println("Generated vidstream download link: $link") - val page = app.get(link, referer = extractorUrl) - - val pageDoc = Jsoup.parse(page.text) - val qualityRegex = Regex("(\\d+)P") - - //a[download] - pageDoc.select(".dowload > a")?.amap { element -> - val href = element.attr("href") ?: return@amap - val qual = if (element.text() - .contains("HDP") - ) "1080" else qualityRegex.find(element.text())?.destructured?.component1() - .toString() - - if (!loadExtractor(href, link, subtitleCallback, callback)) { - callback.invoke( - ExtractorLink( - this.name, - name = this.name, - href, - page.url, - getQualityFromName(qual), - element.attr("href").contains(".m3u8") - ) - ) - } - } - }, { - with(app.get(extractorUrl)) { - val document = Jsoup.parse(this.text) - val primaryLinks = document.select("ul.list-server-items > li.linkserver") - //val extractedLinksList: MutableList = mutableListOf() - - // All vidstream links passed to extractors - primaryLinks.distinctBy { it.attr("data-video") }.forEach { element -> - val link = element.attr("data-video") - //val name = element.text() - - // Matches vidstream links with extractors - extractorApis.filter { !it.requiresReferer || !isCasting }.amap { api -> - if (link.startsWith(api.mainUrl)) { - api.getSafeUrl(link, extractorUrl, subtitleCallback, callback) - } - } - } - } - } - ) - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt deleted file mode 100644 index 2c6998de6..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -class Tubeless : Voe() { - override var mainUrl = "https://tubelessceliolymph.com" -} - -open class Voe : ExtractorApi() { - override val name = "Voe" - override val mainUrl = "https://voe.sx" - override val requiresReferer = true - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val res = app.get(url, referer = referer).document - val script = res.select("script").find { it.data().contains("sources =") }?.data() - val link = Regex("[\"']hls[\"']:\\s*[\"'](.*)[\"']").find(script ?: return)?.groupValues?.get(1) - - M3u8Helper.generateM3u8( - name, - link ?: return, - "$mainUrl/", - headers = mapOf("Origin" to "$mainUrl/") - ).forEach(callback) - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/WcoStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/WcoStream.kt deleted file mode 100644 index 6cc486cd5..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/WcoStream.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.cipher -import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.encrypt -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -class Vidstreamz : WcoStream() { - override var mainUrl = "https://vidstreamz.online" -} - -class Vizcloud : WcoStream() { - override var mainUrl = "https://vizcloud2.ru" -} - -class Vizcloud2 : WcoStream() { - override var mainUrl = "https://vizcloud2.online" -} - -class VizcloudOnline : WcoStream() { - override var mainUrl = "https://vizcloud.online" -} - -class VizcloudXyz : WcoStream() { - override var mainUrl = "https://vizcloud.xyz" -} - -class VizcloudLive : WcoStream() { - override var mainUrl = "https://vizcloud.live" -} - -class VizcloudInfo : WcoStream() { - override var mainUrl = "https://vizcloud.info" -} - -class MwvnVizcloudInfo : WcoStream() { - override var mainUrl = "https://mwvn.vizcloud.info" -} - -class VizcloudDigital : WcoStream() { - override var mainUrl = "https://vizcloud.digital" -} - -class VizcloudCloud : WcoStream() { - override var mainUrl = "https://vizcloud.cloud" -} - -class VizcloudSite : WcoStream() { - override var mainUrl = "https://vizcloud.site" -} - -class Mcloud : WcoStream() { - override var name = "Mcloud" - override var mainUrl = "https://mcloud.to" - override val requiresReferer = true -} - -open class WcoStream : ExtractorApi() { - override var name = "VidStream" // Cause works for animekisa and wco - override var mainUrl = "https://vidstream.pro" - override val requiresReferer = false - private val regex = Regex("(.+?/)e(?:mbed)?/([a-zA-Z0-9]+)") - - companion object { - // taken from https://github.com/saikou-app/saikou/blob/b35364c8c2a00364178a472fccf1ab72f09815b4/app/src/main/java/ani/saikou/parsers/anime/extractors/VizCloud.kt - // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md - private var lastChecked = 0L - private const val jsonLink = - "https://raw.githubusercontent.com/chenkaslowankiya/BruhFlow/main/keys.json" - private var cipherKey: VizCloudKey? = null - suspend fun getKey(): VizCloudKey { - cipherKey = - if (cipherKey != null && (lastChecked - System.currentTimeMillis()) < 1000 * 60 * 30) cipherKey!! - else { - lastChecked = System.currentTimeMillis() - app.get(jsonLink).parsed() - } - return cipherKey!! - } - - data class VizCloudKey( - @JsonProperty("cipherKey") val cipherKey: String, - @JsonProperty("mainKey") val mainKey: String, - @JsonProperty("encryptKey") val encryptKey: String, - @JsonProperty("dashTable") val dashTable: String - ) - - private const val baseTable = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+=/_" - - private fun dashify(id: String, dashTable: String): String { - val table = dashTable.split(" ") - return id.mapIndexedNotNull { i, c -> - table.getOrNull((baseTable.indexOf(c) * 16) + (i % 16)) - }.joinToString("-") - } - } - - //private val key = "LCbu3iYC7ln24K7P" // key credits @Modder4869 - override suspend fun getUrl(url: String, referer: String?): List { - val group = regex.find(url)?.groupValues!! - - val host = group[1] - val viz = getKey() - val id = encrypt( - cipher( - viz.cipherKey, - encrypt(group[2], viz.encryptKey).also { println(it) } - ).also { println(it) }, - viz.encryptKey - ).also { println(it) } - - val link = - "${host}mediainfo/${dashify(id, viz.dashTable)}?key=${viz.mainKey}" // - val response = app.get(link, referer = referer) - - data class Sources(@JsonProperty("file") val file: String) - data class Media(@JsonProperty("sources") val sources: List) - data class Data(@JsonProperty("media") val media: Media) - data class Response(@JsonProperty("data") val data: Data) - - - if (!response.text.startsWith("{")) throw ErrorLoadingException("Seems like 9Anime kiddies changed stuff again, Go touch some grass for bout an hour Or use a different Server") - return response.parsed().data.media.sources.map { - ExtractorLink(name, it.file,it.file,host,Qualities.Unknown.value,it.file.contains(".m3u8")) - } - - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt deleted file mode 100644 index 23704e90a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.schemaStripRegex -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor -import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeStreamLinkHandlerFactory -import org.schabi.newpipe.extractor.stream.SubtitlesStream -import org.schabi.newpipe.extractor.stream.VideoStream - -class YoutubeShortLinkExtractor : YoutubeExtractor() { - override val mainUrl = "https://youtu.be" - - override fun getExtractorUrl(id: String): String { - return "$mainUrl/$id" - } -} - -class YoutubeMobileExtractor : YoutubeExtractor() { - override val mainUrl = "https://m.youtube.com" -} -class YoutubeNoCookieExtractor : YoutubeExtractor() { - override val mainUrl = "https://www.youtube-nocookie.com" -} - -open class YoutubeExtractor : ExtractorApi() { - override val mainUrl = "https://www.youtube.com" - override val requiresReferer = false - override val name = "YouTube" - - companion object { - private var ytVideos: MutableMap> = mutableMapOf() - private var ytVideosSubtitles: MutableMap> = mutableMapOf() - } - - override fun getExtractorUrl(id: String): String { - return "$mainUrl/watch?v=$id" - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - if (ytVideos[url].isNullOrEmpty()) { - val link = - YoutubeStreamLinkHandlerFactory.getInstance().fromUrl( - url.replace( - schemaStripRegex, "" - ) - ) - - val s = object : YoutubeStreamExtractor( - ServiceList.YouTube, - link - ) { - - } - s.fetchPage() - ytVideos[url] = s.videoStreams - ytVideosSubtitles[url] = try { - s.subtitlesDefault.filterNotNull() - } catch (e: Exception) { - logError(e) - emptyList() - } - } - ytVideos[url]?.mapNotNull { - if (it.isVideoOnly || it.height <= 0) return@mapNotNull null - - ExtractorLink( - this.name, - this.name, - it.url ?: return@mapNotNull null, - "", - it.height - ) - }?.forEach(callback) - ytVideosSubtitles[url]?.mapNotNull { - SubtitleFile(it.languageTag ?: return@mapNotNull null, it.url ?: return@mapNotNull null) - }?.forEach(subtitleCallback) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Zorofile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Zorofile.kt deleted file mode 100644 index 43c4eefb2..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Zorofile.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder.getCaptchaToken -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class Zorofile : ExtractorApi() { - override val name = "Zorofile" - override val mainUrl = "https://zorofile.com" - override val requiresReferer = true - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val id = url.split("?").first().split("/").last() - val token = app.get( - url, - referer = referer - ).document.select("button.g-recaptcha").attr("data-sitekey").let { captchaKey -> - getCaptchaToken( - url, - captchaKey, - referer = referer - ) - } ?: throw ErrorLoadingException("can't bypass captcha") - - val data = app.post( - "$mainUrl/dl", - data = mapOf( - "op" to "embed", - "file_code" to id, - "auto" to "1", - "referer" to "$referer/", - "g-recaptcha-response" to token - ), - referer = url, - headers = mapOf( - "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Content-Type" to "application/x-www-form-urlencoded", - "Origin" to mainUrl, - "Sec-Fetch-Dest" to "iframe", - "Sec-Fetch-Mode" to "navigate", - "Sec-Fetch-Site" to "same-origin", - "Sec-Fetch-User" to "?1", - "Upgrade-Insecure-Requests" to "1", - ) - ).document.select("script").find { it.data().contains("var holaplayer;") }?.data() - ?.substringAfter("sources: [")?.substringBefore("],")?.replace("src", "\"src\"") - ?.replace("type", "\"type\"") - - tryParseJson("$data")?.let { res -> - return M3u8Helper.generateM3u8( - name, - res.src ?: return@let, - "$mainUrl/", - headers = mapOf( - "Origin" to mainUrl, - ) - ).forEach(callback) - } - } - - private data class Sources( - @JsonProperty("src") val src: String? = null, - @JsonProperty("type") val type: String? = null, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt deleted file mode 100644 index 8cfe1e9a9..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.lagradost.cloudstream3.metaproviders - -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId -import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi -import com.lagradost.cloudstream3.syncproviders.SyncAPI -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi -import com.lagradost.cloudstream3.syncproviders.providers.MALApi -import com.lagradost.cloudstream3.utils.SyncUtil - -// wont be implemented -class MultiAnimeProvider : MainAPI() { - override var name = "MultiAnime" - override var lang = "en" - override val usesWebView = true - override val supportedTypes = setOf(TvType.Anime) - private val syncApi: SyncAPI = aniListApi - - private val syncUtilType by lazy { - when (syncApi) { - is AniListApi -> "anilist" - is MALApi -> "myanimelist" - else -> throw ErrorLoadingException("Invalid Api") - } - } - - private val validApis - get() = - synchronized(APIHolder.apis) { - APIHolder.apis.filter { - it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains( - TvType.Anime - ) - } - } - - - private fun filterName(name: String): String { - return Regex("""[^a-zA-Z0-9-]""").replace(name, "") - } - - override suspend fun search(query: String): List? { - return syncApi.search(query)?.map { - AnimeSearchResponse(it.name, it.url, this.name, TvType.Anime, it.posterUrl) - } - } - - override suspend fun load(url: String): LoadResponse? { - return syncApi.getResult(url)?.let { res -> - val data = SyncUtil.getUrlsFromId(res.id, syncUtilType).amap { url -> - validApis.firstOrNull { api -> url.startsWith(api.mainUrl) }?.load(url) - }.filterNotNull() - - val type = - if (data.any { it.type == TvType.AnimeMovie }) TvType.AnimeMovie else TvType.Anime - - newAnimeLoadResponse( - res.title ?: throw ErrorLoadingException("No Title found"), - url, - type - ) { - posterUrl = res.posterUrl - plot = res.synopsis - tags = res.genres - rating = res.publicScore - addTrailer(res.trailers) - addAniListId(res.id.toIntOrNull()) - recommendations = res.recommendations - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt new file mode 100644 index 000000000..482ec05fc --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.mvvm + +import android.view.View +import androidx.activity.ComponentActivity +import androidx.core.view.doOnAttach +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.ui.BaseFragment + +/** NOTE: Only one observer at a time per value */ +fun 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) { + liveData.removeObservers(this) + liveData.observe(this, action) +} + +/** NOTE: Only one observer at a time per value */ +fun BaseFragment.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } +} + +/** + * Attaches an observable to the root binding, instead of the fragment. This is more efficient as + * it will not call observe if the view is in the background. + * + * NOTE: Only one observer at a time per value + * */ +fun BaseFragment.observeNullable( + liveData: LiveData, action: (T?) -> Unit +) { + val root = this.binding?.root + if (root == null) { + liveData.removeObservers(this) + liveData.observe(this, action) + } else { + root.doOnAttach { view -> + // On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case + val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable + liveData.removeObservers(owner) + liveData.observe(owner, action) + } + } +} + +/** NOTE: Only one observer at a time per value */ +fun View.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } +} + +/** NOTE: Only one observer at a time per value */ +fun View.observeNullable(liveData: LiveData, action: (T?) -> Unit) { + doOnAttach { view -> + // On attach should make findViewTreeLifecycleOwner non-null + val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner() + if(owner == null) { + debugException { "Expected non-null findViewTreeLifecycleOwner" } + return@doOnAttach + } + liveData.removeObservers(owner) + liveData.observe(owner, action) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt index 6950d9614..9efa88a37 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -5,11 +5,14 @@ import android.webkit.CookieManager import androidx.annotation.AnyThread import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugWarning -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.nicehttp.Requests.Companion.await import com.lagradost.nicehttp.cookies import kotlinx.coroutines.runBlocking -import okhttp3.* +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response import java.net.URI @@ -17,6 +20,8 @@ import java.net.URI class CloudflareKiller : Interceptor { companion object { const val TAG = "CloudflareKiller" + private val ERROR_CODES = listOf(403, 503) + private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare") fun parseCookieMap(cookie: String): Map { return cookie.split(";").associate { val split = it.split("=") @@ -27,7 +32,7 @@ class CloudflareKiller : Interceptor { init { // Needs to clear cookies between sessions to generate new cookies. - normalSafeApiCall { + safe { // This can throw an exception on unsupported devices :( CookieManager.getInstance().removeAllCookies(null) } @@ -48,15 +53,23 @@ class CloudflareKiller : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = runBlocking { val request = chain.request() - val cookies = savedCookies[request.url.host] - if (cookies == null) { - bypassCloudflare(request)?.let { - Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}") - return@runBlocking it + when (val cookies = savedCookies[request.url.host]) { + null -> { + val response = chain.proceed(request) + if(!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) { + return@runBlocking response + } else { + response.close() + bypassCloudflare(request)?.let { + Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}") + return@runBlocking it + } + } + } + else -> { + return@runBlocking proceed(request, cookies) } - } else { - return@runBlocking proceed(request, cookies) } debugWarning({ true }) { "Failed cloudflare at: ${request.url}" } @@ -64,7 +77,7 @@ class CloudflareKiller : Interceptor { } private fun getWebViewCookie(url: String): String? { - return normalSafeApiCall { + return safe { CookieManager.getInstance()?.getCookie(url) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt index 55e092513..4127799e8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt @@ -84,4 +84,24 @@ fun OkHttpClient.Builder.addQuad9Dns() = ( "9.9.9.9", "149.112.112.112", ) - )) \ No newline at end of file + )) + +fun OkHttpClient.Builder.addDnsSbDns() = ( + addGenericDns( + "https://doh.dns.sb/dns-query", + //https://dns.sb/guide/ + listOf( + "185.222.222.222", + "45.11.45.11", + ) + )) + +fun OkHttpClient.Builder.addCanadianShieldDns() = ( + addGenericDns( + "https://private.canadianshield.cira.ca/dns-query", + //https://www.cira.ca/en/canadian-shield/configure/summary-cira-canadian-shield-dns-resolver-addresses/ + listOf( + "149.112.121.10", + "149.112.122.10", + ) + )) 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 a1d84f6cd..6234297d0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -2,9 +2,10 @@ 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.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ignoreAllSSLErrors import okhttp3.Cache @@ -15,14 +16,38 @@ import org.conscrypt.Conscrypt import java.io.File import java.security.Security -fun Requests.initClient(context: Context): OkHttpClient { - normalSafeApiCall { Security.insertProviderAt(Conscrypt.newProvider(), 1) } +// Backwards compatible constructor, mark as deprecated later +fun Requests.initClient(context: Context) { + this.baseClient = buildDefaultClient(context) +} + +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) { + this.baseClient = buildDefaultClient(context, ignoreSSL) +} + + +// Backwards compatible constructor, mark as deprecated later +fun buildDefaultClient(context: Context): OkHttpClient { + return buildDefaultClient(context, false) +} + +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient { + safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0) - baseClient = OkHttpClient.Builder() + val baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) - .ignoreAllSSLErrors() + .apply { + if (ignoreSSL) { + ignoreAllSSLErrors() + } + } .cache( // Note that you need to add a ResponseInterceptor to make this 100% active. // The server response dictates if and when stuff should be cached. @@ -38,6 +63,8 @@ fun Requests.initClient(context: Context): OkHttpClient { 4 -> addAdGuardDns() 5 -> addDNSWatchDns() 6 -> addQuad9Dns() + 7 -> addDnsSbDns() + 8 -> addCanadianShieldDns() } } // Needs to be build as otherwise the other builders will change this object @@ -45,11 +72,6 @@ fun Requests.initClient(context: Context): OkHttpClient { return baseClient } -//val Request.cookies: Map -// get() { -// return this.headers.getCookies("Cookie") -// } - private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) /** diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt index 6b7dc90b7..e1496db06 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -2,75 +2,39 @@ package com.lagradost.cloudstream3.plugins import android.content.Context import android.content.res.Resources -import kotlin.Throws -import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.extractorApis import android.util.Log -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import kotlin.Throws -const val PLUGIN_TAG = "PluginInstance" - -abstract class Plugin { +abstract class Plugin : BasePlugin() { /** * Called when your Plugin is loaded * @param context Context */ @Throws(Throwable::class) open fun load(context: Context) { + // If not overridden by an extension then try the cross-platform load() + load() } /** - * Called when your Plugin is being unloaded + * Used to register VideoClickAction instances + * @param element VideoClickAction you want to register */ - @Throws(Throwable::class) - open fun beforeUnload() { - } - - /** - * Used to register providers instances of MainAPI - * @param element MainAPI provider you want to register - */ - fun registerMainAPI(element: MainAPI) { - Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI") - element.sourcePlugin = this.__filename - // Race condition causing which would case duplicates if not for distinctBy - synchronized(APIHolder.allProviders) { - APIHolder.allProviders.add(element) - } - APIHolder.addPluginMapping(element) - } - - /** - * Used to register extractor instances of ExtractorApi - * @param element ExtractorApi provider you want to register - */ - fun registerExtractorAPI(element: ExtractorApi) { - Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi") - element.sourcePlugin = this.__filename - extractorApis.add(element) - } - - class Manifest { - @JsonProperty("name") - var name: String? = null - @JsonProperty("pluginClassName") - var pluginClassName: String? = null - @JsonProperty("version") - var version: Int? = null - @JsonProperty("requiresResources") - var requiresResources: Boolean = false + fun registerVideoClickAction(element: VideoClickAction) { + Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") + element.sourcePlugin = this.filename + VideoClickActionHolder.allVideoClickActions.add(element) } /** * This will contain your resources if you specified requiresResources in gradle */ var resources: Resources? = null - var __filename: String? = null /** * This will add a button in the settings allowing you to add custom settings */ var openSettings: ((context: Context) -> Unit)? = null -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 87b0ba3b7..debd3f0eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -1,50 +1,68 @@ package com.lagradost.cloudstream3.plugins -import android.app.* +import android.Manifest +import android.app.Activity +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.content.pm.PackageManager import android.content.res.AssetManager import android.content.res.Resources import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast +import androidx.annotation.WorkerThread +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty -import com.google.gson.Gson -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings +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.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe 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.ui.result.UiText -import com.lagradost.cloudstream3.ui.result.txt +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 +import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename import com.lagradost.cloudstream3.utils.extractorApis +import com.lagradost.cloudstream3.utils.txt import dalvik.system.PathClassLoader import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File import java.io.InputStreamReader -import java.util.* // Different keys for local and not since local can be removed at any time without app knowing, hence the local are getting rebuilt on every app start const val PLUGINS_KEY = "PLUGINS_KEY" @@ -62,6 +80,7 @@ data class PluginData( @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { + @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -76,7 +95,9 @@ data class PluginData( null, null, null, - File(this.filePath).length() + File(this.filePath).length(), + // No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute. + null ) } } @@ -130,13 +151,27 @@ object PluginManager { !it.filePath.contains(repositoryPath) } val file = File(repositoryPath) - normalSafeApiCall { + safe { if (file.exists()) file.deleteRecursively() } setKey(PLUGINS_KEY, plugins) } } + /** + * Deletes all generated oat files which will force Android to recompile the dex extensions. + * This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update. + */ + fun deleteAllOatFiles(context: Context) { + File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo -> + repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file -> + val success = file.deleteRecursively() + Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success") + } + } + } + + fun getPluginsOnline(): Array { return getKey(PLUGINS_KEY) ?: emptyArray() } @@ -150,25 +185,24 @@ object PluginManager { private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins" - public var currentlyLoading: String? = null + var currentlyLoading: String? = null // Maps filepath to plugin - val plugins: MutableMap = - LinkedHashMap() + val plugins: MutableMap = + LinkedHashMap() // Maps urls to plugin - val urlPlugins: MutableMap = - LinkedHashMap() + val urlPlugins: MutableMap = + LinkedHashMap() - private val classLoaders: MutableMap = - HashMap() + private val classLoaders: MutableMap = + HashMap() var loadedLocalPlugins = false private set var loadedOnlinePlugins = false private set - private val gson = Gson() private suspend fun maybeLoadPlugin(context: Context, file: File) { val name = file.name @@ -227,16 +261,24 @@ object PluginManager { * 2. If disabled do nothing * 3. If outdated download and load the plugin * 4. Else load the plugin normally - **/ - fun updateAllOnlinePluginsAndLoadThem(activity: Activity) { + * + * 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() + // Load all plugins as fast as possible! - loadAllOnlinePlugins(activity) + ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity) afterPluginsLoadedEvent.invoke(false) val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES - val onlinePlugins = urls.toList().apmap { + val onlinePlugins = urls.toList().amap { getRepoPlugins(it.url)?.toList() ?: emptyList() }.flatten().distinctBy { it.second.url } @@ -257,7 +299,7 @@ object PluginManager { val updatedPlugins = mutableListOf() - outdatedPlugins.apmap { pluginData -> + outdatedPlugins.amap { pluginData -> if (pluginData.isDisabled) { //updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name)) unloadPlugin(pluginData.savedData.filePath) @@ -265,6 +307,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true @@ -278,6 +321,9 @@ object PluginManager { main { val uitext = txt(R.string.plugins_updated, updatedPlugins.size) createNotification(activity, uitext, updatedPlugins) + /*val navBadge = (activity as MainActivity).binding?.navRailView?.getOrCreateBadge(R.id.navigation_settings) + navBadge?.isVisible = true + navBadge?.number = 5*/ } // ioSafe { @@ -293,12 +339,23 @@ object PluginManager { * 1. Gets all online data from online plugins repo * 2. Fetch all not downloaded plugins * 3. Download them and reload plugins - **/ - fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) { + * + * 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() + val newDownloadPlugins = mutableListOf() val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES - val onlinePlugins = urls.toList().apmap { + val onlinePlugins = urls.toList().amap { getRepoPlugins(it.url)?.toList() ?: emptyList() }.flatten().distinctBy { it.second.url } @@ -326,7 +383,7 @@ object PluginManager { //Omit non-NSFW if mode is set to NSFW only if (mode == AutoDownloadMode.NsfwOnly) { - if (tvtypes.contains(TvType.NSFW.name) == false) { + if (!tvtypes.contains(TvType.NSFW.name)) { return@mapNotNull null } } @@ -358,10 +415,11 @@ object PluginManager { } //Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}") - notDownloadedPlugins.apmap { pluginData -> + notDownloadedPlugins.amap { pluginData -> downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled @@ -383,12 +441,27 @@ 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 - * */ - fun loadAllOnlinePlugins(context: Context) { + * + * 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() + // Load all plugins as fast as possible! - (getPluginsOnline()).toList().apmap { pluginData -> + (getPluginsOnline()).toList().amap { pluginData -> loadPlugin( context, File(pluginData.filePath), @@ -399,23 +472,38 @@ object PluginManager { /** * Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb - **/ - fun hotReloadAllLocalPlugins(activity: FragmentActivity?) { + * + * 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() + Log.d(TAG, "Reloading all local plugins!") if (activity == null) return getPluginsLocal().forEach { unloadPlugin(it.filePath) } - loadAllLocalPlugins(activity, true) + ___DO_NOT_CALL_FROM_A_PLUGIN_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 - **/ - fun loadAllLocalPlugins(context: Context, forceReload: Boolean) { + * + * 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() + val dir = File(LOCAL_PLUGINS_PATH) - removeKey(PLUGINS_KEY_LOCAL) if (!dir.exists()) { val res = dir.mkdirs() @@ -428,24 +516,64 @@ object PluginManager { val sortedPlugins = dir.listFiles() // Always sort plugins alphabetically for reproducible results - Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins") + Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: ${sortedPlugins?.size}") - sortedPlugins?.sortedBy { it.name }?.apmap { file -> - maybeLoadPlugin(context, file) + // Use app-specific external files directory and copy the file there. + // We have to do this because on Android 14+, it otherwise gives SecurityException + // due to dex files and setReadOnly seems to have no effect unless it it here. + val pluginDirectory = File(context.getExternalFilesDir(null), "plugins") + if (!pluginDirectory.exists()) { + pluginDirectory.mkdirs() // Ensure the plugins directory exists + } + + // Make sure all local plugins are fully refreshed. + removeKey(PLUGINS_KEY_LOCAL) + + sortedPlugins?.sortedBy { it.name }?.amap { file -> + try { + val destinationFile = File(pluginDirectory, file.name) + + // Only copy the file if the destination file doesn't exist or if it + // has been modified (check file length and modification time). + if (!destinationFile.exists() || + destinationFile.length() != file.length() || + destinationFile.lastModified() != file.lastModified() + ) { + + // Copy the file to the app-specific plugin directory + file.copyTo(destinationFile, overwrite = true) + + // After copying, set the destination file's modification time + // to match the source file. We do this for performance so that we + // can check the modification time and not make redundant writes. + destinationFile.setLastModified(file.lastModified()) + } + + // Load the plugin after it has been copied + maybeLoadPlugin(context, destinationFile) + } catch (t: Throwable) { + Log.e(TAG, "Failed to copy the file") + logError(t) + } } loadedLocalPlugins = true afterPluginsLoadedEvent.invoke(forceReload) } + /** @return true if safe mode is enabled in any possible way. */ + fun isSafeMode(): Boolean { + return checkSafeModeFile() || lastError != null + } + /** * This can be used to override any extension loading to fix crashes! * @return true if safe mode file is present **/ fun checkSafeModeFile(): Boolean { - return normalSafeApiCall { + return safe { val folder = File(CLOUD_STREAM_FOLDER) - if (!folder.exists()) return@normalSafeApiCall false + if (!folder.exists()) return@safe false val files = folder.listFiles { _, name -> name.equals("safe", ignoreCase = true) } @@ -463,18 +591,26 @@ object PluginManager { Log.i(TAG, "Loading plugin: $data") return try { + // In case of Android 14+ then + try { + // Set the file as read-only and log if it fails + if (!file.setReadOnly()) { + Log.e(TAG, "Failed to set read-only on plugin file: ${file.name}") + } + } catch (t: Throwable) { + Log.e(TAG, "Failed to set dex as read-only") + logError(t) + } + val loader = PathClassLoader(filePath, context.classLoader) - var manifest: Plugin.Manifest + var manifest: BasePlugin.Manifest loader.getResourceAsStream("manifest.json").use { stream -> if (stream == null) { Log.e(TAG, "Failed to load plugin $fileName: No manifest found") return false } InputStreamReader(stream).use { reader -> - manifest = gson.fromJson( - reader, - Plugin.Manifest::class.java - ) + manifest = parseJson(reader.readText()) } } @@ -484,10 +620,12 @@ object PluginManager { val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also { Log.d(TAG, "No manifest version for ${data.internalName}") } + + @Suppress("UNCHECKED_CAST") val pluginClass: Class<*> = - loader.loadClass(manifest.pluginClassName) as Class - val pluginInstance: Plugin = - pluginClass.newInstance() as Plugin + loader.loadClass(manifest.pluginClassName) as Class + val pluginInstance: BasePlugin = + pluginClass.getDeclaredConstructor().newInstance() as BasePlugin // Sets with the proper version setPluginData(data.copy(version = version)) @@ -497,31 +635,43 @@ object PluginManager { return true } - pluginInstance.__filename = fileName + pluginInstance.filename = file.absolutePath if (manifest.requiresResources) { Log.d(TAG, "Loading resources for ${data.internalName}") // based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk - val assets = AssetManager::class.java.newInstance() + val assets = AssetManager::class.java.getDeclaredConstructor().newInstance() val addAssetPath = AssetManager::class.java.getMethod("addAssetPath", String::class.java) addAssetPath.invoke(assets, file.absolutePath) - pluginInstance.resources = Resources( + + @Suppress("DEPRECATION") + (pluginInstance as? Plugin)?.resources = Resources( assets, context.resources.displayMetrics, context.resources.configuration ) } - plugins[filePath] = pluginInstance - classLoaders[loader] = pluginInstance - urlPlugins[data.url ?: filePath] = pluginInstance - pluginInstance.load(context) + synchronized(plugins) { + plugins[filePath] = pluginInstance + } + synchronized(classLoaders) { + classLoaders[loader] = pluginInstance + } + synchronized(urlPlugins) { + urlPlugins[data.url ?: filePath] = pluginInstance + } + if (pluginInstance is Plugin) { + pluginInstance.load(context) + } else { + pluginInstance.load() + } Log.i(TAG, "Loaded plugin ${data.internalName} successfully") currentlyLoading = null true } catch (e: Throwable) { Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}") showToast( - context.getActivity(), + // context.getActivity(), // we are not always on the main thread context.getString(R.string.plugin_load_fail).format(fileName), Toast.LENGTH_LONG ) @@ -545,20 +695,33 @@ object PluginManager { } // remove all registered apis - synchronized(APIHolder.apis) { - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach { - removePluginMapping(it) - } + 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 } - classLoaders.values.removeIf { v -> v == plugin } + extractorApis.withLock { + extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename } + } - plugins.remove(absolutePath) - urlPlugins.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 } + } } /** @@ -588,25 +751,27 @@ object PluginManager { suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, repositoryUrl: String, loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) - return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) + return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, file: File, - loadPlugin: Boolean + loadPlugin: Boolean, ): Boolean { try { Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names - val newFile = downloadPluginToFile(pluginUrl, file) ?: return false + val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false val data = PluginData( internalName, @@ -649,6 +814,84 @@ object PluginManager { } } + /** + * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. + * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! + */ + @Suppress("FunctionName") + @InternalAPI + @Throws + suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) { + assertNonRecursiveCallstack() + + showToast(activity.getString(R.string.starting_plugin_update_manually), Toast.LENGTH_LONG) + + ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity) + afterPluginsLoadedEvent.invoke(false) + + val urls = (getKey>(REPOSITORIES_KEY) + ?: emptyArray()) + PREBUILT_REPOSITORIES + val onlinePlugins = urls.toList().amap { + getRepoPlugins(it.url)?.toList() ?: emptyList() + }.flatten().distinctBy { it.second.url } + + val allPlugins = getPluginsOnline().flatMap { savedData -> + onlinePlugins + .filter { it.second.internalName == savedData.internalName } + .mapNotNull { onlineData -> + OnlinePluginData(savedData, onlineData).takeIf { it.validOnlineData(activity) } + } + }.distinctBy { it.onlineData.second.url } + + val updatedPlugins = mutableListOf() + + allPlugins.amap { pluginData -> + if (pluginData.isDisabled) { + Log.e( + "PluginManager", + "Unloading disabled plugin: ${pluginData.onlineData.second.name}" + ) + unloadPlugin(pluginData.savedData.filePath) + } else { + val existingFile = File(pluginData.savedData.filePath) + if (existingFile.exists()) existingFile.delete() + + if (downloadPlugin( + activity, + pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, + pluginData.savedData.internalName, + existingFile, + true + ) + ) { + updatedPlugins.add(pluginData.onlineData.second.name) + } + } + }.also { + main { + val message = if (updatedPlugins.isNotEmpty()) { + activity.getString(R.string.plugins_updated_manually, updatedPlugins.size) + } else { + activity.getString(R.string.no_plugins_updated_manually) + } + showToast(message, Toast.LENGTH_LONG) + + val notificationText = UiText.StringResource( + R.string.plugins_updated_manually, + listOf(updatedPlugins.size) + ) + createNotification(activity, notificationText, updatedPlugins) + + } + } + + loadedOnlinePlugins = true + afterPluginsLoadedEvent.invoke(false) + + Log.i("PluginManager", "Plugin update done!") + } + private fun Context.createNotificationChannel() { hasCreatedNotChanel = true // Create the NotificationChannel, but only on API 26+ because @@ -700,9 +943,14 @@ object PluginManager { } val notification = builder.build() - with(NotificationManagerCompat.from(context)) { - // notificationId is a unique int for each notification that you must define - notify((System.currentTimeMillis() / 1000).toInt(), notification) + // notificationId is a unique int for each notification that you must define + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + NotificationManagerCompat.from(context) + .notify((System.currentTimeMillis() / 1000).toInt(), notification) } return notification } catch (e: Exception) { 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 b80a590ef..07d6aaa37 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,16 +1,17 @@ package com.lagradost.cloudstream3.plugins import android.content.Context +import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.context -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeAsync import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY @@ -18,16 +19,19 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.BufferedInputStream import java.io.File -import java.io.InputStream -import java.io.OutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicInteger /** * Comes with the app, always available in the app, non removable. * */ data class Repository( + @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("description") val description: String?, @JsonProperty("manifestVersion") val manifestVersion: Int, @@ -61,10 +65,12 @@ data class SitePlugin( @JsonProperty("repositoryUrl") val repositoryUrl: String?, // These types are yet to be mapped and used, ignore for now @JsonProperty("tvTypes") val tvTypes: List?, + // Most often a language tag like "en" or "zh-TW" @JsonProperty("language") val language: String?, @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin @JsonProperty("fileSize") val fileSize: Long?, + @JsonProperty("fileHash") val fileHash: String?, ) @@ -73,7 +79,26 @@ object RepositoryManager { val PREBUILT_REPOSITORIES: Array by lazy { getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } - val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + private val GH_REGEX = + Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + + + /** Returns a SHA-256 string of the file content. + * Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/ + @WorkerThread + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + + file.inputStream().use { fis -> + val buffer = ByteArray(8192) + var read = fis.read(buffer) + while (read != -1) { + digest.update(buffer, 0, read) + read = fis.read(buffer) + } + } + return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) } + } /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { @@ -94,12 +119,12 @@ object RepositoryManager { else fixedUrl } } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) { - suspendSafeApiCall { + safeAsync { 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 + if (url.startsWith("https://cutt.ly/404")) return@safeAsync null + if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null + return@safeAsync url } } } @@ -107,7 +132,7 @@ object RepositoryManager { } suspend fun parseRepository(url: String): Repository? { - return suspendSafeApiCall { + return safeAsync { // Take manifestVersion and such into account later app.get(convertRawGitUrl(url)).parsedSafe() } @@ -138,21 +163,52 @@ object RepositoryManager { }.flatten() } - suspend fun downloadPluginToFile( - pluginUrl: String, - file: File - ): File? { - return suspendSafeApiCall { - file.mkdirs() - // Overwrite if exists - if (file.exists()) { - file.delete() - } - file.createNewFile() + suspend fun downloadPluginToFile( + context: Context, + pluginUrl: String, + file: File, + expectedFileHash: String? + ): File? { + return safeAsync { + val parentDir = file.parentFile ?: return@safeAsync null + parentDir.mkdirs() + + // Prevent corrupting the plugin file if the operation fails + val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body - write(body.byteStream(), file.outputStream()) + + body.byteStream().use { body -> + tempFile.outputStream().use { fileSteam -> + body.copyTo(fileSteam) + } + } + + if (expectedFileHash != null) { + val downloadHash = sha256(tempFile) + if (expectedFileHash != downloadHash) { + tempFile.delete() + throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.") + } + } + + // We prefer the operation to be atomic + try { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + file } } @@ -191,7 +247,7 @@ object RepositoryManager { // Unload all plugins, not using deletePlugin since we // delete all data and files in deleteRepositoryData - normalSafeApiCall { + safe { file.listFiles { plugin: File -> unloadPlugin(plugin.absolutePath) false @@ -200,13 +256,4 @@ object RepositoryManager { PluginManager.deleteRepositoryData(file.absolutePath) } - - private fun write(stream: InputStream, output: OutputStream) { - val input = BufferedInputStream(stream) - val dataBuffer = ByteArray(512) - var readBytes: Int - while (input.read(dataBuffer).also { readBytes = it } != -1) { - output.write(dataBuffer, 0, readBytes) - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt index a45ab5f02..85a806f0b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins import android.util.Log import android.widget.Toast -import com.lagradost.cloudstream3.AcraApplication.Companion.context -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import java.security.MessageDigest import com.lagradost.cloudstream3.app @@ -12,88 +12,76 @@ import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -object VotingApi { // please do not cheat the votes lol +object VotingApi { + private const val LOGKEY = "VotingApi" + private const val API_DOMAIN = "https://api.countify.xyz" - private const val apiDomain = "https://counterapi.com/api" - - private fun transformUrl(url: String): String = // dont touch or all votes get reset + private fun transformUrl(url: String): String = MessageDigest .getInstance("SHA-256") .digest("${url}#funny-salt".toByteArray()) .fold("") { str, it -> str + "%02x".format(it) } - suspend fun SitePlugin.getVotes(): Int { - return getVotes(url) - } + suspend fun SitePlugin.getVotes(): Int = getVotes(url) + fun SitePlugin.hasVoted(): Boolean = hasVoted(url) + suspend fun SitePlugin.vote(): Int = vote(url) + fun SitePlugin.canVote(): Boolean = canVote(this.url) - fun SitePlugin.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 { - var url = "${apiDomain}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" - Log.d(LOGKEY, "Requesting: $url") - return app.get(url).parsedSafe()?.value ?: 0 + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/get-total/$id" + Log.d(LOGKEY, "Requesting GET: $url") + return app.get(url).parsedSafe()?.count ?: 0 } private suspend fun writeVote(pluginUrl: String): Boolean { - var url = "${apiDomain}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}" - Log.d(LOGKEY, "Requesting: $url") - return app.get(url).parsedSafe()?.value != null + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/increment/$id" + Log.d(LOGKEY, "Requesting POST: $url") + return app.post(url, emptyMap()) + .parsedSafe()?.count != null } suspend fun getVotes(pluginUrl: String): Int = - votesCache[pluginUrl] ?: readVote(pluginUrl).also { - votesCache[pluginUrl] = it - } + votesCache[pluginUrl] ?: readVote(pluginUrl).also { + votesCache[pluginUrl] = it + } fun hasVoted(pluginUrl: String) = getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false - fun canVote(pluginUrl: String): Boolean { - if (!PluginManager.urlPlugins.contains(pluginUrl)) return false - return true - } + fun canVote(pluginUrl: String): Boolean = + PluginManager.urlPlugins.contains(pluginUrl) private val voteLock = Mutex() + suspend fun vote(pluginUrl: String): Int { - // Prevent multiple requests at the same time. voteLock.withLock { if (!canVote(pluginUrl)) { main { - Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT) - .show() + Toast.makeText( + context, + R.string.extension_install_first, + Toast.LENGTH_SHORT + ).show() } return getVotes(pluginUrl) } if (hasVoted(pluginUrl)) { main { - Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT) - .show() + Toast.makeText( + context, + R.string.already_voted, + Toast.LENGTH_SHORT + ).show() } return getVotes(pluginUrl) } - if (writeVote(pluginUrl)) { setKey("cs3-votes/${transformUrl(pluginUrl)}", true) votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 @@ -103,7 +91,8 @@ object VotingApi { // please do not cheat the votes lol } } - private data class Result( - val value: Int? + private data class CountifyResult( + val id: String? = null, + val count: Int? = null ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt new file mode 100644 index 000000000..f130831c6 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.services + +import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import java.util.concurrent.TimeUnit + +const val BACKUP_CHANNEL_ID = "cloudstream3.backups" +const val BACKUP_WORK_NAME = "work_backup" +const val BACKUP_CHANNEL_NAME = "Backups" +const val BACKUP_CHANNEL_DESCRIPTION = "Notifications for background backups" +const val BACKUP_NOTIFICATION_ID = 938712898 // Random unique + +class BackupWorkManager(val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + companion object { + fun enqueuePeriodicWork(context: Context?, intervalHours: Long) { + if (context == null) return + + if (intervalHours == 0L) { + WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME) + return + } + + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .build() + + val periodicSyncDataWork = + PeriodicWorkRequest.Builder( + BackupWorkManager::class.java, + intervalHours, + TimeUnit.HOURS + ) + .addTag(BACKUP_WORK_NAME) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + BACKUP_WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + periodicSyncDataWork + ) + + // Uncomment below for testing + +// val oneTimeBackupWork = +// OneTimeWorkRequest.Builder(BackupWorkManager::class.java) +// .addTag(BACKUP_WORK_NAME) +// .setConstraints(constraints) +// .build() +// +// WorkManager.getInstance(context).enqueue(oneTimeBackupWork) + } + } + + private val backupNotificationBuilder = + NotificationCompat.Builder(context, BACKUP_CHANNEL_ID) + .setColorized(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setAutoCancel(true) + .setContentTitle(context.getString(R.string.pref_category_backup)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big) + + override suspend fun doWork(): Result { + context.createNotificationChannel( + BACKUP_CHANNEL_ID, + BACKUP_CHANNEL_NAME, + BACKUP_CHANNEL_DESCRIPTION + ) + + val foregroundInfo = if (SDK_INT >= 29) + ForegroundInfo( + BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) else ForegroundInfo(BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build()) + setForeground(foregroundInfo) + + BackupUtils.backup(context) + + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt new file mode 100644 index 000000000..e07747a86 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt @@ -0,0 +1,279 @@ +package com.lagradost.cloudstream3.services + +import android.Manifest +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.MainActivity.Companion.setLastError +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugWarning +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.system.measureTimeMillis +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class DownloadQueueService : Service() { + companion object { + const val TAG = "DownloadQueueService" + const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue" + const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service" + const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification." + const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique + @Volatile + var isRunning = false + + fun getIntent( + context: Context, + ): Intent { + return Intent(context, DownloadQueueService::class.java) + } + + private val _downloadInstances: MutableStateFlow> = + MutableStateFlow(emptyList()) + + /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances. + * Completed or failed instances are automatically removed by the download queue service. + * + */ + val downloadInstances: StateFlow> = + _downloadInstances + + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + instances to queue + } + .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads -> + Triple(instances, queue, currentDownloads) + } + } + + + private val baseNotification by lazy { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = + PendingIntentCompat.getActivity(this, 0, intent, 0, false) + + val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0) + val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0) + + NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID) + .setOngoing(true) // Make it persistent + .setAutoCancel(false) + .setColorized(false) + .setOnlyAlertOnce(true) + .setSilent(true) + .setShowWhen(false) + // If low priority then the notification might not show :( + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(this.colorFromAttribute(R.attr.colorPrimary)) + .setContentText(activeDownloads) + .setSubText(activeQueue) + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.download_icon_load) + } + + + private fun updateNotification(context: Context, downloads: Int, queued: Int) { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + + val activeDownloads = + resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) + val activeQueue = + resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued) + + val newNotification = baseNotification + .setContentText(activeDownloads) + .setSubText(activeQueue) + .build() + + safe { + NotificationManagerCompat.from(context) + .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) + } + } + + // We always need to listen to events, even before the download is launched. + // Stopping link loading is an event which can trigger before downloading. + val downloadEventListener = { event: Pair -> + when (event.second) { + VideoDownloadManager.DownloadActionType.Stop -> { + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + removeKey(KEY_RESUME_IN_QUEUE, event.first.toString()) + DownloadQueueManager.cancelDownload(event.first) + } + + else -> {} + } + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + override fun onCreate() { + isRunning = true + val context: Context = this // To make code more readable + + Log.d(TAG, "Download queue service started.") + this.createNotificationChannel( + DOWNLOAD_QUEUE_CHANNEL_ID, + DOWNLOAD_QUEUE_CHANNEL_NAME, + DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION + ) + if (SDK_INT >= 29) { + startForeground( + DOWNLOAD_QUEUE_NOTIFICATION_ID, + baseNotification.build(), + FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build()) + } + + downloadEvent += downloadEventListener + + val queueJob = ioSafe { + // Ensure this is up to date to prevent race conditions with MainActivity launches + setLastError(context) + // Early return, to prevent waiting for plugins in safe mode + if (lastError != null) return@ioSafe + + // Try to ensure all plugins are loaded before starting the downloader. + // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough + val timeout = 15.seconds + val timeTaken = withTimeoutOrNull(timeout) { + measureTimeMillis { + while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) { + delay(100.milliseconds) + } + } + } + + debugWarning({ timeTaken == null || timeTaken > 3_000 }, { + "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms" + }) + debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) + + totalDownloadFlow + .debounce { (instances, queue) -> + // Filter away incorrect transient queue states. + // For example when we pop the queue and add a download instance there exists a transient state where + // there is no queue and no download instances (leading to an early exit) + if (instances.isEmpty() && queue.isEmpty()) { + 500.milliseconds + } else { + 0.milliseconds + } + } + .takeWhile { (instances, queue) -> + // Stop if destroyed + isRunning + // Run as long as there is a queue to process + && (instances.isNotEmpty() || queue.isNotEmpty()) + // Run as long as there are no app crashes + && lastError == null + } + .collect { (_, queue, currentDownloads) -> + // Remove completed or failed + val newInstances = _downloadInstances.updateAndGet { currentInstances -> + currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled } + } + + val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) + val currentInstanceCount = newInstances.size + + val newDownloads = minOf( + // Cannot exceed the max downloads + maxOf(0, maxDownloads - currentInstanceCount), + // Cannot start more downloads than the queue size + queue.size + ) + + // Cant start multiple downloads at once. If this is rerun it may start too many downloads. + if (newDownloads > 0) { + _downloadInstances.update { instances -> + val downloadInstance = DownloadQueueManager.popQueue(context) + if (downloadInstance != null) { + downloadInstance.startDownload() + instances + downloadInstance + } else { + instances + } + } + } + + // The downloads actually displayed to the user with a notification + val currentVisualDownloads = + currentDownloads.size + newInstances.count { + currentDownloads.contains(it.downloadQueueWrapper.id) + .not() + } + // Just the queue + val currentVisualQueue = queue.size + + updateNotification(context, currentVisualDownloads, currentVisualQueue) + } + } + + // Stop self regardless of job outcome + queueJob.invokeOnCompletion { throwable -> + if (throwable != null) { + logError(throwable) + } + safe { + stopSelf() + } + } + } + + override fun onDestroy() { + Log.d(TAG, "Download queue service stopped.") + downloadEvent -= downloadEventListener + isRunning = false + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY // We want the service restarted if its killed + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onTimeout(reason: Int) { + stopSelf() + Log.e(TAG, "Service stopped due to timeout: $reason") + } + +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt similarity index 81% rename from app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt rename to app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt index dcb1e047a..fa7754718 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt @@ -1,20 +1,22 @@ -package com.lagradost.cloudstream3.utils +package com.lagradost.cloudstream3.services -import android.app.NotificationChannel import android.app.NotificationManager -import android.app.PendingIntent import android.app.Service -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.os.Build +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.PendingIntentCompat import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.ApkInstaller +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import kotlinx.coroutines.delay @@ -22,18 +24,13 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.math.roundToInt - class PackageInstallerService : Service() { - val receivers = mutableListOf() + private var installer: ApkInstaller? = null private val baseNotification by lazy { - val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE - } else 0 - val intent = Intent(this, MainActivity::class.java) val pendingIntent = - PendingIntent.getActivity(this, 0, intent, flag) + PendingIntentCompat.getActivity(this, 0, intent, 0, false) NotificationCompat.Builder(this, UPDATE_CHANNEL_ID) .setAutoCancel(false) @@ -54,14 +51,16 @@ class PackageInstallerService : Service() { UPDATE_CHANNEL_NAME, UPDATE_CHANNEL_DESCRIPTION ) - startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) + if (SDK_INT >= 29) + startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC) + else startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) } private val updateLock = Mutex() private suspend fun downloadUpdate(url: String): Boolean { try { - Log.d(LOG_TAG, "Downloading update: $url") + Log.d("PackageInstallerService", "Downloading update: $url") // Delete all old updates ioSafe { @@ -71,7 +70,7 @@ class PackageInstallerService : Service() { this@PackageInstallerService.cacheDir.listFiles()?.filter { it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix }?.forEach { - it.deleteOnExit() + deleteFileOnExit(it) } } @@ -83,11 +82,11 @@ class PackageInstallerService : Service() { val body = app.get(url).body val inputStream = body.byteStream() - val installer = ApkInstaller(this) + installer = ApkInstaller(this) val totalSize = body.contentLength() var currentSize = 0 - installer.installApk(this, inputStream, totalSize, { + installer?.installApk(this, inputStream, totalSize, { currentSize += it // Prevent div 0 if (totalSize == 0L) return@installApk @@ -103,6 +102,7 @@ class PackageInstallerService : Service() { } return true } catch (e: Exception) { + logError(e) updateNotificationProgress(0f, ApkInstaller.InstallProgressStatus.Failed) return false } @@ -135,7 +135,7 @@ class PackageInstallerService : Service() { .build() val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + getSystemService(NOTIFICATION_SERVICE) as NotificationManager // Persistent notification on failure val id = @@ -157,21 +157,21 @@ class PackageInstallerService : Service() { } override fun onDestroy() { - receivers.forEach { - try { - this.unregisterReceiver(it) - } catch (_: IllegalArgumentException) { - // Receiver not registered - } - } + installer?.unregisterInstallActionReceiver() + installer = null + this.stopSelf() super.onDestroy() } override fun onBind(i: Intent?): IBinder? = null + override fun onTimeout(reason: Int) { + stopSelf() + Log.e("PackageInstallerService", "Service stopped due to timeout: $reason") + } + companion object { private const val EXTRA_URL = "EXTRA_URL" - private const val LOG_TAG = "PackageInstallerService" const val UPDATE_CHANNEL_ID = "cloudstream3.updates" const val UPDATE_CHANNEL_NAME = "App Updates" @@ -186,4 +186,4 @@ class PackageInstallerService : Service() { .putExtra(EXTRA_URL, url) } } -} +} \ No newline at end of file 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 adf5abfab..7134650ed 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -1,27 +1,28 @@ package com.lagradost.cloudstream3.services import android.app.NotificationManager -import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.Build +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT import androidx.core.app.NotificationCompat +import androidx.core.app.PendingIntentCompat import androidx.core.net.toUri import androidx.work.* import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.ui.result.txt -import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.TimeUnit @@ -74,7 +75,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setColor(context.colorFromAttribute(R.attr.colorPrimary)) .setContentTitle(context.getString(R.string.subscription_in_progress_notification)) - .setSmallIcon(R.drawable.quantum_ic_refresh_white_24) + .setSmallIcon(com.google.android.gms.cast.framework.R.drawable.quantum_ic_refresh_white_24) .setProgress(0, 0, true) private val updateNotificationBuilder = @@ -98,127 +99,128 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete } override suspend fun doWork(): Result { + try { // println("Update subscriptions!") - context.createNotificationChannel( - SUBSCRIPTION_CHANNEL_ID, - SUBSCRIPTION_CHANNEL_NAME, - SUBSCRIPTION_CHANNEL_DESCRIPTION - ) - - setForeground( - ForegroundInfo( - SUBSCRIPTION_NOTIFICATION_ID, - progressNotificationBuilder.build() + context.createNotificationChannel( + SUBSCRIPTION_CHANNEL_ID, + SUBSCRIPTION_CHANNEL_NAME, + SUBSCRIPTION_CHANNEL_DESCRIPTION ) - ) - val subscriptions = getAllSubscriptions() + val foregroundInfo = if (SDK_INT >= 29) + ForegroundInfo( + SUBSCRIPTION_NOTIFICATION_ID, + progressNotificationBuilder.build(), + FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) else ForegroundInfo(SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder.build(),) + setForeground(foregroundInfo) - if (subscriptions.isEmpty()) { - WorkManager.getInstance(context).cancelWorkById(this.id) + val subscriptions = getAllSubscriptions() + + if (subscriptions.isEmpty()) { + WorkManager.getInstance(context).cancelWorkById(this.id) + return Result.success() + } + + val max = subscriptions.size + var progress = 0 + + 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) + + subscriptions.amap { savedData -> + try { + val id = savedData.id ?: return@amap null + val api = getApiFromNameNull(savedData.apiName) ?: return@amap null + + // Reasonable timeout to prevent having this worker run forever. + val response = withTimeoutOrNull(60_000) { + api.load(savedData.url) as? EpisodeResponse + } ?: return@amap null + + val dubPreference = + getDub(id) ?: if ( + context.getApiDubstatusSettings().contains(DubStatus.Dubbed) + ) { + DubStatus.Dubbed + } else { + DubStatus.Subbed + } + + val latestEpisodes = response.getLatestEpisodes() + val latestPreferredEpisode = latestEpisodes[dubPreference] + + val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) { + val latestSeenEpisode = + savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE + val shouldUpdate = latestPreferredEpisode > latestSeenEpisode + shouldUpdate to latestPreferredEpisode + } else { + val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE + val latestSeenEpisode = + savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE + val shouldUpdate = latestEpisode > latestSeenEpisode + shouldUpdate to latestEpisode + } + + DataStoreHelper.updateSubscribedData( + id, + savedData, + response + ) + + if (shouldUpdate) { + val updateHeader = savedData.name + val updateDescription = txt( + R.string.subscription_episode_released, + latestEpisode, + savedData.name + ).asString(context) + + val intent = Intent(context, MainActivity::class.java).apply { + data = savedData.url.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + }.putExtra(MainActivity.API_NAME_EXTRA_KEY, api.name) + + val pendingIntent = + PendingIntentCompat.getActivity(context, 0, intent, 0, false) + + val poster = ioWork { + savedData.posterUrl?.let { url -> + context.getImageBitmapFromUrl( + url, + savedData.posterHeaders + ) + } + } + + val updateNotification = + updateNotificationBuilder.setContentTitle(updateHeader) + .setContentText(updateDescription) + .setContentIntent(pendingIntent) + .setLargeIcon(poster) + .build() + + notificationManager.notify(id, updateNotification) + } + + // You can probably get some issues here since this is async but it does not matter much. + updateProgress(max, ++progress, false) + } catch (t: Throwable) { + logError(t) + } + } + + return Result.success() + } catch (t: Throwable) { + logError(t) + // ye, while this is not correct, but because gods know why android just crashes + // and this causes major battery usage as it retries it inf times. This is better, just + // in case android decides to be android and fuck us return Result.success() } - - val max = subscriptions.size - var progress = 0 - - updateProgress(max, progress, true) - - // We need all plugins loaded. - PluginManager.loadAllOnlinePlugins(context) - PluginManager.loadAllLocalPlugins(context, false) - - subscriptions.apmap { savedData -> - try { - 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@apmap null - - val dubPreference = - getDub(id) ?: if ( - context.getApiDubstatusSettings().contains(DubStatus.Dubbed) - ) { - DubStatus.Dubbed - } else { - DubStatus.Subbed - } - - val latestEpisodes = response.getLatestEpisodes() - val latestPreferredEpisode = latestEpisodes[dubPreference] - - val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) { - val latestSeenEpisode = - savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE - val shouldUpdate = latestPreferredEpisode > latestSeenEpisode - shouldUpdate to latestPreferredEpisode - } else { - val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE - val latestSeenEpisode = - savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE - val shouldUpdate = latestEpisode > latestSeenEpisode - shouldUpdate to latestEpisode - } - - DataStoreHelper.updateSubscribedData( - id, - savedData, - response - ) - - if (shouldUpdate) { - val updateHeader = savedData.name - val updateDescription = txt( - R.string.subscription_episode_released, - latestEpisode, - savedData.name - ).asString(context) - - val intent = Intent(context, MainActivity::class.java).apply { - data = savedData.url.toUri() - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - - val pendingIntent = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE - ) - } else { - PendingIntent.getActivity(context, 0, intent, 0) - } - - val poster = ioWork { - savedData.posterUrl?.let { url -> - context.getImageBitmapFromUrl( - url, - savedData.posterHeaders - ) - } - } - - val updateNotification = - updateNotificationBuilder.setContentTitle(updateHeader) - .setContentText(updateDescription) - .setContentIntent(pendingIntent) - .setLargeIcon(poster) - .build() - - notificationManager.notify(id, updateNotification) - } - - // You can probably get some issues here since this is async but it does not matter much. - updateProgress(max, ++progress, false) - } catch (_: Throwable) { - } - } - - return Result.success() } } \ No newline at end of file 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 6151a0edd..d63b18cdc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt @@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.services import android.app.Service import android.content.Intent import android.os.IBinder -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +/** Handle notification actions such as pause/resume downloads */ class VideoDownloadService : Service() { private val downloadScope = CoroutineScope(Dispatchers.Default) @@ -42,19 +43,3 @@ class VideoDownloadService : Service() { super.onDestroy() } } -// override fun onHandleIntent(intent: Intent?) { -// if (intent != null) { -// val id = intent.getIntExtra("id", -1) -// val type = intent.getStringExtra("type") -// if (id != -1 && type != null) { -// val state = when (type) { -// "resume" -> VideoDownloadManager.DownloadActionType.Resume -// "pause" -> VideoDownloadManager.DownloadActionType.Pause -// "stop" -> VideoDownloadManager.DownloadActionType.Stop -// else -> return -// } -// VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) -// } -// } -// } -//} diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt index 77a1b0b58..9e6f241fb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt @@ -1,20 +1,93 @@ package com.lagradost.cloudstream3.subtitles -import androidx.annotation.WorkerThread -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import androidx.core.net.toUri +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.player.SubtitleOrigin +import okio.BufferedSource +import okio.buffer +import okio.sink +import okio.source +import java.io.File +import java.util.zip.ZipInputStream -interface AbstractSubProvider { - @WorkerThread - suspend fun search(query: SubtitleSearch): List? { - throw NotImplementedError() +/** + * A builder for subtitle files. + * @see addUrl + * @see addFile + */ +class SubtitleResource { + fun downloadFile(source: BufferedSource): File { + val file = File.createTempFile("temp-subtitle", ".tmp").apply { + deleteFileOnExit(this) + } + val sink = file.sink().buffer() + sink.writeAll(source) + sink.close() + source.close() + + return file } - @WorkerThread - suspend fun load(data: SubtitleEntity): String? { - throw NotImplementedError() + private fun unzip(file: File): List> { + val entries = mutableListOf>() + + ZipInputStream(file.inputStream()).use { zipInputStream -> + var zipEntry = zipInputStream.nextEntry + + while (zipEntry != null) { + val tempFile = File.createTempFile("unzipped-subtitle", ".tmp").apply { + deleteFileOnExit(this) + } + entries.add(zipEntry.name to tempFile) + + tempFile.sink().buffer().use { buffer -> + buffer.writeAll(zipInputStream.source()) + } + + zipEntry = zipInputStream.nextEntry + } + } + return entries + } + + data class SingleSubtitleResource( + val name: String?, + val url: String, + val origin: SubtitleOrigin + ) + + private var resources: MutableList = mutableListOf() + + fun getSubtitles(): List { + return resources.toList() + } + + fun addUrl(url: String?, name: String? = null) { + if (url == null) return + this.resources.add( + SingleSubtitleResource(name, url, SubtitleOrigin.URL) + ) + } + + fun addFile(file: File, name: String? = null) { + this.resources.add( + SingleSubtitleResource(name, file.toUri().toString(), SubtitleOrigin.DOWNLOADED_FILE) + ) + deleteFileOnExit(file) + } + + suspend fun addZipUrl( + url: String, + nameGenerator: (String, File) -> String? = { _, _ -> null } + ) { + val source = app.get(url).okhttpResponse.body.source() + val zip = downloadFile(source) + val realFiles = unzip(zip) + zip.deleteRecursively() + realFiles.forEach { (name, subtitleFile) -> + addFile(subtitleFile, nameGenerator(name, subtitleFile)) + } } } -interface AbstractSubApi : AbstractSubProvider, AuthAPI \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt index f6424c4c7..685b499bb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt @@ -19,8 +19,11 @@ class AbstractSubtitleEntities { data class SubtitleSearch( var query: String = "", - var imdb: Long? = null, var lang: String? = null, + var imdbId: String? = null, + var tmdbId: Int? = null, + var malId: Int? = null, + var aniListId: Int? = null, var epNumber: Int? = null, var seasonNumber: Int? = null, var year: Int? = null 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 8bf8dffae..3bc5f2733 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -1,137 +1,165 @@ -package com.lagradost.cloudstream3.syncproviders - -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey -import com.lagradost.cloudstream3.syncproviders.providers.* -import java.util.concurrent.TimeUnit - -abstract class AccountManager(private val defIndex: Int) : AuthAPI { - companion object { - val malApi = MALApi(0) - val aniListApi = AniListApi(0) - val openSubtitlesApi = OpenSubtitlesApi(0) - val simklApi = SimklApi(0) - val indexSubtitlesApi = IndexSubtitleApi() - val addic7ed = Addic7ed() - val localListApi = LocalList() - - // 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, simklApi //nginxApi - ) - - // used for active syncing - val SyncApis - get() = listOf( - SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi) - ) - - val inAppAuths - get() = listOf(openSubtitlesApi)//, nginxApi) - - val subtitleProviders - get() = listOf( - openSubtitlesApi, - indexSubtitlesApi, // they got anti scraping measures in place :( - addic7ed - ) - - const val appString = "cloudstreamapp" - const val appStringRepo = "cloudstreamrepo" - const val appStringPlayer = "cloudstreamplayer" - - // Instantly start the search given a query - const val appStringSearch = "cloudstreamsearch" - - // Instantly resume watching a show - const val appStringResumeWatching = "cloudstreamcontinuewatching" - - val unixTime: Long - get() = System.currentTimeMillis() / 1000L - val unixTimeMs: Long - get() = System.currentTimeMillis() - - const val maxStale = 60 * 10 - - fun secondsToReadable(seconds: Int, completedValue: String): String { - var secondsLong = seconds.toLong() - val days = TimeUnit.SECONDS - .toDays(secondsLong) - secondsLong -= TimeUnit.DAYS.toSeconds(days) - - val hours = TimeUnit.SECONDS - .toHours(secondsLong) - secondsLong -= TimeUnit.HOURS.toSeconds(hours) - - val minutes = TimeUnit.SECONDS - .toMinutes(secondsLong) - secondsLong -= TimeUnit.MINUTES.toSeconds(minutes) - if (minutes < 0) { - return completedValue - } - //println("$days $hours $minutes") - return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m" - } - } - - var accountIndex = defIndex - private var lastAccountIndex = defIndex - protected val accountId get() = "${idPrefix}_account_$accountIndex" - private val accountActiveKey get() = "${idPrefix}_active" - - // int array of all accounts indexes - private val accountsKey get() = "${idPrefix}_accounts" - - protected fun removeAccountKeys() { - removeKeys(accountId) - val accounts = getAccounts()?.toMutableList() ?: mutableListOf() - accounts.remove(accountIndex) - setKey(accountsKey, accounts.toIntArray()) - - init() - } - - fun getAccounts(): IntArray? { - return getKey(accountsKey, intArrayOf()) - } - - fun init() { - accountIndex = getKey(accountActiveKey, defIndex)!! - val accounts = getAccounts() - if (accounts?.isNotEmpty() == true && this.loginInfo() == null) { - accountIndex = accounts.first() - } - } - - protected fun switchToNewAccount() { - val accounts = getAccounts() - lastAccountIndex = accountIndex - accountIndex = (accounts?.maxOrNull() ?: 0) + 1 - } - protected fun switchToOldAccount() { - accountIndex = lastAccountIndex - } - - protected fun registerAccount() { - setKey(accountActiveKey, accountIndex) - val accounts = getAccounts()?.toMutableList() ?: mutableListOf() - if (!accounts.contains(accountIndex)) { - accounts.add(accountIndex) - } - - setKey(accountsKey, accounts.toIntArray()) - } - - fun changeAccount(index: Int) { - accountIndex = index - setKey(accountActiveKey, index) - } -} +package com.lagradost.cloudstream3.syncproviders + +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed +import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi +import com.lagradost.cloudstream3.syncproviders.providers.LocalList +import com.lagradost.cloudstream3.syncproviders.providers.MALApi +import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi +import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi +import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth +import java.util.concurrent.TimeUnit + +abstract class AccountManager { + companion object { + const val NONE_ID: Int = -1 + val malApi = MALApi() + val kitsuApi = KitsuApi() + val aniListApi = AniListApi() + val simklApi = SimklApi() + val localListApi = LocalList() + + val openSubtitlesApi = OpenSubtitlesApi() + val addic7ed = Addic7ed() + val subDlApi = SubDlApi() + val subSourceApi = SubSourceApi() + val animeSkipApi = AnimeSkipAuth() + + var cachedAccounts: MutableMap> + var cachedAccountIds: MutableMap + + const val ACCOUNT_TOKEN = "auth_tokens" + const val ACCOUNT_IDS = "auth_ids" + + fun accounts(prefix: String): Array { + require(prefix != "NONE") + return getKey>( + ACCOUNT_TOKEN, + "${prefix}/${DataStoreHelper.currentAccount}" + ) ?: arrayOf() + } + + fun updateAccounts(prefix: String, array: Array) { + require(prefix != "NONE") + setKey(ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}", array) + synchronized(cachedAccounts) { + cachedAccounts[prefix] = array + } + } + + fun updateAccountsId(prefix: String, id: Int) { + require(prefix != "NONE") + setKey(ACCOUNT_IDS, "${prefix}/${DataStoreHelper.currentAccount}", id) + synchronized(cachedAccountIds) { + cachedAccountIds[prefix] = id + } + } + + val allApis = arrayOf( + SyncRepo(malApi), + SyncRepo(kitsuApi), + SyncRepo(aniListApi), + SyncRepo(simklApi), + SyncRepo(localListApi), + SubtitleRepo(openSubtitlesApi), + SubtitleRepo(addic7ed), + SubtitleRepo(subDlApi), + PlainAuthRepo(animeSkipApi) + ) + + fun updateAccountIds() { + val ids = mutableMapOf() + for (api in allApis) { + ids.put( + api.idPrefix, + getKey( + ACCOUNT_IDS, + "${api.idPrefix}/${DataStoreHelper.currentAccount}", + NONE_ID + ) ?: NONE_ID + ) + } + synchronized(cachedAccountIds) { + cachedAccountIds = ids + } + } + + init { + val data = mutableMapOf>() + val ids = mutableMapOf() + for (api in allApis) { + data.put(api.idPrefix, accounts(api.idPrefix)) + ids.put( + api.idPrefix, + getKey( + ACCOUNT_IDS, + "${api.idPrefix}/${DataStoreHelper.currentAccount}", + NONE_ID + ) ?: NONE_ID + ) + } + cachedAccounts = data + cachedAccountIds = ids + } + + // I do not want to place this in the init block as JVM initialization order is weird, and it may cause exceptions + // accessing other classes + fun initMainAPI() { + LoadResponse.malIdPrefix = malApi.idPrefix + LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix + LoadResponse.aniListIdPrefix = aniListApi.idPrefix + LoadResponse.simklIdPrefix = simklApi.idPrefix + } + + val subtitleProviders = arrayOf( + SubtitleRepo(openSubtitlesApi), + SubtitleRepo(addic7ed), + SubtitleRepo(subDlApi) + ) + val syncApis = arrayOf( + SyncRepo(malApi), + SyncRepo(kitsuApi), + SyncRepo(aniListApi), + SyncRepo(simklApi), + SyncRepo(localListApi) + ) + + const val APP_STRING = "cloudstreamapp" + const val APP_STRING_REPO = "cloudstreamrepo" + const val APP_STRING_PLAYER = "cloudstreamplayer" + + // Instantly start the search given a query + const val APP_STRING_SEARCH = "cloudstreamsearch" + + // Instantly resume watching a show + const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" + + const val APP_STRING_SHARE = "csshare" + + fun secondsToReadable(seconds: Int, completedValue: String): String { + var secondsLong = seconds.toLong() + val days = TimeUnit.SECONDS + .toDays(secondsLong) + secondsLong -= TimeUnit.DAYS.toSeconds(days) + + val hours = TimeUnit.SECONDS + .toHours(secondsLong) + secondsLong -= TimeUnit.HOURS.toSeconds(hours) + + val minutes = TimeUnit.SECONDS + .toMinutes(secondsLong) + secondsLong -= TimeUnit.MINUTES.toSeconds(minutes) + if (minutes < 0) { + return completedValue + } + //println("$days $hours $minutes") + return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt index 8b085bc0b..184a9fbcc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -1,23 +1,280 @@ package com.lagradost.cloudstream3.syncproviders -interface AuthAPI { - val name: String - val icon: Int? +import android.util.Base64 +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID +import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed +import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.syncproviders.providers.LocalList +import com.lagradost.cloudstream3.syncproviders.providers.MALApi +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi +import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi +import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi +import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.txt +import java.net.URL +import java.security.SecureRandom +import java.util.Date +import java.util.concurrent.TimeUnit - val requiresLogin: Boolean +data class AuthLoginPage( + /** The website to open to authenticate */ + val url: String, + /** + * State/control code to verify against the redirectUrl to make sure the request is valid. + * This parameter will be saved, and then used in AuthAPI::login. + * */ + val payload: String? = null, +) - val createAccountUrl : String? +data class AuthToken( + /** + * This is the general access tokens/api token representing a logged in user. + * + * `Access tokens are the thing that applications use to make API requests on behalf of a user.` + * */ + @JsonProperty("accessToken") + val accessToken: String? = null, + /** + * For OAuth a special refresh token is issues to refresh the access token. + * */ + @JsonProperty("refreshToken") + val refreshToken: String? = null, + /** In UnixTime (sec) when it expires */ + @JsonProperty("accessTokenLifetime") + val accessTokenLifetime: Long? = null, + /** In UnixTime (sec) when it expires */ + @JsonProperty("refreshTokenLifetime") + val refreshTokenLifetime: Long? = null, + /** Sometimes AuthToken needs to be customized to store e.g. username/password, + * this acts as a catch all to store text or JSON data. */ + @JsonProperty("payload") + val payload: String? = null, +) { + fun isAccessTokenExpired(marginSec: Long = 10L) = + accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime - // don't change this as all keys depend on it - val idPrefix: String + fun isRefreshTokenExpired(marginSec: Long = 10L) = + refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime +} - // if this returns null then you are not logged in - fun loginInfo(): LoginInfo? - fun logOut() +data class AuthUser( + /** Account display-name, can also be email if name does not exist */ + @JsonProperty("name") + val name: String?, + /** Unique account identifier, + * if a subsequent login is done then it will be refused if another account with the same id exists*/ + @JsonProperty("id") + val id: Int, + /** Profile picture URL */ + @JsonProperty("profilePicture") + val profilePicture: String? = null, + /** Profile picture Headers of the URL */ + @JsonProperty("profilePictureHeader") + val profilePictureHeaders: Map? = null +) +/** + * Stores all information that should be used to authorize access. + * Be aware that token and user may change independently when a refresh is needed, + * and as such there should be no strong pairing between the two. + * + * Any local set/get key should use user.id.toString(), + * as token.accessToken (even hashed) is unsecure, and will rotate. + * */ +data class AuthData( + @JsonProperty("user") + val user: AuthUser, + @JsonProperty("token") + val token: AuthToken, +) + +data class AuthPinData( + val deviceCode: String, + val userCode: String, + /** QR Code url */ + val verificationUrl: String, + /** In seconds */ + val expiresIn: Int, + /** Check if the code has been verified interval */ + val interval: Int, +) + +/** The login field requirements to display to the user */ +data class AuthLoginRequirement( + val password: Boolean = false, + val username: Boolean = false, + val email: Boolean = false, + val server: Boolean = false, +) + +/** What the user responds to the AuthLoginRequirement */ +data class AuthLoginResponse( + @JsonProperty("password") + val password: String?, + @JsonProperty("username") + val username: String?, + @JsonProperty("email") + val email: String?, + @JsonProperty("server") + val server: String?, +) + +/** Stateless Authentication class used for all personalized content */ +abstract class AuthAPI { + open val name: String = "NONE" + open val idPrefix: String = "NONE" + + /** Drawable icon of the service */ + open val icon: Int? = null + + /** If this service requires an account to use */ + open val requiresLogin: Boolean = true + + /** Link to a website for creating a new account */ + open val createAccountUrl: String? = null + + /** The sensitive redirect URL from OAuth should contain "/redirectUrlIdentifier" to trigger the login */ + open val redirectUrlIdentifier: String? = null + + /** Has OAuth2 login support, including login, loginRequest and refreshToken */ + open val hasOAuth2: Boolean = false + + /** Has on device pin support, aka login with a QR code */ + open val hasPin: Boolean = false + + /** Has in app login support, aka login with a dialog */ + open val hasInApp: Boolean = false + + /** The requirements to login in app */ + open val inAppLoginRequirement: AuthLoginRequirement? = null + + companion object { + val unixTime: Long + get() = System.currentTimeMillis() / 1000L + val unixTimeMs: Long + get() = System.currentTimeMillis() + + fun splitRedirectUrl(redirectUrl: String): Map { + return splitQuery( + URL( + redirectUrl.replace(APP_STRING, "https").replace("/#", "?") + ) + ) + } + + fun generateCodeVerifier(): String { + // It is recommended to use a URL-safe string as code_verifier. + // See section 4 of RFC 7636 for more details. + val secureRandom = SecureRandom() + val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 + secureRandom.nextBytes(codeVerifierBytes) + return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=') + .replace("+", "-") + .replace("/", "_").replace("\n", "") + } + } + + /** Is this url a valid redirect url for this service? */ + @Throws + open fun isValidRedirectUrl(url: String): Boolean = + redirectUrlIdentifier != null && url.contains("/$redirectUrlIdentifier") + + /** OAuth2 login from a valid redirectUrl, and payload given in loginRequest */ + @Throws + open suspend fun login(redirectUrl: String, payload: String?): AuthToken? = + throw NotImplementedError() + + /** OAuth2 login request, asking the service to provide a url to open in the browser */ + @Throws + open fun loginRequest(): AuthLoginPage? = throw NotImplementedError() + + /** Pin login request, asking the service to provide an verificationUrl to display with a QR code */ + @Throws + open suspend fun pinRequest(): AuthPinData? = throw NotImplementedError() + + /** OAuth2 token refresh, this ensures that all token passed to other functions will be valid */ + @Throws + open suspend fun refreshToken(token: AuthToken): AuthToken? = throw NotImplementedError() + + /** Pin login, this will be called periodically while logging in to check if the pin has been verified by the user */ + @Throws + open suspend fun login(payload: AuthPinData): AuthToken? = throw NotImplementedError() + + /** In app login */ + @Throws + open suspend fun login(form: AuthLoginResponse): AuthToken? = throw NotImplementedError() + + /** Get the visible user account */ + @Throws + open suspend fun user(token: AuthToken?): AuthUser? = throw NotImplementedError() + + /** + * An optional security measure to make sure that even if an attacker gets ahold of the token, it will be invalid. + * + * Note that this will currently only be called *once* on logout, + * and as such any network issues it will fail silently, and the token will not be revoked. + **/ + @Throws + open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError() + + @Throws + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + fun toRepo(): AuthRepo = when (this) { + is SubtitleAPI -> SubtitleRepo(this) + is SyncAPI -> SyncRepo(this) + else -> throw NotImplementedError("Unknown inheritance from AuthAPI") + } + + @Suppress("DEPRECATION_ERROR") + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + fun loginInfo(): LoginInfo? { + return this.toRepo().authUser()?.let { user -> + LoginInfo( + profilePicture = user.profilePicture, + name = user.name, + accountIndex = -1, + ) + } + } + + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + @Suppress("DEPRECATION_ERROR") + return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow() + } + + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) class LoginInfo( val profilePicture: String? = null, val name: String?, val accountIndex: Int, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt new file mode 100644 index 000000000..645a19e3a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -0,0 +1,168 @@ +package com.lagradost.cloudstream3.syncproviders + +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID +import com.lagradost.cloudstream3.utils.txt + +/** General-purpose repo */ +class PlainAuthRepo(api: AuthAPI) : AuthRepo(api) + +/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ +abstract class AuthRepo(open val api: AuthAPI) { + fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false + val idPrefix get() = api.idPrefix + val name get() = api.name + val icon get() = api.icon + val requiresLogin get() = api.requiresLogin + val createAccountUrl get() = api.createAccountUrl + val hasOAuth2 get() = api.hasOAuth2 + val hasPin get() = api.hasPin + val hasInApp get() = api.hasInApp + val inAppLoginRequirement get() = api.inAppLoginRequirement + val isAvailable get() = !api.requiresLogin || authUser() != null + + companion object { + private val oauthPayload: MutableMap = mutableMapOf() + } + + @Throws + protected suspend fun freshAuth(): AuthData? { + val data = authData() ?: return null + if (data.token.isAccessTokenExpired()) { + val newToken = api.refreshToken(data.token) ?: return null + val newAuth = AuthData(user = data.user, token = newToken) + refreshUser(newAuth) + return newAuth + } + return data + } + + @Throws + fun openOAuth2Page(): Boolean { + val page = api.loginRequest() ?: return false + synchronized(oauthPayload) { + oauthPayload.put(idPrefix, page.payload) + } + openBrowser(page.url) + return true + } + + fun openOAuth2PageWithToast() { + try { + if (!openOAuth2Page()) { + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } catch (t: Throwable) { + logError(t) + if (t is ErrorLoadingException && t.message != null) { + showToast(t.message) + return + } + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } + + suspend fun logout(from: AuthUser) { + val currentAccounts = AccountManager.accounts(idPrefix) + val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id } + if (newAccounts.size < currentAccounts.size) { + AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray()) + AccountManager.updateAccountsId(idPrefix, 0) + } + + for (oldAccount in oldAccounts) { + try { + api.invalidateToken(oldAccount.token) + } catch (_: NotImplementedError) { + // no-op + } catch (t: Throwable) { + logError(t) + } + } + } + + fun refreshUser(newAuth: AuthData) { + val currentAccounts = AccountManager.accounts(idPrefix) + val newAccounts = currentAccounts.map { + if (it.user.id == newAuth.user.id) { + newAuth + } else { + it + } + }.toTypedArray() + AccountManager.updateAccounts(idPrefix, newAccounts) + } + + fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix]?.let { id -> + AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id } + } + } + + fun authToken(): AuthToken? = authData()?.token + + fun authUser(): AuthUser? = authData()?.user + + val accounts + get() = synchronized(AccountManager.cachedAccounts) { + AccountManager.cachedAccounts[idPrefix] ?: emptyArray() + } + var accountId + get() = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID + } + set(value) { + AccountManager.updateAccountsId(idPrefix, value) + } + + @Throws + suspend fun pinRequest() = + api.pinRequest() + + @Throws + private suspend fun setupLogin(token: AuthToken): Boolean { + val user = api.user(token) ?: return false + + val newAccount = AuthData( + token = token, + user = user, + ) + + val currentAccounts = AccountManager.accounts(idPrefix) + if (currentAccounts.any { it.user.id == newAccount.user.id }) { + throw ErrorLoadingException("Already logged into this account") + } + + val newAccounts = currentAccounts + newAccount + AccountManager.updateAccounts(idPrefix, newAccounts) + AccountManager.updateAccountsId(idPrefix, user.id) + if (this is SyncRepo) { + requireLibraryRefresh = true + } + return true + } + + @Throws + suspend fun login(form: AuthLoginResponse): Boolean { + return setupLogin(api.login(form) ?: return false) + } + + @Throws + suspend fun login(payload: AuthPinData): Boolean { + return setupLogin(api.login(payload) ?: return false) + } + + @Throws + suspend fun login(redirectUrl: String): Boolean { + return setupLogin( + api.login( + redirectUrl, + synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt new file mode 100644 index 000000000..5efb88e5b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -0,0 +1,14 @@ +package com.lagradost.cloudstream3.syncproviders + +/** Work in progress */ +abstract class BackupAPI : AuthAPI() { + open val filename : String = "cloudstream-backup.json" + + /** Get the backup file as a JSON string from the remote storage. Return null if not found/empty */ + @Throws + open suspend fun downloadFile(auth: AuthData?) : String? = throw NotImplementedError() + + /** Get the backup file as a JSON string from the remote storage. */ + @Throws + open suspend fun uploadFile(auth: AuthData?, data : String) : String? = throw NotImplementedError() +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt deleted file mode 100644 index 8b6fdf463..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders - -import androidx.annotation.WorkerThread - -interface InAppAuthAPI : AuthAPI { - data class LoginData( - val username: String? = null, - val password: String? = null, - val server: String? = null, - val email: String? = null, - ) - - // this is for displaying the UI - val requiresPassword: Boolean - val requiresUsername: Boolean - val requiresServer: Boolean - val requiresEmail: Boolean - - // if this is false we can assume that getLatestLoginData returns null and wont be called - // this is used in case for some reason it is not preferred to store any login data besides the "token" or encrypted data - val storesPasswordInPlainText: Boolean - - // return true if logged in successfully - suspend fun login(data: LoginData): Boolean - - // used to fill the UI if you want to edit any data about your login info - fun getLatestLoginData(): LoginData? -} - -abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), InAppAuthAPI { - override val requiresPassword = false - override val requiresUsername = false - override val requiresEmail = false - override val requiresServer = false - override val storesPasswordInPlainText = true - override val requiresLogin = true - - // runs on startup - @WorkerThread - open suspend fun initialize() { - } - - override fun logOut() { - throw NotImplementedError() - } - - override val idPrefix: String - get() = throw NotImplementedError() - - override val name: String - get() = throw NotImplementedError() - - override val icon: Int? = null - - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { - throw NotImplementedError() - } - - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { - throw NotImplementedError() - } - - override fun loginInfo(): AuthAPI.LoginInfo? { - throw NotImplementedError() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt deleted file mode 100644 index ef74edfcb..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders - -import androidx.fragment.app.FragmentActivity - -interface OAuth2API : AuthAPI { - val key: String - val redirectUrl: String - - suspend fun handleRedirect(url: String) : Boolean - fun authenticate(activity: FragmentActivity?) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt new file mode 100644 index 000000000..a1149b5f8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt @@ -0,0 +1,37 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource + +/** + * Stateless subtitle class for external subtitles. + * + * All non-null `AuthToken` will be non-expired when each function is called. + */ +abstract class SubtitleAPI : AuthAPI() { + @WorkerThread + @Throws + open suspend fun search(auth: AuthData?, query: SubtitleSearch): List? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun load(auth: AuthData?, subtitle: SubtitleEntity): String? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun SubtitleResource.getResources(auth: AuthData?, subtitle: SubtitleEntity) { + this.addUrl(load(auth, subtitle)) + } + + @WorkerThread + @Throws + suspend fun resource(auth: AuthData?, subtitle: SubtitleEntity): SubtitleResource { + return SubtitleResource().apply { + this.getResources(auth, subtitle) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt new file mode 100644 index 000000000..0b8c3e5ae --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt @@ -0,0 +1,95 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf + +/** Stateless safe abstraction of SubtitleAPI */ +class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { + companion object { + data class SavedSearchResponse( + val unixTime: Long, + val response: List, + val query: SubtitleSearch + ) + + data class SavedResourceResponse( + val unixTime: Long, + val response: SubtitleResource, + val query: SubtitleEntity + ) + + // maybe make this a generic struct? right now there is a lot of boilerplate + private val searchCache = atomicListOf() + private var searchCacheIndex: Int = 0 + private val resourceCache = atomicListOf() + private var resourceCacheIndex: Int = 0 + const val CACHE_SIZE = 20 + } + + @WorkerThread + suspend fun resource(data: SubtitleEntity): Result = runCatching { + val cached = resourceCache.withLock { + var found: SubtitleResource? = null + for (item in resourceCache) { + // 20 min save + if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { + found = item.response + break + } + } + found + } + if (cached != null) return@runCatching cached + + val returnValue = api.resource(freshAuth(), data) + resourceCache.withLock { + val add = SavedResourceResponse(unixTime, returnValue, data) + if (resourceCache.size > CACHE_SIZE) { + resourceCache[resourceCacheIndex] = add // rolling cache + resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE + } else { + resourceCache.add(add) + } + } + returnValue + } + + @WorkerThread + suspend fun search(query: SubtitleSearch): Result> { + return runCatching { + val cached = searchCache.withLock { + var found: List? = null + for (item in searchCache) { + // 120 min save + if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { + found = item.response + break + } + } + found + } + + if (cached != null) return@runCatching cached + val returnValue = api.search(freshAuth(), query) ?: emptyList() + + // only cache valid return values + if (returnValue.isNotEmpty()) { + val add = SavedSearchResponse(unixTime, returnValue, query) + searchCache.withLock { + if (searchCache.size > CACHE_SIZE) { + searchCache[searchCacheIndex] = add // rolling cache + searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE + } else { + searchCache.add(add) + } + } + } + returnValue + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index ed496326a..f30a64748 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -1,170 +1,194 @@ -package com.lagradost.cloudstream3.syncproviders - -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.result.UiText -import me.xdrop.fuzzywuzzy.FuzzySearch - -enum class SyncIdName { - Anilist, - MyAnimeList, - Trakt, - Imdb, - Simkl, - LocalList, -} - -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: Int - - /** 1-10 */ - abstract var score: Int? - abstract var watchedEpisodes: Int? - abstract var isFavorite: Boolean? - abstract var maxEpisodes: Int? - } - - data class SyncStatus( - override var status: Int, - /** 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 } - 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?, - override var id: Int? = null, - ) : SearchResponse -} \ No newline at end of file +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 +} 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 9363cb6fb..de82624fc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -1,48 +1,30 @@ -package com.lagradost.cloudstream3.syncproviders - -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.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 +package com.lagradost.cloudstream3.syncproviders + +/** Stateless safe abstraction of SyncAPI */ +class SyncRepo(override val api: SyncAPI) : AuthRepo(api) { + val syncIdName = api.syncIdName + var requireLibraryRefresh: Boolean + get() = api.requireLibraryRefresh + set(value) { + api.requireLibraryRefresh = value + } + + suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result = + runCatching { + val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus) + requireLibraryRefresh = true + status + } + + suspend fun status(id: String): Result = runCatching { + api.status(freshAuth(), id) + } + + suspend fun load(id: String): Result = runCatching { + api.load(freshAuth(), id) + } + + suspend fun library(): Result = runCatching { + api.library(freshAuth()) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt index 507c5e2ac..144efff99 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt @@ -1,108 +1,205 @@ package com.lagradost.cloudstream3.syncproviders.providers -import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.subtitles.AbstractSubApi -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName -class Addic7ed : AbstractSubApi { +class Addic7ed : SubtitleAPI() { override val name = "Addic7ed" override val idPrefix = "addic7ed" override val requiresLogin = false - override val icon: Nothing? = null - override val createAccountUrl: Nothing? = null - - override fun loginInfo(): Nothing? = null - - override fun logOut() {} companion object { - const val host = "https://www.addic7ed.com" + const val HOST = "https://www.addic7ed.com" const val TAG = "ADDIC7ED" } - private fun fixUrl(url: String): String { - return if (url.startsWith("/")) host + url - else if (!url.startsWith("http")) "$host/$url" + private fun String.fixUrl(): String { + val url = this + return if (url.startsWith("/")) HOST + url + else if (!url.startsWith("http")) "$HOST/$url" else url - } - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List { - val lang = query.lang - val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString()) - val queryText = query.query.trim() + override suspend fun search( + auth: AuthData?, + query: SubtitleSearch + ): List? { + val langTagIETF = query.lang ?: AllLanguagesName + val langNumAddic7ed = + langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0 + val langName = + langTagIETF2Addic7ed[langTagIETF]?.second ?: + fromTagToEnglishLanguageName(langTagIETF) ?: + "Completed" // this bypasses language filtering + val title = query.query.trim() val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 + val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title + var downloadPage = "" - fun cleanResources( - results: MutableList, - name: String, - link: String, - headers: Map, + fun newSubtitleEntity ( + displayName: String?, + link: String?, isHearingImpaired: Boolean - ) { - results.add( - AbstractSubtitleEntities.SubtitleEntity( - idPrefix = idPrefix, - name = name, - lang = queryLang.toString(), - data = link, - source = this.name, - type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, - epNumber = epNum, - seasonNumber = seasonNum, - year = yearNum, - headers = headers, - isHearingImpaired = isHearingImpaired - ) + ): SubtitleEntity? { + if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null + return SubtitleEntity( + idPrefix = this.idPrefix, + name = displayName, + lang = langTagIETF, + data = link, + source = this.name, + type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, + epNumber = epNum, + seasonNumber = seasonNum, + year = yearNum, + headers = mapOf("referer" to "$HOST/"), + isHearingImpaired = isHearingImpaired ) } - val title = queryText.substringBefore("(").trim() - val url = "$host/search.php?search=${title}&Submit=Search" - val hostDocument = app.get(url).document - var searchResult = "" - if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url - else if (!hostDocument.select("table.tabel") - .isNullOrEmpty() - ) searchResult = hostDocument.select("a:contains($title)").attr("href").toString() - else { - val show = - hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(") - ?.substringBefore(",") + val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search") + val hostDocument = response.document + + // 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name + if (response.url.contains("/movie/") || response.url.contains("/serie/")) + downloadPage = response.url + + // 2nd case: found tv series ep list. Redirected to $HOST/show/1234 + else if (response.url.contains("/show/")) { + val showId = response.url.substringAfterLast("/") val doc = app.get( - "$host/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", - referer = "$host/" + "$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0", + referer = "$HOST/" ).document - doc.select("#season tr:contains($queryLang)").mapNotNull { node -> - if (node.selectFirst("td")?.text() - ?.toIntOrNull() == seasonNum && node.select("td:eq(1)") - .text() - .toIntOrNull() == epNum - ) searchResult = fixUrl(node.select("a").attr("href")) + + // get direct subtitles links from list + return doc.select("#season tbody tr").mapNotNull { node -> + if (node.select("td:eq(1)").text().toIntOrNull() == epNum) + newSubtitleEntity( + displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(), + link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(), + isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty() + ) + else null } + // 3rd case: found several or no results. Still in $HOST/search.php?search=title + } else {// (response.url.contains("/search.php")) + downloadPage = hostDocument.select("table.tabel a").selectFirst({ + // tv series + if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]" + // movie + year + else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)" + // movie + else "a[href~=movie\\/]" + }())?.attr("href")?.fixUrl() ?: return null } - val results = mutableListOf() - val document = app.get( - url = fixUrl(searchResult), - ).document - document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node -> - val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${ - node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration") - }" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}" - val link = fixUrl(node.select("a.buttonDownload").attr("href")) + // filter download page by language. Do not work for movies :/ + if (downloadPage.contains("/serie/")) + downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed" + val doc = app.get(url = downloadPage).document + + // get subtitles links from download page + return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node -> + val displayName = + doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" + + node.parent()!!.select(".NewsTitle").text().substringAfter("Version ").substringBefore(", Duration") + val link = + node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl() val isHearingImpaired = - !node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty() - cleanResources(results, name, link, mapOf("referer" to "$host/"), isHearingImpaired) + node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty() + + newSubtitleEntity(displayName, link, isHearingImpaired) } - return results } - override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String { - return data.data + override suspend fun load( + auth: AuthData?, + subtitle: SubtitleEntity + ): String? { + return subtitle.data } + + // Missing (?_?) + // Pair("2", ""), + // Pair("3", ""), + // Pair("33", ""), + // Pair("34", ""), + // Do not modify unless Addic7ed changes them! + // as they are the exact values from their website + private val langTagIETF2Addic7ed = mapOf( + "ar" to Pair("38", "Arabic"), + "az" to Pair("48", "Azerbaijani"), + "bg" to Pair("35", "Bulgarian"), + "bn" to Pair("47", "Bengali"), + "bs" to Pair("44", "Bosnian"), + "ca" to Pair("12", "Català"), + "cs" to Pair("14", "Czech"), + "cy" to Pair("65", "Welsh"), + "da" to Pair("30", "Danish"), + "de" to Pair("11", "German"), + "el" to Pair("27", "Greek"), + "en" to Pair("1", "English"), + "es-419" to Pair("6", "Spanish (Latin America)"), + "es-ar" to Pair("69", "Spanish (Argentina)"), + "es-es" to Pair("5", "Spanish (Spain)"), + "es" to Pair("4", "Spanish"), + "et" to Pair("54", "Estonian"), + "eu" to Pair("13", "Euskera"), + "fa" to Pair("43", "Persian"), + "fi" to Pair("28", "Finnish"), + "fr-ca" to Pair("53", "French (Canadian)"), + "fr" to Pair("8", "French"), + "gl" to Pair("15", "Galego"), + "he" to Pair("23", "Hebrew"), + "hi" to Pair("55", "Hindi"), + "hr" to Pair("31", "Croatian"), + "hu" to Pair("20", "Hungarian"), + "hy" to Pair("50", "Armenian"), + "id" to Pair("37", "Indonesian"), + "is" to Pair("56", "Icelandic"), + "it" to Pair("7", "Italian"), + "ja" to Pair("32", "Japanese"), + "kn" to Pair("66", "Kannada"), + "ko" to Pair("42", "Korean"), + "lt" to Pair("58", "Lithuanian"), + "lv" to Pair("57", "Latvian"), + "mk" to Pair("49", "Macedonian"), + "ml" to Pair("67", "Malayalam"), + "mr" to Pair("62", "Marathi"), + "ms" to Pair("40", "Malay"), + "nl" to Pair("17", "Dutch"), + "no" to Pair("29", "Norwegian"), + "pl" to Pair("21", "Polish"), + "pt-br" to Pair("10", "Portuguese (Brazilian)"), + "pt" to Pair("9", "Portuguese"), + "ro" to Pair("26", "Romanian"), + "ru" to Pair("19", "Russian"), + "si" to Pair("60", "Sinhala"), + "sk" to Pair("25", "Slovak"), + "sl" to Pair("22", "Slovenian"), + "sq" to Pair("52", "Albanian"), + "sr-latn" to Pair("36", "Serbian (Latin)"), + "sr" to Pair("39", "Serbian (Cyrillic)"), + "sv" to Pair("18", "Swedish"), + "ta" to Pair("59", "Tamil"), + "te" to Pair("63", "Telugu"), + "th" to Pair("46", "Thai"), + "tl" to Pair("68", "Tagalog"), + "tlh" to Pair("61", "Klingon"), + "tr" to Pair("16", "Turkish"), + "uk" to Pair("51", "Ukrainian"), + "vi" to Pair("45", "Vietnamese"), + "yue" to Pair("64", "Cantonese"), + "zh-hans" to Pair("41", "Chinese (Simplified)"), + "zh-hant" to Pair("24", "Chinese (Traditional)"), + ) } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index d0c88901f..7a46b4113 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -1,91 +1,91 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes -import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.Actor +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.ActorRole +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.splitQuery 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 java.net.URL +import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear +import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder -import java.util.* +import java.util.Locale -class AniListApi(index: Int) : AccountManager(index), SyncAPI { +class AniListApi : SyncAPI() { override var name = "AniList" - override val key = "6871" - override val redirectUrl = "anilistlogin" override val idPrefix = "anilist" + + val key = "6871" + override val redirectUrlIdentifier = "anilistlogin" override var requireLibraryRefresh = true + override val hasOAuth2 = true override var mainUrl = "https://anilist.co" override val icon = R.drawable.ic_anilist_icon - override val requiresLogin = false override val createAccountUrl = "$mainUrl/signup" override val syncIdName = SyncIdName.Anilist - override fun loginInfo(): AuthAPI.LoginInfo? { - // context.getUser(true)?. - getKey(accountId, ANILIST_USER_KEY)?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = user.picture, - name = user.name, - accountIndex = accountIndex - ) - } - return null + override fun loginRequest(): AuthLoginPage? = + AuthLoginPage("https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token") + + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val sanitizer = splitRedirectUrl(redirectUrl) + val token = AuthToken( + accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"), + //refreshToken = sanitizer["refresh_token"], + accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), + ) + return token } - override fun logOut() { - requireLibraryRefresh = true - removeAccountKeys() + // 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 authenticate(activity: FragmentActivity?) { - val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token" - openBrowser(request, activity) + 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 suspend fun handleRedirect(url: String): Boolean { - val sanitizer = - splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR - val token = sanitizer["access_token"]!! - val expiresIn = sanitizer["expires_in"]!! + override fun urlToId(url: String): String? = + url.removePrefix("$mainUrl/anime/").removeSuffix("/") - 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(name: String): List? { + override suspend fun search(auth : AuthData?, query: String): List? { val data = searchShows(name) ?: return null - return data.data?.Page?.media?.map { + return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, this.name, @@ -96,10 +96,10 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun getResult(id: String): SyncAPI.SyncResult { + override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") - val season = getSeason(internalId).data.Media + val season = getSeason(internalId).data.media return SyncAPI.SyncResult( season.id.toString(), @@ -138,11 +138,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } ) }, - publicScore = season.averageScore?.times(100), + publicScore = Score.from100(season.averageScore), recommendations = season.recommendations?.edges?.mapNotNull { rec -> val recMedia = rec.node.mediaRecommendation SyncAPI.SyncSearchResult( - 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), @@ -158,37 +158,38 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null - val data = getDataAboutId(internalId) ?: return null + val data = getDataAboutId(auth ?: return null, internalId) ?: return null return SyncAPI.SyncStatus( - score = data.score, + score = Score.from100(data.score), watchedEpisodes = data.progress, - status = data.type?.value ?: return null, + status = SyncWatchType.fromInternalId(data.type?.value ?: return null), isFavorite = data.isFavourite, maxEpisodes = data.episodes, ) } - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + override suspend fun updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { return postDataAboutId( + auth ?: return false, id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(status.status), - status.score, - status.watchedEpisodes - ).also { - requireLibraryRefresh = requireLibraryRefresh || it - } + fromIntToAnimeStatus(newStatus.status.internalId), + newStatus.score, + newStatus.watchedEpisodes + ) } companion object { + const val MAX_STALE = 60 * 10 private val aniListStatusString = arrayOf("CURRENT", "COMPLETED", "PAUSED", "DROPPED", "PLANNING", "REPEATING") - const val ANILIST_UNIXTIME_KEY: String = "anilist_unixtime" // When token expires - const val ANILIST_TOKEN_KEY: String = "anilist_token" // anilist token for api - const val ANILIST_USER_KEY: String = "anilist_user" // user data like profile const val ANILIST_CACHED_LIST: String = "anilist_cached_list" private fun fixName(name: String): String { @@ -299,12 +300,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { //println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}") val shows = searchShows(name.replace(blackListRegex, "")) - shows?.data?.Page?.media?.find { + shows?.data?.page?.media?.find { (malId ?: "NONE") == it.idMal.toString() }?.let { return it } val filtered = - shows?.data?.Page?.media?.filter { + shows?.data?.page?.media?.filter { (((it.startDate.year ?: year.toString()) == year.toString() || year == null)) } @@ -458,21 +459,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } - fun initGetUser() { - if (getAuth() == null) return - ioSafe { - getUser() - } - } - - private fun checkToken(): Boolean { - return unixTime > getKey( - accountId, - ANILIST_UNIXTIME_KEY, 0L - )!! - } - - private suspend fun getDataAboutId(id: Int): AniListTitleHolder? { + private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? { val q = """query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id) Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query) @@ -482,7 +469,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { mediaListEntry { progress status - score (format: POINT_10) + score (format: POINT_100) } title { english @@ -491,10 +478,10 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } }""" - val data = postApi(q, true) + val data = postApi(auth.token, q, true) val d = parseJson(data ?: return null) - val main = d.data?.Media + val main = d.data?.media if (main?.mediaListEntry != null) { return AniListTitleHolder( title = main.title, @@ -519,37 +506,24 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } - private fun getAuth(): String? { - return getKey( - accountId, - ANILIST_TOKEN_KEY - ) + private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? { + return app.post( + "https://graphql.anilist.co/", + headers = mapOf( + "Authorization" to "Bearer ${token.accessToken ?: return null}", + if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache" + ), + cacheTime = 0, + data = mapOf( + "query" to URLEncoder.encode( + q, + "UTF-8" + ) + ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) + timeout = 5 // REASONABLE TIMEOUT + ).text.replace("\\/", "/") } - private suspend fun postApi(q: String, cache: Boolean = false): String? { - return 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=$maxStale" else "Cache-Control" to "no-cache" - ), - cacheTime = 0, - data = mapOf( - "query" to URLEncoder.encode( - q, - "UTF-8" - ) - ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) - timeout = 5 // REASONABLE TIMEOUT - ).text.replace("\\/", "/") - } else { - null - } - } - } data class MediaRecommendation( @JsonProperty("id") val id: Int, @@ -595,7 +569,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { //@JsonProperty("source") val source: String, @JsonProperty("episodes") val episodes: Int, @JsonProperty("title") val title: Title, - //@JsonProperty("description") val description: String, + @JsonProperty("description") val description: String?, @JsonProperty("coverImage") val coverImage: CoverImage, @JsonProperty("synonyms") val synonyms: List, @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, @@ -621,7 +595,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { this.media.id.toString(), this.progress, this.media.episodes, - this.score, + Score.from100(this.score), this.updatedAt.toLong(), "AniList", TvType.Anime, @@ -629,7 +603,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ?: this.media.coverImage.medium, null, null, - null + this.media.seasonYear.toYear(), + null, + plot = this.media.description, ) } } @@ -644,30 +620,26 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class Data( - @JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection + @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection ) - private fun getAniListListCached(): Array? { - return getKey(ANILIST_CACHED_LIST) as? Array - } - - private suspend fun getAniListAnimeListSmart(): Array? { - if (getAuth() == null) return null - - if (checkToken()) return null + private suspend fun getAniListAnimeListSmart(auth: AuthData): Array? { return if (requireLibraryRefresh) { - val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray() + val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray() if (list != null) { - setKey(ANILIST_CACHED_LIST, list) + setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list) } list } else { - getAniListListCached() + getKey>( + ANILIST_CACHED_LIST, + auth.user.id.toString() + ) as? Array } } - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { - val list = getAniListAnimeListSmart()?.groupBy { + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { + val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { convertAniListStringToStatus(it.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten() @@ -675,7 +647,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { // To fill empty lists when AniList does not return them val baseMap = - AniListStatusType.values().filter { it.value >= 0 }.associate { + AniListStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -686,16 +658,16 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) ) } - private suspend fun getFullAniListList(): FullAnilistList? { - /** WARNING ASSUMES ONE USER! **/ - - val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return null + private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? { + val userID = auth.user.id val mediaType = "ANIME" val query = """ @@ -738,11 +710,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } """ - val text = postApi(query) + val text = postApi(auth.token, query) return text?.toKotlinObject() } - suspend fun toggleLike(id: Int): Boolean { + suspend fun toggleLike(auth : AuthData, id: Int): Boolean { val q = """mutation (${'$'}animeId: Int = $id) { ToggleFavourite (animeId: ${'$'}animeId) { anime { @@ -755,25 +727,27 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } }""" - val data = postApi(q) + val data = postApi(auth.token, q) return data != "" } /** Used to query a saved MediaItem on the list to get the id for removal */ data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null) - data class MediaListItem(@JsonProperty("MediaList") val MediaList: MediaListId? = 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: Int?, + score: Score?, progress: Int? ): Boolean { + val userID = auth.user.id + val q = // Delete item if status type is None if (type == AniListStatusType.None) { - val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return false // Get list ID for deletion val idQuery = """ query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) { @@ -782,9 +756,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } """ - val response = postApi(idQuery) + val response = postApi(auth.token, idQuery) val listId = - tryParseJson(response)?.data?.MediaList?.id ?: return false + tryParseJson(response)?.data?.mediaList?.id ?: return false """ mutation(${'$'}id: Int = $listId) { DeleteMediaListEntry(id: ${'$'}id) { @@ -798,7 +772,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { 0, type.value )] - }, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { + }, ${if (score != null) "${'$'}scoreRaw: Int = ${score.toInt(100)}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) { id status @@ -808,11 +782,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { }""" } - val data = postApi(q) + val data = postApi(auth.token, q) return data != "" } - private suspend fun getUser(setSettings: Boolean = true): AniListUser? { + private suspend fun getUser(token : AuthToken): AniListUser? { val q = """ { Viewer { @@ -830,23 +804,15 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } }""" - val data = postApi(q) + val data = postApi(token, q) if (data.isNullOrBlank()) return null val userData = parseJson(data) - val u = userData.data?.Viewer + val u = userData.data?.viewer ?: return null val user = AniListUser( - u?.id, - u?.name, - u?.avatar?.large, + u.id, + u.name, + u.avatar?.large, ) - if (setSettings) { - setKey(accountId, ANILIST_USER_KEY, user) - registerAccount() - } - /* // TODO FIX FAVS - for(i in u.favourites.anime.nodes) { - println("FFAV:" + i.id) - }*/ return user } @@ -855,8 +821,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { suspend fun getSeasonRecursive(id: Int) { val season = getSeason(id) seasons.add(season) - if (season.data.Media.format?.startsWith("TV") == true) { - season.data.Media.relations?.edges?.forEach { + if (season.data.media.format?.startsWith("TV") == true) { + season.data.media.relations?.edges?.forEach { if (it.node?.format != null) { if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) { getSeasonRecursive(it.node.id) @@ -875,7 +841,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class SeasonData( - @JsonProperty("Media") val Media: SeasonMedia, + @JsonProperty("Media") val media: SeasonMedia, ) data class SeasonMedia( @@ -910,7 +876,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class Recommendation( - @JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia, + val id: Long, + @JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia?, ) data class CharacterName( @@ -1040,14 +1007,14 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class AniListViewer( - @JsonProperty("id") val id: Int?, - @JsonProperty("name") val name: String?, + @JsonProperty("id") val id: Int, + @JsonProperty("name") val name: String, @JsonProperty("avatar") val avatar: AniListAvatar?, @JsonProperty("favourites") val favourites: AniListFavourites?, ) data class AniListData( - @JsonProperty("Viewer") val Viewer: AniListViewer?, + @JsonProperty("Viewer") val viewer: AniListViewer?, ) data class AniListRoot( @@ -1055,8 +1022,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class AniListUser( - @JsonProperty("id") val id: Int?, - @JsonProperty("name") val name: String?, + @JsonProperty("id") val id: Int, + @JsonProperty("name") val name: String, @JsonProperty("picture") val picture: String?, ) @@ -1087,7 +1054,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class LikeData( - @JsonProperty("Viewer") val Viewer: LikeViewer?, + @JsonProperty("Viewer") val viewer: LikeViewer?, ) data class LikeRoot( @@ -1127,7 +1094,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class GetDataData( - @JsonProperty("Media") val Media: GetDataMedia?, + @JsonProperty("Media") val media: GetDataMedia?, ) data class GetDataRoot( @@ -1160,7 +1127,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class GetSearchPage( - @JsonProperty("Page") val Page: GetSearchData?, + @JsonProperty("Page") val page: GetSearchData?, ) data class GetSearchData( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt deleted file mode 100644 index 7ec168da8..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders.providers - -import androidx.fragment.app.FragmentActivity -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.OAuth2API - -//TODO dropbox sync -class Dropbox : OAuth2API { - override val idPrefix = "dropbox" - override var name = "Dropbox" - override val key = "zlqsamadlwydvb2" - override val redirectUrl = "dropboxlogin" - override val requiresLogin = true - override val 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/IndexSubtitleApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt deleted file mode 100644 index 668d10bd7..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt +++ /dev/null @@ -1,265 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders.providers - -import android.util.Log -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.imdbUrlToIdNullable -import com.lagradost.cloudstream3.subtitles.AbstractSubApi -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.utils.SubtitleHelper - -class IndexSubtitleApi : AbstractSubApi { - override val name = "IndexSubtitle" - override val idPrefix = "indexsubtitle" - 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://indexsubtitle.com" - const val TAG = "INDEXSUBS" - } - - private fun fixUrl(url: String): String { - if (url.startsWith("http")) { - return url - } - if (url.isEmpty()) { - return "" - } - - val startsWithNoHttp = url.startsWith("//") - if (startsWithNoHttp) { - return "https:$url" - } else { - if (url.startsWith('/')) { - return host + url - } - return "$host/$url" - } - } - - private fun getOrdinal(num: Int?): String? { - return when (num) { - 1 -> "First" - 2 -> "Second" - 3 -> "Third" - 4 -> "Fourth" - 5 -> "Fifth" - 6 -> "Sixth" - 7 -> "Seventh" - 8 -> "Eighth" - 9 -> "Ninth" - 10 -> "Tenth" - 11 -> "Eleventh" - 12 -> "Twelfth" - 13 -> "Thirteenth" - 14 -> "Fourteenth" - 15 -> "Fifteenth" - 16 -> "Sixteenth" - 17 -> "Seventeenth" - 18 -> "Eighteenth" - 19 -> "Nineteenth" - 20 -> "Twentieth" - 21 -> "Twenty-First" - 22 -> "Twenty-Second" - 23 -> "Twenty-Third" - 24 -> "Twenty-Fourth" - 25 -> "Twenty-Fifth" - 26 -> "Twenty-Sixth" - 27 -> "Twenty-Seventh" - 28 -> "Twenty-Eighth" - 29 -> "Twenty-Ninth" - 30 -> "Thirtieth" - 31 -> "Thirty-First" - 32 -> "Thirty-Second" - 33 -> "Thirty-Third" - 34 -> "Thirty-Fourth" - 35 -> "Thirty-Fifth" - else -> null - } - } - - private fun isRightEps(text: String, seasonNum: Int?, epNum: Int?): Boolean { - val FILTER_EPS_REGEX = - Regex("(?i)((Chapter\\s?0?${epNum})|((Season)?\\s?0?${seasonNum}?\\s?(Episode)\\s?0?${epNum}[^0-9]))|(?i)((S?0?${seasonNum}?E0?${epNum}[^0-9])|(0?${seasonNum}[a-z]0?${epNum}[^0-9]))") - return text.contains(FILTER_EPS_REGEX) - } - - private fun haveEps(text: String): Boolean { - val HAVE_EPS_REGEX = - Regex("(?i)((Chapter\\s?0?\\d)|((Season)?\\s?0?\\d?\\s?(Episode)\\s?0?\\d))|(?i)((S?0?\\d?E0?\\d)|(0?\\d[a-z]0?\\d))") - return text.contains(HAVE_EPS_REGEX) - } - - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List { - val imdbId = query.imdb ?: 0 - val lang = query.lang - val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString()) - val queryText = query.query - val epNum = query.epNumber ?: 0 - val seasonNum = query.seasonNumber ?: 0 - val yearNum = query.year ?: 0 - - val urlItems = ArrayList() - - fun cleanResources( - results: MutableList, - name: String, - link: String - ) { - 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, - ) - ) - } - - val document = app.get("$host/?search=$queryText").document - - document.select("div.my-3.p-3 div.media").map { block -> - if (seasonNum > 0) { - val name = block.select("strong.text-primary, strong.text-info").text().trim() - val season = getOrdinal(seasonNum) - if ((block.selectFirst("a")?.attr("href") - ?.contains( - "$season", - ignoreCase = true - )!! || name.contains( - "$season", - ignoreCase = true - )) && name.contains(queryText, ignoreCase = true) - ) { - block.select("div.media").mapNotNull { - urlItems.add( - fixUrl( - it.selectFirst("a")!!.attr("href") - ) - ) - } - } - } else { - if (block.selectFirst("strong")!!.text().trim() - .matches(Regex("(?i)^$queryText\$")) - ) { - if (block.select("span[title=Release]").isNullOrEmpty()) { - block.select("div.media").mapNotNull { - val urlItem = fixUrl( - it.selectFirst("a")!!.attr("href") - ) - val itemDoc = app.get(urlItem).document - val id = imdbUrlToIdNullable( - itemDoc.selectFirst("div.d-flex span.badge.badge-primary")?.parent() - ?.attr("href") - )?.toLongOrNull() - val year = itemDoc.selectFirst("div.d-flex span.badge.badge-success") - ?.ownText() - ?.trim().toString() - Log.i(TAG, "id => $id \nyear => $year||$yearNum") - if (imdbId > 0) { - if (id == imdbId) { - urlItems.add(urlItem) - } - } else { - if (year.contains("$yearNum")) { - urlItems.add(urlItem) - } - } - } - } else { - if (block.select("span[title=Release]").text().trim() - .contains("$yearNum") - ) { - block.select("div.media").mapNotNull { - urlItems.add( - fixUrl( - it.selectFirst("a")!!.attr("href") - ) - ) - } - } - } - } - } - } - Log.i(TAG, "urlItems => $urlItems") - val results = mutableListOf() - - urlItems.forEach { url -> - val request = app.get(url) - if (request.isSuccessful) { - request.document.select("div.my-3.p-3 div.media").map { block -> - if (block.select("span.d-block span[data-original-title=Language]").text() - .trim() - .contains("$queryLang") - ) { - var name = block.select("strong.text-primary, strong.text-info").text().trim() - val link = fixUrl(block.selectFirst("a")!!.attr("href")) - if (seasonNum > 0) { - when { - isRightEps(name, seasonNum, epNum) -> { - cleanResources(results, name, link) - } - !(haveEps(name)) -> { - name = "$name (S${seasonNum}:E${epNum})" - cleanResources(results, name, link) - } - } - } else { - cleanResources(results, name, link) - } - } - } - } - } - return results - } - - override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? { - val seasonNum = data.seasonNumber - val epNum = data.epNumber - - val req = app.get(data.data) - - if (req.isSuccessful) { - val document = req.document - val link = if (document.select("div.my-3.p-3 div.media").size == 1) { - fixUrl( - document.selectFirst("div.my-3.p-3 div.media a")!!.attr("href") - ) - } else { - document.select("div.my-3.p-3 div.media").firstNotNullOf { block -> - val name = - block.selectFirst("strong.d-block")?.text()?.trim().toString() - if (seasonNum!! > 0) { - if (isRightEps(name, seasonNum, epNum)) { - fixUrl(block.selectFirst("a")!!.attr("href")) - } else { - null - } - } else { - fixUrl(block.selectFirst("a")!!.attr("href")) - } - } - } - return link - } - - return null - - } - -} \ 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 724d72163..e15a77c64 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -1,8 +1,676 @@ package com.lagradost.cloudstream3.syncproviders.providers + +import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.txt +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.ZoneId +import java.util.Date +import java.util.Locale + +const val KITSU_MAX_SEARCH_LIMIT = 20 + +class KitsuApi: SyncAPI() { + override var name = "Kitsu" + override val idPrefix = "kitsu" + + private val apiUrl = "https://kitsu.io/api/edge" + private val fallbackApiUrl = "https://kitsu.app/api/edge" + private val oauthUrl = "https://kitsu.io/api/oauth" + private val fallbackOauthUrl = "https://kitsu.app/api/oauth" + override val hasInApp = true + override val mainUrl = "https://kitsu.app" + override val icon = R.drawable.kitsu_icon + override val syncIdName = SyncIdName.Kitsu + override val createAccountUrl = mainUrl + + override val supportedWatchTypes = setOf( + SyncWatchType.WATCHING, + SyncWatchType.COMPLETED, + SyncWatchType.PLANTOWATCH, + SyncWatchType.DROPPED, + SyncWatchType.ONHOLD, + SyncWatchType.NONE + ) + + override val inAppLoginRequirement = AuthLoginRequirement( + password = true, + email = true + ) + + private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + + try { + + val response = chain.proceed(request); + + if (response.isSuccessful) return response + + response.close() + + } catch (_: Exception) { + } + + val fallbackRequest: Request = request.newBuilder() + .url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl)) + .build() + + return chain.proceed(fallbackRequest) + + } + } + + private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl) + private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl) + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.email ?: return null + val password = form.password ?: return null + + val grantType = "password" + + val token = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to grantType, + "username" to username, + "password" to password + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessTokenLifetime = unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken, + ) + } + + override suspend fun refreshToken(token: AuthToken): AuthToken { + val res = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to "refresh_token", + "refresh_token" to token.refreshToken!! + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessToken = res.accessToken, + refreshToken = res.refreshToken, + accessTokenLifetime = unixTime + res.expiresIn.toLong() + ) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val user = app.get( + "$apiUrl/users?filter[self]=true", + headers = mapOf( + "Authorization" to "Bearer ${token?.accessToken ?: return null}" + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + if (user.data.isEmpty()) { + return null + } + + return AuthUser( + id = user.data[0].id.toInt(), + name = user.data[0].attributes.name, + profilePicture = user.data[0].attributes.avatar?.original + ) + } + + override suspend fun search(auth: AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") + val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth", + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + return res.data.map { + val attributes = it.attributes + + val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" + + SyncSearchResult( + title, + this.name, + it.id, + "$mainUrl/anime/${it.id}/", + attributes.posterImage?.large ?: attributes.posterImage?.medium + ) + } + } + + override suspend fun load(auth : AuthData?, id: String): SyncResult? { + val auth = auth?.token?.accessToken ?: return null + if (id.toIntOrNull() == null) { + return null + } + + data class KitsuResponse( + @field:JsonProperty(value = "data") + val data: KitsuNode, + ) + + val url = + "$apiUrl/anime/$id" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.attributes + + return SyncResult( + id = id, + totalEpisodes = anime.episodeCount, + title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), + publicScore = Score.from(anime.ratingTwenty, 20), + duration = anime.episodeLength, + synopsis = anime.synopsis, + airStatus = when(anime.status) { + "finished" -> ShowStatus.Completed + "current" -> ShowStatus.Ongoing + else -> null + }, + nextAiring = null, + studio = null, + genres = null, + trailers = null, + startDate = LocalDate.parse(anime.startDate).toEpochDay(), + endDate = LocalDate.parse(anime.endDate).toEpochDay(), + recommendations = null, + nextSeason =null, + prevSeason = null, + actors = null, + ) + + } + + override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? { + val accessToken = auth?.token?.accessToken ?: return null + val userId = auth.user.id + + val selectedFields = arrayOf("status","ratingTwenty", "progress") + + val url = + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $accessToken" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.firstOrNull()?.attributes + + if (anime == null) { + return SyncStatus( + score = null, + status = SyncWatchType.NONE, + isFavorite = null, + watchedEpisodes = null + ) + } + + return SyncStatus( + score = Score.from(anime.ratingTwenty, 20), + status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)), + isFavorite = null, + watchedEpisodes = anime.progress, + ) + } + suspend fun getAnimeIdByTitle(title: String): String? { + + val animeSelectedFields = arrayOf("titles","canonicalTitle") + val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + + val res = app.get(url, interceptor = apiFallbackInterceptor).parsed() + + return res.data.firstOrNull()?.id + + } + + override fun urlToId(url: String): String? = + Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first() + + override suspend fun updateStatus( + auth : AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { + + return setScoreRequest( + auth ?: return false, + id.toIntOrNull() ?: return false, + fromIntToAnimeStatus(newStatus.status), + newStatus.score?.toInt(20), + newStatus.watchedEpisodes + ) + } + + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: KitsuStatusType? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + + val libraryEntryId = getAnimeLibraryEntryId(auth, id) + + // Exists entry for anime in library + if (libraryEntryId != null) { + + // Delete anime from library + if (status == null || status == KitsuStatusType.None) { + + val res = app.delete( + "$apiUrl/library-entries/$libraryEntryId", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + interceptor = apiFallbackInterceptor + ) + + + return res.isSuccessful + + } + + return setScoreRequest( + auth, + libraryEntryId, + kitsuStatusAsString[maxOf(0, status.value)], + score, + numWatchedEpisodes + ) + + } + + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)], + ), + "relationships" to mapOf( + "anime" to mapOf( + "data" to mapOf( + "type" to "anime", + "id" to id.toString() + ) + ), + "user" to mapOf( + "data" to mapOf( + "type" to "users", + "id" to auth.user.id + ) + ) + ) + ) + ) + + val res = app.post( + "$apiUrl/library-entries", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor + ) + + return res.isSuccessful + + } + + @Suppress("UNCHECKED_CAST") + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: String? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "id" to id.toString(), + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to status + ) + ) + ) + + val res = app.patch( + "$apiUrl/library-entries/$id", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor + ) + + + return res.isSuccessful + + } + + private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? { + + val userId = auth.user.id + + val res = app.get( + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.firstOrNull() ?: return null + + return res.id.toInt() + + } + + override suspend fun library(auth : AuthData?): LibraryMetadata? { + val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy { + convertToStatus(it.attributes.status ?: "").stringRes + }?.mapValues { group -> + group.value.map { it.toLibraryItem() } + } ?: emptyMap() + + // To fill empty lists when Kitsu does not return them + val baseMap = + KitsuStatusType.entries.filter { it.value >= 0 }.associate { + it.stringRes to emptyList() + } + + return LibraryMetadata( + (baseMap + list).map { LibraryList(txt(it.key), it.value) }, + setOf( + ListSorting.AlphabeticalA, + ListSorting.AlphabeticalZ, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, + ListSorting.RatingHigh, + ListSorting.RatingLow, + ) + ) + } + + private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array? { + return if (requireLibraryRefresh) { + val list = getKitsuAnimeList(auth.token, auth.user.id) + setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list) + list + } else { + getKey>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array + } + } + + private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array { + + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount") + val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status") + val limit = 500 + var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}" + + val fullList = mutableListOf() + + while (true) { + + val data: KitsuResponse = getKitsuAnimeListSlice(token, url) + + data.data.forEachIndexed { index, value -> + value.anime = data.included?.get(index) + } + + fullList.addAll(data.data) + + url = data.links?.next ?: break + } + + + return fullList.toTypedArray() + } + + private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse { + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer ${token.accessToken}", + ), + interceptor = apiFallbackInterceptor + ).parsed() + return res + } + + + data class ResponseToken( + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String, + ) + + data class KitsuNode( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuNodeAttributes, + /* User list anime node */ + @JsonProperty("relationships") val relationships: KitsuRelationships?, + var anime: KitsuAnimeData? + ) { + fun toLibraryItem(): LibraryItem { + + val animeItem = this.anime + + val numEpisodes = animeItem?.attributes?.episodeCount + + val startDate = animeItem?.attributes?.startDate + + val posterImage = animeItem?.attributes?.posterImage + + val canonicalTitle = animeItem?.attributes?.canonicalTitle + val titles = animeItem?.attributes?.titles + + val animeId = animeItem?.id + + val synopsis: String? = animeItem?.attributes?.synopsis + + return LibraryItem( + canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(), + "https://kitsu.app/anime/${animeId}/", + this.id, + this.attributes.progress, + numEpisodes, + Score.from(this.attributes.ratingTwenty, 20), + parseDateLong(this.attributes.updatedAt), + "Kitsu", + TvType.Anime, + posterImage?.large ?: posterImage?.medium, + null, + null, + plot = synopsis, + releaseDate = if (startDate == null) null else try { + Date.from(LocalDate.parse(startDate).atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toInstant()) + } catch (_: RuntimeException) { + null + } + ) + } + + } + + data class KitsuAnimeAttributes( + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + ) + + data class KitsuAnimeData( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuAnimeAttributes, + ) + + + data class KitsuNodeAttributes( + /* General attributes */ + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + /* User attributes */ + @JsonProperty("name") val name: String?, + @JsonProperty("location") val location: String?, + @JsonProperty("createdAt") val createdAt: String?, + @JsonProperty("avatar") val avatar: KitsuUserAvatar?, + /* User list anime attributes */ + @JsonProperty("progress") val progress: Int?, + @JsonProperty("ratingTwenty") val ratingTwenty: Int?, + @JsonProperty("updatedAt") val updatedAt: String?, + @JsonProperty("status") val status: String?, + ) + + data class KitsuRelationships( + @JsonProperty("anime") val anime: KitsuRelationshipsAnime? + ) + + data class KitsuRelationshipsAnime( + @JsonProperty("links") val links: KitsuLinks? + ) + + data class KitsuPosterImage( + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, + ) + + data class KitsuTitles( + @JsonProperty("en_jp") val enJp: String?, + @JsonProperty("ja_jp") val jaJp: String? + ) + + data class KitsuUserAvatar( + @JsonProperty("original") val original: String? + ) + + data class KitsuLinks( + /* Pagination */ + @JsonProperty("first") val first: String?, + @JsonProperty("next") val next: String?, + @JsonProperty("last") val last: String?, + /* Relationships */ + @JsonProperty("related") val related: String? + ) + + data class KitsuResponse( + @JsonProperty("links") val links: KitsuLinks?, + @JsonProperty("data") val data: List, + /* When requesting related info (User library entry -> anime) */ + @JsonProperty("included") val included: List?, + ) + + + companion object { + + const val KITSU_CACHED_LIST: String = "kitsu_cached_list" + private fun parseDateLong(string: String?): Long? { + return try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse( + string ?: return null + )?.time?.div(1000) + } catch (e: Exception) { + null + } + } + + private val kitsuStatusAsString = + arrayOf("current", "completed", "on_hold", "dropped", "planned") + private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType { + return when (inp) { + SyncWatchType.NONE -> KitsuStatusType.None + SyncWatchType.WATCHING -> KitsuStatusType.Watching + SyncWatchType.COMPLETED -> KitsuStatusType.Completed + SyncWatchType.ONHOLD -> KitsuStatusType.OnHold + SyncWatchType.DROPPED -> KitsuStatusType.Dropped + SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch + SyncWatchType.REWATCHING -> KitsuStatusType.Watching + } + } + + enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) { + Watching(0, R.string.type_watching), + Completed(1, R.string.type_completed), + OnHold(2, R.string.type_on_hold), + Dropped(3, R.string.type_dropped), + PlanToWatch(4, R.string.type_plan_to_watch), + None(-1, R.string.type_none) + } + + private fun convertToStatus(string: String): KitsuStatusType { + return when (string) { + "current" -> KitsuStatusType.Watching + "completed" -> KitsuStatusType.Completed + "on_hold" -> KitsuStatusType.OnHold + "dropped" -> KitsuStatusType.Dropped + "planned" -> KitsuStatusType.PlanToWatch + else -> KitsuStatusType.None + } + } + } +} // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md @@ -142,4 +810,4 @@ query { val canonical: String? = null ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt index e6ca97116..8f0d7ca6d 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,67 +1,32 @@ package com.lagradost.cloudstream3.syncproviders.providers -import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioWork +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites 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: Nothing? = null - override val idPrefix = "local" + override val createAccountUrl = null override var requireLibraryRefresh = true - - override fun loginInfo(): AuthAPI.LoginInfo { - return AuthAPI.LoginInfo( - null, - null, - 0 - ) - } - - override fun logOut() { - - } - - override val key: String = "" - override val redirectUrl = "" - override suspend fun handleRedirect(url: String): Boolean { - return true - } - - override fun authenticate(activity: FragmentActivity?) { - } - - override val mainUrl = "" override val syncIdName = SyncIdName.LocalList - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { - return true - } - override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { - return null - } - - override suspend fun getResult(id: String): SyncAPI.SyncResult? { - return null - } - - override suspend fun search(name: String): List? { - return null - } - - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { val watchStatusIds = ioWork { getAllWatchStateIds()?.map { id -> Pair(id, getResultWatchState(id)) @@ -69,36 +34,59 @@ class LocalList : SyncAPI { }?.distinctBy { it.first } ?: return null val list = ioWork { - watchStatusIds.groupBy { - it.second.stringRes - }.mapValues { group -> + val isTrueTv = isLayout(TV) + + val baseMap = WatchType.entries.filter { it != WatchType.NONE }.associate { + // None is not something to display + it.stringRes to emptyList() + } + mapOf( + R.string.favorites_list_name to emptyList() + ) + if (!isTrueTv) { + mapOf( + R.string.subscription_list_name to emptyList() + ) + } else { + emptyMap() + } + + val watchStatusMap = watchStatusIds.groupBy { it.second.stringRes }.mapValues { group -> group.value.mapNotNull { getBookmarkedData(it.first)?.toLibraryItem(it.first.toString()) } - } + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull { + } + + val favoritesMap = mapOf(R.string.favorites_list_name to getAllFavorites().mapNotNull { it.toLibraryItem() }) + + // Don't show subscriptions on TV + val result = if (isTrueTv) { + baseMap + watchStatusMap + favoritesMap + } else { + val subscriptionsMap = + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull { + it.toLibraryItem() + }) + + baseMap + watchStatusMap + subscriptionsMap + favoritesMap + } + + result } - val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate { - // None is not something to display - it.stringRes to emptyList() - } + mapOf(R.string.subscription_list_name to emptyList()) - - return SyncAPI.LibraryMetadata( - (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) }, + return LibraryMetadata( + list.map { LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, -// ListSorting.UpdatedNew, -// ListSorting.UpdatedOld, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, // ListSorting.RatingHigh, // ListSorting.RatingLow, + ) ) } - - 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 028264012..ba0195be6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -1,105 +1,138 @@ package com.lagradost.cloudstream3.syncproviders.providers -import android.util.Base64 import androidx.annotation.StringRes -import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.splitQuery +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject -import java.net.URL -import java.security.SecureRandom -import java.text.ParseException +import com.lagradost.cloudstream3.utils.txt import java.text.SimpleDateFormat -import java.util.* +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.Locale /** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */ const val MAL_MAX_SEARCH_LIMIT = 25 -class MALApi(index: Int) : AccountManager(index), SyncAPI { +class MALApi : SyncAPI() { override var name = "MAL" - override val key = "1714d6f2f4f7cc19644384f8c4629910" - override val redirectUrl = "mallogin" override val idPrefix = "mal" - override var mainUrl = "https://myanimelist.net" + + val key = "1714d6f2f4f7cc19644384f8c4629910" private val apiUrl = "https://api.myanimelist.net" + override val hasOAuth2 = true + override val redirectUrlIdentifier: String? = "mallogin" + override val mainUrl = "https://myanimelist.net" override val icon = R.drawable.mal_logo - override val requiresLogin = false override val syncIdName = SyncIdName.MyAnimeList - override var requireLibraryRefresh = true override val createAccountUrl = "$mainUrl/register.php" - override fun logOut() { - requireLibraryRefresh = true - removeAccountKeys() - } + override val supportedWatchTypes = setOf( + SyncWatchType.WATCHING, + SyncWatchType.COMPLETED, + SyncWatchType.PLANTOWATCH, + SyncWatchType.DROPPED, + SyncWatchType.ONHOLD, + SyncWatchType.NONE + ) - override fun loginInfo(): AuthAPI.LoginInfo? { - //getMalUser(true)? - getKey(accountId, MAL_USER_KEY)?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = user.picture, - name = user.name, - accountIndex = accountIndex - ) + data class PayLoad( + val requestId: Int, + val codeVerifier: String + ) + + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val payloadData = parseJson(payload!!) + val sanitizer = splitRedirectUrl(redirectUrl) + val state = sanitizer["state"]!! + + if (state != "RequestID${payloadData.requestId}") { + return null } - return null - } - private fun getAuth(): String? { - return getKey( - accountId, - MAL_TOKEN_KEY + val currentCode = sanitizer["code"]!! + + val token = app.post( + "$mainUrl/v1/oauth2/token", + data = mapOf( + "client_id" to key, + "code" to currentCode, + "code_verifier" to payloadData.codeVerifier, + "grant_type" to "authorization_code" + ) + ).parsed() + return AuthToken( + accessTokenLifetime = unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken ) } - override suspend fun search(name: String): List { + 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=$name&limit=$MAL_MAX_SEARCH_LIMIT" - val auth = getAuth() ?: return emptyList() val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", ), cacheTime = 0 - ).text - return parseJson(res).data.map { + ).parsed() + return res.data.map { val node = it.node SyncAPI.SyncSearchResult( node.title, this.name, node.id.toString(), "$mainUrl/anime/${node.id}/", - node.main_picture?.large ?: node.main_picture?.medium + node.mainPicture?.large ?: node.mainPicture?.medium ) } } - override fun getIdFromUrl(url: String): String { - return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() - } + override fun urlToId(url: String): String? = + Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + override suspend fun updateStatus( + auth : AuthData?, + id: String, + newStatus: SyncAPI.AbstractSyncStatus + ): Boolean { return setScoreRequest( + auth?.token ?: return false, id.toIntOrNull() ?: return false, - fromIntToAnimeStatus(status.status), - status.score, - status.watchedEpisodes - ).also { - requireLibraryRefresh = requireLibraryRefresh || it - } + fromIntToAnimeStatus(newStatus.status), + newStatus.score?.toInt(10), + newStatus.watchedEpisodes + ) } data class MalAnime( @@ -176,7 +209,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private fun parseDate(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd")?.parse(string ?: return null)?.time + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time } catch (e: Exception) { null } @@ -188,18 +221,18 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { apiName = this.name, syncId = node.id.toString(), url = "$mainUrl/anime/${node.id}", - posterUrl = node.main_picture?.large + posterUrl = node.mainPicture?.large ) } - override suspend fun getResult(id: String): SyncAPI.SyncResult? { + override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { + val auth = auth?.token?.accessToken ?: return null val internalId = id.toIntOrNull() ?: return null val url = "$apiUrl/v2/anime/$internalId?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,created_at,updated_at,media_type,status,genres,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,related_manga,recommendations,studios,statistics" - val auth = getAuth() val res = app.get( - url, headers = if (auth == null) emptyMap() else mapOf( + url, headers = mapOf( "Authorization" to "Bearer $auth" ) ).text @@ -208,7 +241,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { id = internalId.toString(), totalEpisodes = malAnime.numEpisodes, title = malAnime.title, - publicScore = malAnime.mean?.toFloat()?.times(1000)?.toInt(), + publicScore = Score.from10(malAnime.mean), duration = malAnime.averageEpisodeDuration, synopsis = malAnime.synopsis, airStatus = when (malAnime.status) { @@ -238,16 +271,23 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { - val internalId = id.toIntOrNull() ?: return null + override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + val auth = auth?.token?.accessToken ?: return null + + // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get + val url = + "$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status" + val data = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth" + ), cacheTime = 0 + ).parsed().myListStatus - val data = - getDataAboutMalId(internalId)?.my_list_status //?: throw ErrorLoadingException("No my_list_status") return SyncAPI.SyncStatus( - score = data?.score, - status = malStatusAsString.indexOf(data?.status), + score = Score.from10(data?.score), + status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)), isFavorite = null, - watchedEpisodes = data?.num_episodes_watched, + watchedEpisodes = data?.numEpisodesWatched, ) } @@ -255,14 +295,17 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private val malStatusAsString = arrayOf("watching", "completed", "on_hold", "dropped", "plan_to_watch") - const val MAL_USER_KEY: String = "mal_user" // user data like profile const val MAL_CACHED_LIST: String = "mal_cached_list" - const val MAL_UNIXTIME_KEY: String = "mal_unixtime" // When token expires - const val MAL_REFRESH_TOKEN_KEY: String = "mal_refresh_token" // refresh token - const val MAL_TOKEN_KEY: String = "mal_token" // anilist token for api fun convertToStatus(string: String): MalStatusType { - return fromIntToAnimeStatus(malStatusAsString.indexOf(string)) + return when (string) { + "watching" -> MalStatusType.Watching + "completed" -> MalStatusType.Completed + "on_hold" -> MalStatusType.OnHold + "dropped" -> MalStatusType.Dropped + "plan_to_watch" -> MalStatusType.PlanToWatch + else -> MalStatusType.None + } } enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) { @@ -274,22 +317,21 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { None(-1, R.string.type_none) } - private fun fromIntToAnimeStatus(inp: Int): MalStatusType {//= AniListStatusType.values().first { it.value == inp } + private fun fromIntToAnimeStatus(inp: SyncWatchType): MalStatusType {//= AniListStatusType.values().first { it.value == inp } return when (inp) { - -1 -> MalStatusType.None - 0 -> MalStatusType.Watching - 1 -> MalStatusType.Completed - 2 -> MalStatusType.OnHold - 3 -> MalStatusType.Dropped - 4 -> MalStatusType.PlanToWatch - 5 -> MalStatusType.Watching - else -> MalStatusType.None + SyncWatchType.NONE -> MalStatusType.None + SyncWatchType.WATCHING -> MalStatusType.Watching + SyncWatchType.COMPLETED -> MalStatusType.Completed + SyncWatchType.ONHOLD -> MalStatusType.OnHold + SyncWatchType.DROPPED -> MalStatusType.Dropped + SyncWatchType.PLANTOWATCH -> MalStatusType.PlanToWatch + SyncWatchType.REWATCHING -> MalStatusType.Watching } } private fun parseDateLong(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( string ?: return null )?.time?.div(1000) } catch (e: Exception) { @@ -298,85 +340,38 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun handleRedirect(url: String): Boolean { - val sanitizer = - splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR - val state = sanitizer["state"]!! - if (state == "RequestID$requestId") { - val currentCode = sanitizer["code"]!! - - val res = app.post( - "$mainUrl/v1/oauth2/token", - data = mapOf( - "client_id" to key, - "code" to currentCode, - "code_verifier" to codeVerifier, - "grant_type" to "authorization_code" - ) - ).text - - if (res.isNotBlank()) { - switchToNewAccount() - storeToken(res) - val user = getMalUser() - requireLibraryRefresh = true - return user != null - } - } - return false - } - - override fun authenticate(activity: FragmentActivity?) { - // It is recommended to use a URL-safe string as code_verifier. - // See section 4 of RFC 7636 for more details. - - val secureRandom = SecureRandom() - val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 - secureRandom.nextBytes(codeVerifierBytes) - codeVerifier = - Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=').replace("+", "-") - .replace("/", "_").replace("\n", "") + override fun loginRequest(): AuthLoginPage? { + val codeVerifier = generateCodeVerifier() + val requestId = ++requestIdCounter val codeChallenge = codeVerifier val request = "$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId" - openBrowser(request, activity) + + return AuthLoginPage( + url = request, + payload = PayLoad(requestId, codeVerifier).toJson() + ) } - private var requestId = 0 - private var codeVerifier = "" + override suspend fun refreshToken(token: AuthToken): AuthToken? { + val res = app.post( + "$mainUrl/v1/oauth2/token", + data = mapOf( + "client_id" to key, + "grant_type" to "refresh_token", + "refresh_token" to token.refreshToken!! + ) + ).parsed() - private fun storeToken(response: String) { - try { - if (response != "") { - val token = parseJson(response) - setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime)) - setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token) - setKey(accountId, MAL_TOKEN_KEY, token.access_token) - requireLibraryRefresh = true - } - } catch (e: Exception) { - logError(e) - } + return AuthToken( + accessToken = res.accessToken, + refreshToken = res.refreshToken, + accessTokenLifetime = unixTime + res.expiresIn.toLong() + ) } - 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() @@ -393,55 +388,66 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class Node( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String, - @JsonProperty("main_picture") val main_picture: MainPicture?, - @JsonProperty("alternative_titles") val alternative_titles: AlternativeTitles?, - @JsonProperty("media_type") val media_type: String?, - @JsonProperty("num_episodes") val num_episodes: Int?, + @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 start_date: String?, - @JsonProperty("end_date") val end_date: String?, - @JsonProperty("average_episode_duration") val average_episode_duration: Int?, + @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 num_list_users: Int?, - @JsonProperty("num_favorites") val num_favorites: Int?, - @JsonProperty("num_scoring_users") val num_scoring_users: Int?, - @JsonProperty("start_season") val start_season: StartSeason?, + @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 created_at: String?, - @JsonProperty("updated_at") val updated_at: String? + @JsonProperty("created_at") val createdAt: String?, + @JsonProperty("updated_at") val updatedAt: String? ) data class ListStatus( @JsonProperty("status") val status: String?, @JsonProperty("score") val score: Int, - @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, - @JsonProperty("is_rewatching") val is_rewatching: Boolean, - @JsonProperty("updated_at") val updated_at: String, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) data class Data( @JsonProperty("node") val node: Node, - @JsonProperty("list_status") val list_status: ListStatus?, + @JsonProperty("list_status") val listStatus: ListStatus?, ) { fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( this.node.title, "https://myanimelist.net/anime/${this.node.id}/", this.node.id.toString(), - this.list_status?.num_episodes_watched, - this.node.num_episodes, - this.list_status?.score?.times(10), - parseDateLong(this.list_status?.updated_at), + this.listStatus?.numEpisodesWatched, + this.node.numEpisodes, + Score.from10(this.listStatus?.score), + parseDateLong(this.listStatus?.updatedAt), "MAL", TvType.Anime, - this.node.main_picture?.large ?: this.node.main_picture?.medium, + this.node.mainPicture?.large ?: this.node.mainPicture?.medium, 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) + ) + ) + } catch (_: RuntimeException) { + null + } ) } } @@ -467,35 +473,20 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ) data class Broadcast( - @JsonProperty("day_of_the_week") val day_of_the_week: String?, - @JsonProperty("start_time") val start_time: String? + @JsonProperty("day_of_the_week") val dayOfTheWeek: String?, + @JsonProperty("start_time") val startTime: String? ) - private fun getMalAnimeListCached(): Array? { - return getKey(MAL_CACHED_LIST) as? Array - } - - private suspend fun getMalAnimeListSmart(): Array? { - if (getAuth() == null) return null - return if (requireLibraryRefresh) { - val list = getMalAnimeList() - setKey(MAL_CACHED_LIST, list) - list - } else { - getMalAnimeListCached() - } - } - - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { - val list = getMalAnimeListSmart()?.groupBy { - convertToStatus(it.list_status?.status ?: "").stringRes + override suspend fun library(auth : AuthData?): LibraryMetadata? { + val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { + convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } } ?: emptyMap() // To fill empty lists when MAL does not return them val baseMap = - MalStatusType.values().filter { it.value >= 0 }.associate { + MalStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -506,19 +497,30 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) ) } - private suspend fun getMalAnimeList(): Array { - checkMalToken() + private suspend fun getMalAnimeListSmart(auth : AuthData): Array? { + return if (requireLibraryRefresh) { + val list = getMalAnimeList(auth.token) + setKey(MAL_CACHED_LIST, auth.user.id.toString(), list) + list + } else { + getKey>(MAL_CACHED_LIST, auth.user.id.toString()) as? Array + } + } + + private suspend fun getMalAnimeList(token: AuthToken): Array { var offset = 0 val fullList = mutableListOf() val offsetRegex = Regex("""offset=(\d+)""") while (true) { - val data: MalList = getMalAnimeListSlice(offset) ?: break + val data: MalList = getMalAnimeListSlice(token, offset) ?: break fullList.addAll(data.data) offset = data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() } @@ -527,130 +529,33 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { return fullList.toTypedArray() } - private suspend fun getMalAnimeListSlice(offset: Int = 0): MalList? { + private suspend fun getMalAnimeListSlice(token: AuthToken, offset: Int = 0): MalList? { val user = "@me" - val auth = getAuth() ?: return null // Very lackluster docs // https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get val url = "$apiUrl/v2/users/$user/animelist?fields=list_status,num_episodes,media_type,status,start_date,end_date,synopsis,alternative_titles,mean,genres,rank,num_list_users,nsfw,average_episode_duration,num_favorites,popularity,num_scoring_users,start_season,favorites_info,broadcast,created_at,updated_at&nsfw=1&limit=100&offset=$offset" val res = app.get( url, headers = mapOf( - "Authorization" to "Bearer $auth", + "Authorization" to "Bearer ${token.accessToken}", ), cacheTime = 0 ).text return res.toKotlinObject() } - private suspend fun getDataAboutMalId(id: Int): SmallMalAnime? { - // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get - val url = - "$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status" - val res = app.get( - url, headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return null) - ), cacheTime = 0 - ).text - - return parseJson(res) - } - - suspend fun setAllMalData() { - val user = "@me" - var isDone = false - var index = 0 - allTitles.clear() - checkMalToken() - while (!isDone) { - val res = app.get( - "$apiUrl/v2/users/$user/animelist?fields=list_status&limit=1000&offset=${index * 1000}", - headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return) - ), cacheTime = 0 - ).text - val values = parseJson(res) - val titles = - values.data.map { MalTitleHolder(it.list_status, it.node.id, it.node.title) } - for (t in titles) { - allTitles[t.id] = t - } - isDone = titles.size < 1000 - index++ - } - } - - 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").parse(it).time < System.currentTimeMillis()) 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") - 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, - num_watched_episodes: Int? = null, + numWatchedEpisodes: Int? = null, ): Boolean { val res = setScoreRequest( + token, id, if (status == null) null else malStatusAsString[maxOf(0, status.value)], score, - num_watched_episodes + numWatchedEpisodes ) return if (res.isNullOrBlank()) { @@ -667,22 +572,24 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } + @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( + token: AuthToken, id: Int, status: String? = null, score: Int? = null, - num_watched_episodes: Int? = null, + numWatchedEpisodes: Int? = null, ): String? { val data = mapOf( "status" to status, "score" to score?.toString(), - "num_watched_episodes" to num_watched_episodes?.toString() - ).filter { it.value != null } as Map + "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 " + (getAuth() ?: return null) + "Authorization" to "Bearer ${token.accessToken}" ), data = data ).text @@ -690,10 +597,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class ResponseToken( - @JsonProperty("token_type") val token_type: String, - @JsonProperty("expires_in") val expires_in: Int, - @JsonProperty("access_token") val access_token: String, - @JsonProperty("refresh_token") val refresh_token: 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, ) data class MalRoot( @@ -702,7 +609,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class MalDatum( @JsonProperty("node") val node: MalNode, - @JsonProperty("list_status") val list_status: MalStatus, + @JsonProperty("list_status") val listStatus: MalStatus, ) data class MalNode( @@ -719,16 +626,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class MalStatus( @JsonProperty("status") val status: String, @JsonProperty("score") val score: Int, - @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, - @JsonProperty("is_rewatching") val is_rewatching: Boolean, - @JsonProperty("updated_at") val updated_at: String, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) data class MalUser( @JsonProperty("id") val id: Int, @JsonProperty("name") val name: String, @JsonProperty("location") val location: String, - @JsonProperty("joined_at") val joined_at: String, + @JsonProperty("joined_at") val joinedAt: String, @JsonProperty("picture") val picture: String?, ) @@ -741,9 +648,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class SmallMalAnime( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String?, - @JsonProperty("num_episodes") val num_episodes: Int, - @JsonProperty("my_list_status") val my_list_status: MalStatus?, - @JsonProperty("main_picture") val main_picture: MalMainPicture?, + @JsonProperty("num_episodes") val numEpisodes: Int, + @JsonProperty("my_list_status") val myListStatus: MalStatus?, + @JsonProperty("main_picture") val mainPicture: MalMainPicture?, ) data class MalSearchNode( 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 3e372c2d5..4b17fdb29 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,38 +2,44 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty -import com.google.common.collect.BiMap -import com.google.common.collect.HashBiMap -import com.lagradost.cloudstream3.* -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.mvvm.logError -import com.lagradost.cloudstream3.subtitles.AbstractSubApi +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.utils.AppUtils -import java.net.URLEncoder -import java.nio.charset.StandardCharsets +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag -class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { - override val idPrefix = "opensubtitles" +class OpenSubtitlesApi : SubtitleAPI() { override val name = "OpenSubtitles" + override val idPrefix = "opensubtitles" + override val icon = R.drawable.open_subtitles_icon - override val requiresPassword = true - override val requiresUsername = true + override val hasInApp = true + override val inAppLoginRequirement = AuthLoginRequirement( + password = true, + username = true, + ) + override val createAccountUrl = "https://www.opensubtitles.com/en/users/sign_up" companion object { - const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile - const val apiKey = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" - const val host = "https://api.opensubtitles.com/api/v1" + const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" + const val HOST = "https://api.opensubtitles.com/api/v1" const val TAG = "OPENSUBS" - const val coolDownDuration: Long = 1000L * 30L // CoolDown if 429 error code in ms + const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms var currentCoolDown: Long = 0L - var currentSession: SubtitleOAuthEntity? = null + const val userAgent = "Cloudstream3 v0.2" + val headers = mapOf("user-agent" to userAgent, "Api-Key" to API_KEY) } private fun canDoRequest(): Boolean { @@ -47,125 +53,59 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi } private fun throwGotTooManyRequests() { - currentCoolDown = unixTimeMs + coolDownDuration + currentCoolDown = unixTimeMs + COOLDOWN_DURATION throw ErrorLoadingException("Too many requests") } - private fun getAuthKey(): SubtitleOAuthEntity? { - return getKey(accountId, OPEN_SUBTITLES_USER_KEY) + override suspend fun refreshToken(token: AuthToken): AuthToken? { + return login(parseJson(token.payload ?: return null)) } - 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 user(token: AuthToken?): AuthUser? { + val user = parseJson(token?.payload ?: return null) + val username = user.username ?: return null + return AuthUser( + id = username.hashCode(), + name = username + ) } - override fun loginInfo(): AuthAPI.LoginInfo? { - getAuthKey()?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = null, - name = user.user, - accountIndex = accountIndex - ) - } - return null - } + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.username ?: return null + val password = form.password ?: 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", + url = "$HOST/login", headers = mapOf( - "Api-Key" to apiKey, - "Content-Type" to "application/json" - ), - data = 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 = unixTime + 60 * 60 * 24, + payload = form.toJson() ) - //Log.i(TAG, "Responsecode = ${response.code}") - //Log.i(TAG, "Result => ${response.text}") - - if (response.isSuccessful) { - AppUtils.tryParseJson(response.text)?.let { token -> - setAuthKey( - SubtitleOAuthEntity( - user = username, - pass = password, - access_token = token.token ?: run { - return false - }) - ) - } - return true - } - return false - } - - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { - val username = data.username ?: throw ErrorLoadingException("Requires Username") - val password = data.password ?: throw ErrorLoadingException("Requires Password") - switchToNewAccount() - try { - if (initLogin(username, password)) { - registerAccount() - return true - } - } catch (e: Exception) { - logError(e) - switchToOldAccount() - } - switchToOldAccount() - return false - } - - /** - * Some languages do not use the normal country codes on OpenSubtitles - * */ - private val languageExceptions = mapOf( -// "pt" to "pt-PT", -// "pt" to "pt-BR" - ) - private fun fixLanguage(language: String?) : String? { - return languageExceptions[language] ?: language - } - // O(n) but good enough, BiMap did not want to work properly - private fun fixLanguageReverse(language: String?) : String? { - return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language } /** * Fetch subtitles using token authenticated on previous method (see authorize). * Returns list of Subtitles which user can select to download (see load). * */ - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? { + override suspend fun search( + auth : AuthData?, + query: AbstractSubtitleEntities.SubtitleSearch + ): List? { throwIfCantDoRequest() - val fixedLang = fixLanguage(query.lang) + val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: "" - val imdbId = query.imdb ?: 0 + val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val queryText = query.query val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 @@ -176,17 +116,17 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi val searchQueryUrl = when (imdbId > 0) { //Use imdb_id to search if its valid - true -> "$host/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" - false -> "$host/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" + true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" + false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" } val req = app.get( url = searchQueryUrl, headers = mapOf( - Pair("Api-Key", apiKey), Pair("Content-Type", "application/json") - ) + ) + headers, ) + Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}") Log.i(TAG, "Search Req => ${req.text}") if (!req.isSuccessful) { if (req.code == 429) @@ -207,12 +147,12 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi //Use any valid name/title in hierarchy val name = filename ?: featureDetails?.movieName ?: featureDetails?.title ?: featureDetails?.parentTitle ?: attr.release ?: query.query - val lang = fixLanguageReverse(attr.language)?: "" + val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: "" val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie - val isHearingImpaired = attr.hearing_impaired ?: false + val isHearingImpaired = attr.hearingImpaired ?: false //Log.i(TAG, "Result id/name => ${item.id} / $name") item.attributes?.files?.forEach { file -> val resultData = file.fileId?.toString() ?: "" @@ -221,7 +161,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = name, - lang = lang, + lang = langTagIETF, data = resultData, type = type, source = this.name, @@ -241,22 +181,26 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi Process data returned from search. Returns string url for the subtitle file. */ - override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? { + + override suspend fun load( + auth : AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity + ): String? { + if(auth == null) return null throwIfCantDoRequest() val req = app.post( - url = "$host/download", + url = "$HOST/download", headers = mapOf( Pair( "Authorization", - "Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}" + "Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" ), - Pair("Api-Key", apiKey), Pair("Content-Type", "application/json"), Pair("Accept", "*/*") - ), + ) + headers, data = mapOf( - Pair("file_id", data.data) + Pair("file_id", subtitle.data) ) ) Log.i(TAG, "Request result => (${req.code}) ${req.text}") @@ -274,13 +218,6 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi return null } - - data class SubtitleOAuthEntity( - var user: String, - var pass: String, - var access_token: String, - ) - data class OAuthToken( @JsonProperty("token") var token: String? = null, @JsonProperty("status") var status: Int? = null @@ -303,7 +240,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi @JsonProperty("url") var url: String? = null, @JsonProperty("files") var files: List? = listOf(), @JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(), - @JsonProperty("hearing_impaired") var hearing_impaired: Boolean? = null, + @JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null, ) data class ResultFiles( 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 b4a9d7896..84a498bb0 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,56 +2,63 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import androidx.core.net.toUri -import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SimklSyncServices import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthPinData +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import okhttp3.Interceptor -import okhttp3.Response +import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear +import com.lagradost.cloudstream3.utils.txt import java.math.BigInteger import java.security.SecureRandom import java.text.SimpleDateFormat import java.time.Instant import java.util.Date +import java.util.Locale import java.util.TimeZone +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration -class SimklApi(index: Int) : AccountManager(index), SyncAPI { +class SimklApi : SyncAPI() { override var name = "Simkl" - override val key = "simkl-key" - override val redirectUrl = "simkl" override val idPrefix = "simkl" + + val key = "simkl-key" + override val redirectUrlIdentifier = "simkl" + override val hasOAuth2 = true + override val hasPin = true override var requireLibraryRefresh = true override var mainUrl = "https://api.simkl.com" override val icon = R.drawable.simkl_logo - override val requiresLogin = false override val createAccountUrl = "$mainUrl/signup" override val syncIdName = SyncIdName.Simkl - private val token: String? - get() = getKey(accountId, SIMKL_TOKEN_KEY).also { - debugAssert({ it == null }) { "No ${this.name} token!" } - } /** Automatically adds simkl auth headers */ - private val interceptor = HeaderInterceptor() + // private val interceptor = HeaderInterceptor() /** * This is required to override the reported last activity as simkl activites @@ -59,21 +66,86 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { */ private var lastScoreTime = -1L - companion object { - private const val clientId: String = BuildConfig.SIMKL_CLIENT_ID - private const val clientSecret: String = BuildConfig.SIMKL_CLIENT_SECRET - private var lastLoginState = "" + private object SimklCache { + private const val SIMKL_CACHE_KEY = "SIMKL_API_CACHE" - const val SIMKL_TOKEN_KEY: String = "simkl_token" - const val SIMKL_USER_KEY: String = "simkl_user" + enum class CacheTimes(val value: String) { + OneMonth("30d"), + ThirtyMinutes("30m") + } + + 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 + } + + 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 = CloudStreamApp.getKey>(it)?.isFresh() == false + if (isOld) { + removeKey(it) + } + } + } + + 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, + // Storing as plain sting is required to make generics work. + SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson() + ) + } + + /** + * Gets cached object, if object is not fresh returns null and removes it from cache + */ + inline fun getKey(path: String): T? { + val cache = getKey(SIMKL_CACHE_KEY, path)?.let { + tryParseJson>(it) + } + + return if (cache?.isFresh() == true) { + debugPrint { + "Cache hit at: $SIMKL_CACHE_KEY/$path. " + + "Remains fresh for ${cache.remainingTime().inWholeDays} days or ${cache.remainingTime().inWholeSeconds} seconds." + } + cache.obj + } else { + debugPrint { "Cache miss at: $SIMKL_CACHE_KEY/$path" } + removeKey(SIMKL_CACHE_KEY, path) + null + } + } + } + + companion object { + private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID + private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET const val SIMKL_CACHED_LIST: String = "simkl_cached_list" const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time" /** 2014-09-01T09:10:11Z -> 1409562611 */ - private const val simklDateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + private const val SIMKL_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'" fun getUnixTime(string: String?): Long? { return try { - SimpleDateFormat(simklDateFormat).apply { + SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { this.timeZone = TimeZone.getTimeZone("UTC") }.parse( string ?: return null @@ -87,7 +159,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { /** 1409562611 -> 2014-09-01T09:10:11Z */ fun getDateTime(unixTime: Long?): String? { return try { - SimpleDateFormat(simklDateFormat).apply { + SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { this.timeZone = TimeZone.getTimeZone("UTC") }.format( Date.from( @@ -101,32 +173,6 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } } - /** - * Set of sync services simkl is compatible with. - * Add more as required: https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id - */ - enum class SyncServices(val originalName: String) { - Simkl("simkl"), - Imdb("imdb"), - Tmdb("tmdb"), - AniList("anilist"), - Mal("mal"), - } - - /** - * The ID string is a way to keep a collection of services in one single ID using a map - * This adds a database service (like imdb) to the string and returns the new string. - */ - fun addIdToString(idString: String?, database: SyncServices, id: String?): String? { - if (id == null) return idString - return (readIdFromString(idString) + mapOf(database to id)).toJson() - } - - /** Read the id string to get all other ids */ - private fun readIdFromString(idString: String?): Map { - return tryParseJson(idString) ?: return emptyMap() - } - fun getPosterUrl(poster: String): String { return "https://wsrv.nl/?url=https://simkl.in/posters/${poster}_m.webp" } @@ -150,7 +196,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { companion object { fun fromString(string: String): SimklListStatusType? { - return SimklListStatusType.values().firstOrNull { + return SimklListStatusType.entries.firstOrNull { it.originalName == string } } @@ -161,42 +207,67 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonInclude(JsonInclude.Include.NON_EMPTY) data class TokenRequest( @JsonProperty("code") val code: String, - @JsonProperty("client_id") val client_id: String = clientId, - @JsonProperty("client_secret") val client_secret: String = clientSecret, - @JsonProperty("redirect_uri") val redirect_uri: String = "$appString://simkl", - @JsonProperty("grant_type") val grant_type: String = "authorization_code" + @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" ) data class TokenResponse( /** No expiration date */ - val access_token: String, - val token_type: String, - 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 */ data class SettingsResponse( - val user: User + @JsonProperty("user") + val user: User, + @JsonProperty("account") + val account: Account, ) { data class User( + @JsonProperty("name") val name: String, /** Url */ + @JsonProperty("avatar") val avatar: String ) + + data class Account( + @JsonProperty("id") + val id: Int, + ) } + data class PinAuthResponse( + @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, + ) + + data class PinExchangeResponse( + @JsonProperty("result") val result: String, + @JsonProperty("message") val message: String? = null, + @JsonProperty("access_token") val accessToken: String? = null, + ) + // ------------------- data class ActivitiesResponse( - val all: String?, - val tv_shows: UpdatedAt, - val anime: UpdatedAt, - val movies: UpdatedAt, + @JsonProperty("all") val all: String?, + @JsonProperty("tv_shows") val tvShows: UpdatedAt, + @JsonProperty("anime") val anime: UpdatedAt, + @JsonProperty("movies") val movies: UpdatedAt, ) { data class UpdatedAt( - val all: String?, - val removed_from_list: String?, - val rated_at: String?, + @JsonProperty("all") val all: String?, + @JsonProperty("removed_from_list") val removedFromList: String?, + @JsonProperty("rated_at") val ratedAt: String?, ) } @@ -210,18 +281,18 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("img") val img: String? ) { companion object { - fun convertToEpisodes(list: List?): List { + fun convertToEpisodes(list: List?): List? { return list?.map { MediaObject.Season.Episode(it.episode) - } ?: emptyList() + } } - fun convertToSeasons(list: List?): List { + fun convertToSeasons(list: List?): List? { return list?.filter { it.season != null }?.groupBy { it.season - }?.map { (season, episodes) -> - MediaObject.Season(season!!, convertToEpisodes(episodes)) - } ?: emptyList() + }?.mapNotNull { (season, episodes) -> + convertToEpisodes(episodes)?.let { MediaObject.Season(season!!, it) } + }?.ifEmpty { null } } } } @@ -235,11 +306,17 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @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 ) { + fun hasEnded(): Boolean { + return status == "released" || status == "ended" + } + @JsonInclude(JsonInclude.Include.NON_EMPTY) data class Season( @JsonProperty("number") val number: Int, @@ -257,13 +334,13 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("anilist") val anilist: String? = null, ) { companion object { - fun fromMap(map: Map): Ids { + fun fromMap(map: Map): Ids { return Ids( - simkl = map[SyncServices.Simkl]?.toIntOrNull(), - imdb = map[SyncServices.Imdb], - tmdb = map[SyncServices.Tmdb], - mal = map[SyncServices.Mal], - anilist = map[SyncServices.AniList] + simkl = map[SimklSyncServices.Simkl]?.toIntOrNull(), + imdb = map[SimklSyncServices.Imdb], + tmdb = map[SimklSyncServices.Tmdb], + mal = map[SimklSyncServices.Mal], + anilist = map[SimklSyncServices.AniList] ) } } @@ -281,13 +358,217 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } } + class SimklScoreBuilder private constructor() { + data class Builder( + private var url: String? = null, + private var headers: Map? = 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 + ) { + fun token(token: AuthToken) = apply { this.headers = getHeaders(token) } + fun apiUrl(url: String) = apply { this.url = url } + fun ids(ids: MediaObject.Ids) = apply { this.ids = ids } + fun score(score: Int?, oldScore: Int?) = apply { + if (score != oldScore) { + this.score = score + } + } + + fun status(newStatus: Int?, oldStatus: Int?) = apply { + onList = oldStatus != null + // Only set status if its new + if (newStatus != oldStatus) { + this.status = newStatus + } else { + this.status = null + } + } + + fun episodes( + allEpisodes: List?, + newEpisodes: Int?, + 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 + } else { + null to EpisodeMetadata.convertToEpisodes(rawEpisodes) + } + + // 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(unixTime) + val headers = this.headers ?: emptyMap() + return if (this.status == SimklListStatusType.None.value) { + app.post( + "$url/sync/history/remove", + json = StatusRequest( + shows = listOf(HistoryMediaObject(ids = ids)), + movies = emptyList() + ), + headers = headers + ).isSuccessful + } else { + val statusResponse = this.status?.let { setStatus -> + val newStatus = + SimklListStatusType.entries + .firstOrNull { it.value == setStatus }?.originalName + ?: SimklListStatusType.Watching.originalName!! + + app.post( + "${this.url}/sync/add-to-list", + json = StatusRequest( + shows = listOf( + StatusMediaObject( + null, + null, + ids, + newStatus, + ) + ), movies = emptyList() + ), + headers = headers + ).isSuccessful + } ?: true + + val episodeRemovalResponse = removeEpisodes?.let { (seasons, episodes) -> + app.post( + "${this.url}/sync/history/remove", + json = StatusRequest( + shows = listOf( + HistoryMediaObject( + ids = ids, + seasons = seasons, + episodes = episodes + ) + ), + movies = emptyList() + ), + headers = headers + ).isSuccessful + } ?: true + + // You cannot rate if you are planning to watch it. + 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 = StatusRequest( + shows = listOf( + HistoryMediaObject( + null, + null, + ids, + addEpisodes?.first, + addEpisodes?.second, + realScore, + realScore?.let { time }, + ) + ), movies = emptyList() + ), + headers = headers + ).isSuccessful + } 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? + ): Array? { + if (simklId == null) return null + + val cacheKey = "Episodes/$simklId" + val cache = SimklCache.getKey>(cacheKey) + + // Return cached result if its higher or equal the amount of episodes. + if (cache != null && cache.size >= (episodes ?: 0)) { + return cache + } + + // There is always one season in Anime -> no request necessary + if (type == "anime" && episodes != null) { + return episodes.takeIf { it > 0 }?.let { + (1..it).map { episode -> + EpisodeMetadata( + null, null, null, episode, null + ) + }.toTypedArray() + } + } + val url = when (type) { + "anime" -> "https://api.simkl.com/anime/episodes/$simklId" + "tv" -> "https://api.simkl.com/tv/episodes/$simklId" + "movie" -> return null + else -> return null + } + + debugPrint { "Requesting episodes from $url" } + return app.get(url, params = mapOf("client_id" to CLIENT_ID)) + .parsedSafe>()?.also { + val cacheTime = + if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value + + // 1 Month cache + SimklCache.setKey(cacheKey, it, Duration.parse(cacheTime)) + } + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + 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) class RatingMediaObject( @JsonProperty("title") title: String?, @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("rating") val rating: Int, - @JsonProperty("rated_at") val rated_at: String? = getDateTime(unixTime) + @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime) ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -296,18 +577,9 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("to") val to: String, - @JsonProperty("watched_at") val watched_at: String? = getDateTime(unixTime) + @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime) ) : MediaObject(title, year, ids) - @JsonInclude(JsonInclude.Include.NON_EMPTY) - class HistoryMediaObject( - @JsonProperty("title") title: String?, - @JsonProperty("year") year: Int?, - @JsonProperty("ids") ids: Ids?, - @JsonProperty("seasons") seasons: List?, - @JsonProperty("episodes") episodes: List?, - ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) - @JsonInclude(JsonInclude.Include.NON_EMPTY) data class StatusRequest( @JsonProperty("movies") val movies: List, @@ -360,24 +632,24 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } interface Metadata { - val last_watched_at: String? + val lastWatchedAt: String? val status: String? - val user_rating: Int? - val last_watched: String? - val watched_episodes_count: Int? - val total_episodes_count: Int? + val userRating: Int? + val lastWatched: String? + val watchedEpisodesCount: Int? + val totalEpisodesCount: Int? fun getIds(): ShowMetadata.Show.Ids fun toLibraryItem(): SyncAPI.LibraryItem } data class MovieMetadata( - override val last_watched_at: String?, - override val status: String, - override val user_rating: Int?, - override val last_watched: String?, - override val watched_episodes_count: Int?, - override val total_episodes_count: Int?, + @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 { override fun getIds(): ShowMetadata.Show.Ids { @@ -389,28 +661,29 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { this.movie.title, "https://simkl.com/tv/${movie.ids.simkl}", movie.ids.simkl.toString(), - this.watched_episodes_count, - this.total_episodes_count, - this.user_rating?.times(10), - getUnixTime(last_watched_at) ?: 0, + this.watchedEpisodesCount, + this.totalEpisodesCount, + Score.from10(this.userRating), + getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Movie, this.movie.poster?.let { getPosterUrl(it) }, null, null, + this.movie.year?.toYear(), movie.ids.simkl ) } } data class ShowMetadata( - override val last_watched_at: String?, - override val status: String, - override val user_rating: Int?, - override val last_watched: String?, - override val watched_episodes_count: Int?, - override val total_episodes_count: Int?, - 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 { override fun getIds(): Show.Ids { return this.show.ids @@ -421,45 +694,46 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { this.show.title, "https://simkl.com/tv/${show.ids.simkl}", show.ids.simkl.toString(), - this.watched_episodes_count, - this.total_episodes_count, - this.user_rating?.times(10), - getUnixTime(last_watched_at) ?: 0, + this.watchedEpisodesCount, + this.totalEpisodesCount, + Score.from10(this.userRating), + getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Anime, this.show.poster?.let { getPosterUrl(it) }, null, null, + this.show.year?.toYear(), show.ids.simkl ) } data class Show( - val title: String, - val poster: String?, - val year: Int?, - val ids: Ids, + @JsonProperty("title") val title: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("ids") val ids: Ids, ) { data class Ids( - val simkl: Int, - val slug: String?, - val imdb: String?, - val zap2it: String?, - val tmdb: String?, - val offen: String?, - val tvdb: String?, - val mal: String?, - val anidb: String?, - val anilist: String?, - 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: SyncServices, id: String): Boolean { + fun matchesId(database: SimklSyncServices, id: String): Boolean { return when (database) { - SyncServices.Simkl -> this.simkl == id.toIntOrNull() - SyncServices.AniList -> this.anilist == id - SyncServices.Mal -> this.mal == id - SyncServices.Tmdb -> this.tmdb == id - SyncServices.Imdb -> this.imdb == id + SimklSyncServices.Simkl -> this.simkl == id.toIntOrNull() + SimklSyncServices.AniList -> this.anilist == id + SimklSyncServices.Mal -> this.mal == id + SimklSyncServices.Tmdb -> this.tmdb == id + SimklSyncServices.Imdb -> this.imdb == id } } } @@ -471,41 +745,78 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { /** * Appends api keys to the requests **/ - private inner class HeaderInterceptor : Interceptor { + /*private inner class HeaderInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" } return chain.proceed( chain.request() .newBuilder() .addHeader("Authorization", "Bearer $token") - .addHeader("simkl-api-key", clientId) + .addHeader("simkl-api-key", CLIENT_ID) .build() ) } - } + }*/ - private suspend fun getUser(): SettingsResponse.User? { - return suspendSafeApiCall { - app.post("$mainUrl/users/settings", interceptor = interceptor) - .parsedSafe()?.user + private suspend fun getUser(token: AuthToken): SettingsResponse = + app.post("$mainUrl/users/settings", headers = getHeaders(token)) + .parsed() + + + /** + * Useful to get episodes on demand to prevent unnecessary requests. + */ + class SimklEpisodeConstructor( + private val simklId: Int?, + private val type: String?, + private val totalEpisodeCount: Int?, + private val hasEnded: Boolean? + ) { + suspend fun getEpisodes(): Array? { + return getEpisodes(simklId, type, totalEpisodeCount, hasEnded) } } class SimklSyncStatus( - override var status: Int, - override var score: Int?, + override var status: SyncWatchType, + override var score: Score?, + val oldScore: Int?, override var watchedEpisodes: Int?, - val episodes: Array?, + 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 */ val oldEpisodes: Int, + val oldStatus: String? ) : SyncAPI.AbstractSyncStatus() - override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? { + override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + if (auth == null) return null val realIds = readIdFromString(id) - val foundItem = getSyncListSmart()?.let { list -> + + // 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 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.setKey(idKey, result, Duration.parse(cacheTime)) + }) ?: return null + + val episodeConstructor = SimklEpisodeConstructor( + searchResult.ids?.simkl, + searchResult.type, + searchResult.totalEpisodes, + searchResult.hasEnded() + ) + + val foundItem = getSyncListSmart(auth)?.let { list -> listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show -> realIds.any { (database, id) -> show.getIds().matchesId(database, id) @@ -513,265 +824,145 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } } - // Search to get episodes - val searchResult = searchByIds(realIds)?.firstOrNull() - val episodes = getEpisodes(searchResult?.ids?.simkl, searchResult?.type) - if (foundItem != null) { return SimklSyncStatus( - status = foundItem.status?.let { SimklListStatusType.fromString(it)?.value } + status = foundItem.status?.let { + SyncWatchType.fromInternalId( + SimklListStatusType.fromString( + it + )?.value + ) + } ?: return null, - score = foundItem.user_rating, - watchedEpisodes = foundItem.watched_episodes_count, - maxEpisodes = foundItem.total_episodes_count, - episodes = episodes, - oldEpisodes = foundItem.watched_episodes_count ?: 0, + score = Score.from10(foundItem.userRating), + watchedEpisodes = foundItem.watchedEpisodesCount, + maxEpisodes = searchResult.totalEpisodes, + episodeConstructor = episodeConstructor, + oldEpisodes = foundItem.watchedEpisodesCount ?: 0, + oldScore = foundItem.userRating, + oldStatus = foundItem.status ) } else { - return if (searchResult != null) { - SimklSyncStatus( - status = SimklListStatusType.None.value, - score = 0, - watchedEpisodes = 0, - maxEpisodes = if (searchResult.type == "movie") 0 else null, - episodes = episodes, - oldEpisodes = 0, - ) - } else { - null - } + return SimklSyncStatus( + status = SyncWatchType.fromInternalId(SimklListStatusType.None.value), + score = null, + watchedEpisodes = 0, + maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes, + episodeConstructor = episodeConstructor, + oldEpisodes = 0, + oldStatus = null, + oldScore = null + ) } } - override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean { + override suspend fun updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { val parsedId = readIdFromString(id) lastScoreTime = unixTime + val simklStatus = newStatus as? SimklSyncStatus - if (status.status == SimklListStatusType.None.value) { - return app.post( - "$mainUrl/sync/history/remove", - json = StatusRequest( - shows = listOf( - HistoryMediaObject( - null, - null, - MediaObject.Ids.fromMap(parsedId), - emptyList(), - emptyList() - ) - ), - movies = emptyList() - ), - interceptor = interceptor - ).isSuccessful - } + val builder = SimklScoreBuilder.Builder() + .apiUrl(this.mainUrl) + .score(newStatus.score?.toInt(10), simklStatus?.oldScore) + .status( + newStatus.status.internalId, + (newStatus as? SimklSyncStatus)?.oldStatus?.let { oldStatus -> + SimklListStatusType.entries.firstOrNull { + it.originalName == oldStatus + }?.value + }) + .token(auth?.token ?: return false) + .ids(MediaObject.Ids.fromMap(parsedId)) - val realScore = status.score - val ratingResponseSuccess = if (realScore != null) { - // Remove rating if score is 0 - val ratingsSuffix = if (realScore == 0) "/remove" else "" - debugPrint { "Rate ${this.name} item: rating=$realScore" } - app.post( - "$mainUrl/sync/ratings$ratingsSuffix", - json = StatusRequest( - // Not possible to know if TV or Movie - shows = listOf( - RatingMediaObject( - null, - null, - MediaObject.Ids.fromMap(parsedId), - realScore - ) - ), - movies = emptyList() - ), - interceptor = interceptor - ).isSuccessful - } else { - true - } - val simklStatus = status as? SimklSyncStatus + // Get episodes only when required + val episodes = simklStatus?.episodeConstructor?.getEpisodes() + // All episodes if marked as completed - val watchedEpisodes = if (status.status == SimklListStatusType.Completed.value) { - simklStatus?.episodes?.size - } else { - status.watchedEpisodes - } + val watchedEpisodes = + if (newStatus.status.internalId == SimklListStatusType.Completed.value) { + episodes?.size + } else { + newStatus.watchedEpisodes + } - // Only post episodes if available episodes and the status is correct - val episodeResponseSuccess = - if (simklStatus != null && watchedEpisodes != null && !simklStatus.episodes.isNullOrEmpty() && listOf( - SimklListStatusType.Paused.value, - SimklListStatusType.Dropped.value, - SimklListStatusType.Watching.value, - SimklListStatusType.Completed.value, - SimklListStatusType.ReWatching.value - ).contains(status.status) - ) { - suspend fun postEpisodes( - url: String, - rawEpisodes: List - ): Boolean { - val (seasons, episodes) = if (rawEpisodes.any { it.season != null }) { - EpisodeMetadata.convertToSeasons(rawEpisodes) to null - } else { - null to EpisodeMetadata.convertToEpisodes(rawEpisodes) - } - debugPrint { "Synced history using $url: seasons=${seasons?.toList()}, episodes=${episodes?.toList()}" } - return app.post( - url, - json = StatusRequest( - shows = listOf( - HistoryMediaObject( - null, - null, - MediaObject.Ids.fromMap(parsedId), - seasons, - episodes - ) - ), - movies = emptyList() - ), - interceptor = interceptor - ).isSuccessful - } + builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes) - // If episodes decrease: remove all episodes beyond watched episodes. - val removeResponse = if (simklStatus.oldEpisodes > watchedEpisodes) { - val removeEpisodes = simklStatus.episodes - .drop(watchedEpisodes) - postEpisodes("$mainUrl/sync/history/remove", removeEpisodes) - } else { - true - } - val cutEpisodes = simklStatus.episodes.take(watchedEpisodes) - val addResponse = postEpisodes("$mainUrl/sync/history/", cutEpisodes) - - removeResponse && addResponse - } else true - - val newStatus = - SimklListStatusType.values().firstOrNull { it.value == status.status }?.originalName - ?: SimklListStatusType.Watching.originalName - - val statusResponseSuccess = if (newStatus != null) { - debugPrint { "Add to ${this.name} list: status=$newStatus" } - app.post( - "$mainUrl/sync/add-to-list", - json = StatusRequest( - shows = listOf( - StatusMediaObject( - null, - null, - MediaObject.Ids.fromMap(parsedId), - newStatus - ) - ), - movies = emptyList() - ), - interceptor = interceptor - ).isSuccessful - } else { - true - } - - debugPrint { "All scoring complete: rating=$ratingResponseSuccess, status=$statusResponseSuccess, episode=$episodeResponseSuccess" } requireLibraryRefresh = true - return ratingResponseSuccess && statusResponseSuccess && episodeResponseSuccess + return builder.execute() } /** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */ - suspend fun searchByIds(serviceMap: Map): Array? { + private suspend fun searchByIds(serviceMap: Map): Array? { if (serviceMap.isEmpty()) return emptyArray() return app.get( "$mainUrl/search/id", - params = mapOf("client_id" to clientId) + serviceMap.map { (service, id) -> + params = mapOf("client_id" to CLIENT_ID) + serviceMap.map { (service, id) -> service.originalName to id } ).parsedSafe() } - suspend fun getEpisodes(simklId: Int?, type: String?): Array? { - if (simklId == null) return null - val url = when (type) { - "anime" -> "https://api.simkl.com/anime/episodes/$simklId" - "tv" -> "https://api.simkl.com/tv/episodes/$simklId" - "movie" -> return null - else -> return null - } - return app.get(url, params = mapOf("client_id" to clientId)).parsedSafe() - } - - override suspend fun search(name: String): List? { + override suspend fun search(auth: AuthData?, query: String): List? { return app.get( - "$mainUrl/search/", params = mapOf("client_id" to clientId, "q" to name) + "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } } - override fun authenticate(activity: FragmentActivity?) { - lastLoginState = BigInteger(130, SecureRandom()).toString(32) + override fun loginRequest(): AuthLoginPage? { + val lastLoginState = BigInteger(130, SecureRandom()).toString(32) val url = - "https://simkl.com/oauth/authorize?response_type=code&client_id=$clientId&redirect_uri=$appString://${redirectUrl}&state=$lastLoginState" - openBrowser(url, activity) + "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 loginInfo(): AuthAPI.LoginInfo? { - return getKey(accountId, SIMKL_USER_KEY)?.let { user -> - AuthAPI.LoginInfo( - name = user.name, - profilePicture = user.avatar, - accountIndex = accountIndex - ) - } - } + override suspend fun load(auth: AuthData?, id: String): SyncResult? = null - override fun logOut() { - requireLibraryRefresh = true - removeAccountKeys() - } - - override suspend fun getResult(id: String): SyncAPI.SyncResult? { - return null - } - - private suspend fun getSyncListSince(since: Long?): AllItemsResponse { + private suspend fun getSyncListSince(auth: AuthData, since: Long?): AllItemsResponse? { val params = getDateTime(since)?.let { mapOf("date_from" to it) } ?: emptyMap() + // Can return null on no change. return app.get( "$mainUrl/sync/all-items/", params = params, - interceptor = interceptor - ).parsed() + headers = getHeaders(auth.token) + ).parsedSafe() } - private suspend fun getActivities(): ActivitiesResponse? { - return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe() + private suspend fun getActivities(token: AuthToken): ActivitiesResponse? { + return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe() } - private fun getSyncListCached(): AllItemsResponse? { - return getKey(accountId, SIMKL_CACHED_LIST) + private fun getSyncListCached(auth: AuthData): AllItemsResponse? { + return getKey(SIMKL_CACHED_LIST, auth.user.id.toString()) } - private suspend fun getSyncListSmart(): AllItemsResponse? { - if (token == null) return null - - val activities = getActivities() - val lastCacheUpdate = getKey(accountId, SIMKL_CACHED_LIST_TIME) + private suspend fun getSyncListSmart(auth: AuthData): AllItemsResponse? { + val activities = getActivities(auth.token) + val userId = auth.user.id.toString() + val lastCacheUpdate = getKey(SIMKL_CACHED_LIST_TIME, auth.user.id.toString()) val lastRemoval = listOf( - activities?.tv_shows?.removed_from_list, - activities?.anime?.removed_from_list, - activities?.movies?.removed_from_list + activities?.tvShows?.removedFromList, + activities?.anime?.removedFromList, + activities?.movies?.removedFromList ).maxOf { getUnixTime(it) ?: -1 } val lastRealUpdate = listOf( - activities?.tv_shows?.all, + activities?.tvShows?.all, activities?.anime?.all, activities?.movies?.all, ).maxOf { @@ -781,29 +972,31 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" } val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) { debugPrint { "Full list update in ${this.name}." } - setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval) - getSyncListSince(null) + setKey(SIMKL_CACHED_LIST_TIME, userId, lastRemoval) + getSyncListSince(auth, null) } else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) { debugPrint { "Partial list update in ${this.name}." } - setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate) - AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate)) + setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate) + AllItemsResponse.merge( + getSyncListCached(auth), + getSyncListSince(auth, lastCacheUpdate) + ) } else { debugPrint { "Cached list update in ${this.name}." } - getSyncListCached() + getSyncListCached(auth) } debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } - setKey(accountId, SIMKL_CACHED_LIST, list) + setKey(SIMKL_CACHED_LIST, userId, list) return list } - - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { - val list = getSyncListSmart() ?: return null + override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { + val list = getSyncListSmart(auth ?: return null) ?: return null val baseMap = - SimklListStatusType.values() + SimklListStatusType.entries .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value } .associate { it.stringRes to emptyList() @@ -828,43 +1021,65 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) ) } - override fun getIdFromUrl(url: String): String { + override fun urlToId(url: String): String? { val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""") return simklUrlRegex.find(url)?.groupValues?.get(1) ?: "" } - override suspend fun handleRedirect(url: String): Boolean { - val uri = url.toUri() + override suspend fun pinRequest(): AuthPinData? { + val pinAuthResp = app.get( + "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}" + ).parsedSafe() ?: return null + + return AuthPinData( + deviceCode = pinAuthResp.deviceCode, + userCode = pinAuthResp.userCode, + verificationUrl = pinAuthResp.verificationUrl, + expiresIn = pinAuthResp.expiresIn, + interval = pinAuthResp.interval + ) + } + + override suspend fun login(payload: AuthPinData): AuthToken? { + val pinAuthResp = app.get( + "$mainUrl/oauth/pin/${payload.userCode}?client_id=$CLIENT_ID" + ).parsedSafe() ?: return null + + return AuthToken( + accessToken = pinAuthResp.accessToken ?: return null, + ) + } + + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val uri = redirectUrl.toUri() val state = uri.getQueryParameter("state") // Ensure consistent state - if (state != lastLoginState) return false - lastLoginState = "" + if (state != payload) return null - val code = uri.getQueryParameter("code") ?: return false - val token = app.post( + val code = uri.getQueryParameter("code") ?: return null + val tokenResponse = app.post( "$mainUrl/oauth/token", json = TokenRequest(code) - ).parsedSafe() ?: return false + ).parsedSafe() ?: return null - switchToNewAccount() - setKey(accountId, SIMKL_TOKEN_KEY, token.access_token) - - 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 AuthToken( + accessToken = tokenResponse.accessToken, + ) } -} \ No newline at end of file + + override suspend fun user(token: AuthToken?): AuthUser? { + val user = getUser(token ?: return null) + return AuthUser( + id = user.account.id, + name = user.user.name, + profilePicture = user.user.avatar + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt new file mode 100644 index 000000000..19122768e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt @@ -0,0 +1,167 @@ +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.AbstractSubtitleEntities +import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.SubtitleHelper + +class SubSourceApi : SubtitleAPI() { + override val name = "SubSource" + override val idPrefix = "subsource" + + override val requiresLogin = false + + companion object { + const val APIURL = "https://api.subsource.net/api" + const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub" + } + + override suspend fun search( + auth: AuthData?, + query: AbstractSubtitleEntities.SubtitleSearch + ): List? { + + //Only supports Imdb Id search for now + if (query.imdbId == null) return null + val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang) + val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie + + 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, + ) + } + + 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.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.hi == 1, + ) + } + } + + override suspend fun SubtitleResource.getResources( + auth: AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity + ) { + val parsedSub = parseJson(subtitle.data) + + val subRes = app.post( + url = "$APIURL/getSub", + data = mapOf( + "movie" to parsedSub.movie, + "lang" to subtitle.lang, + "id" to parsedSub.id + ) + ).parsedSafe() ?: return + + this.addZipUrl( + "$DOWNLOADENDPOINT/${subRes.sub.downloadToken}" + ) { name, _ -> + name + } + } + + data class ApiSearch( + @JsonProperty("success") val success: Boolean, + @JsonProperty("found") val found: List, + ) + + 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, + ) + + data class ApiResponse( + @JsonProperty("success") val success: Boolean, + @JsonProperty("movie") val movie: Movie, + @JsonProperty("subs") val subs: List, + ) + + 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, + ) + + 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, + ) + + data class SubData( + @JsonProperty("movie") val movie: String, + @JsonProperty("lang") val lang: String, + @JsonProperty("id") val id: 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 new file mode 100644 index 000000000..1f1e6de44 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt @@ -0,0 +1,259 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.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 + +class SubDlApi : SubtitleAPI() { + override val name = "SubDL" + override val idPrefix = "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 createAccountUrl = "https://subdl.com/panel/register" + + companion object { + const val APIURL = "https://apiold.subdl.com" + const val APIENDPOINT = "$APIURL/api/v1/subtitles" + const val DOWNLOADENDPOINT = "https://dl.subdl.com" + } + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val email = form.email ?: return null + val password = form.password ?: return null + val tokenResponse = app.post( + url = "$APIURL/login", + json = mapOf( + "email" to email, + "password" to password + ) + ).parsed() + + val apiResponse = app.get( + url = "$APIURL/user/userApi", + headers = mapOf( + "Authorization" to "Bearer ${tokenResponse.token}" + ) + ).parsed() + + return AuthToken(accessToken = apiResponse.apiKey, payload = email) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val name = token?.payload ?: return null + return AuthUser(id = name.hashCode(), name = name) + } + + 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}" + query.tmdbId != null -> "&tmdb_id=${query.tmdbId}" + else -> null + } + + val epQuery = if (epNum > 0) "&episode_number=$epNum" else "" + val seasonQuery = if (seasonNum > 0) "&season_number=$seasonNum" else "" + val yearQuery = if (yearNum > 0) "&year=$yearNum" else "" + + 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" + } + + val req = app.get( + url = searchQueryUrl, + headers = mapOf( + "Accept" to "application/json" + ) + ) + + return req.parsedSafe()?.subtitles?.map { subtitle -> + + val langTagIETF = + langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?: + subtitle.lang + val resEpNum = subtitle.episode ?: query.epNumber + val resSeasonNum = subtitle.season ?: query.seasonNumber + val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie + + AbstractSubtitleEntities.SubtitleEntity( + idPrefix = this.idPrefix, + name = subtitle.releaseName, + lang = langTagIETF, + data = "${DOWNLOADENDPOINT}${subtitle.url}", + type = type, + source = this.name, + epNumber = resEpNum, + seasonNumber = resSeasonNum, + isHearingImpaired = subtitle.hearingImpaired ?: false, + ) + } + } + + override suspend fun SubtitleResource.getResources( + auth: AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity + ) { + this.addZipUrl(subtitle.data) { name, _ -> + name + } + } + + data class SubtitleOAuthEntity( + @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, + ) + + data class OAuthTokenResponse( + @JsonProperty("token") val token: String, + @JsonProperty("userData") val userData: UserData? = null, + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("message") val message: String? = null, + ) + + data class UserData( + @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, + ) + + data class ApiKeyResponse( + @JsonProperty("ok") val ok: Boolean? = false, + @JsonProperty("api_key") val apiKey: String, + @JsonProperty("usage") val usage: Usage? = null, + ) + + data class Usage( + @JsonProperty("total") val total: Long? = 0, + @JsonProperty("today") val today: Long? = 0, + ) + + data class ApiResponse( + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("results") val results: List? = null, + @JsonProperty("subtitles") val subtitles: List? = null, + ) + + data class Result( + @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, + ) + + data class Subtitle( + @JsonProperty("release_name") val releaseName: String, + @JsonProperty("name") val name: String, + @JsonProperty("lang") val lang: String, // subdl language code + @JsonProperty("author") val author: String? = null, + @JsonProperty("url") val url: String? = null, + @JsonProperty("subtitlePage") val subtitlePage: String? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + @JsonProperty("language") val language: String? = null, // full language name + @JsonProperty("hi") val hearingImpaired: Boolean? = null, + ) + + // https://subdl.com/api-files/language_list.json + // most of it is IETF BPC 47 conformant tag + // but there are some exceptions + private val langTagIETF2subdl = mapOf( + "en-bg" to "BG_EN", // "Bulgarian_English" + "en-de" to "EN_DE", // "English_German" + "en-hu" to "HU_EN", // "Hungarian_English" + "en-nl" to "NL_EN", // "Dutch_English" + "pt-br" to "BR_PT", // "Brazillian Portuguese" + "zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?) + // "ar" to "AR", // "Arabic" + // "az" to "AZ", // "Azerbaijani" + // "be" to "BE", // "Belarusian" + // "bg" to "BG", // "Bulgarian" + // "bn" to "BN", // "Bengali" + // "bs" to "BS", // "Bosnian" + // "ca" to "CA", // "Catalan" + // "cs" to "CS", // "Czech" + // "da" to "DA", // "Danish" + // "de" to "DE", // "German" + // "el" to "EL", // "Greek" + // "en" to "EN", // "English" + // "eo" to "EO", // "Esperanto" + // "es" to "ES", // "Spanish" + // "et" to "ET", // "Estonian" + // "fa" to "FA", // "Farsi_Persian" + // "fi" to "FI", // "Finnish" + // "fr" to "FR", // "French" + // "he" to "HE", // "Hebrew" + // "hi" to "HI", // "Hindi" + // "hr" to "HR", // "Croatian" + // "hu" to "HU", // "Hungarian" + // "id" to "ID", // "Indonesian" + // "is" to "IS", // "Icelandic" + // "it" to "IT", // "Italian" + // "ja" to "JA", // "Japanese" + // "ka" to "KA", // "Georgian" + // "kl" to "KL", // "Greenlandic" + // "ko" to "KO", // "Korean" + // "ku" to "KU", // "Kurdish" + // "lt" to "LT", // "Lithuanian" + // "lv" to "LV", // "Latvian" + // "mk" to "MK", // "Macedonian" + // "ml" to "ML", // "Malayalam" + // "mni" to "MNI", // "Manipuri" + // "ms" to "MS", // "Malay" + // "my" to "MY", // "Burmese" + // "nl" to "NL", // "Dutch" + // "no" to "NO", // "Norwegian" + // "pl" to "PL", // "Polish" + // "pt" to "PT", // "Portuguese" + // "ro" to "RO", // "Romanian" + // "ru" to "RU", // "Russian" + // "si" to "SI", // "Sinhala" + // "sk" to "SK", // "Slovak" + // "sl" to "SL", // "Slovenian" + // "sq" to "SQ", // "Albanian" + // "sr" to "SR", // "Serbian" + // "sv" to "SV", // "Swedish" + // "ta" to "TA", // "Tamil" + // "te" to "TE", // "Telugu" + // "th" to "TH", // "Thai" + // "tl" to "TL", // "Tagalog" + // "tr" to "TR", // "Turkish" + // "uk" to "UK", // "Ukranian" + // "ur" to "UR", // "Urdu" + // "vi" to "VI", // "Vietnamese" + // "zh" to "ZH", // "Chinese BG code" + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index 4ab2e8e29..8ec082520 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -1,22 +1,37 @@ package com.lagradost.cloudstream3.ui -import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.HomePageResponse +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.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.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.newSearchResponseList +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf 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() { @@ -40,16 +55,18 @@ class APIRepository(val api: MainAPI) { val hash: Pair ) - private val cache = threadSafeListOf() + private val cache = atomicListOf() private var cacheIndex: Int = 0 - const val cacheSize = 20 + 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) { - synchronized(cache) { - cache.clear() - } + cache.clear() } } @@ -67,54 +84,66 @@ class APIRepository(val api: MainAPI) { suspend fun load(url: String): Resource { return safeApiCall { - if (isInvalidData(url)) throw ErrorLoadingException() - val fixedUrl = api.fixUrl(url) - val lookingForHash = Pair(api.name, fixedUrl) + withTimeout(getTimeout(api.loadTimeoutMs)) { + if (isInvalidData(url)) throw ErrorLoadingException() + val fixedUrl = api.fixUrl(url) + val lookingForHash = Pair(api.name, fixedUrl) - synchronized(cache) { - for (item in cache) { - // 10 min save - if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { - return@safeApiCall item.response + 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 + } } + found } + + if (cached != null) return@withTimeout cached + api.load(fixedUrl)?.also { response -> + // Remove all blank tags as early as possible + response.tags = response.tags?.filter { it.isNotBlank() } + val add = SavedLoadResponse(unixTime, response, lookingForHash) + + cache.withLock { + if (cache.size > CACHE_SIZE) { + cache[cacheIndex] = add // rolling cache + cacheIndex = (cacheIndex + 1) % CACHE_SIZE + } else { + cache.add(add) + } + } + } ?: throw ErrorLoadingException() } - - 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) - - synchronized(cache) { - if (cache.size > cacheSize) { - cache[cacheIndex] = add // rolling cache - cacheIndex = (cacheIndex + 1) % cacheSize - } else { - cache.add(add) - } - } - } ?: throw ErrorLoadingException() } } - suspend fun search(query: String): Resource> { + suspend fun search(query: String, page: Int): Resource { if (query.isEmpty()) - return Resource.Success(emptyList()) + return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { - return@safeApiCall (api.search(query) - ?: throw ErrorLoadingException()) -// .filter { typesActive.contains(it.type) } - .toList() + withTimeout(getTimeout(api.searchTimeoutMs)) { + (api.search(query, page) + ?: throw ErrorLoadingException()) + // .filter { typesActive.contains(it.type) } + } } } - suspend fun quickSearch(query: String): Resource> { + suspend fun quickSearch(query: String): Resource { if (query.isEmpty()) - return Resource.Success(emptyList()) + return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { - api.quickSearch(query) ?: throw ErrorLoadingException() + withTimeout(getTimeout(api.quickSearchTimeoutMs)) { + newSearchResponseList( + api.quickSearch(query) ?: throw ErrorLoadingException(), + false + ) + } } } @@ -126,38 +155,40 @@ class APIRepository(val api: MainAPI) { suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource> { return safeApiCall { - 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 + withTimeout(getTimeout(api.getMainPageTimeoutMs)) { + api.lastHomepageRequest = unixTimeMS + nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data -> + listOf( api.getMainPage( page, MainPageRequest(data.name, data.data, data.horizontalImages) ) - } - } else { - with(CoroutineScope(coroutineContext)) { + ) + } ?: run { + if (api.sequentialMainPage) { + var first = true api.mainPage.map { data -> - async { - api.getMainPage( - page, - MainPageRequest(data.name, data.data, data.horizontalImages) - ) - } - }.map { it.await() } + 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() } + } } } } @@ -174,14 +205,16 @@ class APIRepository(val api: MainAPI) { data: String, isCasting: Boolean, subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit + callback: (ExtractorLink) -> Unit, ): Boolean { if (isInvalidData(data)) return false // this makes providers cleaner return try { - api.loadLinks(data, isCasting, subtitleCallback, callback) + withTimeout(getTimeout(api.loadLinksTimeoutMs)) { + 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 new file mode 100644 index 000000000..4ebb7564c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -0,0 +1,327 @@ +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.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 +} + +abstract class NoStateAdapter( + diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() +) : BaseAdapter(0, diffCallback) + +/** Creates a new shared pool, using the supplied lambda as a constructor. + * + * The reason for this complicated structure is that a pool should not be shared between contexts + * as it makes coil fuck up, and theming. + * */ +fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair, RecyclerView.RecycledViewPool.() -> Unit> = + WeakHashMap() to lambda + +/** Sets the shared pool of the recyclerview */ +fun RecyclerView.setRecycledViewPool(pool: Pair, RecyclerView.RecycledViewPool.() -> Unit>) { + val ctx = context ?: return + synchronized(pool.first) { + this.setRecycledViewPool(pool.first.getOrPut(ctx) { + RecyclerView.RecycledViewPool().apply(pool.second) + }) + } +} + +/** Clears the shared pool of views */ +fun Pair, RecyclerView.RecycledViewPool.() -> Unit>.clear() { + synchronized(this.first) { + for (pool in this.first.values) { + pool?.clear() + } + } +} + +/** + * BaseAdapter is a persistent state stored adapter that supports headers and footers. + * This should be used for restoring eg scroll or focus related to a view when it is recreated. + * + * Id is a per fragment based unique id used to store the underlying data done in an internal ViewModel. + * + * diffCallback is how the view should be handled when updating, override onUpdateContent for updates + * + * NOTE: + * + * By default it should save automatically, but you can also call save(recycle) + * + * By default no state is stored, but doing an id != 0 will store + * + * By default no headers or footers exist, override footers and headers count + */ +abstract class BaseAdapter< + T : Any, + S : Any>( + 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] + } + + fun getItemOrNull(position: Int): T? { + return mDiffer.currentList.getOrNull(position) + } + + private val mDiffer: AsyncListDiffer = AsyncListDiffer( + object : NonFinalAdapterListUpdateCallback(this) { + override fun onMoved(fromPosition: Int, toPosition: Int) { + super.onMoved(fromPosition + headers, toPosition + headers) + } + + override fun onRemoved(position: Int, count: Int) { + super.onRemoved(position + headers, count) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + super.onChanged(position + headers, count, payload) + } + + override fun onInserted(position: Int, count: Int) { + super.onInserted(position + headers, count) + } + }, + 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) { + // 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) + } + } + + override fun getItemCount(): Int { + return mDiffer.currentList.size + footers + headers + } + + open fun onUpdateContent(holder: ViewHolderState, item: T, position: Int) = + onBindContent(holder, item, position) + + open fun onBindContent(holder: ViewHolderState, item: T, position: Int) = Unit + 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) {} + + @Suppress("UNCHECKED_CAST") + fun save(recyclerView: RecyclerView) { + for (child in recyclerView.children) { + val holder = + recyclerView.findContainingViewHolder(child) as? ViewHolderState ?: continue + setState(holder) + } + } + + fun clearState() { + layoutManagerStates[id]?.clear() + } + + @Suppress("UNCHECKED_CAST") + private fun getState(holder: ViewHolderState): S? = + layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S + + private fun setState(holder: ViewHolderState) { + if (id == 0) return + if (!layoutManagerStates.contains(id)) { + layoutManagerStates[id] = HashMap() + } + layoutManagerStates[id]?.let { map -> + map[holder.absoluteAdapterPosition] = holder.save() + } + } + + private val attachListener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) = Unit + override fun onViewDetachedFromWindow(v: View) { + if (v !is RecyclerView) return + save(v) + } + } + + final override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + recyclerView.addOnAttachStateChangeListener(attachListener) + super.onAttachedToRecyclerView(recyclerView) + } + + final override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + recyclerView.removeOnAttachStateChangeListener(attachListener) + 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() + } + val realPosition = position - headers + if (realPosition >= mDiffer.currentList.size) { + return FOOTER or customFooterViewType() + } + return CONTENT or customContentViewType(getItem(realPosition)) + } + + final override fun onViewRecycled(holder: ViewHolderState) { + setState(holder) + onClearView(holder) + super.onViewRecycled(holder) + } + + /** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data. + * + * If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources. + * + * Use this with `clearImage` + * */ + open fun onClearView(holder: ViewHolderState) {} + + final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState { + return when (viewType and TYPE_MASK) { + CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK) + HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK) + FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK) + else -> throw NotImplementedError() + } + } + + // https://medium.com/@domen.lanisnik/efficiently-updating-recyclerview-items-using-payloads-1305f65f3068 + override fun onBindViewHolder( + holder: ViewHolderState, + position: Int, + payloads: MutableList + ) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + return + } + when (getItemViewType(position) and TYPE_MASK) { + CONTENT -> { + val realPosition = position - headers + val item = getItem(realPosition) + onUpdateContent(holder, item, realPosition) + } + + FOOTER -> { + onBindFooter(holder) + } + + HEADER -> { + onBindHeader(holder) + } + } + } + + final override fun onBindViewHolder(holder: ViewHolderState, position: Int) { + when (getItemViewType(position) and TYPE_MASK) { + CONTENT -> { + val realPosition = position - headers + val item = getItem(realPosition) + onBindContent(holder, item, realPosition) + } + + FOOTER -> { + onBindFooter(holder) + } + + HEADER -> { + onBindHeader(holder) + } + } + + getState(holder)?.let { state -> + holder.restore(state) + } + } + + 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 + } +} + +class BaseDiffCallback( + val itemSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() }, + val contentSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() } +) : 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() +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt new file mode 100644 index 000000000..72955e7cf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt @@ -0,0 +1,278 @@ +package com.lagradost.cloudstream3.ui + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.LayoutRes +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceFragmentCompat +import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding +import com.lagradost.cloudstream3.utils.txt + +/** + * A base Fragment class that simplifies ViewBinding usage and handles view inflation safely. + * + * This class allows two modes of creating ViewBinding: + * 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes. + * 2. Bind: Using `bind()` on an existing root view. + * + * It also provides hooks for: + * - Safe initialization of the binding (`onBindingCreated`) + * - Automatic padding adjustment for system bars (`fixPadding`) + * - Optional layout resource selection via `pickLayout()` + * + * @param T The type of ViewBinding for this Fragment. + * @param bindingCreator The strategy used to create the binding instance. + */ +private interface BaseFragmentHelper { + val bindingCreator: BaseFragment.BindingCreator + + var _binding: T? + val binding: T? get() = _binding + + fun createBinding( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val layoutId = pickLayout() + val root: View? = layoutId?.let { inflater.inflate(it, container, false) } + _binding = try { + when (val creator = bindingCreator) { + is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false) + is BaseFragment.BindingCreator.Bind -> { + if (root != null) creator.fn(root) + else throw IllegalStateException("Root view is null for bind()") + } + } + } catch (t: Throwable) { + showToast( + txt(R.string.unable_to_inflate, t.message ?: ""), + Toast.LENGTH_LONG + ) + logError(t) + null + } + + return _binding?.root ?: root + } + + /** + * Called after the fragment's view has been created. + * + * This method is `final` to ensure that the binding is properly initialized and + * system bar padding adjustments are applied before any subclass logic runs. + * Subclasses should use [onBindingCreated] instead of overriding this method directly. + */ + fun onViewReady(view: View, savedInstanceState: Bundle?) { + fixLayout(view) + binding?.let { onBindingCreated(it, savedInstanceState) } + } + + /** + * Called when the binding is safely created and view is ready. + * Can be overridden to provide fragment-specific initialization. + * + * @param binding The safely created ViewBinding. + * @param savedInstanceState Saved state bundle or null. + */ + fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { + onBindingCreated(binding) + } + + /** + * Called when the binding is safely created and view is ready. + * Overload without savedInstanceState for convenience. + * + * @param binding The safely created ViewBinding. + */ + fun onBindingCreated(binding: T) {} + + /** + * Pick a layout resource ID for the fragment. + * + * Return `null` by default. Override to provide a layout resource when using + * `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`. + * + * @return Layout resource ID or null. + */ + @LayoutRes + fun pickLayout(): Int? = null + + /** + * Ensures the layout of the root view is correctly adjusted for the current configuration. + * + * This may include applying padding for system bars, adjusting insets, or performing other + * layout updates. `fixLayout` should remain idempotent, as it can be called multiple + * times on the same view, such as during configuration changes (e.g. device rotation) or when + * the view is recreated. + * + * @param view The root view to adjust. + */ + fun fixLayout(view: View) +} + +abstract class BaseFragment( + override val bindingCreator: BindingCreator +) : Fragment(), BaseFragmentHelper { + override var _binding: T? = null + + /** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */ + fun dispatchBackPressed() { + try { + activity?.onBackPressedDispatcher?.onBackPressed() + } catch (_: IllegalStateException) { + // FragmentManager is already executing transactions, so try again + delayedDispatchBackPressed(5) + } catch (t: Throwable) { + logError(t) + } + } + + /** Recursive back press when available */ + private fun delayedDispatchBackPressed(remaining: Int) { + if (remaining <= 0) return + binding?.root?.postDelayed({ + try { + activity?.onBackPressedDispatcher?.onBackPressed() + } catch (_: IllegalStateException) { + // FragmentManager is already executing transactions, so try again + delayedDispatchBackPressed(remaining - 1) + } catch (t: Throwable) { + logError(t) + } + }, 200) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** + * Called when the device configuration changes (e.g., orientation). + * Re-applies system bar padding fixes to the root view to ensure it + * readjusts for orientation changes. + */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + /** + * Sealed class representing the two strategies for creating a ViewBinding instance. + */ + sealed class BindingCreator { + + /** + * Use the standard inflate() method for creating the binding. + * + * @param fn Lambda that inflates the binding. + */ + class Inflate( + val fn: (LayoutInflater, ViewGroup?, Boolean) -> T + ) : BindingCreator() + + /** + * Use bind() on an existing root view to create the binding. This should + * be used if you are differing per device layouts, such as different + * layouts for TV and Phone. + * + * @param fn Lambda that binds the root view. + */ + class Bind( + val fn: (View) -> T + ) : BindingCreator() + } +} + +abstract class BaseDialogFragment( + override val bindingCreator: BaseFragment.BindingCreator +) : DialogFragment(), BaseFragmentHelper { + override var _binding: T? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** @see [BaseFragment.onConfigurationChanged] for documentation. */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +abstract class BaseBottomSheetDialogFragment( + override val bindingCreator: BaseFragment.BindingCreator +) : BottomSheetDialogFragment(), BaseFragmentHelper { + override var _binding: T? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** @see [BaseFragment.onConfigurationChanged] for documentation. */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setSystemBarsPadding() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + setSystemBarsPadding() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index 46ddce09c..2aadfb13c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -3,12 +3,16 @@ package com.lagradost.cloudstream3.ui import android.os.Bundle import android.util.Log import android.view.Menu -import android.view.View.* -import android.widget.* +import android.view.View.GONE +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import android.widget.AbsListView +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ListView import androidx.appcompat.app.AlertDialog -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF @@ -23,12 +27,13 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall -import com.lagradost.cloudstream3.sortSubs import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator 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.toJson import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo @@ -97,9 +102,6 @@ data class MetadataHolder( class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { - private val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() - init { view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setOnClickListener { @@ -232,12 +234,27 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi loadMirror(index + 1) } } else { - awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) { + val mediaLoadOptions = + MediaLoadOptions.Builder() + .setPlayPosition(startAt) + .setAutoplay(true) + .build() + awaitLinks( + remoteMediaClient?.load( + mediaItem, + mediaLoadOptions + ) + ) { loadMirror(index + 1) } } } catch (e: Exception) { - awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) { + val mediaLoadOptions = + MediaLoadOptions.Builder() + .setPlayPosition(startAt) + .setAutoplay(true) + .build() + awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) { loadMirror(index + 1) } } @@ -262,6 +279,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi var isLoadingMore = false + override fun onMediaStatusUpdated() { super.onMediaStatusUpdated() val meta = getCurrentMetaData() @@ -280,7 +298,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val currentDuration = remoteMediaClient?.streamDuration val currentPosition = remoteMediaClient?.approximateStreamPosition if (currentDuration != null && currentPosition != null) - DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration) + DataStoreHelper.setViewPosAndResume( + epData.id, + currentPosition, + currentDuration, + epData, + meta.episodes.getOrNull(index + 1) + ) } catch (t: Throwable) { logError(t) } @@ -294,14 +318,19 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val generator = RepoLinkGenerator(listOf(epData)) val isSuccessful = safeApiCall { - generator.generateLinks(clearCache = false, isCasting = true, + generator.generateLinks( + clearCache = false, + sourceTypes = LOADTYPE_CHROMECAST, callback = { it.first?.let { link -> currentLinks.add(link) } }, subtitleCallback = { currentSubs.add(it) - }) + }, + offset = 0, + isCasting = true + ) } val sortedLinks = sortUrls(currentLinks) @@ -414,4 +443,4 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt similarity index 74% rename from app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt rename to app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 28ced48c0..302358538 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,12 +3,14 @@ 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 import kotlin.math.abs -class GrdLayoutManager(val context: Context, _spanCount: Int) : - GridLayoutManager(context, _spanCount) { +class GrdLayoutManager(val context: Context, spanCount: Int) : + GridLayoutManager(context, spanCount) { override fun onFocusSearchFailed( focused: View, focusDirection: Int, @@ -70,8 +72,8 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : val orientation = this.orientation // fixes arabic by inverting left and right layout focus - val correctDirection = if(this.isLayoutRTL) { - when(direction) { + val correctDirection = if (this.isLayoutRTL) { + when (direction) { View.FOCUS_RIGHT -> View.FOCUS_LEFT View.FOCUS_LEFT -> View.FOCUS_RIGHT else -> direction @@ -83,12 +85,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : View.FOCUS_DOWN -> { return spanCount } + View.FOCUS_UP -> { return -spanCount } + View.FOCUS_RIGHT -> { return 1 } + View.FOCUS_LEFT -> { return -1 } @@ -98,12 +103,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : View.FOCUS_DOWN -> { return 1 } + View.FOCUS_UP -> { return -1 } + View.FOCUS_RIGHT -> { return spanCount } + View.FOCUS_LEFT -> { return -spanCount } @@ -147,12 +155,39 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att init { if (attrs != null) { - val attrsArray = intArrayOf(android.R.attr.columnWidth) - val array = context.obtainStyledAttributes(attrs, attrsArray) - columnWidth = array.getDimensionPixelSize(0, -1) - array.recycle() + context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { + columnWidth = getDimensionPixelSize(0, -1) + } } layoutManager = manager } +} + +/** + * Recyclerview wherein the max item width or height is set by the biggest view to prevent inconsistent view sizes. + */ +class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) { + private var biggestObserved: Int = 0 + private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation + private val isHorizontal = orientation == HORIZONTAL + private fun View.updateMaxSize() { + if (isHorizontal) { + this.minimumHeight = biggestObserved + } else { + this.minimumWidth = biggestObserved + } + } + + override fun onChildAttachedToWindow(child: View) { + child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) + val observed = if (isHorizontal) child.measuredHeight else child.measuredWidth + if (observed > biggestObserved) { + biggestObserved = observed + children.forEach { it.updateMaxSize() } + } else { + child.updateMaxSize() + } + super.onChildAttachedToWindow(child) + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt deleted file mode 100644 index c70417763..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt +++ /dev/null @@ -1,97 +0,0 @@ -// 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.scaleX - 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 new file mode 100644 index 000000000..9be862077 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt @@ -0,0 +1,177 @@ +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 deleted file mode 100644 index 40c03012a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.ui - -import android.graphics.Canvas -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView - -class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() { - override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { - super.onDraw(c, parent, state) - customView.layout(parent.left, 0, parent.right, customView.measuredHeight) - for (i in 0 until parent.childCount) { - val view = parent.getChildAt(i) - if (parent.getChildAdapterPosition(view) == 0) { - c.save() - val height = customView.measuredHeight - val top = view.top - height - c.translate(0f, top.toFloat()) - customView.draw(c) - c.restore() - break - } - } - } - - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State - ) { - if (parent.getChildAdapterPosition(view) == 0) { - customView.measure( - View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST), - View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST) - ) - outRect.set(0, customView.measuredHeight, 0, 0) - } else { - outRect.setEmpty() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt index aba6395f9..bd8541e6b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt @@ -7,12 +7,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.RelativeLayout +import androidx.core.content.withStyledAttributes import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.toPx -import java.lang.ref.WeakReference class MyMiniControllerFragment : MiniControllerFragment() { @@ -25,26 +25,15 @@ class MyMiniControllerFragment : MiniControllerFragment() { // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { - super.onInflate(context, attributeSet, bundle) - - // somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks???? if (currentColor == 0) { - WeakReference( - context.obtainStyledAttributes( - attributeSet, - R.styleable.CustomCast - ) - ).apply { - if (get() - ?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true - ) { - currentColor = - get() - ?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0 + context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { + if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { + currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) } - get()?.recycle() - }.clear() + } } + + super.onInflate(context, attributeSet, bundle) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -53,8 +42,8 @@ class MyMiniControllerFragment : MiniControllerFragment() { // SEE https://github.com/dandar3/android-google-play-services-cast-framework/blob/master/res/layout/cast_mini_controller.xml try { val progressBar: ProgressBar? = view.findViewById(R.id.progressBar) - val containerAll: LinearLayout? = view.findViewById(R.id.container_all) - val containerCurrent: RelativeLayout? = view.findViewById(R.id.container_current) + val containerAll: LinearLayout? = view.findViewById(com.google.android.gms.cast.framework.R.id.container_all) + val containerCurrent: RelativeLayout? = view.findViewById(com.google.android.gms.cast.framework.R.id.container_current) context?.let { ctx -> progressBar?.setBackgroundColor( @@ -79,4 +68,4 @@ class MyMiniControllerFragment : MiniControllerFragment() { // JUST IN CASE } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt new file mode 100644 index 000000000..12a5ae2a2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt @@ -0,0 +1,39 @@ +package com.lagradost.cloudstream3.ui + +import android.annotation.SuppressLint +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView + + +/** + * ListUpdateCallback that dispatches update events to the given adapter. + * + * @see DiffUtil.DiffResult.dispatchUpdatesTo + */ +open class NonFinalAdapterListUpdateCallback +/** + * Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter. + * + * @param mAdapter The Adapter to send updates to. + */(private var mAdapter: RecyclerView.Adapter<*>) : + ListUpdateCallback { + + override fun onInserted(position: Int, count: Int) { + mAdapter.notifyItemRangeInserted(position, count) + } + + override fun onRemoved(position: Int, count: Int) { + mAdapter.notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + mAdapter.notifyItemMoved(fromPosition, toPosition) + } + + @SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly + override fun onChanged(position: Int, count: Int, payload: Any?) { + mAdapter.notifyItemRangeChanged(position, count, payload) + } +} + 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 eb4eb6665..ec0ef5c6b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt @@ -13,6 +13,20 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24); companion object { - fun fromInternalId(id: Int?) = values().find { value -> value.internalId == id } ?: NONE + fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE } -} \ No newline at end of file +} + +enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { + 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), + ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24), + DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24), + PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24), + REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24); + + companion object { + fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE + } +} 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 9ed58e2c7..0d951bf6a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -1,34 +1,31 @@ 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.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding import com.lagradost.cloudstream3.network.WebViewResolver -import com.lagradost.cloudstream3.utils.AppUtils.loadRepository +import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository +class WebviewFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate) +) { -class WebviewFragment : Fragment() { + override fun fixLayout(view: View) = Unit - var binding: FragmentWebviewBinding? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentWebviewBinding) { val url = arguments?.getString(WEBVIEW_URL) ?: "".also { findNavController().popBackStack() } - binding?.webView?.webViewClient = object : WebViewClient() { + binding.webView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? @@ -43,28 +40,17 @@ class WebviewFragment : Fragment() { return super.shouldOverrideUrlLoading(view, request) } } - binding?.webView?.apply { + + binding.webView.apply { WebViewResolver.webViewUserAgent = settings.userAgentString addJavascriptInterface(RepoApi(activity), "RepoApi") settings.javaScriptEnabled = true settings.userAgentString = USER_AGENT settings.domStorageEnabled = true -// WebView.setWebContentsDebuggingEnabled(true) loadUrl(url) } - - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val localBinding = FragmentWebviewBinding.inflate(inflater, container, false) - binding = localBinding - // Inflate the layout for this fragment - return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false) } companion object { @@ -81,4 +67,4 @@ class WebviewFragment : Fragment() { activity?.loadRepository(repoUrl) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WhoIsWatchingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WhoIsWatchingAdapter.kt deleted file mode 100644 index 6b3090a97..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WhoIsWatchingAdapter.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.lagradost.cloudstream3.ui - -import android.view.LayoutInflater -import android.view.ViewGroup -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.databinding.WhoIsWatchingAccountAddBinding -import com.lagradost.cloudstream3.databinding.WhoIsWatchingAccountBinding -import com.lagradost.cloudstream3.ui.result.setImage -import com.lagradost.cloudstream3.utils.DataStoreHelper - -class WhoIsWatchingAdapter( - private val selectCallBack: (DataStoreHelper.Account) -> Unit = { }, - private val editCallBack: (DataStoreHelper.Account) -> Unit = { }, - private val addAccountCallback: () -> Unit = {} -) : - ListAdapter(DiffCallback()) { - - companion object { - const val FOOTER = 1 - const val NORMAL = 0 - } - - override fun getItemCount(): Int { - return currentList.size + 1 - } - - override fun getItemViewType(position: Int): Int = when (position) { - currentList.size -> FOOTER - else -> NORMAL - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WhoIsWatchingHolder = - WhoIsWatchingHolder( - binding = when (viewType) { - NORMAL -> WhoIsWatchingAccountBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - - FOOTER -> WhoIsWatchingAccountAddBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - - else -> throw NotImplementedError() - }, - selectCallBack = selectCallBack, - addAccountCallback = addAccountCallback, - editCallBack = editCallBack, - ) - - - override fun onBindViewHolder(holder: WhoIsWatchingHolder, position: Int) = - holder.bind(currentList.getOrNull(position)) - - class WhoIsWatchingHolder( - val binding: ViewBinding, - val selectCallBack: (DataStoreHelper.Account) -> Unit, - val addAccountCallback: () -> Unit, - val editCallBack: (DataStoreHelper.Account) -> Unit - ) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(card: DataStoreHelper.Account?) { - when (binding) { - is WhoIsWatchingAccountBinding -> binding.apply { - if(card == null) return@apply - outline.isVisible = card.keyIndex == DataStoreHelper.selectedKeyIndex - profileText.text = card.name - profileImageBackground.setImage(card.image) - root.setOnClickListener { - selectCallBack(card) - } - root.setOnLongClickListener { - editCallBack(card) - return@setOnLongClickListener true - } - } - - is WhoIsWatchingAccountAddBinding -> binding.apply { - root.setOnClickListener { - addAccountCallback() - } - } - } - } - } - - class DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: DataStoreHelper.Account, - newItem: DataStoreHelper.Account - ): Boolean = oldItem.keyIndex == newItem.keyIndex - - override fun areContentsTheSame( - oldItem: DataStoreHelper.Account, - newItem: DataStoreHelper.Account - ): Boolean = oldItem == newItem - } -} \ 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 new file mode 100644 index 000000000..92d33d0f3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt @@ -0,0 +1,211 @@ +package com.lagradost.cloudstream3.ui.account + +import android.os.Build +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import coil3.transform.RoundedCornersTransformation +import 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 +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage + +class AccountAdapter( + 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() { + + companion object { + const val VIEW_TYPE_SELECT_ACCOUNT = 0 + const val VIEW_TYPE_EDIT_ACCOUNT = 2 + } + + + override val footers: Int = 1 + var viewType = VIEW_TYPE_SELECT_ACCOUNT + + override fun customContentViewType(item: DataStoreHelper.Account): Int { + return viewType + } + + override fun onBindContent( + holder: ViewHolderState, + item: DataStoreHelper.Account, + position: Int + ) { + when (val binding = holder.view) { + is AccountListItemBinding -> binding.apply { + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + + accountName.text = item.name + accountImage.loadImage(item.image) + lockIcon.isVisible = item.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount + + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + root.foreground = ContextCompat.getDrawable( + root.context, + R.drawable.outline_drawable + ) + } + } else { + root.setOnLongClickListener { + showAccountEditDialog( + context = root.context, + account = item, + isNewAccount = false, + accountEditCallback = { account -> + accountEditCallback.invoke( + account + ) + }, + accountDeleteCallback = { account -> + accountDeleteCallback.invoke( + account + ) + } + ) + + true + } + } + + root.setOnClickListener { + accountSelectCallback.invoke(item) + } + } + + is AccountListItemEditBinding -> binding.apply { + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + + 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( + root.context, + R.drawable.outline_drawable + ) + } + } + + 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) { + VIEW_TYPE_SELECT_ACCOUNT -> { + AccountListItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } + + VIEW_TYPE_EDIT_ACCOUNT -> { + AccountListItemEditBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } + + else -> throw IllegalArgumentException("Invalid view type") + } + ) + } +} \ 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 new file mode 100644 index 000000000..1d6b41e5b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -0,0 +1,413 @@ +package com.lagradost.cloudstream3.ui.account + +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +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 +import androidx.core.view.isVisible +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.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding +import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding +import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding +import com.lagradost.cloudstream3.databinding.LockPinDialogBinding +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod + +object AccountHelper { + fun showAccountEditDialog( + context: Context, + account: DataStoreHelper.Account, + isNewAccount: Boolean, + accountEditCallback: (DataStoreHelper.Account) -> Unit, + accountDeleteCallback: (DataStoreHelper.Account) -> Unit + ) { + val binding = AccountEditDialogBinding.inflate(LayoutInflater.from(context), null, false) + val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setView(binding.root) + + var currentEditAccount = account + val dialog = builder.show() + + if (!isNewAccount) binding.title.setText(R.string.edit_account) + + // Set up the dialog content + binding.accountName.text = Editable.Factory.getInstance()?.newEditable(account.name) + binding.accountName.doOnTextChanged { text, _, _, _ -> + currentEditAccount = currentEditAccount.copy(name = text?.toString() ?: "") + } + + binding.deleteBtt.isGone = isNewAccount + binding.deleteBtt.setOnClickListener { + val dialogClickListener = DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + accountDeleteCallback.invoke(account) + dialog?.dismissSafe() + } + + DialogInterface.BUTTON_NEGATIVE -> { + dialog?.dismissSafe() + } + } + } + + try { + AlertDialog.Builder(context).setTitle(R.string.delete).setMessage( + context.getString(R.string.delete_message).format( + currentEditAccount.name + ) + ) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (t: Throwable) { + logError(t) + } + } + + binding.cancelBtt.setOnClickListener { + dialog?.dismissSafe() + } + + // Handle the profile picture and its interactions + 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) + } + + // Handle applying changes + binding.applyBtt.setOnClickListener { + if (currentEditAccount.lockPin != null) { + // Ask for the current PIN + showPinInputDialog(context, currentEditAccount.lockPin, false) { pin -> + if (pin == null) return@showPinInputDialog + // PIN is correct, proceed to update the account + accountEditCallback.invoke(currentEditAccount) + dialog.dismissSafe() + } + } else { + // No lock PIN set, proceed to update the account + accountEditCallback.invoke(currentEditAccount) + dialog.dismissSafe() + } + } + + // Handle setting or changing the PIN + if (currentEditAccount.keyIndex == getDefaultAccount(context).keyIndex) { + binding.lockProfileCheckbox.isVisible = false + if (currentEditAccount.lockPin != null) { + currentEditAccount = currentEditAccount.copy(lockPin = null) + } + } + + var canSetPin = true + + binding.lockProfileCheckbox.isChecked = currentEditAccount.lockPin != null + + binding.lockProfileCheckbox.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + if (canSetPin) { + showPinInputDialog(context, null, true) { pin -> + if (pin == null) { + binding.lockProfileCheckbox.isChecked = false + return@showPinInputDialog + } + + currentEditAccount = currentEditAccount.copy(lockPin = pin) + } + } + } else { + if (currentEditAccount.lockPin != null) { + // Ask for the current PIN + showPinInputDialog(context, currentEditAccount.lockPin, true) { pin -> + if (pin == null || pin != currentEditAccount.lockPin) { + canSetPin = false + binding.lockProfileCheckbox.isChecked = true + } else { + currentEditAccount = currentEditAccount.copy(lockPin = null) + } + } + } + } + } + + canSetPin = true + + binding.editProfilePhotoButton.setOnClickListener({ + val bottomSheetDialog = BottomSheetDialog(context) + val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context)) + bottomSheetDialog.setContentView(sheetBinding.root) + bottomSheetDialog.show() + + sheetBinding.apply { + text1.text = context.getString(R.string.edit_profile_image_title) + nginxTextInput.hint = context.getString(R.string.edit_profile_image_hint) + + applyBtt.setOnClickListener({ + val url = sheetBinding.nginxTextInput.text.toString() + if (url.isNotEmpty()) { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .listener( + onSuccess = { _, _ -> + currentEditAccount = currentEditAccount.copy(customImage = url) + binding.accountImage.loadImage(url) + showToast( + R.string.edit_profile_image_success, + Toast.LENGTH_SHORT + ) + bottomSheetDialog.dismiss() + }, + onError = { _, _ -> + showToast( + R.string.edit_profile_image_error_invalid, + Toast.LENGTH_SHORT + ) + } + ) + .build() + imageLoader.enqueue(request) + } else { + showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT) + } + bottomSheetDialog.dismissSafe() + }) + sheetBinding.cancelBtt.setOnClickListener({ + bottomSheetDialog.dismissSafe() + }) + } + }) + } + + fun showPinInputDialog( + context: Context, + currentPin: String?, + editAccount: Boolean, + forStartup: Boolean = false, + errorText: String? = null, + callback: (String?) -> Unit + ) { + fun TextView.visibleWithText(@StringRes textRes: Int) { + isVisible = true + setText(textRes) + } + + fun TextView.visibleWithText(text: String?) { + isVisible = true + setText(text) + } + + val binding = LockPinDialogBinding.inflate(LayoutInflater.from(context)) + + val isPinSet = currentPin != null + val isNewPin = editAccount && !isPinSet + val isEditPin = editAccount && isPinSet + + val titleRes = if (isEditPin) R.string.enter_current_pin else R.string.enter_pin + + var isPinValid = false + + val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setView(binding.root) + .setTitle(titleRes) + .setNegativeButton(R.string.cancel) { _, _ -> + callback.invoke(null) + } + .setOnCancelListener { + callback.invoke(null) + } + .setOnDismissListener { + if (!isPinValid) { + callback.invoke(null) + } + } + + if (forStartup) { + val currentAccount = DataStoreHelper.accounts.firstOrNull { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } + + builder.setTitle(context.getString(R.string.enter_pin_with_name, currentAccount?.name)) + builder.setOnDismissListener { + if (!isPinValid) { + context.getActivity()?.finish() + } + } + // So that if they don't know the PIN for the current account, + // they don't get completely locked out + builder.setNeutralButton(R.string.use_default_account) { _, _ -> + val activity = context.getActivity() + if (activity is AccountSelectActivity) { + isPinValid = true + activity.accountViewModel.handleAccountSelect(getDefaultAccount(context), activity) + } + } + } + + if (isNewPin) { + if (errorText != null) binding.pinEditTextError.visibleWithText(errorText) + builder.setPositiveButton(R.string.setup_done) { _, _ -> + if (!isPinValid) { + // If the done button is pressed and there is an error, + // ask again, and mention the error that caused this. + showPinInputDialog( + context = binding.root.context, + currentPin = null, + editAccount = true, + errorText = binding.pinEditTextError.text.toString(), + callback = callback + ) + } else { + val enteredPin = binding.pinEditText.text.toString() + callback.invoke(enteredPin) + } + } + } + + val dialog = builder.create() + + binding.pinEditText.doOnTextChanged { text, _, _, _ -> + val enteredPin = text.toString() + val isEnteredPinValid = enteredPin.length == 4 + + if (isEnteredPinValid) { + if (isPinSet) { + if (enteredPin != currentPin) { + binding.pinEditTextError.visibleWithText(R.string.pin_error_incorrect) + binding.pinEditText.text = null + isPinValid = false + } else { + binding.pinEditTextError.isVisible = false + isPinValid = true + + callback.invoke(enteredPin) + dialog.dismissSafe() + } + } else { + binding.pinEditTextError.isVisible = false + isPinValid = true + } + } else if (isNewPin) { + binding.pinEditTextError.visibleWithText(R.string.pin_error_length) + isPinValid = false + } + } + + // Detect IME_ACTION_DONE + binding.pinEditText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE && isPinValid) { + val enteredPin = binding.pinEditText.text.toString() + callback.invoke(enteredPin) + dialog.dismissSafe() + } + true + } + + // We don't want to accidentally have the dialog dismiss when clicking outside of it. + // That is what the cancel button is for. + dialog.setCanceledOnTouchOutside(false) + + dialog.show() + + // Auto focus on PIN input and show keyboard + binding.pinEditText.requestFocus() + binding.pinEditText.postDelayed({ + showInputMethod(binding.pinEditText) + }, 200) + } + + fun Activity?.showAccountSelectLinear() { + val activity = this as? MainActivity ?: return + val viewModel = ViewModelProvider(activity)[AccountViewModel::class.java] + + val binding: AccountSelectLinearBinding = AccountSelectLinearBinding.inflate( + LayoutInflater.from(activity) + ) + + val builder = BottomSheetDialog(activity) + builder.setContentView(binding.root) + builder.show() + + binding.manageAccountsButton.setOnClickListener { + activity.navigate( + R.id.accountSelectActivity, + Bundle().apply { putBoolean("isEditingFromMainActivity", true) } + ) + builder.dismissSafe() + } + + val recyclerView: RecyclerView = binding.accountRecyclerView + + val itemSize = recyclerView.resources.getDimensionPixelSize( + R.dimen.account_select_linear_item_size + ) + + recyclerView.addItemDecoration(AccountSelectLinearItemDecoration(itemSize)) + + recyclerView.setLinearListLayout(isHorizontal = true) + + val currentAccount = DataStoreHelper.accounts.firstOrNull { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } ?: getDefaultAccount(activity) + + // We want to make sure the accounts are up-to-date + viewModel.handleAccountSelect( + currentAccount, + activity, + reloadForActivity = true + ) + + activity.observe(viewModel.accounts) { liveAccounts -> + recyclerView.adapter = AccountAdapter( + accountSelectCallback = { account -> + viewModel.handleAccountSelect(account, activity) + builder.dismissSafe() + }, + 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) + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..ad323c7d1 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt @@ -0,0 +1,219 @@ +package com.lagradost.cloudstream3.ui.account + +import android.annotation.SuppressLint +import android.os.Bundle +import android.util.Log +import androidx.fragment.app.FragmentActivity +import androidx.activity.viewModels +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.CommonActivity.loadThemes +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.ActivityAccountSelectBinding +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.AutofitRecyclerView +import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_EDIT_ACCOUNT +import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_SELECT_ACCOUNT +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.BiometricAuthenticator +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication +import com.lagradost.cloudstream3.utils.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 + +class AccountSelectActivity : FragmentActivity(), BiometricCallback { + + companion object { + var hasLoggedIn: Boolean = false + } + + val accountViewModel: AccountViewModel by viewModels() + + @SuppressLint("NotifyDataSetChanged") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Are we editing and coming from MainActivity? + val isEditingFromMainActivity = intent.getBooleanExtra( + "isEditingFromMainActivity", + 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 + ) || accounts.count() <= 1 + + fun askBiometricAuth() { + + if (isLayout(PHONE) && isAuthEnabled(this)) { + if (deviceHasPasswordPinLock(this)) { + startBiometricAuthentication( + this, + R.string.biometric_authentication_title, + false + ) + + promptInfo?.let { prompt -> + biometricPrompt?.authenticate(prompt) + } + } + } + } + + observe(accountViewModel.isAllowedLogin) { isAllowedLogin -> + if (isAllowedLogin) { + // We are allowed to continue to MainActivity + navigateToMainActivity() + } + } + + // Don't show account selection if there is only + // one account that exists + if (!isEditingFromMainActivity && skipStartup) { + val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex } + if (currentAccount?.lockPin != null) { + CommonActivity.init(this) + accountViewModel.handleAccountSelect(currentAccount, this, true) + } else { + if (accounts.count() > 1) { + showToast( + this, getString( + R.string.logged_account, + currentAccount?.name + ) + ) + } + + navigateToMainActivity() + } + + return + } + + CommonActivity.init(this) + + val binding = ActivityAccountSelectBinding.inflate(layoutInflater) + setContentView(binding.root) + fixSystemBarsPadding(binding.root, padTop = false) + + val recyclerView: AutofitRecyclerView = binding.accountRecyclerView + + observe(accountViewModel.accounts) { liveAccounts -> + val adapter = AccountAdapter( + // Handle the selected account + accountSelectCallback = { + accountViewModel.handleAccountSelect(it, this) + }, + accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) }, + accountEditCallback = { + accountViewModel.handleAccountUpdate(it, this) + // We came from MainActivity, return there + // and switch to the edited account + if (isEditingFromMainActivity) { + setAccount(it) + navigateToMainActivity() + } + }, + accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) } + ).apply { + submitList(liveAccounts) + } + + recyclerView.adapter = adapter + + if (isLayout(TV or EMULATOR)) { + binding.editAccountButton.setBackgroundResource( + R.drawable.player_button_tv_attr_no_bg + ) + } + + observe(accountViewModel.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 -> + if (isEditing) { + binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24) + binding.title.setText(R.string.manage_accounts) + adapter.viewType = VIEW_TYPE_EDIT_ACCOUNT + } else { + binding.editAccountButton.setImageResource(R.drawable.ic_baseline_edit_24) + binding.title.setText(R.string.select_an_account) + adapter.viewType = VIEW_TYPE_SELECT_ACCOUNT + } + + adapter.notifyDataSetChanged() + } + + if (isEditingFromMainActivity) { + accountViewModel.setIsEditing(true) + } + + binding.editAccountButton.setOnClickListener { + // We came from MainActivity, return there + // and resume its state + if (isEditingFromMainActivity) { + navigateToMainActivity() + return@setOnClickListener + } + + accountViewModel.toggleIsEditing() + } + + if (isLayout(TV or EMULATOR)) { + recyclerView.spanCount = if (liveAccounts.count() + 1 <= 6) { + liveAccounts.count() + 1 + } else 6 + } + } + + 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) + finish() // Finish the account selection activity + } + + override fun onAuthenticationSuccess() { + Log.i(BiometricAuthenticator.TAG, "Authentication successful in AccountSelectActivity") + } + + override fun onAuthenticationError() { + finish() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt new file mode 100644 index 000000000..eb907b344 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt @@ -0,0 +1,14 @@ +package com.lagradost.cloudstream3.ui.account + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class AccountSelectLinearItemDecoration(private val size: Int) : RecyclerView.ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val layoutParams = view.layoutParams as RecyclerView.LayoutParams + layoutParams.width = size + layoutParams.height = size + view.layoutParams = layoutParams + } +} \ 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 new file mode 100644 index 000000000..96eaf52a7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt @@ -0,0 +1,129 @@ +package com.lagradost.cloudstream3.ui.account + +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.MainActivity +import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAccounts +import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount +import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount + +class AccountViewModel : ViewModel() { + private fun getAllAccounts(): List { + return context?.let { getAccounts(it) } ?: DataStoreHelper.accounts.toList() + } + + private val _accounts: MutableLiveData> = MutableLiveData(getAllAccounts()) + val accounts: LiveData> = _accounts + + private val _isEditing = MutableLiveData(false) + val isEditing: LiveData = _isEditing + + private val _isAllowedLogin = MutableLiveData(false) + val isAllowedLogin: LiveData = _isAllowedLogin + + private val _selectedKeyIndex = MutableLiveData( + getAllAccounts().indexOfFirst { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } + ) + val selectedKeyIndex: LiveData = _selectedKeyIndex + + fun setIsEditing(value: Boolean) { + _isEditing.postValue(value) + } + + fun toggleIsEditing() { + _isEditing.postValue(!(_isEditing.value ?: false)) + } + + fun handleAccountUpdate( + account: DataStoreHelper.Account, + context: Context + ) { + val currentAccounts = getAccounts(context).toMutableList() + + val overrideIndex = currentAccounts.indexOfFirst { it.keyIndex == account.keyIndex } + + if (overrideIndex != -1) { + currentAccounts[overrideIndex] = account + } else currentAccounts.add(account) + + val currentHomePage = DataStoreHelper.currentHomePage + + setAccount(account) + + DataStoreHelper.currentHomePage = currentHomePage + DataStoreHelper.accounts = currentAccounts.toTypedArray() + + _accounts.postValue(getAccounts(context)) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + MainActivity.reloadAccountEvent(true) + } + + fun handleAccountDelete( + account: DataStoreHelper.Account, + context: Context + ) { + removeKeys(account.keyIndex.toString()) + + val currentAccounts = getAccounts(context).toMutableList() + + currentAccounts.removeIf { it.keyIndex == account.keyIndex } + + DataStoreHelper.accounts = currentAccounts.toTypedArray() + + if (account.keyIndex == DataStoreHelper.selectedKeyIndex) { + setAccount(getDefaultAccount(context)) + } + + _accounts.postValue(getAccounts(context)) + _selectedKeyIndex.postValue(getAllAccounts().indexOfFirst { + it.keyIndex == DataStoreHelper.selectedKeyIndex + }) + MainActivity.reloadAccountEvent(true) + } + + fun handleAccountSelect( + account: DataStoreHelper.Account, + context: Context, + forStartup: Boolean = false, + reloadForActivity: Boolean = false + ) { + if (reloadForActivity) { + _accounts.postValue(getAccounts(context)) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + MainActivity.reloadAccountEvent(true) + return + } + + // Check if the selected account has a lock PIN set + if (account.lockPin != null) { + // The selected account has a PIN set, prompt the user to enter the PIN + showPinInputDialog( + context, + account.lockPin, + false, + forStartup + ) { pin -> + if (pin == null) return@showPinInputDialog + // Pin is correct, proceed + _isAllowedLogin.postValue(true) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + setAccount(account) + MainActivity.reloadAccountEvent(true) + } + } else { + // No PIN set for the selected account, proceed + _isAllowedLogin.postValue(true) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + setAccount(account) + MainActivity.reloadAccountEvent(true) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt new file mode 100644 index 000000000..1b48143a6 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -0,0 +1,433 @@ +package com.lagradost.cloudstream3.ui.download + +import android.annotation.SuppressLint +import android.text.format.Formatter.formatShortFileSize +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.CheckBox +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +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.getViewPos +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects + +const val DOWNLOAD_ACTION_PLAY_FILE = 0 +const val DOWNLOAD_ACTION_DELETE_FILE = 1 +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 + +sealed class VisualDownloadCached { + abstract val currentBytes: Long + abstract val totalBytes: Long + abstract val data: DownloadObjects.DownloadCached + abstract var isSelected: Boolean + + data class Child( + override val currentBytes: Long, + override val totalBytes: Long, + override val data: DownloadObjects.DownloadEpisodeCached, + override var isSelected: Boolean, + ) : VisualDownloadCached() + + data class Header( + override val currentBytes: Long, + override val totalBytes: Long, + override val data: DownloadObjects.DownloadHeaderCached, + override var isSelected: Boolean, + val child: DownloadObjects.DownloadEpisodeCached?, + val currentOngoingDownloads: Int, + val totalDownloads: Int, + ) : VisualDownloadCached() +} + +data class DownloadClickEvent( + val action: Int, + val data: DownloadObjects.DownloadEpisodeCached +) + +data class DownloadHeaderClickEvent( + val action: Int, + val data: DownloadObjects.DownloadHeaderCached +) + +class DownloadAdapter( + private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, + private val onItemClickEvent: (DownloadClickEvent) -> Unit, + private val onItemSelectionChanged: (Int, Boolean) -> Unit, +) : NoStateAdapter(DiffCallback()) { + + private var isMultiDeleteState: Boolean = false + + companion object { + private const val VIEW_TYPE_HEADER = 0 + private const val VIEW_TYPE_CHILD = 1 + } + + + private fun bindHeader(binding: ViewBinding, 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 + ) + ) + } + } + + 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 (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 + } + } + } + + downloadButton.resetView() + val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) + // We will let the view model handle this + downloadButton.doSetProgress = false + downloadButton.progressBar.progressDrawable = + downloadButton.getDrawableFromStatus(status) + ?.let { ContextCompat.getDrawable(downloadButton.context, it) } + downloadHeaderInfo.text = formattedSize + } else { + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } + + downloadHeaderInfo.isVisible = true + downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) + downloadButton.isVisible = !isMultiDeleteState + + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + card.child + ) + ) + } + } + } + + private fun DownloadHeaderEpisodeBinding.handleParentDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + downloadButton.resetViewData() + watchProgressContainer.isVisible = false + downloadButton.isVisible = false + downloadHeaderEpisodeProgress.isVisible = false + downloadHeaderGotoChild.isVisible = !isMultiDeleteState + + try { + downloadHeaderInfo.isVisible = true + downloadHeaderInfo.text = + downloadHeaderInfo.context.getString(R.string.extra_info_format).format( + card.totalDownloads, + downloadHeaderInfo.context.resources.getQuantityString( + R.plurals.episodes, + card.totalDownloads + ), + formattedSize + ) + } catch (e: Exception) { + downloadHeaderInfo.text = null + logError(e) + } + + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_GO_TO_CHILD, + card.data + ) + ) + } + } + } + + private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) { + if (binding !is DownloadChildEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + val posDur = getViewPos(data.id) + downloadChildEpisodeProgress.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 + } + } + } + + downloadButton.resetView() + val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(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. + 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) + } + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } + + 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) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected + } + } + } + + override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = when (viewType) { + VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false) + VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) + else -> throw IllegalArgumentException("Invalid view type") + } + return ViewHolderState(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 customContentViewType(item: VisualDownloadCached): Int { + return when (item) { + is VisualDownloadCached.Child -> VIEW_TYPE_CHILD + is VisualDownloadCached.Header -> VIEW_TYPE_HEADER + } + } + + @SuppressLint("NotifyDataSetChanged") + fun setIsMultiDeleteState(value: Boolean) { + if (isMultiDeleteState == value) return + isMultiDeleteState = value + notifyDataSetChanged() // This is shit, but what can you do? + } + + private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { + val isChecked = !checkbox.isChecked + checkbox.isChecked = isChecked + onItemSelectionChanged.invoke(itemId, isChecked) + } + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: VisualDownloadCached, + newItem: VisualDownloadCached + ): Boolean { + return oldItem.data.id == newItem.data.id + } + + override fun areContentsTheSame( + oldItem: VisualDownloadCached, + newItem: VisualDownloadCached + ): Boolean { + return oldItem == newItem + } + } +} \ No newline at end of file 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 10ce67a71..dae70ebd7 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 @@ -1,28 +1,31 @@ package com.lagradost.cloudstream3.ui.download -import android.app.Activity import android.content.DialogInterface -import android.widget.Toast +import android.net.Uri import androidx.appcompat.app.AlertDialog -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +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.CommonActivity.activity -import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator +import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import kotlinx.coroutines.MainScope object DownloadButtonSetup { fun handleDownloadClick(click: DownloadClickEvent) { val id = click.data.id - if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return when (click.action) { DOWNLOAD_ACTION_DELETE_FILE -> { activity?.let { ctx -> @@ -31,9 +34,15 @@ object DownloadButtonSetup { DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { - VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id) + VideoDownloadManager.deleteFilesAndUpdateSettings( + ctx, + setOf(id), + MainScope() + ) } + DialogInterface.BUTTON_NEGATIVE -> { + // Do nothing on cancel } } } @@ -58,11 +67,13 @@ object DownloadButtonSetup { } } } + DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause) ) } + DOWNLOAD_ACTION_RESUME_DOWNLOAD -> { activity?.let { ctx -> if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) { @@ -72,7 +83,7 @@ object DownloadButtonSetup { } else { val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) if (pkg != null) { - VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg) + DownloadQueueManager.addToQueue(pkg.toWrapper()) } else { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) @@ -81,73 +92,79 @@ object DownloadButtonSetup { } } } + DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( + VideoDownloadManager.getDownloadFileInfo( act, click.data.id )?.fileLength ?: 0 if (length > 0) { - showToast(R.string.delete, Toast.LENGTH_LONG) - } else { - showToast(R.string.download, Toast.LENGTH_LONG) + showSnackbar( + act, + R.string.offline_file, + Snackbar.LENGTH_LONG + ) } } } + + DOWNLOAD_ACTION_CANCEL_PENDING -> { + DownloadQueueManager.cancelDownload(id) + } + DOWNLOAD_ACTION_PLAY_FILE -> { activity?.let { act -> - val info = - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( - act, - click.data.id - ) ?: return - val keyInfo = getKey( - VideoDownloadManager.KEY_DOWNLOAD_INFO, - click.data.id.toString() - ) ?: return - val parent = getKey( + val parent = getKey( DOWNLOAD_HEADER_CACHE, click.data.parentId.toString() ) ?: return - act.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator( - listOf( - ExtractorUri( - uri = info.path, + val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) + ?.mapNotNull { + getKey(it) + } + ?.filter { it.parentId == click.data.parentId } - id = click.data.id, - parentId = click.data.parentId, - name = act.getString(R.string.downloaded_file), //click.data.name ?: keyInfo.displayName - season = click.data.season, - episode = click.data.episode, - headerName = parent.name, - tvType = parent.type, + val items = mutableListOf() + val allRelevantEpisodes = + episodes?.sortedWith(compareBy { + it.season ?: 0 + }.thenBy { it.episode }) - basePath = keyInfo.basePath, - displayName = keyInfo.displayName, - relativePath = keyInfo.relativePath, - ) - ) + allRelevantEpisodes?.forEach { + val keyInfo = getKey( + VideoDownloadManager.KEY_DOWNLOAD_INFO, + it.id.toString() + ) ?: return@forEach + + items.add( + ExtractorUri( + // We just use a temporary placeholder for the URI, + // it will be updated in generateLinks(). + // We just do this for performance since getting + // all paths at once can be quite expensive. + uri = Uri.EMPTY, + id = it.id, + parentId = it.parentId, + name = it.name ?: act.getString(R.string.downloaded_file), + season = it.season, + episode = it.episode, + headerName = parent.name, + tvType = parent.type, + basePath = keyInfo.basePath, + displayName = keyInfo.displayName, + relativePath = keyInfo.relativePath, ) ) - //R.id.global_to_navigation_player, PlayerFragment.newInstance( - // UriData( - // info.path.toString(), - // keyInfo.basePath, - // keyInfo.relativePath, - // keyInfo.displayName, - // click.data.parentId, - // click.data.id, - // headerName ?: "null", - // if (click.data.episode <= 0) null else click.data.episode, - // click.data.season - // ), - // getViewPos(click.data.id)?.position ?: 0 - //) + } + act.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + DownloadFileGenerator(items), + items.indexOfFirst { it.id == click.data.id } + ) ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt deleted file mode 100644 index 1d7b5a83f..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual -import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos -import com.lagradost.cloudstream3.utils.VideoDownloadHelper - -const val DOWNLOAD_ACTION_PLAY_FILE = 0 -const val DOWNLOAD_ACTION_DELETE_FILE = 1 -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 - -data class VisualDownloadChildCached( - val currentBytes: Long, - val totalBytes: Long, - val data: VideoDownloadHelper.DownloadEpisodeCached, -) - -data class DownloadClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadEpisodeCached) - -class DownloadChildAdapter( - var cardList: List, - private val clickCallback: (DownloadClickEvent) -> Unit, -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return DownloadChildViewHolder( - DownloadChildEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false), - clickCallback - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is DownloadChildViewHolder -> { - holder.bind(cardList[position]) - } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - class DownloadChildViewHolder - constructor( - val binding: DownloadChildEpisodeBinding, - private val clickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(binding.root) { - - /*private val title: TextView = itemView.download_child_episode_text - private val extraInfo: TextView = itemView.download_child_episode_text_extra - private val holder: CardView = itemView.download_child_episode_holder - private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress - private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded - private val downloadImage: ImageView = itemView.download_child_episode_download*/ - - - fun bind(card: VisualDownloadChildCached) { - val d = card.data - - val posDur = getViewPos(d.id) - binding.downloadChildEpisodeProgress.apply { - if (posDur != null) { - val visualPos = posDur.fixVisual() - max = (visualPos.duration / 1000).toInt() - progress = (visualPos.position / 1000).toInt() - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - - binding.downloadButton.setDefaultClickListener(card.data, binding.downloadChildEpisodeTextExtra, clickCallback) - - binding.downloadChildEpisodeText.apply { - text = context.getNameFull(d.name, d.episode, d.season) - isSelected = true // is needed for text repeating - } - - - binding.downloadChildEpisodeHolder.setOnClickListener { - clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d)) - } - } - } -} 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 f62482ed4..d44ea0020 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,26 +1,36 @@ package com.lagradost.cloudstream3.ui.download import android.os.Bundle -import android.view.LayoutInflater +import android.text.format.Formatter.formatShortFileSize import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.RecyclerView +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +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.setAppBarNoScrollFlagsOnTV + +class DownloadChildFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate) +) { + + private val downloadViewModel: DownloadViewModel by activityViewModels() -class DownloadChildFragment : Fragment() { companion object { fun newInstance(headerName: String, folder: String): Bundle { return Bundle().apply { @@ -31,92 +41,138 @@ class DownloadChildFragment : Fragment() { } override fun onDestroyView() { - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } - binding = null + activity?.detachBackPressedCallback("Downloads") + downloadViewModel.clearChildren() super.onDestroyView() } - var binding: FragmentChildDownloadsBinding? = null - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root//inflater.inflate(R.layout.fragment_child_downloads, container, false) + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - private fun updateList(folder: String) = main { - context?.let { ctx -> - val data = withContext(Dispatchers.IO) { ctx.getKeys(folder) } - val eps = withContext(Dispatchers.IO) { - data.mapNotNull { key -> - context?.getKey(key) - }.mapNotNull { - val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id) - ?: return@mapNotNull null - VisualDownloadChildCached(info.fileLength, info.totalBytes, it) - } - }.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 } - if (eps.isEmpty()) { - activity?.onBackPressed() - return@main - } - - (binding?.downloadChildList?.adapter as DownloadChildAdapter? ?: return@main).cardList = - eps - binding?.downloadChildList?.adapter?.notifyDataSetChanged() - } - } - - private var downloadDeleteEventListener: ((Int) -> Unit)? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - + override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { - activity?.onBackPressed() // TODO FIX + dispatchBackPressed() return } - fixPaddingStatusbar(binding?.downloadChildRoot) - binding?.downloadChildToolbar?.apply { + context?.let { downloadViewModel.updateChildList(it, folder) } + + binding.downloadChildToolbar.apply { title = name - setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - setNavigationOnClickListener { - activity?.onBackPressed() + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + dispatchBackPressed() + } } + setAppBarNoScrollFlagsOnTV() } + binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() - val adapter: RecyclerView.Adapter = - DownloadChildAdapter( - ArrayList(), - ) { click -> - handleDownloadClick(click) - } + observe(downloadViewModel.childCards) { cards -> + when (cards) { + is Resource.Success -> { + if (cards.value.isEmpty()) { + dispatchBackPressed() + } + (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) + } - downloadDeleteEventListener = { id: Int -> - val list = (binding?.downloadChildList?.adapter as DownloadChildAdapter?)?.cardList - if (list != null) { - if (list.any { it.data.id == id }) { - updateList(folder) + else -> { + (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) } } } - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } + observe(downloadViewModel.selectedBytes) { + updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) + } - binding?.downloadChildList?.adapter = adapter - binding?.downloadChildList?.setLinearListLayout( - isHorizontal = false, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF - )//layoutManager = GridLayoutManager(context, 1) - updateList(folder) + binding.apply { + btnDelete.setOnClickListener { view -> + downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) + } + + btnCancel.setOnClickListener { + downloadViewModel.cancelSelection() + } + + btnToggleAll.setOnClickListener { + val allSelected = downloadViewModel.isAllChildrenSelected() + if (allSelected) { + downloadViewModel.clearSelectedItems() + } else { + downloadViewModel.selectAllChildren() + } + } + } + + observeNullable(downloadViewModel.selectedItemIds) { selection -> + val isMultiDeleteState = selection != null + val adapter = binding.downloadChildList.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding.downloadDeleteAppbar.isVisible = isMultiDeleteState + binding.downloadChildToolbar.isGone = isMultiDeleteState + + 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.isAllChildrenSelected() + if (allSelected) { + binding.btnToggleAll.setText(R.string.deselect_all) + } else binding.btnToggleAll.setText(R.string.select_all) + } + + val adapter = DownloadAdapter( + {}, + { click -> + if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { + context?.let { ctx -> + downloadViewModel.handleSingleDelete(ctx, click.data.id) + } + } else handleDownloadClick(click) + }, + { itemId, isChecked -> + if (isChecked) { + downloadViewModel.addSelected(itemId) + } else downloadViewModel.removeSelected(itemId) + } + ) + + binding.downloadChildList.apply { + setHasFixedSize(true) + setItemViewCacheSize(20) + this.adapter = adapter + setLinearListLayout( + isHorizontal = false, + nextRight = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + } + } + + private fun updateDeleteButton(count: Int, selectedBytes: Long) { + val formattedSize = formatShortFileSize(context, selectedBytes) + binding?.btnDelete?.text = + getString(R.string.delete_format).format(count, formattedSize) } } \ No newline at end of file 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 27c2e1a32..abc432ef9 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 @@ -1,55 +1,65 @@ package com.lagradost.cloudstream3.ui.download +import android.app.Activity import android.app.Dialog import android.content.ClipboardManager 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.view.LayoutInflater +import android.text.format.Formatter.formatShortFileSize import android.view.View -import android.view.ViewGroup import android.widget.LinearLayout +import android.widget.TextView import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes 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 androidx.core.widget.doOnTextChanged +import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.isMovieType -import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick -import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -import com.lagradost.cloudstream3.ui.player.LinkGenerator -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DataStore -import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -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.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import android.text.format.Formatter.formatShortFileSize -import androidx.core.widget.doOnTextChanged import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding import com.lagradost.cloudstream3.databinding.StreamInputBinding -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel import com.lagradost.cloudstream3.ui.player.BasicLink +import com.lagradost.cloudstream3.ui.player.GeneratorPlayer +import com.lagradost.cloudstream3.ui.player.LinkGenerator +import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +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 +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback +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.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV import java.net.URI - const val DOWNLOAD_NAVIGATE_TO = "downloadpage" -class DownloadFragment : Fragment() { - private lateinit var downloadsViewModel: DownloadViewModel +class DownloadFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate) +) { + + private val downloadViewModel: DownloadViewModel by activityViewModels() + private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( @@ -60,221 +70,318 @@ class DownloadFragment : Fragment() { this.layoutParams = param } - private fun setList(list: List) { - main { - (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList = list - binding?.downloadList?.adapter?.notifyDataSetChanged() - } - } - override fun onDestroyView() { - if (downloadDeleteEventListener != null) { - VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!! - downloadDeleteEventListener = null - } - binding = null + activity?.detachBackPressedCallback("Downloads") super.onDestroyView() } - var binding: FragmentDownloadsBinding? = null - - 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//inflater.inflate(R.layout.fragment_downloads, container, false) + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - private var downloadDeleteEventListener: ((Int) -> Unit)? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentDownloadsBinding) { hideKeyboard() + binding.downloadAppbar.setAppBarNoScrollFlagsOnTV() + binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() - observe(downloadsViewModel.noDownloadsText) { - binding?.textNoDownloads?.text = it - } - observe(downloadsViewModel.headerCards) { - setList(it) - binding?.downloadLoading?.isVisible = false - } - observe(downloadsViewModel.availableBytes) { - binding?.downloadFreeTxt?.text = - getString(R.string.storage_size_format).format( - getString(R.string.free_storage), - formatShortFileSize(view.context, it) - ) - binding?.downloadFree?.setLayoutWidth(it) - } - observe(downloadsViewModel.usedBytes) { - binding?.apply { - downloadUsedTxt.text = - getString(R.string.storage_size_format).format( - getString(R.string.used_storage), - formatShortFileSize(view.context, it) - ) - downloadUsed.setLayoutWidth(it) - downloadStorageAppbar.isVisible = it > 0 - } - } - observe(downloadsViewModel.downloadBytes) { - binding?.apply { - downloadAppTxt.text = - getString(R.string.storage_size_format).format( - getString(R.string.app_storage), - formatShortFileSize(view.context, it) - ) - downloadApp.setLayoutWidth(it) - } - } - - val adapter: RecyclerView.Adapter = - DownloadHeaderAdapter( - ArrayList(), - { click -> - when (click.action) { - 0 -> { - if (click.data.type.isMovieType()) { - //wont be called - } else { - val folder = DataStore.getFolderName( - DOWNLOAD_EPISODE_CACHE, - click.data.id.toString() - ) - activity?.navigate( - R.id.action_navigation_downloads_to_navigation_download_child, - DownloadChildFragment.newInstance(click.data.name, folder) - ) - } - } - - 1 -> { - (activity as AppCompatActivity?)?.loadResult( - click.data.url, - click.data.apiName - ) - } - } - - }, - { downloadClickEvent -> - if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter - handleDownloadClick(downloadClickEvent) - if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) { - context?.let { ctx -> - downloadsViewModel.updateList(ctx) - } - } + 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 + } + } + } + + observe(downloadViewModel.availableBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.free_storage, + binding.downloadFreeTxt, + binding.downloadFree + ) + } + observe(downloadViewModel.usedBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.used_storage, + binding.downloadUsedTxt, + binding.downloadUsed ) - downloadDeleteEventListener = { id -> - val list = (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList - if (list != null) { - if (list.any { it.data.id == id }) { - context?.let { ctx -> - setList(ArrayList()) - downloadsViewModel.updateList(ctx) - } + val hasBytes = it > 0 + if (hasBytes) { + binding.downloadLoadingBytes.stopShimmer() + } else binding.downloadLoadingBytes.startShimmer() + + binding.downloadBytesBar.isVisible = hasBytes + binding.downloadLoadingBytes.isGone = hasBytes + } + observe(downloadViewModel.downloadBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.app_storage, + 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(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() } } } - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } + 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?.downloadList?.apply { + 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() + if (allSelected) { + binding.btnToggleAll.setText(R.string.deselect_all) + } else binding.btnToggleAll.setText(R.string.select_all) + } + + val adapter = DownloadAdapter( + { click -> handleItemClick(click) }, + { click -> + if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { + context?.let { ctx -> + downloadViewModel.handleSingleDelete(ctx, click.data.id) + } + } else handleDownloadClick(click) + }, + { itemId, isChecked -> + if (isChecked) { + downloadViewModel.addSelected(itemId) + } else downloadViewModel.removeSelected(itemId) + } + ) + + binding.downloadList.apply { + setHasFixedSize(true) + setItemViewCacheSize(20) this.adapter = adapter setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, - nextUp = FOCUS_SELF, - nextDown = FOCUS_SELF + nextDown = R.id.download_queue_button, ) - //layoutManager = GridLayoutManager(context, 1) } - // Should be visible in emulator layout - binding?.downloadStreamButton?.isGone = isTrueTvSettings() - binding?.downloadStreamButton?.setOnClickListener { - val dialog = - Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom) - - val binding = StreamInputBinding.inflate(dialog.layoutInflater) - - dialog.setContentView(binding.root) - - dialog.show() - - // If user has clicked the switch do not interfere - var preventAutoSwitching = false - binding.hlsSwitch.setOnClickListener { - preventAutoSwitching = true + binding.apply { + openLocalVideoButton.apply { + isGone = isLayout(TV) + setOnClickListener { openLocalVideo() } + } + downloadStreamButton.apply { + isGone = isLayout(TV) + setOnClickListener { showStreamInputDialog(it.context) } } - fun activateSwitchOnHls(text: String?) { - binding.hlsSwitch.isChecked = normalSafeApiCall { - URI(text).path?.substringAfterLast(".")?.contains("m3u") - } == true + downloadQueueButton.setOnClickListener { + activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue) } - binding.streamReferer.doOnTextChanged { text, _, _, _ -> - if (!preventAutoSwitching) - activateSwitchOnHls(text?.toString()) + 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 -> + handleScroll(scrollY - oldScrollY) } + } - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( - 0 - )?.text?.toString()?.let { copy -> - val fixedText = copy.trim() - binding.streamUrl.setText(fixedText) - activateSwitchOnHls(fixedText) - } - - binding.applyBtt.setOnClickListener { - val url = binding.streamUrl.text?.toString() - if (url.isNullOrEmpty()) { - showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT) - } else { - val referer = binding.streamReferer.text?.toString() + context?.let { downloadViewModel.updateHeaderList(it) } + } + private fun handleItemClick(click: DownloadHeaderClickEvent) { + when (click.action) { + DOWNLOAD_ACTION_GO_TO_CHILD -> { + if (click.data.type.isEpisodeBased()) { + val folder = + getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) activity?.navigate( - R.id.global_to_navigation_player, - GeneratorPlayer.newInstance( - LinkGenerator( - listOf(BasicLink(url)), - extract = true, - referer = referer, - isM3u8 = binding.hlsSwitch.isChecked - ) - ) + R.id.action_navigation_downloads_to_navigation_download_child, + DownloadChildFragment.newInstance(click.data.name, folder) ) - - dialog.dismissSafe(activity) } } - binding.cancelBtt.setOnClickListener { + DOWNLOAD_ACTION_LOAD_RESULT -> { + activity?.loadResult(click.data.url, click.data.apiName, click.data.name) + } + } + } + + private fun updateDeleteButton(count: Int, selectedBytes: Long) { + val formattedSize = formatShortFileSize(context, selectedBytes) + binding?.btnDelete?.text = + getString(R.string.delete_format).format(count, formattedSize) + } + + private fun updateStorageInfo( + context: Context, + bytes: Long, + @StringRes stringRes: Int, + textView: TextView?, + view: View? + ) { + textView?.text = getString(R.string.storage_size_format).format( + getString(stringRes), + formatShortFileSize(context, bytes) + ) + view?.setLayoutWidth(bytes) + } + + private fun openLocalVideo() { + val intent = Intent() + .setAction(Intent.ACTION_GET_CONTENT) + .setType("video/*") + .addCategory(Intent.CATEGORY_OPENABLE) + .addFlags(FLAG_GRANT_READ_URI_PERMISSION) // Request temporary access + safe { + videoResultLauncher.launch( + Intent.createChooser( + intent, + getString(R.string.open_local_video) + ) + ) + } + } + + private fun showStreamInputDialog(context: Context) { + val dialog = Dialog(context, R.style.AlertDialogCustom) + val binding = StreamInputBinding.inflate(dialog.layoutInflater) + dialog.setContentView(binding.root) + dialog.show() + + var preventAutoSwitching = false + binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true } + + binding.streamReferer.doOnTextChanged { text, _, _, _ -> + if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding) + } + + (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( + 0 + )?.text?.toString()?.let { copy -> + val fixedText = copy.trim() + binding.streamUrl.setText(fixedText) + activateSwitchOnHls(fixedText, binding) + } + + binding.applyBtt.setOnClickListener { + val url = binding.streamUrl.text?.toString() + if (url.isNullOrEmpty()) { + showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT) + } else { + val referer = binding.streamReferer.text?.toString() + activity?.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + LinkGenerator( + listOf(BasicLink(url)), + extract = true, + refererUrl = referer, + id = url.hashCode() + ), 0 + ) + ) dialog.dismissSafe(activity) } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - val dy = scrollY - oldScrollY - if (dy > 0) { //check for scroll down - binding?.downloadStreamButton?.shrink() // hide - } else if (dy < -5) { - binding?.downloadStreamButton?.extend() // show - } - } - } - downloadsViewModel.updateList(requireContext()) - fixPaddingStatusbar(binding?.downloadRoot) + binding.cancelBtt.setOnClickListener { + dialog.dismissSafe(activity) + } + } + + private fun activateSwitchOnHls(text: String?, binding: StreamInputBinding) { + binding.hlsSwitch.isChecked = safe { + URI(text).path?.substringAfterLast(".")?.contains("m3u") + } == true + } + + private fun handleScroll(dy: Int) { + if (dy > 0) { + binding?.downloadStreamButton?.shrink() + } else if (dy < -5) { + binding?.downloadStreamButton?.extend() + } + } + + // Open local video from files using content provider x safeFile + private val videoResultLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) 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/DownloadHeaderAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt deleted file mode 100644 index 65a6441fb..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -import android.annotation.SuppressLint -import android.text.format.Formatter.formatShortFileSize -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.DownloadHeaderEpisodeBinding -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import java.util.* - -data class VisualDownloadHeaderCached( - val currentOngoingDownloads: Int, - val totalDownloads: Int, - val totalBytes: Long, - val currentBytes: Long, - val data: VideoDownloadHelper.DownloadHeaderCached, - val child: VideoDownloadHelper.DownloadEpisodeCached?, -) - -data class DownloadHeaderClickEvent( - val action: Int, - val data: VideoDownloadHelper.DownloadHeaderCached -) - -class DownloadHeaderAdapter( - var cardList: List, - private val clickCallback: (DownloadHeaderClickEvent) -> Unit, - private val movieClickCallback: (DownloadClickEvent) -> Unit, -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return DownloadHeaderViewHolder( - DownloadHeaderEpisodeBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ), - clickCallback, - movieClickCallback - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is DownloadHeaderViewHolder -> { - holder.bind(cardList[position]) - } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - class DownloadHeaderViewHolder - constructor( - val binding: DownloadHeaderEpisodeBinding, - private val clickCallback: (DownloadHeaderClickEvent) -> Unit, - private val movieClickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(binding.root) { - - /*private val poster: ImageView? = itemView.download_header_poster - private val title: TextView = itemView.download_header_title - private val extraInfo: TextView = itemView.download_header_info - private val holder: CardView = itemView.episode_holder - - private val downloadBar: ContentLoadingProgressBar = itemView.download_header_progress_downloaded - private val downloadImage: ImageView = itemView.download_header_episode_download - private val normalImage: ImageView = itemView.download_header_goto_child*/ - - @SuppressLint("SetTextI18n") - fun bind(card: VisualDownloadHeaderCached) { - val d = card.data - - binding.downloadHeaderPoster.apply { - setImage(d.poster) - setOnClickListener { - clickCallback.invoke(DownloadHeaderClickEvent(1, d)) - } - } - - binding.apply { - - binding.downloadHeaderTitle.text = d.name - val mbString = formatShortFileSize(itemView.context, card.totalBytes) - - //val isMovie = d.type.isMovieType() - if (card.child != null) { - //downloadHeaderProgressDownloaded.visibility = View.VISIBLE - - // downloadHeaderEpisodeDownload.visibility = View.VISIBLE - binding.downloadHeaderGotoChild.visibility = View.GONE - - downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, movieClickCallback) - downloadButton.isVisible = true - /*setUpButton( - card.currentBytes, - card.totalBytes, - downloadBar, - downloadImage, - extraInfo, - card.child, - movieClickCallback - )*/ - - episodeHolder.setOnClickListener { - movieClickCallback.invoke( - DownloadClickEvent( - DOWNLOAD_ACTION_PLAY_FILE, - card.child - ) - ) - } - } else { - downloadButton.isVisible = false - // downloadHeaderProgressDownloaded.visibility = View.GONE - // downloadHeaderEpisodeDownload.visibility = View.GONE - binding.downloadHeaderGotoChild.visibility = View.VISIBLE - - try { - downloadHeaderInfo.text = - downloadHeaderInfo.context.getString(R.string.extra_info_format).format( - card.totalDownloads, - if (card.totalDownloads == 1) downloadHeaderInfo.context.getString(R.string.episode) else downloadHeaderInfo.context.getString( - R.string.episodes - ), - mbString - ) - } catch (t: Throwable) { - // you probably formatted incorrectly - downloadHeaderInfo.text = "Error" - logError(t) - } - - - episodeHolder.setOnClickListener { - clickCallback.invoke(DownloadHeaderClickEvent(0, d)) - } - } - } - } - } -} 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 3a74a715c..0d35d5670 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 @@ -1,122 +1,587 @@ package com.lagradost.cloudstream3.ui.download import android.content.Context +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.cloudstream3.isMovieType +import com.lagradost.api.Log +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.ConsistentLiveData +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds +import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched +import com.lagradost.cloudstream3.utils.ResourceLiveData +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { - private val _noDownloadsText = MutableLiveData().apply { - value = "" + companion object { + const val TAG = "DownloadViewModel" } - val noDownloadsText: LiveData = _noDownloadsText private val _headerCards = - MutableLiveData>().apply { listOf() } - val headerCards: LiveData> = _headerCards + ResourceLiveData>(Resource.Loading()) + val headerCards: LiveData>> = _headerCards - private val _usedBytes = MutableLiveData() - private val _availableBytes = MutableLiveData() - private val _downloadBytes = MutableLiveData() + private val _childCards = ResourceLiveData>(Resource.Loading()) + val childCards: LiveData>> = _childCards + private val _usedBytes = ConsistentLiveData() val usedBytes: LiveData = _usedBytes + + private val _availableBytes = ConsistentLiveData() val availableBytes: LiveData = _availableBytes + + private val _downloadBytes = ConsistentLiveData() val downloadBytes: LiveData = _downloadBytes - fun updateList(context: Context) = viewModelScope.launchSafe { - val children = withContext(Dispatchers.IO) { - val headers = context.getKeys(DOWNLOAD_EPISODE_CACHE) - headers.mapNotNull { context.getKey(it) } - .distinctBy { it.id } // Remove duplicates + private val _selectedBytes = ConsistentLiveData(0) + val selectedBytes: LiveData = _selectedBytes + + private val _selectedItemIds = ConsistentLiveData?>(null) + val selectedItemIds: LiveData?> = _selectedItemIds + + + fun cancelSelection() { + updateSelectedItems { null } + } + + fun addSelected(itemId: Int) { + updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) } + } + + fun removeSelected(itemId: Int) { + updateSelectedItems { it?.minus(itemId) ?: emptySet() } + } + + fun selectAllHeaders() { + updateSelectedItems { + _headerCards.success.orEmpty() + .map { item -> item.data.id }.toSet() } + } - // parentId : bytes - val totalBytesUsedByChild = HashMap() - // parentId : bytes - val currentBytesUsedByChild = HashMap() - // parentId : downloadsCount - val totalDownloads = HashMap() + fun selectAllChildren() { + updateSelectedItems { + _childCards.success.orEmpty() + .map { item -> item.data.id }.toSet() + } + } + + fun clearSelectedItems() { + // We need this to be done immediately + // so we can't use postValue + updateSelectedItems { emptySet() } + } + + fun isAllChildrenSelected(): Boolean { + val currentSelected = selectedItemIds.value ?: return false + val children = _childCards.success.orEmpty() + return currentSelected.size == children.size && children.all { it.data.id in currentSelected } + } + + fun 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) + _selectedItemIds.postValue(currentSelected) + postHeaders() + postChildren() + updateSelectedBytes() + } + + private fun updateSelectedBytes() = viewModelScope.launchSafe { + val selectedItemsList = getSelectedItemsData() ?: return@launchSafe + val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes } + _selectedBytes.postValue(totalSelectedBytes) + } - // Gets all children downloads - withContext(Dispatchers.IO) { - for (c in children) { - val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue + 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() + ) - if (childFile.fileLength <= 1) continue - val len = childFile.totalBytes - val flen = childFile.fileLength - - totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len - currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen - totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1 + 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) + } } } + } - val cached = withContext(Dispatchers.IO) { // wont fetch useless keys - totalDownloads.entries.filter { it.value > 0 }.mapNotNull { - context.getKey( - DOWNLOAD_HEADER_CACHE, - it.key.toString() + fun removeRedundantHeaderKeys( + context: Context, + cached: List, + totalBytesUsedByChild: Map, + totalDownloads: Map + ) { + val settingsManager = context.getSharedPrefs() + ioSafe { + // Do not remove headers used by resume watching + val resumeWatchingIds = + getAllResumeStateIds()?.mapNotNull { id -> + getLastWatched(id)?.parentId + }?.toSet() ?: emptySet() + + settingsManager.edit { + cached.forEach { header -> + val downloads = totalDownloads[header.id] ?: 0 + val bytes = totalBytesUsedByChild[header.id] ?: 0 + + if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) { + Log.i(TAG, "Removing download header key: ${header.id}") + val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString()) + val newPath = + getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString()) + val oldPref = settingsManager.getString(oldPAth, null) + // Cowardly future backup solution in case the key removal fails in some edge case. + // This and all backup keys may be removed in a future update if the key removal is proven to be robust. + this.putString(newPath, oldPref) + this.remove(oldPAth) + } + } + } + } + } + + fun updateHeaderList(context: Context) = viewModelScope.launchSafe { + // Do not push loading as it interrupts the UI + //_headerCards.postValue(Resource.Loading()) + + val visual = ioWork { + val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) + .mapNotNull { context.getKey(it) } + .distinctBy { it.id } // Remove duplicates + + val isCurrentlyDownloading = + DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty() + + val downloadStats = + calculateDownloadStats(context, children) + + val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) + .mapNotNull { context.getKey(it) } + + // Download stats and header keys may change when downloading. + // To prevent the downloader and key removal from colliding, simply do not prune keys when downloading. + if (!isCurrentlyDownloading) { + removeRedundantHeaderKeys( + context, + cached, + downloadStats.totalBytesUsedByChild, + downloadStats.totalDownloads ) } + // calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required + removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads) + + createVisualDownloadList( + context, + cached, + downloadStats.totalBytesUsedByChild, + downloadStats.currentBytesUsedByChild, + downloadStats.totalDownloads + ) } + updateStorageStats(visual) + postHeaders(visual) + } + + fun postHeaders(newValue: List? = null) { + val newValue = newValue ?: _headerCards.success ?: return + val selection = selectedItemIds.value ?: emptySet() + _headerCards.postValue(Resource.Success(newValue.map { + it.copy( + isSelected = selection.contains( + it.data.id + ) + ) + })) + } + + fun postChildren(newValue: List? = null) { + val newValue = newValue ?: _childCards.success ?: return + val selection = selectedItemIds.value ?: emptySet() + _childCards.postValue(Resource.Success(newValue.map { + it.copy( + isSelected = selection.contains( + it.data.id + ) + ) + })) + } + + private data class DownloadStats( + val totalBytesUsedByChild: Map, + val currentBytesUsedByChild: Map, + val totalDownloads: Map, + /** Parent ID to child ID. Keys to be removed. */ + val redundantDownloads: List> + ) + + private fun calculateDownloadStats( + context: Context, + children: List + ): DownloadStats { + // 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 + } + if (childFile.fileLength <= 1) return@forEach + + val len = childFile.totalBytes + val flen = childFile.fileLength + + totalBytesUsedByChild.merge(child.parentId, len, Long::plus) + currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) + totalDownloads.merge(child.parentId, 1, Int::plus) + } + return DownloadStats( + totalBytesUsedByChild, + currentBytesUsedByChild, + totalDownloads, + redundantDownloads + ) + } + + private fun createVisualDownloadList( + context: Context, + cached: List, + totalBytesUsedByChild: Map, + currentBytesUsedByChild: Map, + totalDownloads: Map + ): List { + return cached.mapNotNull { + 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 + } + + 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()) + ) + + VisualDownloadCached.Header( + currentBytes = currentBytes, + totalBytes = bytes, + data = it, + child = movieEpisode, + currentOngoingDownloads = 0, + totalDownloads = downloads, + isSelected = isSelected, + ) + // Prevent order being almost completely random, + // making things difficult to find. + }.sortedWith(compareBy { + // Sort by isEpisodeBased() ascending. We put those that + // are episode based at the bottom for UI purposes and to + // make it easier to find by grouping them together. + it.data.type.isEpisodeBased() + }.thenBy { + // Then we sort alphabetically by name (case-insensitive). + // Again, we do this to make things easier to find. + it.data.name.lowercase() + }) + } + + fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { + _childCards.postValue(Resource.Loading()) // always push loading + val visual = withContext(Dispatchers.IO) { - cached.mapNotNull { // TODO FIX - 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 - val movieEpisode = - if (!it.type.isMovieType()) null - else context.getKey( - DOWNLOAD_EPISODE_CACHE, - getFolderName(it.id.toString(), it.id.toString()) - ) - VisualDownloadHeaderCached( - 0, - downloads, - bytes, - currentBytes, - it, - movieEpisode + context.getKeys(folder).mapNotNull { key -> + context.getKey(key) + }.mapNotNull { + val isSelected = selectedItemIds.value?.contains(it.id) ?: false + val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null + VisualDownloadCached.Child( + currentBytes = info.fileLength, + totalBytes = info.totalBytes, + isSelected = isSelected, + data = it, ) - }.sortedBy { - (it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0) - } // episode sorting by episode, lowest to highest - } + } + }.sortedWith( + compareBy( + // Sort by season first, and then by episode number, + // to ensure sorting is consistent. + { it.data.season ?: 0 }, + { it.data.episode } + )) + + postChildren(visual) + } + + private fun removeItems(idsToRemove: Set) = viewModelScope.launchSafe { + _selectedItemIds.postValue(null) + postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove }) + postChildren(_childCards.success?.filter { it.data.id !in idsToRemove }) + } + + private fun updateStorageStats(visual: List) { try { val stat = StatFs(Environment.getExternalStorageDirectory().path) - - val localBytesAvailable = stat.availableBytes//stat.blockSizeLong * stat.blockCountLong + val localBytesAvailable = stat.availableBytes val localTotalBytes = stat.blockSizeLong * stat.blockCountLong val localDownloadedBytes = visual.sumOf { it.totalBytes } - - _usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes) + val localUsedBytes = localTotalBytes - localBytesAvailable + _usedBytes.postValue(localUsedBytes) _availableBytes.postValue(localBytesAvailable) _downloadBytes.postValue(localDownloadedBytes) - } catch (t : Throwable) { + } catch (t: Throwable) { _downloadBytes.postValue(0) logError(t) } - - _headerCards.postValue(visual) } -} + + fun handleMultiDelete(context: Context) = viewModelScope.launchSafe { + val selectedItemsList = getSelectedItemsData().orEmpty() + val deleteData = processSelectedItems(context, selectedItemsList) + val message = buildDeleteMessage(context, deleteData) + showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) + } + + fun handleSingleDelete( + context: Context, + itemId: Int + ) = viewModelScope.launchSafe { + val itemData = getItemDataFromId(itemId) + val deleteData = processSelectedItems(context, itemData) + val message = buildDeleteMessage(context, deleteData) + showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) + } + + private fun processSelectedItems( + context: Context, + selectedItemsList: List + ): DeleteData { + val names = mutableListOf() + val seriesNames = mutableListOf() + + val ids = mutableSetOf() + val parentIds = mutableSetOf() + + var parentName: String? = null + + selectedItemsList.forEach { item -> + when (item) { + is VisualDownloadCached.Header -> { + if (item.data.type.isEpisodeBased()) { + val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) + .mapNotNull { + context.getKey( + it + ) + } + .filter { it.parentId == item.data.id } + .map { it.id } + ids.addAll(episodes) + parentIds.add(item.data.id) + + val episodeInfo = "${item.data.name} (${item.totalDownloads} ${ + context.resources.getQuantityString( + R.plurals.episodes, + item.totalDownloads + ).lowercase() + })" + seriesNames.add(episodeInfo) + } else { + ids.add(item.data.id) + names.add(item.data.name) + } + } + + is VisualDownloadCached.Child -> { + ids.add(item.data.id) + val parent = context.getKey( + DOWNLOAD_HEADER_CACHE, + item.data.parentId.toString() + ) + parentName = parent?.name + names.add( + context.getNameFull( + item.data.name, + item.data.episode, + item.data.season + ) + ) + } + } + } + + return DeleteData(ids, parentIds, seriesNames, names, parentName) + } + + private fun buildDeleteMessage( + context: Context, + data: DeleteData + ): String { + val formattedNames = data.names.sortedBy { it.lowercase() } + .joinToString(separator = "\n") { "• $it" } + val formattedSeriesNames = data.seriesNames.sortedBy { it.lowercase() } + .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.parentName != null && data.names.isNotEmpty() -> { + context.getString(R.string.delete_message_series_episodes) + .format(data.parentName, formattedNames) + } + + data.seriesNames.isNotEmpty() -> { + val seriesSection = context.getString(R.string.delete_message_series_section) + .format(formattedSeriesNames) + context.getString(R.string.delete_message_multiple) + .format(formattedNames) + "\n\n" + seriesSection + } + + else -> context.getString(R.string.delete_message_multiple).format(formattedNames) + } + } + + private fun showDeleteConfirmationDialog( + context: Context, + message: String, + ids: Set, + parentIds: Set + ) { + val builder = AlertDialog.Builder(context) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + viewModelScope.launchSafe { + 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 + // parent header card + removeItems(successfulIds + parentIds) + } + } + } + + DialogInterface.BUTTON_NEGATIVE -> { + // Do nothing on cancel + } + } + } + + try { + val title = if (ids.count() == 1) { + R.string.delete_file + } else R.string.delete_files + builder.setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (e: Exception) { + logError(e) + } + } + + private fun getSelectedItemsData(): List? { + val headers = _headerCards.success.orEmpty() + val children = _childCards.success.orEmpty() + + return selectedItemIds.value?.mapNotNull { id -> + headers.find { it.data.id == id } ?: children.find { it.data.id == id } + } + } + + private fun getItemDataFromId(itemId: Int): List { + return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId } + } + + fun clearChildren() { + _childCards.postValue(Resource.Loading()) + } + + private data class DeleteData( + val ids: Set, + val parentIds: Set, + val seriesNames: List, + 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 b43f1aacf..382a770cd 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 @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.ui.download.button import android.content.Context -import android.text.format.Formatter +import android.text.format.Formatter.formatShortFileSize import android.util.AttributeSet import android.widget.FrameLayout import android.widget.TextView @@ -9,7 +9,9 @@ import androidx.annotation.LayoutRes import androidx.core.view.isVisible import androidx.core.widget.ContentLoadingProgressBar import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.mainWork +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager typealias DownloadStatusTell = VideoDownloadManager.DownloadType @@ -34,7 +36,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : lateinit var progressBar: ContentLoadingProgressBar var progressText: TextView? = null - /*val gid: String? get() = sessionIdToGid[persistentId] + /* val gid: String? get() = sessionIdToGid[persistentId] // used for resuming data var _lastRequestOverride: UriRequest? = null @@ -44,7 +46,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : _lastRequestOverride = value } - var files: List = emptyList()*/ + var files: List = emptyList() */ protected var isZeroBytes: Boolean = true fun inflate(@LayoutRes layout: Int) { @@ -52,12 +54,17 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : } init { + @Suppress("LeakingThis") resetViewData() } + var doSetProgress = true + open fun resetViewData() { // lastRequest = null + progressText = null isZeroBytes = true + doSetProgress = true persistentId = null } @@ -68,37 +75,45 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : persistentId = id currentMetaData.id = id - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)?.let { savedData -> - val downloadedBytes = savedData.fileLength - val totalBytes = savedData.totalBytes + if (!doSetProgress) return + val appContext = context.applicationContext - /*lastRequest = savedData.uriRequest - files = savedData.files + ioSafe { + val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id) + mainWork { + if (savedData != null) { + val downloadedBytes = savedData.fileLength + val totalBytes = savedData.totalBytes - var totalBytes: Long = 0 - var downloadedBytes: Long = 0 - for (file in savedData.files) { - downloadedBytes += file.completedLength - totalBytes += file.length - }*/ - setProgress(downloadedBytes, totalBytes) - // some extra padding for just in case - val status = VideoDownloadManager.downloadStatus[id] - ?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) DownloadStatusTell.IsDone else DownloadStatusTell.IsPaused - currentMetaData.apply { - this.id = id - this.downloadedLength = downloadedBytes - this.totalLength = totalBytes - this.status = status + setProgress(downloadedBytes, totalBytes) + applyMetaData(id, downloadedBytes, totalBytes) + } } - setStatus(status) - } ?: run { - resetView() } } abstract fun setStatus(status: VideoDownloadManager.DownloadType?) + fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell { + // some extra padding for just in case + return VideoDownloadManager.downloadStatus[id] + ?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) { + DownloadStatusTell.IsDone + } else DownloadStatusTell.IsPaused + } + + fun applyMetaData(id: Int, downloadedBytes: Long, totalBytes: Long) { + val status = getStatus(id, downloadedBytes, totalBytes) + + currentMetaData.apply { + this.id = id + this.downloadedLength = downloadedBytes + this.totalLength = totalBytes + this.status = status + } + setStatus(status) + } + open fun setProgress(downloadedBytes: Long, totalBytes: Long) { isZeroBytes = downloadedBytes == 0L progressBar.post { @@ -124,13 +139,16 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : if (isZeroBytes) { progressText?.isVisible = false } else { - progressText?.apply { - val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes) - val totalMbString = Formatter.formatShortFileSize(context, totalBytes) - text = - //if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else - context?.getString(R.string.download_size_format) - ?.format(currentMbString, totalMbString) + if (doSetProgress) { + progressText?.apply { + val currentFormattedSizeString = + formatShortFileSize(context, downloadedBytes) + val totalFormattedSizeString = formatShortFileSize(context, totalBytes) + text = + // if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else + context?.getString(R.string.download_size_format) + ?.format(currentFormattedSizeString, totalFormattedSizeString) + } } } @@ -167,8 +185,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : override fun onAttachedToWindow() { VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent - //VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent - //VideoDownloadManager.downloadEvent += ::downloadEvent + // VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent + // VideoDownloadManager.downloadEvent += ::downloadEvent VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent val pid = persistentId @@ -182,8 +200,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : override fun onDetachedFromWindow() { VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent - //VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent - //VideoDownloadManager.downloadEvent -= ::downloadEvent + // VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent + // VideoDownloadManager.downloadEvent -= ::downloadEvent VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent super.onDetachedFromWindow() @@ -198,5 +216,4 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : * Get a clean slate again, might be useful in recyclerview? * */ abstract fun resetView() - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt index d97a4b887..91c5dd72c 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,16 +8,17 @@ import androidx.core.view.isVisible import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { - var mainText: TextView? = null + private var mainText: TextView? = null override fun onAttachedToWindow() { super.onAttachedToWindow() progressText = findViewById(R.id.result_movie_download_text_precentage) mainText = findViewById(R.id.result_movie_download_text) + setStatus(null) } override fun setStatus(status: DownloadStatusTell?) { @@ -35,7 +36,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) : } override fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt index d20fcf93d..f6f8a5ff8 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 @@ -1,17 +1,23 @@ package com.lagradost.cloudstream3.ui.download.button import android.content.Context -import android.graphics.drawable.Drawable +import android.os.Looper import android.util.AttributeSet import android.util.Log import android.view.View import android.view.animation.AnimationUtils import android.widget.ImageView import android.widget.TextView +import androidx.annotation.MainThread import androidx.core.content.ContextCompat +import androidx.core.content.withStyledAttributes import androidx.core.view.isGone import androidx.core.view.isVisible +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK @@ -20,9 +26,10 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager - +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES open class PieFetchButton(context: Context, attributeSet: AttributeSet) : BaseFetchButton(context, attributeSet) { @@ -41,6 +48,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : private var iconPaused: Int = 0 private var hideWhenIcon: Boolean = true + var progressDrawable: Int = 0 + var overrideLayout: Int? = null companion object { @@ -53,12 +62,12 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } private var progressBarBackground: View - private var statusView: ImageView + var statusView: ImageView open fun onInflate() {} init { - context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply { + context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) { try { inflate( overrideLayout ?: getResourceId( @@ -67,6 +76,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) ) } catch (e: Exception) { + recycle() // Manually call recycle first to avoid memory leaks Log.e( "PieFetchButton", "Error inflating PieFetchButton, " + "check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color" @@ -74,11 +84,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : throw e } - - progressBar = findViewById(R.id.progress_downloaded) - progressBarBackground = findViewById(R.id.progress_downloaded_background) - statusView = findViewById(R.id.image_download_status) - animateWaiting = getBoolean( R.styleable.PieFetchButton_download_animate_waiting, true @@ -87,16 +92,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : R.styleable.PieFetchButton_download_hide_when_icon, true ) - waitingAnimation = getResourceId( R.styleable.PieFetchButton_download_waiting_animation, R.anim.rotate_around_center_point ) - activeOutline = getResourceId( R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape ) - nonActiveOutline = getResourceId( R.styleable.PieFetchButton_download_outline_non_active, R.drawable.circle_shape_dotted @@ -111,10 +113,10 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : R.styleable.PieFetchButton_download_icon_complete, R.drawable.download_icon_done ) iconPaused = getResourceId( - R.styleable.PieFetchButton_download_icon_paused, 0//R.drawable.download_icon_pause + R.styleable.PieFetchButton_download_icon_paused, 0 // R.drawable.download_icon_pause ) iconActive = getResourceId( - R.styleable.PieFetchButton_download_icon_active, 0 //R.drawable.download_icon_load + R.styleable.PieFetchButton_download_icon_active, 0 // R.drawable.download_icon_load ) iconWaiting = getResourceId( R.styleable.PieFetchButton_download_icon_waiting, 0 @@ -124,19 +126,29 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : ) val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0) - - val progressDrawable = getResourceId( + progressDrawable = getResourceId( R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] ) - - progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) - - recycle() } - resetView() + + progressBar = findViewById(R.id.progress_downloaded) + progressBarBackground = findViewById(R.id.progress_downloaded_background) + statusView = findViewById(R.id.image_download_status) + + progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) + + // resetView() onInflate() } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + // Re-run all animations when the view gets visible. + // Otherwise views may run without animations after recycled + setStatusInternal(currentStatus) + } + private var currentStatus: DownloadStatusTell? = null /*private fun getActivity(): Activity? { var context = context @@ -157,15 +169,31 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : }*/ protected fun setDefaultClickListener( - view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached, + view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached, callback: (DownloadClickEvent) -> Unit ) { this.progressText = textView this.setPersistentId(card.id) view.setOnClickListener { if (isZeroBytes) { - callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) - //callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) + val localQueue = queue.value + val localInstances = downloadInstances.value + val id = card.id + + // If the download is already in queue or active downloads, provide an option to cancel it + if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) { + it.popupMenuNoIcons( + arrayListOf( + Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel), + ) + ) { + callback(DownloadClickEvent(itemId, card)) + } + } else { + // Otherwise just start a download instantly + removeKey(KEY_RESUME_PACKAGES, card.id.toString()) + callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) + } } else { val list = arrayListOf( Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), @@ -192,7 +220,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : list ) { callback(DownloadClickEvent(itemId, card)) - //callback.invoke(DownloadClickEvent(itemId, data)) + // callback.invoke(DownloadClickEvent(itemId, data)) } } } @@ -200,20 +228,20 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : view.setOnLongClickListener { callback(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, card)) - //clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data)) + // clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data)) return@setOnLongClickListener true } } open fun setDefaultClickListener( - card: VideoDownloadHelper.DownloadEpisodeCached, + card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { setDefaultClickListener(this, textView, card, callback) } - /*open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List) { + /* open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List) { this.setOnClickListener { when (this.currentStatus) { null -> { @@ -239,42 +267,57 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : else -> {} } } - }*/ + } */ + + @MainThread + private fun setStatusInternal(status: DownloadStatusTell?) { + val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading + if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) { + val animation = AnimationUtils.loadAnimation(context, waitingAnimation) + progressBarBackground.startAnimation(animation) + } else { + progressBarBackground.clearAnimation() + } + + val progressDrawable = + if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline + + progressBarBackground.background = + ContextCompat.getDrawable(context, progressDrawable) + + val drawable = + getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(this.context, it) } + statusView.setImageDrawable(drawable) + val isDrawable = drawable != null + + statusView.isVisible = isDrawable + val hide = hideWhenIcon && isDrawable + if (hide) { + progressBar.clearAnimation() + progressBarBackground.clearAnimation() + } + progressBarBackground.isGone = hide + progressBar.isGone = hide + } /** Also sets currentStatus */ override fun setStatus(status: DownloadStatusTell?) { currentStatus = status - //progressBar.isVisible = - // status != null && status != DownloadStatusTell.Complete && status != DownloadStatusTell.Error - //progressBarBackground.isVisible = status != null && status != DownloadStatusTell.Complete - progressBarBackground.post { - val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading - if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) { - val animation = AnimationUtils.loadAnimation(context, waitingAnimation) - progressBarBackground.startAnimation(animation) - } else { - progressBarBackground.clearAnimation() + // Runs on the main thread, but also instant if it already is. + if (Looper.getMainLooper().isCurrentThread) { + try { + setStatusInternal(status) + } catch (t: Throwable) { + logError(t) // Just in case setStatusInternal throws because thread + progressBarBackground.post { + setStatusInternal(status) + } } - - val progressDrawable = - if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline - - progressBarBackground.background = - ContextCompat.getDrawable(context, progressDrawable) - - val drawable = getDrawableFromStatus(status) - statusView.setImageDrawable(drawable) - val isDrawable = drawable != null - - statusView.isVisible = isDrawable - val hide = hideWhenIcon && isDrawable - if (hide) { - progressBar.clearAnimation() - progressBarBackground.clearAnimation() + } else { + progressBarBackground.post { + setStatusInternal(status) } - progressBarBackground.isGone = hide - progressBar.isGone = hide } } @@ -282,6 +325,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : setStatus(null) currentMetaData = DownloadMetadata(0, 0, 0, null) isZeroBytes = true + doSetProgress = true progressBar.progress = 0 } @@ -305,19 +349,13 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } } - open fun getDrawableFromStatus(status: DownloadStatusTell?): Drawable? { - val drawableInt = when (status) { - DownloadStatusTell.IsPaused -> iconPaused - DownloadStatusTell.IsPending -> iconWaiting - DownloadStatusTell.IsDownloading -> iconActive - DownloadStatusTell.IsFailed -> iconError - DownloadStatusTell.IsDone -> iconComplete - DownloadStatusTell.IsStopped -> iconRemoved - null -> iconInit - } - if (drawableInt == 0) { - return null - } - return ContextCompat.getDrawable(this.context, drawableInt) - } + open fun getDrawableFromStatus(status: DownloadStatusTell?): Int? = when (status) { + DownloadStatusTell.IsPaused -> iconPaused + DownloadStatusTell.IsPending -> iconWaiting + DownloadStatusTell.IsDownloading -> iconActive + DownloadStatusTell.IsFailed -> iconError + DownloadStatusTell.IsDone -> iconComplete + DownloadStatusTell.IsStopped -> iconRemoved + else -> iconInit + }.takeIf { it != 0 } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt new file mode 100644 index 000000000..877fcfea8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt @@ -0,0 +1,274 @@ +package com.lagradost.cloudstream3.ui.download.queue + + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO + +/** An item in the adapter can either be a separator or a real item. + * isCurrentlyDownloading is used to fully update items as opposed to just moving them. */ +class DownloadAdapterItem(val item: DownloadQueueWrapper?) { + val isSeparator = item == null +} + + +class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter( + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.item?.id == b.item?.id }, + contentSame = { a, b -> + a.item == b.item + }) +) { + var currentDownloads = 0 + + companion object { + val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG" + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = DownloadQueueItemBinding.inflate(inflater, parent, false) + return ViewHolderState(binding) + } + + override fun onBindContent( + holder: ViewHolderState, + item: DownloadAdapterItem, + position: Int + ) { + when (val binding = holder.view) { + is DownloadQueueItemBinding -> { + if (item.item == null) { + holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG + bindSeparator(binding) + } else { + holder.itemView.tag = null + bind(binding, item.item) + } + } + } + } + + fun submitQueue(newQueue: DownloadAdapterQueue) { + val index = newQueue.currentDownloads.size + val current = newQueue.currentDownloads + val queue = newQueue.queue + currentDownloads = current.size + + val newList = + (current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList() + .apply { + // Only add the separator if it actually separates something + if (index < this.size) { + add(index, DownloadAdapterItem(null)) + } + } + submitList(newList) + } + + fun bindSeparator(binding: DownloadQueueItemBinding) { + binding.apply { + separatorHolder.isGone = false + downloadChildEpisodeHolder.isGone = true + } + } + + fun bind( + binding: DownloadQueueItemBinding, + queueWrapper: DownloadQueueWrapper, + ) { + val context = binding.root.context + + binding.apply { + separatorHolder.isGone = true + downloadChildEpisodeHolder.isGone = false + + // Only set the child-text if child and parent are not the same + // This prevents setting movie titles twice + if (queueWrapper.id != queueWrapper.parentId) { + val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName + downloadChildEpisodeTextExtra.text = mainName + } else { + downloadChildEpisodeTextExtra.text = null + } + + downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank() + + val status = VideoDownloadManager.downloadStatus[queueWrapper.id] + + downloadButton.setOnClickListener { view -> + val episodeCached = + getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString()) + ) + + val downloadInfo = context.getKey( + KEY_DOWNLOAD_INFO, + queueWrapper.id.toString() + ) + + val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading() + + val actionList = arrayListOf>() + + if (isCurrentlyDownloading && episodeCached != null) { + // KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything + if (downloadInfo != null) { + actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file)) + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + } + + val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id] + + when (currentStatus) { + VideoDownloadManager.DownloadType.IsDownloading -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_PAUSE_DOWNLOAD, + R.string.popup_pause_download + ) + ) + } + + VideoDownloadManager.DownloadType.IsPaused -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_RESUME_DOWNLOAD, + R.string.popup_resume_download + ) + ) + } + + else -> {} + } + + view.popupMenuNoIcons( + actionList + ) { + handleDownloadClick(DownloadClickEvent(itemId, episodeCached)) + } + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + + view.popupMenuNoIcons( + actionList + ) { + when (itemId) { + DOWNLOAD_ACTION_CANCEL_PENDING -> { + DownloadQueueManager.cancelDownload(queueWrapper.id) + } + } + } + } + } + + downloadButton.resetView() + downloadButton.setStatus(status) + downloadButton.setPersistentId(queueWrapper.id) + + downloadChildEpisodeText.apply { + val name = queueWrapper.downloadItem?.episode?.name + ?: queueWrapper.resumePackage?.item?.ep?.name + val episode = + queueWrapper.downloadItem?.episode?.episode + ?: queueWrapper.resumePackage?.item?.ep?.episode + val season = + queueWrapper.downloadItem?.episode?.season + ?: queueWrapper.resumePackage?.item?.ep?.season + text = context.getNameFull(name, episode, season) + isSelected = true // Needed for text repeating + } + } + } +} + + +class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) : + ItemTouchHelper( + DragAndDropTouchHelperCallback(adapter) + ) + +private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) : + ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val item = adapter.getItem(viewHolder.absoluteAdapterPosition) + val isDownloading = item.item?.isCurrentlyDownloading() == true + val dragFlags = if (item.isSeparator || isDownloading) { + 0 + } else { + ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down + } + + val swipeFlags = 0 // Disable swipe functionality + return makeMovementFlags(dragFlags, swipeFlags) + } + + override fun onMove( + recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPosition = source.absoluteAdapterPosition + val toPosition = target.absoluteAdapterPosition + val separatorPosition = adapter.currentDownloads + + val toPositionNoSeparator = + if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition + + if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) { + return false + } else { + adapter.getItem(fromPosition).item?.let { downloadQueueInfo -> + DownloadQueueManager.reorderItem( + downloadQueueInfo, + toPositionNoSeparator - 1 + ) + } + } + + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + + } + + override fun isLongPressDragEnabled(): Boolean { + return true // Enable drag with long press + } + + override fun isItemViewSwipeEnabled(): Boolean { + return false // Disable swipe by default + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt new file mode 100644 index 000000000..071d8913d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt @@ -0,0 +1,79 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.fragment.app.activityViewModels +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.txt + + +class DownloadQueueFragment : + BaseFragment(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) { + private val queueViewModel: DownloadQueueViewModel by activityViewModels() + + override fun onBindingCreated(binding: FragmentDownloadQueueBinding) { + val adapter = DownloadQueueAdapter(this@DownloadQueueFragment) + val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all) + + observe(queueViewModel.childCards) { cards -> + val size = cards.queue.size + cards.currentDownloads.size + val isEmptyQueue = size == 0 + binding.downloadQueueList.isGone = isEmptyQueue + binding.textNoQueue.isGone = !isEmptyQueue + clearQueueItem?.isVisible = !isEmptyQueue + + adapter.submitQueue(cards) + } + + binding.apply { + downloadQueueToolbar.apply { + title = txt(R.string.download_queue).asString(context) + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + dispatchBackPressed() + } + } + setAppBarNoScrollFlagsOnTV() + clearQueueItem?.setOnMenuItemClickListener { + AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setTitle(R.string.cancel_all) + .setMessage(R.string.cancel_queue_message) + .setPositiveButton(R.string.yes) { _, _ -> + DownloadQueueManager.removeAllFromQueue() + } + .setNegativeButton(R.string.no) { _, _ -> + }.show() + + true + } + } + + downloadQueueList.adapter = adapter + + // Drag and drop + val helper = DragAndDropTouchHelper(adapter) + helper.attachToRecyclerView(downloadQueueList) + } + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt new file mode 100644 index 000000000..fc384cb4e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +data class DownloadAdapterQueue( + val currentDownloads: List, + val queue: List, +) + +class DownloadQueueViewModel : ViewModel() { + private val _childCards = MutableLiveData() + val childCards: LiveData = _childCards + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + val current = instances.map { it.downloadQueueWrapper } + DownloadAdapterQueue(current, queue.toList()) + }.combine(VideoDownloadManager.currentDownloads) { total, _ -> + // We want to update the flow when currentDownloads updates, but we do not care about its value + total + } + + init { + viewModelScope.launch { + totalDownloadFlow.collect { queue -> + updateChildList(queue) + } + } + } + + fun updateChildList(downloads: DownloadAdapterQueue) { + _childCards.postValue(downloads) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt index f84966eb4..43f6d19ff 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,202 +1,230 @@ package com.lagradost.cloudstream3.ui.home +import android.content.Context import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import android.widget.FrameLayout +import androidx.preference.PreferenceManager 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.utils.AppUtils.isRtl -import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout +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 HomeChildItemAdapter( - val cardList: MutableList, +class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState(view) { + // 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 + var wasFocused: Boolean = false + override fun save(): Boolean = wasFocused + override fun restore(state: Boolean) { + if (state) { + wasFocused = false + // only refocus if tv + if (isLayout(TV)) { + itemView.requestFocus() + } + } + } +} - private val nextFocusUp: Int? = null, - private val nextFocusDown: Int? = null, - private val clickCallback: (SearchClickCallback) -> Unit, +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( + id: Int, + var nextFocusUp: Int? = null, + var nextFocusDown: Int? = null, + var clickCallback: (SearchClickCallback) -> Unit, ) : - RecyclerView.Adapter() { - var isHorizontal: Boolean = false + BaseAdapter( + id, diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.url == b.url && a.name == b.name + }, + contentSame = { a, b -> + a == b + }) + ) { var hasNext: Boolean = false + var isHorizontal: Boolean = false + set(value) { + field = value + updateCachedPosterSize() + } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val expanded = parent.context.IsBottomLayout() - /* val layout = if (bottom) R.layout.home_result_grid_expanded else R.layout.home_result_grid + private fun updateCachedPosterSize() { + setWidth = if (!isHorizontal) { + minPosterSize + } else { + maxPosterSize + } + setHeight = if (!isHorizontal) { + maxPosterSize + } else { + minPosterSize + } + } - val root = LayoutInflater.from(parent.context).inflate(layout, parent, false) - val binding = HomeResultGridBinding.bind(root)*/ + init { + updateCachedPosterSize() + } + protected var setWidth = 0 + protected var setHeight = 0 + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val expanded = parent.context.isBottomLayout() val inflater = LayoutInflater.from(parent.context) val binding = if (expanded) HomeResultGridExpandedBinding.inflate( inflater, parent, false ) else HomeResultGridBinding.inflate(inflater, parent, false) + 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) } - return CardViewHolder( - binding, - clickCallback, - itemCount, + 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) + + SearchResultBuilder.bind( + clickCallback = { click -> + // ok, so here we hijack the callback to fix the focus + when (click.action) { + SEARCH_ACTION_LOAD -> (holder as? HomeScrollViewHolderState)?.wasFocused = true + } + clickCallback(click) + }, + item, + position, + holder.itemView, nextFocusUp, - nextFocusDown, - isHorizontal, - parent.isRtl() - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.itemCount = itemCount // i know ugly af - holder.bind(cardList[position], position) - } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - override fun getItemId(position: Int): Long { - return (cardList[position].id ?: position).toLong() - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - HomeChildDiffCallback(this.cardList, newList) + nextFocusDown ) - cardList.clear() - cardList.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - class CardViewHolder - constructor( - val binding: ViewBinding, - private val clickCallback: (SearchClickCallback) -> Unit, - var itemCount: Int, - private val nextFocusUp: Int? = null, - private val nextFocusDown: Int? = null, - private val isHorizontal: Boolean = false, - private val isRtl: Boolean - ) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(card: SearchResponse, position: Int) { - - // TV focus fixing - /*val nextFocusBehavior = when (position) { - 0 -> true - itemCount - 1 -> false - else -> null - } - - if (position == 0) { // to fix tv - if (isRtl) { - itemView.nextFocusRightId = R.id.nav_rail_view - itemView.nextFocusLeftId = -1 - } - else { - itemView.nextFocusLeftId = R.id.nav_rail_view - itemView.nextFocusRightId = -1 - } - } else { - itemView.nextFocusRightId = -1 - itemView.nextFocusLeftId = -1 - }*/ - - - when (binding) { - 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, - card, - position, - itemView, - null, // nextFocusBehavior, - nextFocusUp, - nextFocusDown - ) - itemView.tag = position - - //val ani = ScaleAnimation(0.9f, 1.0f, 0.9f, 1f) - //ani.fillAfter = true - //ani.duration = 200 - //itemView.startAnimation(ani) - } + holder.itemView.tag = position } } - -class HomeChildDiffCallback( - 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] && oldItemPosition < oldList.size - 1 // always update the last item -} \ No newline at end of file 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 fa0b6dfb0..b68ef5962 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -5,36 +5,36 @@ import android.app.Activity import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.content.res.Configuration -import android.net.Uri -import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.* +import android.widget.AbsListView +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.ListView +import android.widget.TextView +import android.widget.Toast +import androidx.activity.ComponentActivity import androidx.appcompat.app.AlertDialog +import androidx.core.net.toUri import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.* import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.button.MaterialButton import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup -import com.lagradost.cloudstream3.* +import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent +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,43 +45,51 @@ import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi -import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment -import com.lagradost.cloudstream3.ui.result.txt -import com.lagradost.cloudstream3.ui.search.* +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear +import com.lagradost.cloudstream3.ui.account.AccountViewModel +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE +import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching -import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult -import com.lagradost.cloudstream3.utils.AppUtils.ownHide -import com.lagradost.cloudstream3.utils.AppUtils.ownShow -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +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.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.EmptyEvent import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +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.USER_SELECTED_HOMEPAGE_API +import com.lagradost.cloudstream3.utils.UIHelper.toPx -import java.util.* +private const val TAG = "HomeFragment" - -const val HOME_BOOKMARK_VALUE_LIST = "home_bookmarked_last_list" -const val HOME_PREF_HOMEPAGE = "home_pref_homepage" - -class HomeFragment : Fragment() { +class HomeFragment : BaseFragment( + BindingCreator.Bind(FragmentHomeBinding::bind) +) { companion object { - val configEvent = Event() + // Used for configuration changed events to fix any popups that are not attached to a fragment + val configEvent = EmptyEvent() var currentSpan = 1 - val listHomepageItems = mutableListOf() private val errorProfilePics = listOf( R.drawable.monke_benene, @@ -110,6 +118,7 @@ class HomeFragment : Fragment() { //} // returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView + fun Activity.loadHomepageList( expand: HomeViewModel.ExpandableHomepageList, deleteCallback: (() -> Unit)? = null, @@ -170,7 +179,7 @@ class HomeFragment : Fragment() { } } - builder.setTitle(R.string.delete_file) + builder.setTitle(R.string.clear_history) .setMessage( context.getString(R.string.delete_message).format( item.name @@ -191,16 +200,17 @@ class HomeFragment : Fragment() { // Span settings - binding.homeExpandedRecycler.spanCount = currentSpan - + binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) + binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool) binding.homeExpandedRecycler.adapter = - SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback -> + SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { callback -> handleSearchClickCallback(callback) if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) { bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later //bottomSheetDialogBuilder.dismissSafe(this) } }.apply { + submitList(item.list) hasNext = expand.hasNext } @@ -224,7 +234,7 @@ class HomeFragment : Fragment() { expandCallback?.invoke(name)?.let { newExpand -> (recyclerView.adapter as? SearchAdapter?)?.apply { hasNext = newExpand.hasNext - updateList(newExpand.list.list) + submitList(newExpand.list.list) } } } @@ -232,9 +242,12 @@ class HomeFragment : Fragment() { } }) - val spanListener = { span: Int -> - binding.homeExpandedRecycler.spanCount = span - //(recycle.adapter as SearchAdapter).notifyDataSetChanged() + val spanListener = Runnable { + binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) + // We want to rebind everything to update the UI, however we also want to avoid + // any animations ect, this is the easiest way to do this, and the most correct + @SuppressLint("NotifyDataSetChanged") + binding.homeExpandedRecycler.adapter?.notifyDataSetChanged() } configEvent += spanListener @@ -250,7 +263,7 @@ class HomeFragment : Fragment() { return bottomSheetDialogBuilder } - fun getPairList( + private fun getPairList( anime: Chip?, cartoons: Chip?, tvs: Chip?, @@ -258,18 +271,20 @@ class HomeFragment : Fragment() { 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, TvType.Torrent)), + Pair(movies, listOf(TvType.Movie)), 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)), ) @@ -283,6 +298,7 @@ class HomeFragment : Fragment() { header.homeSelectMovies, header.homeSelectAsian, header.homeSelectLivestreams, + header.homeSelectTorrents, header.homeSelectNsfw, header.homeSelectOthers ) @@ -301,7 +317,7 @@ class HomeFragment : Fragment() { val pairList = getPairList(header) for ((button, types) in pairList) { button?.isChecked = - button?.isVisible == true && selectedTypes.any { types.contains(it) } + button.isVisible && selectedTypes.any { types.contains(it) } } } @@ -329,7 +345,7 @@ class HomeFragment : Fragment() { button?.isVisible = isValid button?.isChecked = isValid && selectedTypes.any { types.contains(it) } button?.isFocusable = true - if (isTrueTvSettings()) { + if (isLayout(TV)) { button?.isFocusableInTouchMode = true } @@ -378,10 +394,7 @@ class HomeFragment : Fragment() { var currentApiName = selectedApiName var currentValidApis: MutableList = mutableListOf() - val preSelectedTypes = this.getKey>(HOME_PREF_HOMEPAGE) - ?.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } } - ?.toMutableList() - ?: mutableListOf(TvType.Movie, TvType.TvSeries) + val preSelectedTypes = DataStoreHelper.homePreference.toMutableList() binding.cancelBtt.setOnClickListener { dialog.dismissSafe() @@ -394,8 +407,31 @@ class HomeFragment : Fragment() { dialog.dismissSafe() } + var pinnedphashset = DataStoreHelper.pinnedProviders.toHashSet() + val listView = dialog.findViewById(R.id.listview1) - val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) + + val arrayAdapter = object : ArrayAdapter( + this, R.layout.sort_bottom_single_provider_choice, + mutableListOf() + ) { + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup + ): View { + val view = convertView ?: LayoutInflater.from(context) + .inflate(R.layout.sort_bottom_single_provider_choice, parent, false) + val titleText = view.findViewById(R.id.text1) + val pinIcon = view.findViewById(R.id.pinicon) + val name = getItem(position) + titleText?.text = name + val isPinned = + pinnedphashset.contains(currentValidApis[position].name) + pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE + return view + } + } listView?.adapter = arrayAdapter listView?.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -403,21 +439,39 @@ class HomeFragment : Fragment() { if (currentValidApis.isNotEmpty()) { currentApiName = currentValidApis[i].name //to switch to apply simply remove this - currentApiName?.let(callback) + currentApiName.let(callback) dialog.dismissSafe() } } fun updateList() { - this.setKey(HOME_PREF_HOMEPAGE, preSelectedTypes) - + DataStoreHelper.homePreference = preSelectedTypes + val pinnedp = DataStoreHelper.pinnedProviders.toList() + pinnedphashset = pinnedp.toHashSet() arrayAdapter.clear() - currentValidApis = validAPIs.filter { api -> - api.hasMainPage && api.supportedTypes.any { - preSelectedTypes.contains(it) + val sortedApis = validAPIs + .filter { + it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any( + preSelectedTypes::contains + )) } - }.sortedBy { it.name.lowercase() }.toMutableList() - currentValidApis.addAll(0, validAPIs.subList(0, 2)) + .sortedBy { it.name.lowercase() } + + val sortedApiMap = LinkedHashMap().apply { + sortedApis.forEach { put(it.name, it) } + } + + val pinnedApis = pinnedp.asReversed().mapNotNull { name -> + sortedApiMap[name] + } + + val remainingApis = sortedApis.filterNot { pinnedphashset.contains(it.name) } + + currentValidApis = mutableListOf().apply { + addAll(validAPIs.take(2)) + addAll(pinnedApis) + addAll(remainingApis) + } val names = currentValidApis.map { if (isMultiLang) "${getFlagFromIso(it.lang)?.plus(" ") ?: ""}${it.name}" else it.name } @@ -426,6 +480,21 @@ class HomeFragment : Fragment() { arrayAdapter.addAll(names) arrayAdapter.notifyDataSetChanged() } + // pin provider on hold + listView?.setOnItemLongClickListener { _, _, i, _ -> + if (currentValidApis.isNotEmpty() && i > 1) { + val pinnedp = DataStoreHelper.pinnedProviders.toMutableList() + val thisapi = currentValidApis[i].name + if (pinnedp.contains(thisapi)) { + pinnedp.remove(thisapi) + } else { + pinnedp.add(thisapi) + } + DataStoreHelper.pinnedProviders = pinnedp.toTypedArray() + updateList() + } + true + } bindChips( binding.tvtypesChipsScroll.tvtypesChips, @@ -442,46 +511,71 @@ class HomeFragment : Fragment() { } private val homeViewModel: HomeViewModel by activityViewModels() + private val accountViewModel: AccountViewModel by activityViewModels() - var binding: FragmentHomeBinding? = null + fun addMovies(cards: List) { + val ctx = context ?: run { + Log.e(TAG, "Context is null, aborting addMovies") + return + } + try { + val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) + if (existingId != null) { + Log.d(TAG, "Channel ID: $existingId") + + val programCards = cards + + TvChannelUtils.addPrograms( + context = ctx, + channelId = existingId, + items = programCards + ) + } else { + Log.d(TAG, "Channel does not exist") + } + } catch (e: Exception) { + Log.e(TAG, "Error adding movies: $e") + } + } + + private fun deleteAll() { + val ctx = context ?: run { + Log.e(TAG, "Context is null, aborting deleteAll") + return + } + + try { + val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) + if (existingId != null) { + Log.d(TAG, "Channel ID: $existingId") + TvChannelUtils.deleteStoredPrograms(ctx) + } else { + Log.d(TAG, "Channel does not exist") + } + } catch (e: Exception) { + Log.e(TAG, "Error deleting programs: ${e.message}") + } + } + + override fun pickLayout(): Int? = + if (isLayout(PHONE)) R.layout.fragment_home else R.layout.fragment_home_tv override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - //homeViewModel = - // ViewModelProvider(this).get(HomeViewModel::class.java) - bottomSheetDialog?.ownShow() - val layout = - if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home - val root = inflater.inflate(layout, container, false) - binding = try { - FragmentHomeBinding.bind(root) - } catch (t: Throwable) { - showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG) - logError(t) - null - } - - return root + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { + (activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress") bottomSheetDialog?.ownHide() - binding = null super.onDestroyView() } - private fun fixGrid() { - activity?.getSpanCount()?.let { - currentSpan = it - } - configEvent.invoke(currentSpan) - } - private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) @@ -495,62 +589,136 @@ class HomeFragment : Fragment() { }*/ } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - //(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.notifyDataSetChanged() - fixGrid() - } - private var currentApiName: String? = null private var toggleRandomButton = false private var bottomSheetDialog: BottomSheetDialog? = null + private var homeMasterAdapter: HomeParentItemAdapterPreview? = null + 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 onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixGrid() - - binding?.apply { + override fun onBindingCreated(binding: FragmentHomeBinding) { + context?.let { HomeChildItemAdapter.updatePosterSize(it) } + (activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { + handleTvBackPress(this) + } + binding.apply { //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) homeApiFab.setOnClickListener(apiChangeClickListener) + homeApiFab.setOnLongClickListener { + if (currentApiName == noneApi.name) return@setOnLongClickListener false + homeViewModel.loadAndCancel(currentApiName, forceReload = true, fromUI = true) + showToast(R.string.action_reload, Toast.LENGTH_SHORT) + true + } homeChangeApi.setOnClickListener(apiChangeClickListener) - homeSwitchAccount.setOnClickListener { v -> - DataStoreHelper.showWhoIsWatching(v?.context ?: return@setOnClickListener) - } - homeRandom.setOnClickListener { - if (listHomepageItems.isNotEmpty()) { - activity.loadSearchResult(listHomepageItems.random()) - } + homeSwitchAccount.setOnClickListener { + activity?.showAccountSelectLinear() } - homeMasterRecycler.adapter = - HomeParentItemAdapterPreview( - mutableListOf(), - homeViewModel + homeMasterAdapter = HomeParentItemAdapterPreview( + homeViewModel, accountViewModel + ) + homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) + homeMasterRecycler.adapter = homeMasterAdapter + + homeApiFab.isVisible = isLayout(PHONE) + + homePreviewReloadProvider.setOnClickListener { + homeViewModel.loadAndCancel( + homeViewModel.apiName.value ?: noneApi.name, + forceReload = true, + fromUI = true ) - //fixPaddingStatusbar(homeLoadingStatusbar) + showToast(R.string.action_reload, Toast.LENGTH_SHORT) + true + } - homeApiFab.isVisible = !isTvSettings() + homePreviewSearchButton.setOnClickListener { _ -> + // Open blank screen. + homeViewModel.queryTextSubmit("") + } homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - if (dy > 0) { //check for scroll down - homeApiFab.shrink() // hide - homeRandom.shrink() - } else if (dy < -5) { - if (!isTvSettings()) { - homeApiFab.extend() // show - homeRandom.extend() + if (isLayout(PHONE)) { + // Fab is only relevant to Phone + if (dy > 0) { //check for scroll down + homeApiFab.shrink() // hide + homeRandom.shrink() + } else if (dy < -5) { + if (isLayout(PHONE)) { + homeApiFab.extend() // show + homeRandom.extend() + } + } + } else { + // Header scrolling is only relevant to TV/Emulator + + val view = recyclerView.findViewHolderForAdapterPosition(0)?.itemView + val scrollParent = binding.homeApiHolder + + if (view == null) { + // The first view is not visible, so we can assume we have scrolled past it + scrollParent.isVisible = false + } else { + // A bit weird, but this is a major limitation we are working around here + // 1. We cant have a real parent to the recyclerview as android cant layout that without lagging + // 2. We cant put the view in the recyclerview, as it should always be shown + // 3. We cant mirror the view in the recyclerview as then it causes focus issues when swaping out the mirror view + // + // This means that if we want to have a parent view to the recyclerview we are out of luck + // Instead this uses getLocationInWindow to calculate how much the view should be scrolled + // as recyclerView has no scrollY (always 0) + // + // Then it manually "scrolls" it to the correct position + // + // Hopefully getLocationInWindow acts correctly on all devices + val rect = IntArray(2) + view.getLocationInWindow(rect) + scrollParent.isVisible = true + scrollParent.translationY = rect[1].toFloat() - 60.toPx } } super.onScrolled(recyclerView, dx, dy) } }) - } + } //Load value for toggling Random button. Hide at startup context?.let { @@ -559,51 +727,61 @@ class HomeFragment : Fragment() { settingsManager.getBoolean( getString(R.string.random_button_key), false - ) && !isTvSettings() - binding?.homeRandom?.visibility = View.GONE + ) + binding.homeRandom.visibility = View.GONE + binding.homeRandomButtonTv.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> currentApiName = apiName - binding?.homeApiFab?.text = apiName - binding?.homeChangeApi?.text = apiName + binding.apply { + homeApiFab.text = apiName + homeChangeApi.text = apiName + homePreviewReloadProvider.isGone = (apiName == noneApi.name) + homePreviewSearchButton.isGone = (apiName == noneApi.name) + } } observe(homeViewModel.page) { data -> - binding?.apply { + binding.apply { when (data) { is Resource.Success -> { - homeLoadingShimmer.stopShimmer() - val d = data.value - val mutableListOfResponse = mutableListOf() - listHomepageItems.clear() + (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map { + it.copy( + list = it.list.copy(list = it.list.list.toMutableList()) + ) + }) - (homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList( - d.values.toMutableList(), - homeMasterRecycler - ) + saveHomepageToTV(d) homeLoading.isVisible = false homeLoadingError.isVisible = false homeMasterRecycler.isVisible = true + homeLoadingShimmer.stopShimmer() //home_loaded?.isVisible = true if (toggleRandomButton) { - //Flatten list - d.values.forEach { dlist -> - mutableListOfResponse.addAll(dlist.list.list) + val distinct = d.values + .flatMap { it.list.list } + .distinctBy { it.url } + val hasItems = distinct.isNotEmpty() + val isPhone = isLayout(PHONE) + val randomClickListener = View.OnClickListener { + distinct.randomOrNull()?.let { activity.loadSearchResult(it) } } - listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url }) - homeRandom.isVisible = listHomepageItems.isNotEmpty() + homeRandom.isVisible = isPhone && hasItems + homeRandom.setOnClickListener(randomClickListener) + homeRandomButtonTv.isVisible = !isPhone && hasItems + homeRandomButtonTv.setOnClickListener(randomClickListener) } else { homeRandom.isGone = true + homeRandomButtonTv.isGone = true } } is Resource.Failure -> { homeLoadingShimmer.stopShimmer() - resultErrorText.text = data.errorString homeReloadConnectionerror.setOnClickListener(apiChangeClickListener) homeReloadConnectionOpenInBrowser.setOnClickListener { view -> val validAPIs = apis//.filter { api -> api.hasMainPage } @@ -616,7 +794,7 @@ class HomeFragment : Fragment() { }) { try { val i = Intent(Intent.ACTION_VIEW) - i.data = Uri.parse(validAPIs[itemId].mainUrl) + i.data = validAPIs[itemId].mainUrl.toUri() startActivity(i) } catch (e: Exception) { logError(e) @@ -626,26 +804,50 @@ class HomeFragment : Fragment() { homeLoading.isVisible = false homeLoadingError.isVisible = true - homeMasterRecycler.isVisible = false - //home_loaded?.isVisible = false + 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() + } } is Resource.Loading -> { - (homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList(listOf()) homeLoadingShimmer.startShimmer() homeLoading.isVisible = true homeLoadingError.isVisible = false - homeMasterRecycler.isVisible = false + homeMasterRecycler.isInvisible = true + (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { + submitList(null) + clearState() + } //home_loaded?.isVisible = false } } } } - - //context?.fixPaddingStatusbarView(home_statusbar) - //context?.fixPaddingStatusbar(home_padding) - observeNullable(homeViewModel.popup) { item -> if (item == null) { bottomSheetDialog?.dismissSafe() @@ -658,16 +860,18 @@ class HomeFragment : Fragment() { return@observeNullable } - bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = { + val (items, delete) = item + + bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = { homeViewModel.expandAndReturn(it) }, dismissCallback = { homeViewModel.popup(null) bottomSheetDialog = null - }) + }, deleteCallback = delete) } homeViewModel.reloadStored() - homeViewModel.loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), false) + homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false) //loadHomePage(false) // nice profile pic on homepage @@ -676,7 +880,7 @@ class HomeFragment : Fragment() { //TODO READD THIS /*for (syncApi in OAuth2Apis) { - val login = syncApi.loginInfo() + val login = SyncAPI2.loginInfo() val pic = login?.profilePicture if (home_profile_picture?.setImage( pic, @@ -688,4 +892,44 @@ class HomeFragment : Fragment() { } }*/ } + + private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) { + // Only apply custom behavior on TV interface + if (!isLayout(TV)) { + helper.runDefault() + return + } + val currentFocus = activity?.currentFocus ?: run { + helper.runDefault() + return + } + // isInsideRecycle is true when focus is inside home_master_recycler + var parent = currentFocus.parent + var isInsideRecycler = false + while (parent != null) { + if (parent is View && parent.id == R.id.home_master_recycler) { + isInsideRecycler = true + break + } + parent = parent.parent + } + when { + // Case 1: Focus is within plugin content -> Move to plugin selector + isInsideRecycler -> { + binding?.homeMasterRecycler?.scrollToPosition(0) + // Defer focus request until after scroll ends + binding?.homeChangeApi?.post { + binding?.homeChangeApi?.requestFocus() + } + } + // Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation + currentFocus.id == R.id.home_change_api || + currentFocus.id == R.id.home_preview_reload_provider || + currentFocus.id == R.id.home_preview_search_button -> { + activity?.findViewById(R.id.navigation_home)?.requestFocus() + } + // Case 3: Any other location -> Use default back behavior + else -> helper.runDefault() + } + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index 163a60a1d..6bdd1bf49 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 @@ -1,22 +1,30 @@ package com.lagradost.cloudstream3.ui.home +import android.os.Build +import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.HomePageList +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.HomepageParentBinding +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.search.SearchFragment.Companion.filterSearchResponse -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable +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.isRecyclerScrollable class LoadClickCallback( val action: Int = 0, @@ -26,191 +34,108 @@ class LoadClickCallback( ) open class ParentItemAdapter( - private var items: MutableList, - //private val viewModel: HomeViewModel, + id: Int, private val clickCallback: (SearchClickCallback) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val expandCallback: ((String) -> Unit)? = null, -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - - val root = LayoutInflater.from(parent.context).inflate( - if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent, - parent, - false - ) - - val binding = HomepageParentBinding.bind(root) - - return ParentViewHolder( - binding, - clickCallback, - moreInfoClickCallback, - expandCallback - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ParentViewHolder -> { - holder.bind(items[position]) - } - } - } - - override fun getItemCount(): Int { - return items.size - } - - override fun getItemId(position: Int): Long { - return items[position].list.name.hashCode().toLong() - } - - @JvmName("updateListHomePageList") - fun updateList(newList: List) { - updateList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) } - .toMutableList()) - } - - @JvmName("updateListExpandableHomepageList") - fun updateList( - newList: MutableList, - recyclerView: RecyclerView? = null - ) { - // this - // 1. prevents deep copy that makes this.items == newList - // 2. filters out undesirable results - // 3. moves empty results to the bottom (sortedBy is a stable sort) - val new = - newList.map { it.copy(list = it.list.copy(list = it.list.list.filterSearchResponse())) } - .sortedBy { it.list.list.isEmpty() } - - val diffResult = DiffUtil.calculateDiff( - SearchDiffCallback(items, new) - ) - items.clear() - items.addAll(new) - - //val mAdapter = this - val delta = if (this@ParentItemAdapter is HomeParentItemAdapterPreview) { - headItems - } else { - 0 - } - - diffResult.dispatchUpdatesTo(object : ListUpdateCallback { - override fun onInserted(position: Int, count: Int) { - //notifyItemRangeChanged(position + delta, count) - notifyItemRangeInserted(position + delta, count) - } - - override fun onRemoved(position: Int, count: Int) { - notifyItemRangeRemoved(position + delta, count) - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - notifyItemMoved(fromPosition + delta, toPosition + delta) - } - - override fun onChanged(_position: Int, count: Int, payload: Any?) { - - val position = _position + delta - - // I know kinda messy, what this does is using the update or bind instead of onCreateViewHolder -> bind - recyclerView?.apply { - // this loops every viewHolder in the recycle view and checks the position to see if it is within the update range - val missingUpdates = (position until (position + count)).toMutableSet() - for (i in 0 until itemCount) { - val child = getChildAt(i) ?: continue - val viewHolder = getChildViewHolder(child) ?: continue - if (viewHolder !is ParentViewHolder) continue - - val absolutePosition = viewHolder.bindingAdapterPosition - if (absolutePosition >= position && absolutePosition < position + count) { - val expand = items.getOrNull(absolutePosition - delta) ?: continue - missingUpdates -= absolutePosition - //println("Updating ${viewHolder.title.text} ($absolutePosition $position) -> ${expand.list.name}") - if (viewHolder.title.text == expand.list.name) { - viewHolder.update(expand) - } else { - viewHolder.bind(expand) - } - } - } - - // just in case some item did not get updated - for (i in missingUpdates) { - notifyItemChanged(i, payload) - } - } ?: run { - // in case we don't have a nice - notifyItemRangeChanged(position, count, payload) - } - } +) : BaseAdapter( + id, + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.list.name == b.list.name }, + contentSame = { a, b -> + a.list.list == b.list.list }) - - //diffResult.dispatchUpdatesTo(this) +) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 4) } } - class ParentViewHolder - constructor( - val binding: HomepageParentBinding, - // val viewModel: HomeViewModel, - private val clickCallback: (SearchClickCallback) -> Unit, - private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, - private val expandCallback: ((String) -> Unit)? = null, - ) : - RecyclerView.ViewHolder(binding.root) { - val title: TextView = binding.homeChildMoreInfo - private val recyclerView: RecyclerView = binding.homeChildRecyclerview - private val startFocus = R.id.nav_rail_view - private val endFocus = FOCUS_SELF - fun update(expand: HomeViewModel.ExpandableHomepageList) { - val info = expand.list - (recyclerView.adapter as? HomeChildItemAdapter?)?.apply { - updateList(info.list.toMutableList()) - hasNext = expand.hasNext - } ?: run { - recyclerView.adapter = HomeChildItemAdapter( - info.list.toMutableList(), + data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { + override fun save(): Bundle = Bundle().apply { + val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview + putParcelable( + "value", + recyclerView?.layoutManager?.onSaveInstanceState() + ) + (recyclerView?.adapter as? BaseAdapter<*, *>)?.save(recyclerView) + } + + override fun restore(state: Bundle) { + (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( + state.getSafeParcelable("value") + ) + } + } + + override fun submitList( + list: Collection?, + commitCallback: Runnable? + ) { + super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) + } + + override fun onUpdateContent( + holder: ViewHolderState, + item: HomeViewModel.ExpandableHomepageList, + position: Int + ) { + val binding = holder.view + if (binding !is HomepageParentBinding) return + (binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list) + } + + override fun onBindContent( + holder: ViewHolderState, + item: HomeViewModel.ExpandableHomepageList, + position: Int + ) { + val startFocus = R.id.nav_rail_view + val endFocus = FOCUS_SELF + val binding = holder.view + 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 = recyclerView.nextFocusUpId, - nextFocusDown = recyclerView.nextFocusDownId, + nextFocusUp = homeChildRecyclerview.nextFocusUpId, + nextFocusDown = homeChildRecyclerview.nextFocusDownId, ).apply { isHorizontal = info.isHorizontalImages - hasNext = expand.hasNext + 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) } - recyclerView.setLinearListLayout( - isHorizontal = true, - nextLeft = startFocus, - nextRight = endFocus, - ) } - } - fun bind(expand: HomeViewModel.ExpandableHomepageList) { - val info = expand.list - recyclerView.adapter = HomeChildItemAdapter( - info.list.toMutableList(), - clickCallback = clickCallback, - nextFocusUp = recyclerView.nextFocusUpId, - nextFocusDown = recyclerView.nextFocusDownId, - ).apply { - isHorizontal = info.isHorizontalImages - hasNext = expand.hasNext - } - recyclerView.setLinearListLayout( + homeChildRecyclerview.setLinearListLayout( isHorizontal = true, nextLeft = startFocus, nextRight = endFocus, ) - title.text = info.name + homeChildMoreInfo.text = info.name - recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + homeChildRecyclerview.addOnScrollListener(object : + RecyclerView.OnScrollListener() { var expandCount = 0 - val name = expand.list.name + val name = item.list.name - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + override fun onScrollStateChanged( + recyclerView: RecyclerView, + newState: Int + ) { super.onScrollStateChanged(recyclerView, newState) val adapter = recyclerView.adapter @@ -234,27 +159,35 @@ open class ParentItemAdapter( }) //(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged() - if (!isTvSettings()) { - title.setOnClickListener { - moreInfoClickCallback.invoke(expand) + if (isLayout(PHONE)) { + homeChildMoreInfo.setOnClickListener { + moreInfoClickCallback.invoke(item) } } } } + + override fun onCreateContent(parent: ViewGroup): ParentItemHolder { + val layoutResId = when { + isLayout(TV) -> R.layout.homepage_parent_tv + isLayout(EMULATOR) -> R.layout.homepage_parent_emulator + else -> R.layout.homepage_parent + } + + val inflater = LayoutInflater.from(parent.context) + val binding = try { + HomepageParentBinding.bind(inflater.inflate(layoutResId, parent, false)) + } catch (t: Throwable) { + logError(t) + // just in case someone forgot we don't want to crash + HomepageParentBinding.inflate(inflater) + } + + return ParentItemHolder(binding) + } } -class SearchDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].list.name == newList[newItemPosition].list.name - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = - oldList[oldItemPosition] == newList[newItemPosition] -} \ No newline at end of file +@Suppress("DEPRECATION") +inline fun Bundle.getSafeParcelable(key: String): T? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key) + else getParcelable(key, T::class.java) \ No newline at end of file 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 13497a998..959806e56 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,20 +1,26 @@ 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.lifecycle.findViewTreeLifecycleOwner import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipGroup -import com.lagradost.cloudstream3.APIHolder.getId -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import com.google.android.material.navigation.NavigationBarItemView +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse @@ -25,166 +31,221 @@ 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.home.HomeFragment.Companion.selectHomepage +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.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 import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA import com.lagradost.cloudstream3.ui.search.SearchClickCallback -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +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( - items: MutableList, private val viewModel: HomeViewModel, -) : ParentItemAdapter(items, clickCallback = { - viewModel.click(it) -}, moreInfoClickCallback = { - viewModel.popup(it) -}, expandCallback = { - viewModel.expand(it) -}) { - val headItems = 1 + private val accountViewModel: AccountViewModel +) : ParentItemAdapter( + id = "HomeParentItemAdapterPreview".hashCode(), + clickCallback = { + viewModel.click(it) + }, moreInfoClickCallback = { + viewModel.popup(it) + }, expandCallback = { + viewModel.expand(it) + }) { + override val headers = 1 + override fun onCreateHeader(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = if (isLayout(TV or EMULATOR)) FragmentHomeHeadTvBinding.inflate( + inflater, + parent, + false + ) else FragmentHomeHeadBinding.inflate(inflater, parent, false) - companion object { - private const val VIEW_TYPE_HEADER = 2 - private const val VIEW_TYPE_ITEM = 1 - } + if (binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR)) { + binding.homeBookmarkParentItemMoreInfo.isVisible = true - override fun getItemViewType(position: Int) = when (position) { - 0 -> VIEW_TYPE_HEADER - else -> VIEW_TYPE_ITEM - } + val marginInDp = 50 + val density = binding.horizontalScrollChips.context.resources.displayMetrics.density + val marginInPixels = (marginInDp * density).toInt() - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is HeaderViewHolder -> {} - else -> super.onBindViewHolder(holder, position - headItems) + val params = binding.horizontalScrollChips.layoutParams as ViewGroup.MarginLayoutParams + params.marginEnd = marginInPixels + binding.horizontalScrollChips.layoutParams = params + binding.homeWatchParentItemTitle.setCompoundDrawablesWithIntrinsicBounds( + null, + null, + ContextCompat.getDrawable( + parent.context, + R.drawable.ic_baseline_arrow_forward_24 + ), + null + ) } + + return HeaderViewHolder(binding, viewModel, accountViewModel) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return when (viewType) { - VIEW_TYPE_HEADER -> { - val inflater = LayoutInflater.from(parent.context) - val binding = if (isTvSettings()) FragmentHomeHeadTvBinding.inflate( - inflater, - parent, - false - ) else FragmentHomeHeadBinding.inflate(inflater, parent, false) - HeaderViewHolder( - binding, - viewModel, - ) - } - - VIEW_TYPE_ITEM -> super.onCreateViewHolder(parent, viewType) - else -> error("Unhandled viewType=$viewType") - } + override fun onBindHeader(holder: ViewHolderState) { + (holder as? HeaderViewHolder)?.bind() } - override fun getItemCount(): Int { - return super.getItemCount() + headItems - } - - override fun getItemId(position: Int): Long { - if (position == 0) return 0//previewData.hashCode().toLong() - return super.getItemId(position - headItems) - } - - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + override fun onViewDetachedFromWindow(holder: ViewHolderState) { when (holder) { is HeaderViewHolder -> { holder.onViewDetachedFromWindow() } - - else -> super.onViewDetachedFromWindow(holder) } } - override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { + override fun onViewAttachedToWindow(holder: ViewHolderState) { when (holder) { is HeaderViewHolder -> { holder.onViewAttachedToWindow() } - - else -> super.onViewAttachedToWindow(holder) } } - class HeaderViewHolder - constructor( + private class HeaderViewHolder( val binding: ViewBinding, val viewModel: HomeViewModel, - ) : RecyclerView.ViewHolder(binding.root) { - private var previewAdapter: HomeScrollAdapter = HomeScrollAdapter() - private var resumeAdapter: HomeChildItemAdapter = HomeChildItemAdapter( - ArrayList(), - nextFocusUp = itemView.nextFocusUpId, - 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 + accountViewModel: AccountViewModel, + ) : + ViewHolderState(binding) { + + override fun save(): Bundle = + Bundle().apply { + putParcelable( + "resumeRecyclerView", + resumeRecyclerView.layoutManager?.onSaveInstanceState() ) - ) { (isTv, actionId) -> - when (actionId + if (isTv) 0 else 1) { - // play - 0 -> { - viewModel.click( - SearchClickCallback( - START_ACTION_RESUME_LATEST, - callback.view, - -1, - callback.card + putParcelable( + "bookmarkRecyclerView", + bookmarkRecyclerView.layoutManager?.onSaveInstanceState() + ) + //putInt("previewViewpager", previewViewpager.currentItem) + } + + override fun restore(state: Bundle) { + state.getSafeParcelable("resumeRecyclerView")?.let { recycle -> + resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle) + } + state.getSafeParcelable("bookmarkRecyclerView")?.let { recycle -> + bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle) + } + } + + val previewAdapter = HomeScrollAdapter { view, position, item -> + viewModel.click( + LoadClickCallback(0, view, position, item) + ) + } + + private val resumeAdapter = ResumeItemAdapter( + 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 + ) ) ) - } - //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) + setNegativeButton(R.string.cancel) { _, _ -> /*NO-OP*/ } + setPositiveButton(R.string.delete) { _, _ -> + DataStoreHelper.deleteAllResumeStateIds() 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 var bookmarkAdapter: HomeChildItemAdapter = HomeChildItemAdapter( - ArrayList(), + }) + private val bookmarkAdapter = HomeChildItemAdapter( + id = "bookmarkAdapter".hashCode(), nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId ) { callback -> @@ -192,6 +253,12 @@ class HomeParentItemAdapterPreview( viewModel.click(callback) return@HomeChildItemAdapter } + + (callback.view.context?.getActivity() as? MainActivity)?.loadPopup( + callback.card, + load = false + ) + /* callback.view.context?.getActivity()?.showOptionSelectStringRes( callback.view, callback.card.posterUrl, @@ -237,74 +304,108 @@ class HomeParentItemAdapterPreview( } } } + */ } - private val previewViewpager: ViewPager2 = itemView.findViewById(R.id.home_preview_viewpager) private val previewViewpagerText: ViewGroup = itemView.findViewById(R.id.home_preview_viewpager_text) - // private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview) - private var resumeHolder: View = itemView.findViewById(R.id.home_watch_holder) - private var resumeRecyclerView: RecyclerView = + // private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview) + private val resumeHolder: View = itemView.findViewById(R.id.home_watch_holder) + private val resumeRecyclerView: RecyclerView = itemView.findViewById(R.id.home_watch_child_recyclerview) - private var bookmarkHolder: View = itemView.findViewById(R.id.home_bookmarked_holder) - private var bookmarkRecyclerView: RecyclerView = + private val bookmarkHolder: View = itemView.findViewById(R.id.home_bookmarked_holder) + private val bookmarkRecyclerView: RecyclerView = itemView.findViewById(R.id.home_bookmarked_child_recyclerview) - private var homeAccount: View? = - itemView.findViewById(R.id.home_preview_switch_account) + private val headProfilePic: ImageView? = itemView.findViewById(R.id.home_head_profile_pic) + private val headProfilePicCard: View? = + itemView.findViewById(R.id.home_head_profile_padding) - private var topPadding : View? = itemView.findViewById(R.id.home_padding) + private val alternateHeadProfilePic: ImageView? = + itemView.findViewById(R.id.alternate_home_head_profile_pic) + private val alternateHeadProfilePicCard: View? = + itemView.findViewById(R.id.alternate_home_head_profile_padding) + + private val topPadding: View? = itemView.findViewById(R.id.home_padding) + + private val alternativeAccountPadding: View? = + itemView.findViewById(R.id.alternative_account_padding) private val homeNonePadding: View = itemView.findViewById(R.id.home_none_padding) - private val previewCallback: ViewPager2.OnPageChangeCallback = - object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - previewAdapter.apply { - if (position >= itemCount - 1 && hasMoreItems) { - hasMoreItems = false // don't make two requests - viewModel.loadMoreHomeScrollResponses() - } - } - val item = previewAdapter.getItem(position) ?: return - onSelect(item, position) - } - } - fun onSelect(item: LoadResponse, position: Int) { (binding as? FragmentHomeHeadTvBinding)?.apply { - homePreviewDescription.isGone = - item.plot.isNullOrBlank() - homePreviewDescription.text = - item.plot ?: "" + homePreviewDescription.isGone = item.plot.isNullOrBlank() + homePreviewDescription.text = item.plot?.html() ?: "" - homePreviewText.text = item.name - populateChips(homePreviewTags,item.tags ?: emptyList(), R.style.ChipFilledSemiTransparent) + 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() + populateChips( + homePreviewTags, + item.tags?.take(6) ?: emptyList(), + R.style.ChipFilledSemiTransparent, + null + ) + + + bindLogo( + url = item.logoUrl, + headers = item.posterHeaders, + titleView = homePreviewText, + logoView = homeBackgroundPosterWatermarkBadgeHolder + ) homePreviewTags.isGone = item.tags.isNullOrEmpty() - homePreviewPlayBtt.setOnClickListener { view -> - viewModel.click( - LoadClickCallback( - START_ACTION_RESUME_LATEST, - view, - position, - item - ) - ) - } - homePreviewInfoBtt.setOnClickListener { view -> viewModel.click( LoadClickCallback(0, view, position, item) ) } - } (binding as? FragmentHomeHeadBinding)?.apply { //homePreviewImage.setImage(item.posterUrl, item.posterHeaders) @@ -343,66 +444,54 @@ class HomeParentItemAdapterPreview( homePreviewBookmark.setOnClickListener { fab -> fab.context.getActivity()?.showBottomDialog( - WatchType.values() + WatchType.entries .map { fab.context.getString(it.stringRes) } .toList(), DataStoreHelper.getResultWatchState(id).ordinal, fab.context.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { - val newValue = WatchType.values()[it] - homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds( - null, - ContextCompat.getDrawable( - homePreviewBookmark.context, - newValue.iconRes - ), - null, - null - ) - homePreviewBookmark.setText(newValue.stringRes) + val newValue = WatchType.entries[it] - ResultViewModel2.updateWatchStatus( - item, - newValue - ) + ResultViewModel2().updateWatchStatus( + newValue, + fab.context, + item + ) { statusChanged: Boolean -> + if (!statusChanged) return@updateWatchStatus + + homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds( + null, + ContextCompat.getDrawable( + homePreviewBookmark.context, + newValue.iconRes + ), + null, + null + ) + homePreviewBookmark.setText(newValue.stringRes) + } } } } } - fun onViewDetachedFromWindow() { - previewViewpager.unregisterOnPageChangeCallback(previewCallback) - } - - fun onViewAttachedToWindow() { - previewViewpager.registerOnPageChangeCallback(previewCallback) - - binding.root.findViewTreeLifecycleOwner()?.apply { - observe(viewModel.preview) { - updatePreview(it) - } - if (binding is FragmentHomeHeadTvBinding) { - observe(viewModel.apiName) { name -> - binding.homePreviewChangeApi.text = name - } - } - observe(viewModel.resumeWatching) { - updateResume(it) - } - observe(viewModel.bookmarks) { - updateBookmarks(it) - } - observe(viewModel.availableWatchStatusTypes) { (checked, visible) -> - for ((chip, watch) in toggleList) { - chip.apply { - isVisible = visible.contains(watch) - isChecked = checked.contains(watch) + private val previewCallback: ViewPager2.OnPageChangeCallback = + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + previewAdapter.apply { + if (position >= itemCount - 1 && hasMoreItems) { + hasMoreItems = false // don't make two requests + viewModel.loadMoreHomeScrollResponses() } } - toggleListHolder?.isGone = visible.isEmpty() + val item = previewAdapter.getItemOrNull(position) ?: return + onSelect(item, position) } - } ?: debugException { "Expected findViewTreeLifecycleOwner" } + } + + fun onViewDetachedFromWindow() { + previewViewpager.unregisterOnPageChangeCallback(previewCallback) } private val toggleList = listOf>( @@ -413,17 +502,27 @@ class HomeParentItemAdapterPreview( Pair(itemView.findViewById(R.id.home_plan_to_watch_btt), WatchType.PLANTOWATCH), ) - private val toggleListHolder : ChipGroup? = itemView.findViewById(R.id.home_type_holder) + private val toggleListHolder: ChipGroup? = itemView.findViewById(R.id.home_type_holder) + + fun bind() = Unit init { previewViewpager.setPageTransformer(HomeScrollTransformer()) 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) + resumeRecyclerView.setLinearListLayout( + nextLeft = R.id.nav_rail_view, + nextRight = FOCUS_SELF + ) + + bookmarkRecyclerView.setLinearListLayout( + nextLeft = R.id.nav_rail_view, + nextRight = FOCUS_SELF + ) fixPaddingStatusbarMargin(topPadding) @@ -440,25 +539,80 @@ class HomeParentItemAdapterPreview( } } - homeAccount?.setOnClickListener { v -> - DataStoreHelper.showWhoIsWatching(v?.context ?: return@setOnClickListener) + headProfilePicCard?.isGone = isLayout(TV or EMULATOR) + alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) + + (headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount -> + headProfilePic?.loadImage(currentAccount?.image) + alternateHeadProfilePic?.loadImage(currentAccount?.image) + } + + headProfilePicCard?.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 { + 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) } } - - // 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 + 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("") + }*/ - homePreviewPlayBtt.setOnFocusChangeListener { _, hasFocus -> - homePreviewHiddenPrevFocus.isFocusable = hasFocus + // A workaround to the focus problem of always centering the view on focus + // as that causes higher android versions to stretch the ui when switching between shows + var lastFocusTimeoutMs = 0L + homePreviewInfoBtt.setOnFocusChangeListener { view, hasFocus -> + val lastFocusMs = lastFocusTimeoutMs + // Always reset timer, as we only want to update + // it if we have not interacted in half a second + lastFocusTimeoutMs = System.currentTimeMillis() + if (!hasFocus) return@setOnFocusChangeListener + if (lastFocusMs + 500L < System.currentTimeMillis()) { + MainActivity.centerView(view) + } } homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> @@ -470,10 +624,14 @@ class HomeParentItemAdapterPreview( homePreviewHiddenPrevFocus.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener if (previewViewpager.currentItem <= 0) { - (activity as? MainActivity)?.binding?.navRailView?.requestFocus() + //Focus the Home item as the default focus will be the header item + (activity as? MainActivity)?.binding?.navRailView?.findViewById( + R.id.navigation_home + )?.requestFocus() } else { previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true) - binding.homePreviewPlayBtt.requestFocus() + binding.homePreviewInfoBtt.requestFocus() + //binding.homePreviewPlayBtt.requestFocus() } } } @@ -500,13 +658,13 @@ class HomeParentItemAdapterPreview( params.height = 0 layoutParams = params } - } else { - fixPaddingStatusbarView(homeNonePadding) - } + } else fixPaddingStatusbarView(homeNonePadding) when (preview) { is Resource.Success -> { - if (!previewAdapter.setItems( + previewAdapter.submitList(preview.value.second) + previewAdapter.hasMoreItems = preview.value.first + /*if (!.setItems( preview.value.second, preview.value.first ) @@ -518,17 +676,32 @@ class HomeParentItemAdapterPreview( previewViewpager.fakeDragBy(1f) previewViewpager.endFakeDrag() previewCallback.onPageSelected(0) - previewViewpager.isVisible = true - previewViewpagerText.isVisible = true //previewHeader.isVisible = true + }*/ + + 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 -> { - previewAdapter.setItems(listOf(), false) + previewAdapter.submitList(listOf()) previewViewpager.setCurrentItem(0, false) previewViewpager.isVisible = false previewViewpagerText.isVisible = false + alternativeAccountPadding?.isVisible = true + (binding as? FragmentHomeHeadTvBinding)?.apply { + homePreviewInfoBtt.isVisible = false + } //previewHeader.isVisible = false } } @@ -536,18 +709,28 @@ class HomeParentItemAdapterPreview( private fun updateResume(resumeWatching: List) { resumeHolder.isVisible = resumeWatching.isNotEmpty() - resumeAdapter.updateList(resumeWatching) + resumeAdapter.submitList(resumeWatching) - if (binding is FragmentHomeHeadBinding) { - binding.homeWatchParentItemTitle.setOnClickListener { + if ( + binding is FragmentHomeHeadBinding || + binding is FragmentHomeHeadTvBinding && + isLayout(EMULATOR) + ) { + val title = (binding as? FragmentHomeHeadBinding)?.homeWatchParentItemTitle + ?: (binding as? FragmentHomeHeadTvBinding)?.homeWatchParentItemTitle + + title?.setOnClickListener { viewModel.popup( HomeViewModel.ExpandableHomepageList( HomePageList( - binding.homeWatchParentItemTitle.text.toString(), + title.text.toString(), resumeWatching, false ), 1, false - ) + ), + deleteCallback = { + viewModel.deleteResumeWatching() + } ) } } @@ -556,10 +739,17 @@ class HomeParentItemAdapterPreview( private fun updateBookmarks(data: Pair>) { val (visible, list) = data bookmarkHolder.isVisible = visible - bookmarkAdapter.updateList(list) + bookmarkAdapter.submitList(list) - if (binding is FragmentHomeHeadBinding) { - binding.homeBookmarkParentItemTitle.setOnClickListener { + if ( + binding is FragmentHomeHeadBinding || + binding is FragmentHomeHeadTvBinding && + isLayout(EMULATOR) + ) { + val title = (binding as? FragmentHomeHeadBinding)?.homeBookmarkParentItemTitle + ?: (binding as? FragmentHomeHeadTvBinding)?.homeBookmarkParentItemTitle + + title?.setOnClickListener { val items = toggleList.map { it.first }.filter { it.isChecked } if (items.isEmpty()) return@setOnClickListener // we don't want to show an empty dialog val textSum = items @@ -572,10 +762,43 @@ class HomeParentItemAdapterPreview( list, false ), 1, false - ) + ), deleteCallback = { + viewModel.deleteBookmarks(list) + } ) } } } + + fun onViewAttachedToWindow() { + previewViewpager.registerOnPageChangeCallback(previewCallback) + + previewViewpager.apply { + observe(viewModel.preview) { + updatePreview(it) + } + /*if (binding is FragmentHomeHeadTvBinding) { + observe(viewModel.apiName) { name -> + binding.homePreviewChangeApi.text = name + binding.homePreviewReloadProvider.isGone = (name == noneApi.name) + } + }*/ + observe(viewModel.resumeWatching) { + updateResume(it) + } + observe(viewModel.bookmarks) { + updateBookmarks(it) + } + observe(viewModel.availableWatchStatusTypes) { (checked, visible) -> + for ((chip, watch) in toggleList) { + chip.apply { + isVisible = visible.contains(watch) + isChecked = checked.contains(watch) + } + } + toggleListHolder?.isGone = visible.isEmpty() + } + } + } } } 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 9d95a5fac..e42e774b5 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,114 +1,86 @@ 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.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.UIHelper.setImage +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 : RecyclerView.Adapter() { - private var items: MutableList = mutableListOf() +class HomeScrollAdapter( + val callback: ((View, Int, LoadResponse) -> Unit) +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.uniqueUrl == b.uniqueUrl && a.name == b.name +})) { var hasMoreItems: Boolean = false - fun getItem(position: Int): LoadResponse? { - return items.getOrNull(position) - } - - fun setItems(newItems: List, hasNext: Boolean): Boolean { - val isSame = newItems.firstOrNull()?.url == items.firstOrNull()?.url - hasMoreItems = hasNext - - val diffResult = DiffUtil.calculateDiff( - HomeScrollDiffCallback(this.items, newItems) - ) - - items.clear() - items.addAll(newItems) - - - diffResult.dispatchUpdatesTo(this) - - return isSame - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) - val binding = if (isTvSettings()) { + val binding = if (isLayout(TV or EMULATOR)) { HomeScrollViewTvBinding.inflate(inflater, parent, false) } else { HomeScrollViewBinding.inflate(inflater, parent, false) } - return CardViewHolder( - binding, - //forceHorizontalPosters - ) + return ViewHolderState(binding) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(items[position]) + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is HomeScrollViewBinding -> { + clearImage(binding.homeScrollPreview) + } + + is HomeScrollViewTvBinding -> { + clearImage(binding.homeScrollPreview) } } } - class CardViewHolder - constructor( - val binding: ViewBinding, - //private val forceHorizontalPosters: Boolean? = null - ) : - RecyclerView.ViewHolder(binding.root) { + override fun onBindContent( + holder: ViewHolderState, + item: LoadResponse, + position: Int, + ) { + val binding = holder.view - fun bind(card: LoadResponse) { - val isHorizontal = - binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val posterUrl = item.backgroundPosterUrl ?: item.posterUrl - val posterUrl = - if (isHorizontal) card.backgroundPosterUrl ?: card.posterUrl else card.posterUrl - ?: card.backgroundPosterUrl - - when (binding) { - is HomeScrollViewBinding -> { - binding.homeScrollPreview.setImage(posterUrl) - binding.homeScrollPreviewTags.apply { - text = card.tags?.joinToString(" • ") ?: "" - isGone = card.tags.isNullOrEmpty() - } - binding.homeScrollPreviewTitle.text = card.name + when (binding) { + is HomeScrollViewBinding -> { + binding.homeScrollPreview.loadImage(posterUrl) + binding.homeScrollPreviewTags.apply { + text = item.tags?.joinToString(" • ") ?: "" + isGone = item.tags.isNullOrEmpty() + maxLines = 2 } + binding.homeScrollPreviewTitle.text = item.name.html() - is HomeScrollViewTvBinding -> { - binding.homeScrollPreview.setImage(posterUrl) + bindLogo( + url = item.logoUrl, + headers = item.posterHeaders, + titleView = binding.homeScrollPreviewTitle, + logoView = binding.homePreviewLogo + ) + } + + is HomeScrollViewTvBinding -> { + binding.homeScrollPreview.isFocusable = false + binding.homeScrollPreview.setOnClickListener { view -> + callback.invoke(view ?: return@setOnClickListener, position, item) } + binding.homeScrollPreview.loadImage(posterUrl) } } } - - class HomeScrollDiffCallback( - private val oldList: List, - private val newList: List - ) : - 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] - } - - override fun getItemCount(): Int { - return items.size - } } \ 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 e8cf8863f..8d48f5a68 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 @@ -6,19 +6,15 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.filterHomePageListByFilmQuality -import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.AcraApplication.Companion.context -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.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 @@ -35,25 +31,31 @@ import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching -import com.lagradost.cloudstream3.utils.AppUtils.loadResult +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.addProgramsToContinueWatching +import com.lagradost.cloudstream3.utils.AppContextUtils.filterHomePageListByFilmQuality +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality +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 import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData +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.USER_SELECTED_HOMEPAGE_API -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext import java.util.EnumSet -import kotlin.collections.set +import java.util.concurrent.CopyOnWriteArrayList class HomeViewModel : ViewModel() { companion object { @@ -65,11 +67,26 @@ class HomeViewModel : ViewModel() { } val resumeWatchingResult = withContext(Dispatchers.IO) { resumeWatching?.mapNotNull { resume -> - - val data = getKey( + val headerCache = getKey( DOWNLOAD_HEADER_CACHE, resume.parentId.toString() - ) ?: return@mapNotNull null + ) + + val data = if (headerCache == null) { + // We store resume watching data in download header cache + // Because downloads automatically pruned outdated download headers we + // removed resume watching data. We should restore the data for affected users. + val oldData = getKey( + DOWNLOAD_HEADER_CACHE_BACKUP, + resume.parentId.toString() + ) ?: return@mapNotNull null + + // Restore data + setKey(DOWNLOAD_HEADER_CACHE, resume.parentId.toString(), oldData) + oldData + } else { + headerCache + } val watchPos = getViewPos(resume.episodeId) @@ -92,18 +109,31 @@ class HomeViewModel : ViewModel() { } } + fun deleteResumeWatching() { + deleteAllResumeStateIds() + loadResumeWatching() + } + + fun deleteBookmarks(list: List) { + list.forEach { DataStoreHelper.deleteBookmarkedData(it.id) } + loadStoredData() + } + var repo: APIRepository? = null private val _apiName = MutableLiveData() val apiName: LiveData = _apiName + private val _currentAccount = MutableLiveData() + val currentAccount: MutableLiveData = _currentAccount + private val _randomItems = MutableLiveData?>(null) val randomItems: LiveData?> = _randomItems private var currentShuffledList: List = listOf() private fun autoloadRepo(): APIRepository { - return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) + return APIRepository(apis.withLock { apis.first { it.hasMainPage } }) } private val _availableWatchStatusTypes = @@ -115,7 +145,7 @@ class HomeViewModel : ViewModel() { private val _resumeWatching = MutableLiveData>() private val _preview = MutableLiveData>>>() - private val previewResponses = mutableListOf() + private val previewResponses = CopyOnWriteArrayList() private val previewResponsesAdded = mutableSetOf() val resumeWatching: LiveData> = _resumeWatching @@ -123,7 +153,7 @@ class HomeViewModel : ViewModel() { private fun loadResumeWatching() = viewModelScope.launchSafe { val resumeWatchingResult = getResumeWatching() - if (isTrueTvSettings() && resumeWatchingResult != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (isLayout(TV) && resumeWatchingResult != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ioSafe { // this WILL crash on non tvs, so keep this inside a try catch activity?.addProgramsToContinueWatching(resumeWatchingResult) @@ -141,7 +171,7 @@ class HomeViewModel : ViewModel() { } }?.distinctBy { it.first } ?: return@launchSafe - val length = WatchType.values().size + val length = WatchType.entries.size val currentWatchTypes = mutableSetOf() for (watch in watchStatusIds) { @@ -154,10 +184,7 @@ class HomeViewModel : ViewModel() { currentWatchTypes.remove(WatchType.NONE) if (currentWatchTypes.size <= 0) { - setKey( - HOME_BOOKMARK_VALUE_LIST, - intArrayOf() - ) + DataStoreHelper.homeBookmarkedList = intArrayOf() _availableWatchStatusTypes.postValue(setOf() to setOf()) _bookmarks.postValue(Pair(false, ArrayList())) return@launchSafe @@ -165,16 +192,14 @@ class HomeViewModel : ViewModel() { val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first()) //if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first()) - setKey( - HOME_BOOKMARK_VALUE_LIST, - watchPrefNotNull.map { it.internalId }.toIntArray() - ) + + DataStoreHelper.homeBookmarkedList = watchPrefNotNull.map { it.internalId }.toIntArray() _availableWatchStatusTypes.postValue( - Pair( - watchPrefNotNull, - currentWatchTypes, + + watchPrefNotNull to + currentWatchTypes, + ) - ) val list = withContext(Dispatchers.IO) { watchStatusIds.filter { watchPrefNotNull.contains(it.second) } @@ -303,7 +328,7 @@ class HomeViewModel : ViewModel() { if (repo?.hasMainPage != true) { _page.postValue(Resource.Success(emptyMap())) - _preview.postValue(Resource.Failure(false, null, null, "No homepage")) + _preview.postValue(Resource.Failure(false, "No homepage")) return@ioSafe } @@ -322,7 +347,13 @@ class HomeViewModel : ViewModel() { val filteredList = context?.filterHomePageListByFilmQuality(list) ?: list expandable[list.name] = - ExpandableHomepageList(filteredList, 1, home.hasNext) + ExpandableHomepageList( + filteredList.copy( + list = CopyOnWriteArrayList( + filteredList.list + ) + ), 1, home.hasNext + ) } } @@ -337,8 +368,7 @@ class HomeViewModel : ViewModel() { val currentList = items.shuffled().filter { it.list.isNotEmpty() } .flatMap { it.list } - .distinctBy { it.url } - .toList() + .distinctBy { it.url }.toList() if (currentList.isNotEmpty()) { val randomItems = @@ -360,8 +390,6 @@ class HomeViewModel : ViewModel() { _preview.postValue( Resource.Failure( false, - null, - null, "No homepage responses" ) ) @@ -376,7 +404,9 @@ class HomeViewModel : ViewModel() { } is Resource.Failure -> { + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") _page.postValue(data!!) + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") _preview.postValue(data!!) } @@ -386,19 +416,19 @@ class HomeViewModel : ViewModel() { } fun click(callback: SearchClickCallback) { - if (callback.action == SEARCH_ACTION_FOCUSED) { - //focusCallback(callback.card) - } else { + if (callback.action != SEARCH_ACTION_FOCUSED) { SearchHelper.handleSearchClickCallback(callback) } } + private val _popup = MutableLiveData Unit)?>?>(null) + val popup: LiveData Unit)?>?> = _popup - private val _popup = MutableLiveData(null) - val popup: LiveData = _popup - - fun popup(list: ExpandableHomepageList?) { - _popup.postValue(list) + fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) { + if (list == null) + _popup.postValue(null) + else + _popup.postValue(list to deleteCallback) } private fun bookmarksUpdated(unused: Boolean) { @@ -406,23 +436,37 @@ class HomeViewModel : ViewModel() { } private fun afterPluginsLoaded(forceReload: Boolean) { - loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), forceReload) + loadAndCancel(DataStoreHelper.currentHomePage, forceReload) } private fun afterMainPluginsLoaded(unused: Boolean = false) { - loadAndCancel(getKey(USER_SELECTED_HOMEPAGE_API), false) + loadAndCancel(DataStoreHelper.currentHomePage, false) + } + + private fun reloadHome(unused: Boolean = false) { + loadAndCancel(DataStoreHelper.currentHomePage, true) + } + + private fun reloadAccount(unused: Boolean = false) { + _currentAccount.postValue( + getCurrentAccount() + ) } init { MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded + MainActivity.reloadHomeEvent += ::reloadHome + MainActivity.reloadAccountEvent += ::reloadAccount } override fun onCleared() { MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded + MainActivity.reloadHomeEvent -= ::reloadHome + MainActivity.reloadAccountEvent -= ::reloadAccount super.onCleared() } @@ -436,17 +480,21 @@ class HomeViewModel : ViewModel() { // do nothing } - fun reloadStored() { - loadResumeWatching() + fun loadStoredData() { val list = EnumSet.noneOf(WatchType::class.java) - getKey(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let { + DataStoreHelper.homeBookmarkedList.map { WatchType.fromInternalId(it) }.let { list.addAll(it) } loadStoredData(list) } + fun reloadStored() { + loadResumeWatching() + loadStoredData() + } + fun click(load: LoadClickCallback) { - loadResult(load.response.url, load.response.apiName, load.action) + loadResult(load.response.url, load.response.apiName, load.response.name, load.action) } // only save the key if it is from UI, as we don't want internal functions changing the setting @@ -471,7 +519,7 @@ class HomeViewModel : ViewModel() { val api = getApiFromNameNull(preferredApiName) if (preferredApiName == noneApi.name) { // just set to random - if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, noneApi.name) + if (fromUI) DataStoreHelper.currentHomePage = noneApi.name loadAndCancel(noneApi) } else if (preferredApiName == randomApi.name) { // randomize the api, if none exist like if not loaded or not installed @@ -482,12 +530,12 @@ class HomeViewModel : ViewModel() { } else { val apiRandom = validAPIs.random() loadAndCancel(apiRandom) - if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, apiRandom.name) + if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name } } else if (api == null) { // API is not found aka not loaded or removed, post the loading // progress if waiting for plugins, otherwise nothing - if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) { + if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) { loadAndCancel(noneApi) } else { _page.postValue(Resource.Loading()) @@ -496,8 +544,9 @@ class HomeViewModel : ViewModel() { } } else { // if the api is found, then set it to it and save key - if (fromUI) setKey(USER_SELECTED_HOMEPAGE_API, api.name) + if (fromUI) DataStoreHelper.currentHomePage = api.name loadAndCancel(api) } + 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 04ef3d967..c5f8fa3d9 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 @@ -1,50 +1,65 @@ package com.lagradost.cloudstream3.ui.library +import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper -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.TextView 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.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.allProviders -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.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.ui.result.txt +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.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult -import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount +import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs const val LIBRARY_FOLDER = "library_folder" enum class LibraryOpenerType(@StringRes val stringRes: Int) { - Default(R.string.default_subtitles), // TODO FIX AFTER MERGE + Default(R.string.action_default), Provider(R.string.none), Browser(R.string.browser), Search(R.string.search), @@ -61,7 +76,9 @@ data class ProviderLibraryData( val apiName: String ) -class LibraryFragment : Fragment() { +class LibraryFragment : BaseFragment( + BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) +) { companion object { fun newInstance() = LibraryFragment() @@ -73,22 +90,10 @@ class LibraryFragment : Fragment() { private val libraryViewModel: LibraryViewModel by activityViewModels() - var binding: FragmentLibraryBinding? = null + private var toggleRandomButton = false - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val localBinding = FragmentLibraryBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - - //return inflater.inflate(R.layout.fragment_library, container, false) - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } + override fun pickLayout(): Int? = + if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv override fun onSaveInstanceState(outState: Bundle) { binding?.viewpager?.currentItem?.let { currentItem -> @@ -97,27 +102,52 @@ class LibraryFragment : Fragment() { super.onSaveInstanceState(outState) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.searchStatusBarPadding) + private fun updateRandomVisibility(binding: FragmentLibraryBinding) { + if (!toggleRandomButton) { + binding.libraryRandom.isGone = true + binding.libraryRandomButtonTv.isGone = true + return + } + val position = libraryViewModel.currentPage.value ?: 0 + val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return + val hasItems = pages[position].items.isNotEmpty() + val isPhone = isLayout(PHONE) - binding?.sortFab?.setOnClickListener { - val methods = libraryViewModel.sortingMethods.map { - txt(it.stringRes).asString(view.context) + binding.libraryRandom.isVisible = isPhone && hasItems + binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = !isLayout(PHONE) + ) + } + + @SuppressLint("ResourceType", "CutPasteId") + override fun onBindingCreated( + binding: FragmentLibraryBinding, + savedInstanceState: Bundle? + ) { + binding.sortFab.setOnClickListener(sortChangeClickListener) + binding.librarySort.setOnClickListener(sortChangeClickListener) + + binding.libraryRoot.findViewById(androidx.appcompat.R.id.search_src_text) + ?.apply { + tag = "tv_no_focus_tag" + // Expand the Appbar when search bar is focused, fixing scroll up issue + setOnFocusChangeListener { _, _ -> + binding.searchBar.setExpanded(true) + } } - activity?.showBottomDialog(methods, - libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), - txt(R.string.sort_by).asString(view.context), - false, - {}, - { - val method = libraryViewModel.sortingMethods[it] - libraryViewModel.sort(method) - }) + val searchCallback = Runnable { + val newText = binding.mainSearch.query.toString() + libraryViewModel.sort(ListSorting.Query, newText) } - binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { libraryViewModel.sort(ListSorting.Query, query) return true @@ -133,18 +163,24 @@ class LibraryFragment : Fragment() { return true } - libraryViewModel.sort(ListSorting.Query, newText) + 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) + return true } }) 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, @@ -154,6 +190,17 @@ class LibraryFragment : Fragment() { } } + //Load value for toggling Random button. Hide at startup + context?.let { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) + toggleRandomButton = + settingsManager.getBoolean( + getString(R.string.random_button_key), + false + ) + binding.libraryRandom.visibility = View.GONE + binding.libraryRandomButtonTv.visibility = View.GONE + } /** * Shows a plugin selection dialogue and saves the response @@ -163,14 +210,13 @@ class LibraryFragment : Fragment() { syncId: SyncIdName, apiName: String? = null, ) { - 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 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 baseOptions = listOf( LibraryOpenerType.Default, LibraryOpenerType.None, @@ -180,7 +226,7 @@ class LibraryFragment : Fragment() { val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders - val savedSelection = getKey(LIBRARY_FOLDER, key) + val savedSelection = getKey("$currentAccount/$LIBRARY_FOLDER", key) val selectedIndex = when { savedSelection == null -> 0 @@ -215,105 +261,69 @@ class LibraryFragment : Fragment() { } setKey( - LIBRARY_FOLDER, + "$currentAccount/$LIBRARY_FOLDER", key, savedData, ) } } - binding?.providerSelector?.setOnClickListener { + binding.providerSelector.setOnClickListener { val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener activity?.showPluginSelectionDialog(syncName.name, syncName) } - binding?.viewpager?.setPageTransformer(LibraryScrollTransformer()) - binding?.viewpager?.adapter = - binding?.viewpager?.adapter ?: ViewpagerAdapter( - mutableListOf(), - { isScrollingDown: Boolean -> - if (isScrollingDown) { - binding?.sortFab?.shrink() - } else { - binding?.sortFab?.extend() - } - }) callback@{ searchClickCallback -> - // To prevent future accidents - debugAssert({ - searchClickCallback.card !is SyncAPI.LibraryItem - }, { - "searchClickCallback ${searchClickCallback.card} is not a LibraryItem" - }) + binding.viewpager.setPageTransformer(LibraryScrollTransformer()) - val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId - val syncName = - libraryViewModel.currentSyncApi?.syncIdName ?: return@callback + binding.viewpager.adapter = ViewpagerAdapter( + { isScrollingDown: Boolean -> + if (isScrollingDown) { + binding.sortFab.shrink() + binding.libraryRandom.shrink() + } else { + binding.sortFab.extend() + binding.libraryRandom.extend() + } + }) callback@{ searchClickCallback -> + // To prevent future accidents + debugAssert({ + searchClickCallback.card !is SyncAPI.LibraryItem + }, { + "searchClickCallback ${searchClickCallback.card} is not a LibraryItem" + }) - when (searchClickCallback.action) { - SEARCH_ACTION_SHOW_METADATA -> { - activity?.showPluginSelectionDialog( + val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId + val syncName = + libraryViewModel.currentSyncApi?.syncIdName ?: return@callback + + when (searchClickCallback.action) { + SEARCH_ACTION_SHOW_METADATA -> { + (activity as? MainActivity)?.loadPopup( + searchClickCallback.card, + load = false + ) + /*activity?.showPluginSelectionDialog( syncId, syncName, searchClickCallback.card.apiName - ) - } + )*/ + } - SEARCH_ACTION_LOAD -> { - // This basically first selects the individual opener and if that is default then - // selects the whole list opener - val savedListSelection = - getKey(LIBRARY_FOLDER, syncName.name) - val savedSelection = getKey(LIBRARY_FOLDER, syncId).takeIf { - it?.openType != LibraryOpenerType.Default - } ?: savedListSelection - - when (savedSelection?.openType) { - null, LibraryOpenerType.Default -> { - // Prevents opening MAL/AniList as a provider - if (APIHolder.getApiFromNameNull(searchClickCallback.card.apiName) != null) { - activity?.loadSearchResult( - searchClickCallback.card - ) - } else { - // Search when no provider can open - QuickSearchFragment.pushSearch( - activity, - searchClickCallback.card.name - ) - } - } - - LibraryOpenerType.None -> {} - LibraryOpenerType.Provider -> - savedSelection.providerData?.apiName?.let { apiName -> - activity?.loadResult( - searchClickCallback.card.url, - apiName, - ) - } - - LibraryOpenerType.Browser -> - openBrowser(searchClickCallback.card.url) - - LibraryOpenerType.Search -> { - QuickSearchFragment.pushSearch( - activity, - searchClickCallback.card.name - ) - } - } - } + SEARCH_ACTION_LOAD -> { + loadLibraryItem(syncName, syncId, searchClickCallback.card) } } + } - binding?.apply { + binding.apply { viewpager.offscreenPageLimit = 2 viewpager.reduceDragSensitivity() + searchBar.setExpanded(true) } val startLoading = Runnable { - binding?.apply { - gridview.numColumns = context?.getSpanCount() ?: 3 + binding.apply { + gridview.numColumns = root.context.getSpanCount() gridview.adapter = context?.let { LoadingPosterAdapter(it, 6 * 3) } libraryLoadingOverlay.isVisible = true @@ -323,7 +333,7 @@ class LibraryFragment : Fragment() { } val stopLoading = Runnable { - binding?.apply { + binding.apply { gridview.adapter = null libraryLoadingOverlay.isVisible = false libraryLoadingShimmer.stopShimmer() @@ -339,8 +349,7 @@ class LibraryFragment : Fragment() { val pages = resource.value val showNotice = pages.all { it.items.isEmpty() } - - binding?.apply { + binding.apply { emptyListTextview.isVisible = showNotice if (showNotice) { if (libraryViewModel.availableApiNames.size > 1) { @@ -350,12 +359,41 @@ class LibraryFragment : Fragment() { } } - (viewpager.adapter as? ViewpagerAdapter)?.pages = pages + (viewpager.adapter as? ViewpagerAdapter)?.submitList(pages.map { + it.copy( + items = CopyOnWriteArrayList(it.items) + ) + }) + //fix focus on the viewpager itself + (viewpager.getChildAt(0) as RecyclerView).apply { + tag = "tv_no_focus_tag" + //isFocusable = false + } + // Using notifyItemRangeChanged keeps the animations when sorting - viewpager.adapter?.notifyItemRangeChanged( + /*viewpager.adapter?.notifyItemRangeChanged( 0, viewpager.adapter?.itemCount ?: 0 - ) + )*/ + + libraryViewModel.currentPage.value?.let { page -> + binding.viewpager.setCurrentItem(page, false) + binding.searchBar.setExpanded(true) + } + + // Set up random button click listener + if (toggleRandomButton) { + val randomClickListener = View.OnClickListener { + val position = libraryViewModel.currentPage.value ?: 0 + val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener + pages[position].items.randomOrNull()?.let { item -> + loadLibraryItem(syncIdName, item.syncId, item) + } + } + libraryRandom.setOnClickListener(randomClickListener) + libraryRandomButtonTv.setOnClickListener(randomClickListener) + } + updateRandomVisibility(binding) // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Without this there would be a flashing effect: @@ -392,13 +430,31 @@ class LibraryFragment : Fragment() { viewpager, ) { tab, position -> tab.text = pages.getOrNull(position)?.title?.asStringNull(context) + tab.view.tag = "tv_no_focus_tag" + tab.view.nextFocusDownId = R.id.search_result_root + tab.view.setOnClickListener { - val currentItem = - binding?.viewpager?.currentItem ?: return@setOnClickListener + val currentItem = binding.viewpager.currentItem val distance = abs(position - currentItem) hideViewpager(distance) } + //Expand the appBar on tab focus + tab.view.setOnFocusChangeListener { _, _ -> + binding.searchBar.setExpanded(true) + } }.attach() + + binding.libraryTabLayout.addOnTabSelectedListener(object : + TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab?) { + binding.libraryTabLayout.selectedTabPosition.let { page -> + libraryViewModel.switchPage(page) + } + } + + override fun onTabUnselected(tab: TabLayout.Tab?) = Unit + override fun onTabReselected(tab: TabLayout.Tab?) = Unit + }) } } @@ -414,16 +470,102 @@ class LibraryFragment : Fragment() { } } } + + observe(libraryViewModel.currentPage) { position -> + updateRandomVisibility(binding) + val all = binding.viewpager.allViews.toList() + .filterIsInstance() + + all.forEach { view -> + view.isVisible = view.tag == position + view.isFocusable = view.tag == position + + if (view.tag == position) + view.descendantFocusability = FOCUS_AFTER_DESCENDANTS + else + view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS + } + } + } + + private fun loadLibraryItem( + syncName: SyncIdName, + syncId: String, + card: SearchResponse + ) { + // This basically first selects the individual opener and if that is default then + // selects the whole list opener + val savedListSelection = + getKey("$currentAccount/$LIBRARY_FOLDER", syncName.name) + + val savedSelection = getKey( + "$currentAccount/$LIBRARY_FOLDER", + syncId + ).takeIf { + it?.openType != LibraryOpenerType.Default + } ?: savedListSelection + + when (savedSelection?.openType) { + null, LibraryOpenerType.Default -> { + // Prevents opening MAL/AniList as a provider + if (APIHolder.getApiFromNameNull(card.apiName) != null) { + activity?.loadSearchResult( + card + ) + } else { + // Search when no provider can open + QuickSearchFragment.pushSearch( + activity, + card.name + ) + } + } + + LibraryOpenerType.None -> {} + LibraryOpenerType.Provider -> + savedSelection.providerData?.apiName?.let { apiName -> + activity?.loadResult( + card.url, + apiName, + card.name + ) + } + + LibraryOpenerType.Browser -> + openBrowser(card.url) + + LibraryOpenerType.Search -> { + QuickSearchFragment.pushSearch( + activity, + card.name + ) + } + } + } override fun onConfigurationChanged(newConfig: Configuration) { - (binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind() super.onConfigurationChanged(newConfig) + val adapter = binding?.viewpager?.adapter ?: return + adapter.notifyItemRangeChanged(0, adapter.itemCount) + } + + private val sortChangeClickListener = View.OnClickListener { view -> + val methods = libraryViewModel.sortingMethods.map { + txt(it.stringRes).asString(view.context) + } + + activity?.showBottomDialog( + methods, + libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), + txt(R.string.sort_by).asString(view.context), + false, + {}, + { + val method = libraryViewModel.sortingMethods[it] + libraryViewModel.sort(method) + }) } } -class MenuSearchView(context: Context) : SearchView(context) { - override fun onActionViewCollapsed() { - super.onActionViewCollapsed() - } -} \ No newline at end of file +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 14d313568..38f7fcf9d 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,17 @@ import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +import com.lagradost.cloudstream3.mvvm.throwAbleToResource +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount enum class ListSorting(@StringRes val stringRes: Int) { Query(R.string.none), @@ -20,11 +24,20 @@ enum class ListSorting(@StringRes val stringRes: Int) { UpdatedOld(R.string.sort_updated_old), AlphabeticalA(R.string.sort_alphabetical_a), AlphabeticalZ(R.string.sort_alphabetical_z), + ReleaseDateNew(R.string.sort_release_date_new), + ReleaseDateOld(R.string.sort_release_date_old), } const val LAST_SYNC_API_KEY = "last_sync_api" class LibraryViewModel : ViewModel() { + 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 @@ -32,15 +45,15 @@ class LibraryViewModel : ViewModel() { val currentApiName: LiveData = _currentApiName private val availableSyncApis - get() = SyncApis.filter { it.hasAccount() } + get() = AccountManager.syncApis.filter { it.isAvailable } var currentSyncApi = availableSyncApis.let { allApis -> - val lastSelection = getKey(LAST_SYNC_API_KEY) + val lastSelection = getKey("$currentAccount/$LAST_SYNC_API_KEY") availableSyncApis.firstOrNull { it.name == lastSelection } ?: allApis.firstOrNull() } private set(value) { field = value - setKey(LAST_SYNC_API_KEY, field?.name) + setKey("$currentAccount/$LAST_SYNC_API_KEY", field?.name) } val availableApiNames: List @@ -58,13 +71,21 @@ class LibraryViewModel : ViewModel() { reloadPages(true) } - fun sort(method: ListSorting, query: String? = null) { - val currentList = pages.value ?: return + fun sort(method: ListSorting, query: String? = null) = ioSafe { + val value = _pages.value ?: return@ioSafe + if (value is Resource.Success) { + sort(method, query, value.value) + } + } + + private fun sort(method: ListSorting, query: String? = null, items: List) { currentSortingMethod = method - (currentList as? Resource.Success)?.value?.forEachIndexed { _, page -> + DataStoreHelper.librarySortingMode = method.ordinal + + items.forEach { page -> page.sort(method, query) } - _pages.postValue(currentList) + _pages.postValue(Resource.Success(items)) } fun reloadPages(forceReload: Boolean) { @@ -77,16 +98,19 @@ class LibraryViewModel : ViewModel() { currentSyncApi?.let { repo -> _currentApiName.postValue(repo.name) _pages.postValue(Resource.Loading()) - val libraryResource = repo.getPersonalLibrary() - if (libraryResource is Resource.Failure) { - _pages.postValue(libraryResource) + val libraryResource = repo.library() + val err = libraryResource.exceptionOrNull() + if (err != null) { + _pages.postValue(throwAbleToResource(err)) + return@let + } + val library = libraryResource.getOrNull() + if (library == null) { + _pages.postValue(Resource.Failure(false, "Unable to fetch library")) return@let } - val library = (libraryResource as? Resource.Success)?.value ?: return@let sortingMethods = library.supportedListSorting.toList() - currentSortingMethod = null - repo.requireLibraryRefresh = false val pages = library.allLibraryLists.map { @@ -96,8 +120,27 @@ class LibraryViewModel : ViewModel() { ) } - _pages.postValue(Resource.Success(pages)) + val desiredSortingMethod = + ListSorting.entries.getOrNull(DataStoreHelper.librarySortingMode) + if (desiredSortingMethod != null && library.supportedListSorting.contains( + desiredSortingMethod + ) + ) { + sort(desiredSortingMethod, null, pages) + } else { + // null query = no sorting + sort(ListSorting.Query, null, pages) + } } } } -} \ No newline at end of file + + init { + MainActivity.reloadLibraryEvent += ::reloadPages + } + + override fun onCleared() { + MainActivity.reloadLibraryEvent -= ::reloadPages + super.onCleared() + } +} 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 b8feb656d..066cf468d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt @@ -1,35 +1,34 @@ package com.lagradost.cloudstream3.ui.library -import android.content.res.ColorStateList -import android.graphics.Color import android.view.LayoutInflater import android.view.ViewGroup import android.widget.FrameLayout -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.AcraApplication -import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.AutofitRecyclerView +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder -import com.lagradost.cloudstream3.utils.AppUtils -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 ) : - AppUtils.DiffAdapter(items) { + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + if (a.id != null || b.id != null) { + a.id == b.id + } else { + a.name == b.name && a.url == b.url + } + })) { + private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return LibraryItemViewHolder( + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( SearchResultGridExpandedBinding.inflate( LayoutInflater.from(parent.context), parent, @@ -38,85 +37,45 @@ class PageAdapter( ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is LibraryItemViewHolder -> { - holder.bind(items[position], position) + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> { + clearImage(binding.imageView) } } } - private fun isDark(color: Int): Boolean { - return ColorUtils.calculateLuminance(color) < 0.5 - } + override fun onBindContent( + holder: ViewHolderState, + item: SyncAPI.LibraryItem, + position: Int + ) { + val binding = holder.view as? SearchResultGridExpandedBinding ?: return - 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) + /** 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 } - } - 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 + val showProgress = item.episodesCompleted?.let{ it>0 } ?: false && item.episodesTotal != null + binding.watchProgress.isVisible = showProgress + if (showProgress) { + binding.watchProgress.max = item.episodesTotal + binding.watchProgress.progress = item.episodesCompleted } + + binding.imageText.text = item.name } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt index 95fefcbe5..68b6eb273 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 @@ -1,90 +1,125 @@ package com.lagradost.cloudstream3.ui.library import android.os.Build +import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.doOnAttach -import androidx.recyclerview.widget.RecyclerView +import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView.OnFlingListener +import com.google.android.material.appbar.AppBarLayout +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.LibraryViewpagerPageBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.home.getSafeParcelable 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.UIHelper.getSpanCount +class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) : + ViewHolderState(binding) { + override fun save(): Bundle = + Bundle().apply { + putParcelable( + "pageRecyclerview", + binding.pageRecyclerview.layoutManager?.onSaveInstanceState() + ) + } + + override fun restore(state: Bundle) { + state.getSafeParcelable("pageRecyclerview")?.let { recycle -> + binding.pageRecyclerview.layoutManager?.onRestoreInstanceState(recycle) + } + } +} + class ViewpagerAdapter( - var pages: List, val scrollCallback: (isScrollingDown: Boolean) -> Unit, val clickCallback: (SearchClickCallback) -> Unit -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return PageViewHolder( +) : BaseAdapter( + id = "ViewpagerAdapter".hashCode(), + diffCallback = BaseDiffCallback( + 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) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PageViewHolder -> { - holder.bind(pages[position], unbound.remove(position)) - } - } + override fun onUpdateContent( + holder: ViewHolderState, + item: SyncAPI.Page, + position: Int + ) { + val binding = holder.view + if (binding !is LibraryViewpagerPageBinding) return + (binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items) + binding.pageRecyclerview.scrollToPosition(0) } - private val unbound = mutableSetOf() + override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { + val binding = holder.view + if (binding !is LibraryViewpagerPageBinding) return - /** - * Used to mark all pages for re-binding and forces all items to be refreshed - * Without this the pages will still use the same adapters - **/ - fun rebind() { - unbound.addAll(0..pages.size) - this.notifyItemRangeChanged(0, pages.size) - } - - inner class PageViewHolder(private val binding: LibraryViewpagerPageBinding) : - RecyclerView.ViewHolder(binding.root) { - fun bind(page: SyncAPI.Page, rebind: Boolean) { - binding.pageRecyclerview.apply { - spanCount = - this@PageViewHolder.itemView.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( - page.items.toMutableList(), - this, - clickCallback - ) + binding.pageRecyclerview.tag = position + binding.pageRecyclerview.apply { + spanCount = binding.root.context.getSpanCount() + if (adapter == null) { // || rebind + // Only add the items after it has been attached since the items rely on ItemWidth + // Which is only determined after the recyclerview is attached. + // If this fails then item height becomes 0 when there is only one item + doOnAttach { + adapter = PageAdapter( + this, + clickCallback + ).apply { + submitList(item.items) } - } else { - (adapter as? PageAdapter)?.updateList(page.items) - scrollToPosition(0) } + } else { + (adapter as? PageAdapter)?.submitList(item.items) + // scrollToPosition(0) + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - val diff = scrollY - oldScrollY - if (diff == 0) return@setOnScrollChangeListener + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + val diff = scrollY - oldScrollY - scrollCallback.invoke(diff > 0) + //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 { + if (diff <= 0) + setExpanded(true) + else + setExpanded(false) + } } - } else { - onFlingListener = object : OnFlingListener() { - override fun onFling(velocityX: Int, velocityY: Int): Boolean { - scrollCallback.invoke(velocityY > 0) - return false - } + if (diff == 0) return@setOnScrollChangeListener + + scrollCallback.invoke(diff > 0) + } + } else { + onFlingListener = object : OnFlingListener() { + override fun onFling(velocityX: Int, velocityY: Int): Boolean { + scrollCallback.invoke(velocityY > 0) + return false } } } - - } } - - override fun getItemCount(): Int { - return pages.size - } } \ No newline at end of file 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 53ee5e12e..e5a460b9a 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,51 +1,16 @@ package com.lagradost.cloudstream3.ui.player -import android.annotation.SuppressLint -import android.content.* -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.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.widget.FrameLayout import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.Toast -import androidx.annotation.LayoutRes +import androidx.annotation.OptIn import androidx.annotation.StringRes -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.preference.PreferenceManager -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import androidx.media3.common.PlaybackException -import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaSession -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey -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.showToast +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R -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.AppUtils -import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus -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 com.lagradost.cloudstream3.ui.BaseFragment enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -65,440 +30,132 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90 // when the player should sync the progress of "watched", TODO MAKE SETTING const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80 -abstract class AbstractPlayerFragment( - 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 +@OptIn(UnstableApi::class) +abstract class AbstractPlayerFragment( + bindingCreator: BindingCreator +) : BaseFragment(bindingCreator), PlayerView.Callbacks { - var playerPausePlayHolderHolder : FrameLayout? = null - var playerPausePlay : ImageView? = null - var playerBuffering : ProgressBar? = null - var playerView : PlayerView? = null - var piphide : FrameLayout? = null - var subtitleHolder : FrameLayout? = null + // Stored pre-initialization so subclasses can set them before onBindingCreated. + private var _player: IPlayer = CS3IPlayer() - @LayoutRes - protected open var layout: Int = R.layout.fragment_player + /** The shared [PlayerView] host that owns all player state and view references. */ + protected var playerHostView: PlayerView? = null - open fun nextEpisode() { - throw NotImplementedError() - } - - open fun prevEpisode() { - throw NotImplementedError() - } - - open fun playerPositionChanged(posDur: Pair) { - throw NotImplementedError() - } - - open fun playerDimensionsLoaded(widthHeight: Pair) { - 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(playing: Pair) { - val (wasPlaying, isPlaying) = playing - 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) - } + var player: IPlayer + get() = playerHostView?.player ?: _player + set(value) { + _player = value + playerHostView?.player = value } - canEnterPipMode = isPlayingRightNow && hasPipModeSupport - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.let { act -> - PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow, player.getAspectRatio()) - } - } + 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() } - private var pipReceiver: BroadcastReceiver? = null override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) - try { - isInPIPMode = isInPictureInPictureMode - if (isInPictureInPictureMode) { - // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. - piphide?.isVisible = false - pipReceiver = object : BroadcastReceiver() { - override fun onReceive( - context: Context, - intent: Intent, - ) { - if (ACTION_MEDIA_CONTROL != intent.action) { - return - } - player.handleEvent( - CSPlayerEvent.values()[intent.getIntExtra( - EXTRA_CONTROL_TYPE, - 0 - )] - ) - } - } - val filter = IntentFilter() - filter.addAction(ACTION_MEDIA_CONTROL) - activity?.registerReceiver(pipReceiver, filter) - val isPlaying = player.getIsPlaying() - val isPlayingValue = - if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused - updateIsPlaying(Pair(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(AppUtils.getFocusRequest()) - } - } - - open fun playerError(exception: Exception) { - fun showToast(message: String, gotoNext: Boolean = false) { - if (gotoNext && hasNextMirror()) { - showToast( - message, - Toast.LENGTH_SHORT - ) - nextMirror() - } else { - showToast( - context?.getString(R.string.no_links_found_toast) + "\n" + message, - Toast.LENGTH_LONG - ) - activity?.popCurrentPage() - } - } - - val ctx = context ?: return - when (exception) { - is PlaybackException -> { - val msg = exception.message ?: "" - val errorName = exception.errorCodeName - when (val code = exception.errorCode) { - PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, PlaybackException.ERROR_CODE_IO_NO_PERMISSION, PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> { - showToast( - "${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackException.ERROR_CODE_REMOTE_ERROR, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, PlaybackException.ERROR_CODE_TIMEOUT, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> { - showToast( - "${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - PlaybackException.ERROR_CODE_DECODING_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> { - showToast( - "${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg", - gotoNext = true - ) - } - - else -> { - showToast( - "${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", - gotoNext = false - ) - } - } - } - - is InvalidFileException -> { - showToast( - "${ctx.getString(R.string.source_error)}\n${exception.message}", - gotoNext = true - ) - } - - else -> { - exception.message?.let { - showToast( - it, - gotoNext = false - ) - } - } - } - } - - private fun onSubStyleChanged(style: SaveCaptionStyle) { - if (player is CS3IPlayer) { - player.updateSubtitleStyle(style) - } - } - - private fun playerUpdated(player: Any?) { - if (player is ExoPlayer) { - context?.let { ctx -> - mMediaSession?.release() - mMediaSession = MediaSession.Builder(ctx, player) - // Ensure unique ID for concurrent players - .setId(unixTimeMs.toString()) - .build() - } - - // Necessary for multiple combined videos - playerView?.setShowMultiWindowTimeBar(true) - playerView?.player = player - playerView?.performClick() - } - } - - private var mMediaSession: MediaSession? = null - - // this can be used in the future for players other than exoplayer - //private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() { - // override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - // val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as KeyEvent? - // if (keyEvent != null) { - // if (keyEvent.action == KeyEvent.ACTION_DOWN) { // NO DOUBLE SKIP - // val consumed = when (keyEvent.keyCode) { - // KeyEvent.KEYCODE_MEDIA_PAUSE -> callOnPause() - // KeyEvent.KEYCODE_MEDIA_PLAY -> callOnPlay() - // KeyEvent.KEYCODE_MEDIA_STOP -> callOnStop() - // KeyEvent.KEYCODE_MEDIA_NEXT -> callOnNext() - // else -> false - // } - // if (consumed) return true - // } - // } - // - // return super.onMediaButtonEvent(mediaButtonEvent) - // } - //} - - - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - resizeMode = getKey(RESIZE_MODE_KEY) ?: 0 - resize(resizeMode, false) - - player.releaseCallbacks() - player.initCallbacks( - playerUpdated = ::playerUpdated, - updateIsPlaying = ::updateIsPlaying, - playerError = ::playerError, - requestAutoFocus = ::requestAudioFocus, - nextEpisode = ::nextEpisode, - prevEpisode = ::prevEpisode, - playerPositionChanged = ::playerPositionChanged, - playerDimensionsLoaded = ::playerDimensionsLoaded, - requestedListeningPercentages = listOf( - SKIP_OP_VIDEO_PERCENTAGE, - PRELOAD_NEXT_EPISODE_PERCENTAGE, - NEXT_WATCH_EPISODE_PERCENTAGE, - UPDATE_SYNC_PROGRESS_PERCENTAGE, - ), - subtitlesUpdates = ::subtitlesChanged, - embeddedSubtitlesFetched = ::embeddedSubtitlesFetched, - onTracksInfoChanged = ::onTracksInfoChanged, - onTimestampInvoked = ::onTimestamp, - onTimestampSkipped = ::onTimestampSkipped - ) - - if (player is CS3IPlayer) { - subView = playerView?.findViewById(R.id.exo_subtitles) - subStyle = SubtitlesFragment.getCurrentSavedStyle() - player.initSubtitles(subView, subtitleHolder, subStyle) - - 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 - ), - ) - }*/ + playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) } override fun onDestroy() { - playerEventListener = null - keyEventListener = null - canEnterPipMode = false - mMediaSession?.release() - mMediaSession = null - SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged - - keepScreenOn(false) + playerHostView?.release() super.onDestroy() } - fun nextResize() { - resizeMode = (resizeMode + 1) % PlayerResize.values().size - resize(resizeMode, true) - } - - fun resize(resize: Int, showToast: Boolean) { - resize(PlayerResize.values()[resize], showToast) - } - - fun resize(resize: PlayerResize, showToast: Boolean) { - setKey(RESIZE_MODE_KEY, resize.ordinal) - val type = when (resize) { - PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL - PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT - PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM - } - playerView?.resizeMode = type - - if (showToast) - showToast(resize.nameRes, Toast.LENGTH_SHORT) + override fun onPause() { + playerHostView?.releaseKeyEventListener() + super.onPause() } override fun onStop() { - player.onStop() + playerHostView?.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> - player.onResume(ctx) + playerHostView?.onResume(ctx) } - super.onResume() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val root = inflater.inflate(layout, container, false) - playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) - playerPausePlay = root.findViewById(R.id.player_pause_play) - playerBuffering = root.findViewById(R.id.player_buffering) - playerView = root.findViewById(R.id.player_view) - piphide = root.findViewById(R.id.piphide) - subtitleHolder = root.findViewById(R.id.subtitle_holder) - return root + fun nextResize() { + playerHostView?.nextResize() } -} \ No newline at end of file + + open fun resize(resize: PlayerResize, showToast: Boolean) { + playerHostView?.resize(resize, showToast) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 2067eb04b..d7e10c814 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1,13 +1,26 @@ +@file:Suppress("DEPRECATION") + package com.lagradost.cloudstream3.ui.player +import android.annotation.SuppressLint import android.content.Context +import android.content.DialogInterface +import android.graphics.Bitmap import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log import android.util.Rational import android.widget.FrameLayout -import androidx.media3.common.C.* +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 +import androidx.media3.common.C.TRACK_TYPE_VIDEO import androidx.media3.common.Format import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes @@ -17,48 +30,95 @@ 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 -import androidx.media3.datasource.DefaultDataSourceFactory +import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource 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.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.Renderer.STATE_ENABLED +import androidx.media3.exoplayer.Renderer.STATE_STARTED import androidx.media3.exoplayer.SeekParameters +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.drm.DefaultDrmSessionManager +import androidx.media3.exoplayer.drm.FrameworkMediaDrm +import androidx.media3.exoplayer.drm.HttpMediaDrmCallback +import androidx.media3.exoplayer.drm.LocalMediaDrmCallback +import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource +import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource +import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector +import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.AudioFile +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment +import com.lagradost.cloudstream3.ui.player.live.LiveHelper +import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData -import com.lagradost.cloudstream3.utils.EpisodeSkip +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.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList -import com.lagradost.cloudstream3.utils.ExtractorUri -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import kotlinx.coroutines.delay +import okhttp3.Interceptor +import org.chromium.net.CronetEngine import 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" @@ -73,14 +133,30 @@ const val toleranceBeforeUs = 300_000L */ 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) { + // If the old value is not null then the player has not been properly released. + debugAssert( + { field != null && value != null }, + { "Previous player instance should be released!" }) + field = value + } + var cacheSize = 0L var simpleCacheSize = 0L var videoBufferMs = 0L + 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 @@ -96,13 +172,26 @@ 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 * */ data class MediaItemSlice( val mediaItem: MediaItem, - val durationUs: Long + val durationUs: Long, + val drm: DrmMetadata? = null + ) + + data class DrmMetadata( + val kid: String? = null, + val key: String? = null, + val uuid: UUID, + val kty: String? = null, + val licenseUrl: String? = null, + val keyRequestParameters: HashMap, ) override fun getDuration(): Long? = exoPlayer?.duration @@ -112,107 +201,73 @@ class CS3IPlayer : IPlayer { /** * Tracks reported to be used by exoplayer, since sometimes it has a mind of it's own when selecting subs. - * String = id + * String = id (without exoplayer track number) * Boolean = if it's active * */ private var playerSelectedSubtitleTracks = listOf>() - - /** isPlaying */ - private var updateIsPlaying: ((Pair) -> Unit)? = null - private var requestAutoFocus: (() -> Unit)? = null - private var playerError: ((Exception) -> Unit)? = null - private var subtitlesUpdates: (() -> Unit)? = null - - /** width x height */ - private var playerDimensionsLoaded: ((Pair) -> Unit)? = null - - /** used for playerPositionChanged */ private var requestedListeningPercentages: List? = null - /** Fired when seeking the player or on requestedListeningPercentages, - * used to make things appear on que - * position, duration */ - private var playerPositionChanged: ((Pair) -> Unit)? = null + private var eventHandler: ((PlayerEvent) -> Unit)? = null - private var nextEpisode: (() -> Unit)? = null - private var prevEpisode: (() -> 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) + } + } - private var playerUpdated: ((Any?) -> Unit)? = null - private var embeddedSubtitlesFetched: ((List) -> Unit)? = null - private var onTracksInfoChanged: (() -> Unit)? = null - private var onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null - private var onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null + /** + * 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() { - playerUpdated = null - updateIsPlaying = null - requestAutoFocus = null - playerError = null - playerDimensionsLoaded = null - requestedListeningPercentages = null - playerPositionChanged = null - nextEpisode = null - prevEpisode = null - subtitlesUpdates = null - onTracksInfoChanged = null - onTimestampInvoked = null - requestSubtitleUpdate = null - onTimestampSkipped = null - } - - override fun initCallbacks( - playerUpdated: (Any?) -> Unit, - updateIsPlaying: ((Pair) -> Unit)?, - requestAutoFocus: (() -> Unit)?, - playerError: ((Exception) -> Unit)?, - playerDimensionsLoaded: ((Pair) -> Unit)?, - requestedListeningPercentages: List?, - playerPositionChanged: ((Pair) -> Unit)?, - nextEpisode: (() -> Unit)?, - prevEpisode: (() -> Unit)?, - subtitlesUpdates: (() -> Unit)?, - embeddedSubtitlesFetched: ((List) -> Unit)?, - onTracksInfoChanged: (() -> Unit)?, - onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)?, - onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)?, - ) { - this.playerUpdated = playerUpdated - this.updateIsPlaying = updateIsPlaying - this.requestAutoFocus = requestAutoFocus - this.playerError = playerError - this.playerDimensionsLoaded = playerDimensionsLoaded - this.requestedListeningPercentages = requestedListeningPercentages - this.playerPositionChanged = playerPositionChanged - this.nextEpisode = nextEpisode - this.prevEpisode = prevEpisode - this.subtitlesUpdates = subtitlesUpdates - this.embeddedSubtitlesFetched = embeddedSubtitlesFetched - this.onTracksInfoChanged = onTracksInfoChanged - this.onTimestampInvoked = onTimestampInvoked - this.onTimestampSkipped = onTimestampSkipped - } - - // 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) - } catch (e: Exception) { - logError(e) - } - } - } catch (e: Exception) { - logError(e) - } + eventHandler = null + if (isPlayerActive) { + isPlayerActive = false + activePlayers -= 1 + releaseCronetEngine() } } + @AnyThread + override fun initCallbacks( + @MainThread eventHandler: ((PlayerEvent) -> Unit), + requestedListeningPercentages: List?, + ) { + this.requestedListeningPercentages = requestedListeningPercentages + this.eventHandler = eventHandler + if (!isPlayerActive) { + isPlayerActive = true + activePlayers += 1 + } + } + + fun String.stripTrackId(): String { + return this.replace(Regex("""^\d+:"""), "") + } + fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { subtitleHelper.initSubtitles(subView, subHolder, style) } + override fun getPreview(fraction: Float): Bitmap? { + return imageGenerator.getPreviewImage(fraction) + } + + override fun hasPreview(): Boolean { + // No previews on livestreams because the previews get outdated + if (exoPlayer?.isCurrentMediaItemDynamic == true) { + return false + } + return imageGenerator.hasPreview() + } + override fun loadPlayer( context: Context, sameEpisode: Boolean, @@ -221,7 +276,8 @@ class CS3IPlayer : IPlayer { startPosition: Long?, subtitles: Set, subtitle: SubtitleData?, - autoPlay: Boolean? + autoPlay: Boolean?, + preview: Boolean, ) { Log.i(TAG, "loadPlayer") if (sameEpisode) { @@ -240,11 +296,31 @@ class CS3IPlayer : IPlayer { // release the current exoplayer and cache releasePlayer() + if (link != null) { + // only video support atm + (imageGenerator as? PreviewGenerator)?.let { gen -> + if (preview) { + gen.load(link, sameEpisode) + } else { + gen.clear(sameEpisode) + } + } + loadOnlinePlayer(context, link) } else if (data != null) { + (imageGenerator as? PreviewGenerator)?.let { gen -> + if (preview) { + gen.load(context, data, sameEpisode) + } else { + gen.clear(sameEpisode) + } + } loadOfflinePlayer(context, data) + } else { + throw IllegalArgumentException("Requires link or uri") } + } override fun setActiveSubtitles(subtitles: Set) { @@ -252,7 +328,7 @@ class CS3IPlayer : IPlayer { subtitleHelper.setAllSubtitles(subtitles) } - var currentSubtitles: SubtitleData? = null + private var currentSubtitles: SubtitleData? = null private fun List.getTrack(id: String?): Pair? { if (id == null) return null @@ -265,7 +341,11 @@ class CS3IPlayer : IPlayer { return this.firstNotNullOfOrNull { group -> (0 until group.mediaTrackGroup.length).map { group.getTrackFormat(it) to it - }.firstOrNull { it.first.id == id } + }.firstOrNull { + // The format id system is "trackNumber:trackID" + // The track number is not generated by us so we filter it out + it.first.id?.stripTrackId() == id + } ?.let { group.mediaTrackGroup to it.second } } } @@ -298,44 +378,47 @@ class CS3IPlayer : IPlayer { ?: return } - override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) { + override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { preferredAudioTrackLanguage = trackLanguage - - if (id != null) { - val audioTrack = - exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_AUDIO } - ?.getTrack(id) - - if (audioTrack != null) { - exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters - ?.buildUpon() - ?.setOverrideForType( - TrackSelectionOverride( - audioTrack.first, - audioTrack.second + id?.let { trackId -> + val trackFormatIndex = formatIndex ?: 0 + exoPlayer?.currentTracks?.groups + ?.filter { it.type == TRACK_TYPE_AUDIO } + ?.find { group -> + group.getFormats().any { (format, _) -> + format.id == trackId + } + } + ?.let { group -> + exoPlayer?.trackSelectionParameters + ?.buildUpon() + ?.setOverrideForType( + TrackSelectionOverride( + group.mediaTrackGroup, + trackFormatIndex + ) ) - ) - ?.build() - ?: return - return - } + ?.build() + } + ?.let { newParams -> + exoPlayer?.trackSelectionParameters = newParams + return + } } - + // Fallback to language-based selection exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setPreferredAudioLanguage(trackLanguage) - ?.build() - ?: return + ?.build() ?: return } - /** * Gets all supported formats in a list * */ private fun List.getFormats(): List> { - return this.map { + return this.flatMap { it.getFormats() - }.flatten() + } } private fun Tracks.Group.getFormats(): List> { @@ -346,39 +429,66 @@ class CS3IPlayer : IPlayer { } } - private fun Format.toAudioTrack(): AudioTrack { + private fun Format.toAudioTrack(formatIndex: Int?): AudioTrack { return AudioTrack( this.id, this.label, -// isPlaying, - this.language + this.language, + this.sampleMimeType, + this.channelCount, + formatIndex ?: 0, + ) + } + + private fun Format.toSubtitleTrack(): TextTrack { + return TextTrack( + this.id?.stripTrackId(), + this.label, + this.language, + this.sampleMimeType, ) } private fun Format.toVideoTrack(): VideoTrack { return VideoTrack( - this.id, + this.id?.stripTrackId(), this.label, -// isPlaying, this.language, this.width, - this.height + this.height, + this.sampleMimeType ) } override fun getVideoTracks(): CurrentTracks { - val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() - val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } + val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() + val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } .getFormats() .map { it.first.toVideoTrack() } - val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats() - .map { it.first.toAudioTrack() } - + var currentAudioTrack: AudioTrack? = null + val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO } + .flatMap { group -> + group.getFormats().map { (format, formatIndex) -> + val audioTrack = format.toAudioTrack(formatIndex) + if (group.isTrackSelected(formatIndex)) { + currentAudioTrack = audioTrack + } + audioTrack + } + } + val textTracks = allTrackGroups.filter { it.type == TRACK_TYPE_TEXT } + .getFormats() + .map { it.first.toSubtitleTrack() } + val currentTextTracks = textTracks.filter { track -> + playerSelectedSubtitleTracks.any { it.second && it.first == track.id } + } return CurrentTracks( exoPlayer?.videoFormat?.toVideoTrack(), - exoPlayer?.audioFormat?.toAudioTrack(), + currentAudioTrack, + currentTextTracks, videoTracks, - audioTracks + audioTracks, + textTracks ) } @@ -388,71 +498,65 @@ class CS3IPlayer : IPlayer { override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { Log.i(TAG, "setPreferredSubtitles init $subtitle") currentSubtitles = subtitle + val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false + // Disable subtitles if null + if (subtitle == null) { + trackSelector.setParameters( + trackSelector.buildUponParameters() + .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) + .clearOverridesOfType(TRACK_TYPE_TEXT) + ) + return false + } + // Handle subtitle based on status + when (subtitleHelper.subtitleStatus(subtitle)) { + SubtitleStatus.REQUIRES_RELOAD -> { + Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") + return true + } - fun getTextTrack(id: String) = - exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_TEXT } - ?.getTrack(id) - - return (exoPlayer?.trackSelector as? DefaultTrackSelector?)?.let { trackSelector -> - if (subtitle == null) { - trackSelector.setParameters( - trackSelector.buildUponParameters() - .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) - .clearOverridesOfType(TRACK_TYPE_TEXT) - ) - } else { - when (subtitleHelper.subtitleStatus(subtitle)) { - SubtitleStatus.REQUIRES_RELOAD -> { - Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") - return@let true - } - - SubtitleStatus.IS_ACTIVE -> { - Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") + SubtitleStatus.NOT_FOUND -> { + Log.i(TAG, "setPreferredSubtitles NOT_FOUND") + return true + } + SubtitleStatus.IS_ACTIVE -> { + Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") + exoPlayer?.currentTracks?.groups + ?.filter { it.type == TRACK_TYPE_TEXT } + ?.getTrack(subtitle.getId()) + ?.let { (trackGroup, trackIndex) -> trackSelector.setParameters( trackSelector.buildUponParameters() - .apply { - val track = getTextTrack(subtitle.getId()) - if (track != null) { - setTrackTypeDisabled(TRACK_TYPE_TEXT, false) - setOverrideForType( - TrackSelectionOverride( - track.first, - track.second - ) - ) - } - } + .setTrackTypeDisabled(TRACK_TYPE_TEXT, false) + .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex)) ) - - // ugliest code I have written, it seeks 1ms to *update* the subtitles - //exoPlayer?.applicationLooper?.let { - // Handler(it).postDelayed({ - // seekTime(1L) - // }, 1) - //} } - - SubtitleStatus.NOT_FOUND -> { - Log.i(TAG, "setPreferredSubtitles NOT_FOUND") - return@let true - } - } + return false } - return false - } ?: false + } } - var currentSubtitleOffset: Long = 0 + private var currentSubtitleOffset: Long = 0 override fun setSubtitleOffset(offset: Long) { currentSubtitleOffset = offset - currentTextRenderer?.setRenderOffsetMs(offset) + CustomDecoder.subtitleOffset = offset + if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { + exoPlayer?.currentPosition?.also { pos -> + // This seems to properly refresh all subtitles + // It needs to be done as all subtitle cues with timings are pre-processed + currentTextRenderer?.resetPosition(pos, false) + } + } } override fun getSubtitleOffset(): Long { - return currentSubtitleOffset //currentTextRenderer?.getRenderOffsetMs() ?: currentSubtitleOffset + return currentSubtitleOffset + } + + override fun getSubtitleCues(): List { + return currentSubtitleDecoder?.getSubtitleCues() ?: emptyList() } override fun getCurrentPreferredSubtitle(): SubtitleData? { @@ -479,22 +583,42 @@ class CS3IPlayer : IPlayer { exoPlayer?.let { exo -> playbackPosition = exo.currentPosition - currentWindow = exo.currentWindowIndex + currentWindow = exo.currentMediaItemIndex isPlaying = exo.isPlaying } } private fun releasePlayer(saveTime: Boolean = true) { Log.i(TAG, "releasePlayer") - + eventLooperIndex += 1 if (saveTime) updatedTime() - exoPlayer?.release() - //simpleCache?.release() currentTextRenderer = null + currentSubtitleDecoder = null + + exoPlayer?.apply { + playWhenReady = false + + // This may look weird, however on some TV devices the audio does not stop playing + // so this may fix it? + try { + pause() + } catch (t: Throwable) { + // No documented exception, but just to be extra safe + logError(t) + } + playerListener?.let { + removeListener(it) + playerListener = null + } + stop() + release() + } + //simpleCache?.release() exoPlayer = null + event(PlayerAttachedEvent(null)) //simpleCache = null } @@ -502,23 +626,29 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "onStop") saveData() - exoPlayer?.pause() + if (!isAudioOnlyBackground) { + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + } //releasePlayer() } override fun onPause() { Log.i(TAG, "onPause") saveData() - exoPlayer?.pause() + if (!isAudioOnlyBackground) { + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + } //releasePlayer() } override fun onResume(context: Context) { + isAudioOnlyBackground = false if (exoPlayer == null) reloadPlayer(context) } override fun release() { + imageGenerator.release() releasePlayer() } @@ -528,57 +658,172 @@ 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(PREFERRED_AUDIO_LANGUAGE_KEY, field)?.also { + return field ?: getKey( + "$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", + field + )?.also { field = it } } set(value) { - setKey(PREFERRED_AUDIO_LANGUAGE_KEY, value) + setKey("$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", value) field = value } private var simpleCache: SimpleCache? = null - var requestSubtitleUpdate: (() -> Unit)? = 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) - private fun createOnlineSource(headers: Map): HttpDataSource.Factory { - val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) - return source.apply { - setDefaultRequestProperties(headers) + 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(link: ExtractorLink): HttpDataSource.Factory { - val provider = getApiFromNameNull(link.source) - val interceptor = provider?.getVideoInterceptor(link) + private fun createVideoSource( + link: ExtractorLink, + engine: CronetEngine?, + interceptor: Interceptor?, + ): HttpDataSource.Factory { + val userAgent = link.headers.entries.find { + it.key.equals("User-Agent", ignoreCase = true) + }?.value ?: USER_AGENT val source = if (interceptor == null) { - DefaultHttpDataSource.Factory() //TODO USE app.baseClient - .setUserAgent(USER_AGENT) - .setAllowCrossProtocolRedirects(true) //https://stackoverflow.com/questions/69040127/error-code-io-bad-http-status-exoplayer-android + if (engine == null) { + Log.d(TAG, "Using DefaultHttpDataSource for $link") + OkHttpDataSource.Factory(app.baseClient).setUserAgent(userAgent) + } else { + Log.d(TAG, "Using CronetDataSource for $link") + CronetDataSource.Factory(engine, Executors.newSingleThreadExecutor()) + .setUserAgent(userAgent) + .setConnectionTimeoutMs(CRONET_TIMEOUT_MS) + .setReadTimeoutMs(CRONET_TIMEOUT_MS) + .setResetTimeoutOnRedirects(true) + .setHandleSetCookieRequests(true) + } } else { + Log.d(TAG, "Using OkHttpDataSource for $link") val client = app.baseClient.newBuilder() .addInterceptor(interceptor) .build() - OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) + OkHttpDataSource.Factory(client).setUserAgent(userAgent) } // Do no include empty referer, if the provider wants those they can use the header map. val refererMap = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) - val headers = mapOf( - "accept" to "*/*", - "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", - "sec-ch-ua-mobile" to "?0", - "sec-fetch-user" to "?1", - "sec-fetch-mode" to "navigate", - "sec-fetch-dest" to "video" - ) + refererMap + link.headers // Adds the headers from the provider, e.g Authorization + + // These are extra headers the browser like to insert, not sure if we want to include them + // for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue. + val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) @@ -586,57 +831,19 @@ class CS3IPlayer : IPlayer { } private fun Context.createOfflineSource(): DataSource.Factory { - return DefaultDataSourceFactory(this, USER_AGENT) + return DefaultDataSource.Factory( + this, + DefaultHttpDataSource.Factory().setUserAgent(USER_AGENT) + ) } - /*private fun getSubSources( - onlineSourceFactory: DataSource.Factory?, - offlineSourceFactory: DataSource.Factory?, - subHelper: PlayerSubtitleHelper, - ): Pair, List> { - val activeSubtitles = ArrayList() - val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url)) - .setMimeType(sub.mimeType) - .setLanguage("_${sub.name}") - .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) - .build() - when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE -> { - if (offlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(offlineSourceFactory) - .createMediaSource(subConfig, C.TIME_UNSET) - } else { - null - } - } - SubtitleOrigin.URL -> { - if (onlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(onlineSourceFactory) - .createMediaSource(subConfig, C.TIME_UNSET) - } else { - null - } - } - SubtitleOrigin.OPEN_SUBTITLES -> { - // TODO - throw NotImplementedError() - } - } - } - println("SUBSRC: ${subSources.size} activeSubtitles : ${activeSubtitles.size} of ${subHelper.getAllSubtitles().size} ") - return Pair(subSources, activeSubtitles) - }*/ - private fun getCache(context: Context, cacheSize: Long): SimpleCache? { return try { val databaseProvider = StandaloneDatabaseProvider(context) SimpleCache( File( context.cacheDir, "exoplayer" - ).also { it.deleteOnExit() }, // Ensures always fresh file + ).also { deleteFileOnExit(it) }, // Ensures always fresh file LeastRecentlyUsedCacheEvictor(cacheSize), databaseProvider ) @@ -672,170 +879,93 @@ class CS3IPlayer : IPlayer { return trackSelector } - var currentTextRenderer: CustomTextRenderer? = null - - private fun buildExoPlayer( - context: Context, - mediaItemSlices: List, - subSources: List, - currentWindow: Int, - playbackPosition: Long, - playBackSpeed: Float, - subtitleOffset: Long, - cacheSize: Long, - videoBufferMs: Long, - playWhenReady: Boolean = true, - cacheFactory: CacheDataSource.Factory? = null, - trackSelector: TrackSelector? = null, - /** - * Sets the m3u8 preferred video quality, will not force stop anything with higher quality. - * Does not work if trackSelector is defined. - **/ - maxVideoHeight: Int? = null - ): ExoPlayer { - val exoPlayerBuilder = - ExoPlayer.Builder(context) - .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> - DefaultRenderersFactory(context).apply { - setEnableDecoderFallback(true) - // Enable Ffmpeg extension - setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON) - }.createRenderers( - eventHandler, - videoRendererEventListener, - audioRendererEventListener, - textRendererOutput, - metadataRendererOutput - ).map { - if (it is TextRenderer) { - currentTextRenderer = CustomTextRenderer( - subtitleOffset, - textRendererOutput, - eventHandler.looper, - CustomSubtitleDecoderFactory() - ) - 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) { - factory.createMediaSource(mediaItemSlices.first().mediaItem) - } else { - val source = ConcatenatingMediaSource() - mediaItemSlices.map { - source.addMediaSource( - // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 - ClippingMediaSource( - factory.createMediaSource(it.mediaItem), - it.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 var currentSubtitleDecoder: CustomSubtitleDecoderFactory? = null + private var currentTextRenderer: TextRenderer? = null } - private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { + private fun getCurrentTimestamp(writePosition: Long? = null): VideoSkipStamp? { val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null for (lastTimeStamp in lastTimeStamps) { - if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { + if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) { return lastTimeStamp } } return null } - fun updatedTime(writePosition: Long? = null) { + fun updatedTime( + writePosition: Long? = null, + source: PlayerEventSource = PlayerEventSource.Player + ) { val position = writePosition ?: exoPlayer?.currentPosition getCurrentTimestamp(position)?.let { timestamp -> - onTimestampInvoked?.invoke(timestamp) + event(TimestampInvokedEvent(timestamp, source)) } val duration = exoPlayer?.contentDuration if (duration != null && position != null) { - playerPositionChanged?.invoke(Pair(position, duration)) + event( + PositionEvent( + source, + fromMs = exoPlayer?.currentPosition ?: 0, + position, + duration + ) + ) } } - override fun seekTime(time: Long) { - exoPlayer?.seekTime(time) + override fun seekTime(time: Long, source: PlayerEventSource) { + exoPlayer?.seekTime(time, source) } - override fun seekTo(time: Long) { - updatedTime(time) - exoPlayer?.seekTo(time) + 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") + } } - private fun ExoPlayer.seekTime(time: Long) { - updatedTime(currentPosition + time) - seekTo(currentPosition + 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") + } } - override fun handleEvent(event: CSPlayerEvent) { + override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) { Log.i(TAG, "handleEvent ${event.name}") try { exoPlayer?.apply { 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() } CSPlayerEvent.Pause -> { + event(PauseEvent(source)) pause() } @@ -852,32 +982,397 @@ class CS3IPlayer : IPlayer { CSPlayerEvent.PlayPauseToggle -> { if (isPlaying) { - pause() + handleEvent(CSPlayerEvent.Pause, source) } else { - play() + handleEvent(CSPlayerEvent.Play, source) } } - CSPlayerEvent.SeekForward -> seekTime(seekActionTime) - CSPlayerEvent.SeekBack -> seekTime(-seekActionTime) - CSPlayerEvent.NextEpisode -> nextEpisode?.invoke() - CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke() + CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source) + + CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source) + + CSPlayerEvent.Restart -> seekTo(0, source) + + CSPlayerEvent.NextEpisode -> event( + EpisodeSeekEvent( + offset = 1, + source = source + ) + ) + + CSPlayerEvent.PrevEpisode -> event( + EpisodeSeekEvent( + offset = -1, + source = source + ) + ) + CSPlayerEvent.SkipCurrentChapter -> { //val dur = this@CS3IPlayer.getDuration() ?: return@apply getCurrentTimestamp()?.let { lastTimeStamp -> if (lastTimeStamp.skipToNextEpisode) { - handleEvent(CSPlayerEvent.NextEpisode) + handleEvent(CSPlayerEvent.NextEpisode, source) } else { - seekTo(lastTimeStamp.endMs + 1L) + seekTo(lastTimeStamp.timestamp.endMs + 1L) } - onTimestampSkipped?.invoke(lastTimeStamp) + event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) } } + + CSPlayerEvent.PlayAsAudio -> { + isAudioOnlyBackground = true + activity?.moveTaskToBack(false) + } } } - } catch (e: Exception) { - Log.e(TAG, "handleEvent error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "handleEvent error", t) + event(ErrorEvent(t)) + } + } + + // we want to push metadata when loading torrents, so we just set up a looper that loops until + // the index changes, this way only 1 looper is active at a time, and modifying eventLooperIndex + // will kill any active loopers + private var eventLooperIndex = 0 + private fun torrentEventLooper(hash: String) = ioSafe { + eventLooperIndex += 2 + // very shitty, but should work fine + // release player is called once for the new link + val currentIndex = eventLooperIndex + 1 + while (eventLooperIndex <= currentIndex && eventHandler != null) { + try { + val status = Torrent.get(hash) + event( + DownloadEvent( + connections = status.activePeers, + downloadSpeed = status.downloadSpeed?.toLong()!!, + totalBytes = status.torrentSize!!, + downloadedBytes = status.bytesRead!!, + ) + ) + } catch (_: NullPointerException) { + } catch (t: Throwable) { + logError(t) + } + delay(1000) + } + } + + private fun buildExoPlayer( + context: Context, + mediaItemSlices: List, + subSources: List, + currentWindow: Int, + playbackPosition: Long, + playBackSpeed: Float, + subtitleOffset: Long, + cacheSize: Long, + videoBufferMs: Long, + onlineSource: HttpDataSource.Factory? = null, + playWhenReady: Boolean = true, + trackSelector: TrackSelector? = null, + /** + * Sets the m3u8 preferred video quality, will not force stop anything with higher quality. + * Does not work if trackSelector is defined. + **/ + maxVideoHeight: Int? = null, + /** External audio tracks to merge with the video */ + audioSources: List = emptyList() + ): ExoPlayer { + val exoPlayerBuilder = + ExoPlayer.Builder(context) + .setMediaSourceFactory( + DefaultMediaSourceFactory(context).setLiveTargetOffsetMs( + PREFERRED_LIVE_OFFSET + ) + ) + .setLivePlaybackSpeedControl( + DefaultLivePlaybackSpeedControl.Builder() + .setFallbackMaxPlaybackSpeed(1.03f) + .setFallbackMinPlaybackSpeed(0.97f) + .build() + ) + .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput -> + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) + val current = settingsManager.getInt( + context.getString(R.string.software_decoding_key), + -1 + ) + val (isSoftwareDecodingEnabled, isSoftwareDecodingPreferred) = when (current) { + 0 -> true to false // HW+SW, aka on but prefer hw + 2 -> true to true // SW+HW, aka on but prefer sw + 1 -> false to false // HW, aka off + // -1 = automatic + // We do not want tv to have software decoding, because of crashes + else -> isLayout(PHONE or EMULATOR) to false + } + + val factory = if (isSoftwareDecodingEnabled) { + FixedNextRenderersFactory(context).apply { + setEnableDecoderFallback(true) + setExtensionRendererMode( + if (isSoftwareDecodingPreferred) + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + else + DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + ) + } + } else { + // no nextlib = EXTENSION_RENDERER_MODE_OFF + DefaultRenderersFactory(context) + } + + val style = CustomDecoder.style + // Custom TextOutput to apply cue styling and rules to all subtitles + val customTextOutput = TextOutput { cue -> + // Do not remove filterNotNull as Java typesystem is fucked + val (bitmapCues, textCues) = cue.cues.toList() + .partition { it.bitmap != null } + + val styledBitmapCues = bitmapCues.map { bitmapCue -> + bitmapCue + .buildUpon() + .fixSubtitleAlignment() + .applyStyle(style) + .build() + } + + // Reuse memory, to avoid many allocations + val set = HashSet() + val buffer = StringBuilder() + + // Move cues into one single one + // This is to prevent text overlap in vtt (and potentially other) subtitle files + val styledTextCues = textCues.groupBy { + // Groups cues which share the same positon + it.lineAnchor to it.position.times(1000.0f).toInt() + }.mapNotNull { (_, entries) -> + set.clear() + buffer.clear() + var count = 0 + for (x in entries) { + // Only allow non null text, otherwise we might have "a\n\nb" + val text = x.text ?: continue + + // Prevent duplicate entries, this often happens when the subtitle file + // uses multiple text lines as outlines. Most commonly found in fansubs + // with fancy subtitle styling. + if (!set.add(text)) { + continue + } + if (++count > 1) buffer.append('\n') + + // Trim to avoid weird formatting if the last line ends with a newline + buffer.append(text.trim()) + } + + val combinedCueText = buffer.toString() + + // Use the style of the first entry as the base + entries + .firstOrNull() + ?.buildUpon() + ?.setText(combinedCueText) + ?.fixSubtitleAlignment() + ?.applyStyle(style) + ?.build() + } + + val combinedCues = styledBitmapCues + styledTextCues + + subtitleHelper.subtitleView?.setCues(combinedCues) + } + + factory.createRenderers( + eventHandler, + videoRendererEventListener, + audioRendererEventListener, + customTextOutput, + metadataRendererOutput + ).map { + if (it is TextRenderer) { + CustomDecoder.subtitleOffset = subtitleOffset + val decoder = CustomSubtitleDecoderFactory() + + // @OptIn(ExperimentalApi::class) + val currentTextRenderer = TextRenderer( + customTextOutput, + eventHandler.looper, + decoder + ).apply { + // Required to make the decoder work with old subtitles + // Upgrade CustomSubtitleDecoderFactory when media3 supports it + @Suppress("DEPRECATION") + experimentalSetLegacyDecodingEnabled(true) + }.also { renderer -> + currentTextRenderer = renderer + currentSubtitleDecoder = decoder + } + currentTextRenderer + } else + it + }.toTypedArray() + } + .setTrackSelector( + trackSelector ?: getTrackSelector( + context, + maxVideoHeight + ) + ) + // Allows any seeking to be +- 0.3s to allow for faster seeking + .setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs)) + .setLoadControl( + DefaultLoadControl.Builder() + .setTargetBufferBytes( + if (cacheSize <= 0) { + DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES + } else { + if (cacheSize > Int.MAX_VALUE) Int.MAX_VALUE else cacheSize.toInt() + } + ) + .setBackBuffer( + 30000, + true + ) + .setBufferDurationsMs( + DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, + if (videoBufferMs <= 0) { + DefaultLoadControl.DEFAULT_MAX_BUFFER_MS + } else { + videoBufferMs.toInt() + }, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + ).build() + ) + + // Because "Java rules" the media3 team hates to do open classes so we have to copy paste the entire thing to add a custom extractor + // This includes the updated MKV extractor that enabled seeking in formats where the seek information is at the back of the file + val extractorFactor = UpdatedDefaultExtractorsFactory() + .setFragmentedMp4ExtractorFlags(FragmentedMp4Extractor.FLAG_MERGE_FRAGMENTED_SIDX) + + // Create an online connection with cache for all online sources + val dataSourceFactory = if (onlineSource == null) { + null + } else { + if (simpleCache == null) + simpleCache = getCache(context, simpleCacheSize) + + val cacheFactory = CacheDataSource.Factory().apply { + simpleCache?.let { setCache(it) } + setUpstreamDataSourceFactory(onlineSource) + } + cacheFactory + } + + val defaultMediaSourceFactory = if (dataSourceFactory != null) { + DefaultMediaSourceFactory(dataSourceFactory, extractorFactor) + } else { + DefaultMediaSourceFactory(context, extractorFactor) + } + + // If there is only one item then treat it as normal, if multiple: concatenate the items. + val videoMediaSource = if (mediaItemSlices.size == 1) { + val item = mediaItemSlices.first() + + item.drm?.let { drm -> + when (drm.uuid) { + CLEARKEY_DRM_UUID.toJavaUuid() -> { + // Use headers from DrmMetadata for media requests + val client = dataSourceFactory + ?: throw IllegalArgumentException("Must supply onlineSource") + val drmCallback = + LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray()) + val manager = DefaultDrmSessionManager.Builder() + .setPlayClearSamplesWithoutKeys(true) + .setMultiSession(false) + .setKeyRequestParameters(drm.keyRequestParameters) + .setUuidAndExoMediaDrmProvider( + drm.uuid, + FrameworkMediaDrm.DEFAULT_PROVIDER + ) + .build(drmCallback) + + DashMediaSource.Factory(client) + .setDrmSessionManagerProvider { manager } + .createMediaSource(item.mediaItem) + } + + WIDEVINE_DRM_UUID.toJavaUuid(), + PLAYREADY_DRM_UUID.toJavaUuid() -> { + // Use headers from DrmMetadata for media requests + val client = dataSourceFactory + ?: throw IllegalArgumentException("Must supply onlineSource") + val drmCallback = HttpMediaDrmCallback(drm.licenseUrl, client) + val manager = DefaultDrmSessionManager.Builder() + .setPlayClearSamplesWithoutKeys(true) + .setMultiSession(true) + .setKeyRequestParameters(drm.keyRequestParameters) + .setUuidAndExoMediaDrmProvider( + drm.uuid, + FrameworkMediaDrm.DEFAULT_PROVIDER + ) + .build(drmCallback) + + DashMediaSource.Factory(client) + .setDrmSessionManagerProvider { manager } + .createMediaSource(item.mediaItem) + } + + else -> { + Log.e( + TAG, + "DRM Metadata class is not supported: ${drm::class.simpleName}" + ) + null + } + } + } ?: run { + defaultMediaSourceFactory.createMediaSource(item.mediaItem) + } + } else { + try { + val source = ConcatenatingMediaSource2.Builder() + mediaItemSlices.forEach { item -> + source.add( + // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 + ClippingMediaSource( + defaultMediaSourceFactory.createMediaSource(item.mediaItem), + item.durationUs + ) + ) + } + source.build() + } catch (_: IllegalArgumentException) { + @Suppress("DEPRECATION") + val source = + ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only + mediaItemSlices.forEach { item -> + source.addMediaSource( + // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 + ClippingMediaSource( + defaultMediaSourceFactory.createMediaSource(item.mediaItem), + item.durationUs + ) + ) + } + source + } + } + return exoPlayerBuilder.build().apply { + setPlayWhenReady(playWhenReady) + seekTo(currentWindow, playbackPosition) + // Merge video, subtitles and external audio tracks + val allSources = listOf(videoMediaSource) + subSources + audioSources + setMediaSource( + MergingMediaSource(*allSources.toTypedArray()), + playbackPosition + ) + setHandleAudioBecomingNoisy(true) + setPlaybackSpeed(playBackSpeed) + this.addAnalyticsListener(tracksAnalyticsListener) } } @@ -885,12 +1380,13 @@ class CS3IPlayer : IPlayer { context: Context, mediaSlices: List, subSources: List, - cacheFactory: CacheDataSource.Factory? = null + audioSources: List = emptyList(), + onlineSource: HttpDataSource.Factory? = null, ) { Log.i(TAG, "loadExo") val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val maxVideoHeight = settingsManager.getInt( - context.getString(if (context.isUsingMobileData()) com.lagradost.cloudstream3.R.string.quality_pref_mobile_data_key else com.lagradost.cloudstream3.R.string.quality_pref_key), + context.getString(if (context.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), Int.MAX_VALUE ) @@ -909,34 +1405,56 @@ class CS3IPlayer : IPlayer { cacheSize = cacheSize, videoBufferMs = videoBufferMs, playWhenReady = isPlaying, // this keep the current state of the player - cacheFactory = cacheFactory, subtitleOffset = currentSubtitleOffset, - maxVideoHeight = maxVideoHeight + maxVideoHeight = maxVideoHeight, + audioSources = audioSources, + onlineSource = onlineSource, ) - requestSubtitleUpdate = ::reloadSubs - - playerUpdated?.invoke(exoPlayer) + 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 -> - updateIsPlaying?.invoke( - Pair( - CSPlayerLoading.IsBuffering, - CSPlayerLoading.IsBuffering - ) - ) + event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) isPlaying = exo.isPlaying } + + // we want to avoid an empty exoplayer from sending events + // this is because we need PlayerAttachedEvent to be called to render the UI + // but don't really want the rest like Player.STATE_ENDED calling next episode + if (mediaSlices.isEmpty() && subSources.isEmpty()) { + return + } + + LiveHelper.registerPlayer(exoPlayer) + exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { - normalSafeApiCall { + safe { val textTracks = tracks.groups.filter { it.type == TRACK_TYPE_TEXT } playerSelectedSubtitleTracks = textTracks.map { group -> group.getFormats().mapNotNull { (format, _) -> - (format.id ?: return@mapNotNull null) to group.isSelected + (format.id?.stripTrackId() + ?: return@mapNotNull null) to group.isSelected } }.flatten() @@ -951,28 +1469,37 @@ class CS3IPlayer : IPlayer { return@mapNotNull SubtitleData( // Nicer looking displayed names - fromTwoLettersToLanguage(format.language!!) + fromTagToLanguageName(format.language) ?: format.language!!, + format.label ?: "", // See setPreferredTextLanguage - format.id!!, + format.id!!.stripTrackId(), SubtitleOrigin.EMBEDDED_IN_VIDEO, format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP, - emptyMap() + emptyMap(), + format.language, ) } - embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks) - onTracksInfoChanged?.invoke() - subtitlesUpdates?.invoke() + event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks)) + event(TracksChangedEvent()) + event(SubtitlesUpdatedEvent()) } } + // fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. + @Suppress("OVERRIDE_DEPRECATION") override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> - updateIsPlaying?.invoke( - Pair( - if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, - if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + 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 = exo.isPlaying @@ -994,23 +1521,15 @@ class CS3IPlayer : IPlayer { } Player.STATE_ENDED -> { - // Only play next episode if autoplay is on (default) - if (PreferenceManager.getDefaultSharedPreferences(context) - ?.getBoolean( - context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key), - true - ) == true - ) { - handleEvent(CSPlayerEvent.NextEpisode) - } + event(VideoEndedEvent()) } Player.STATE_BUFFERING -> { - updatedTime() + updatedTime(source = PlayerEventSource.Player) } Player.STATE_IDLE -> { - // IDLE + } else -> Unit @@ -1034,8 +1553,25 @@ 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 -> { - playerError?.invoke(error) + event(ErrorEvent(error)) } } @@ -1049,7 +1585,7 @@ class CS3IPlayer : IPlayer { override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) if (isPlaying) { - requestAutoFocus?.invoke() + event(RequestAudioFocusEvent()) onRenderFirst() } } @@ -1065,16 +1601,19 @@ class CS3IPlayer : IPlayer { // Only play next episode if autoplay is on (default) if (PreferenceManager.getDefaultSharedPreferences(context) ?.getBoolean( - context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key), + context.getString(R.string.autoplay_next_key), true ) == true ) { - handleEvent(CSPlayerEvent.NextEpisode) + handleEvent( + CSPlayerEvent.NextEpisode, + source = PlayerEventSource.Player + ) } } Player.STATE_BUFFERING -> { - updatedTime() + updatedTime(source = PlayerEventSource.Player) } Player.STATE_IDLE -> { @@ -1087,37 +1626,38 @@ class CS3IPlayer : IPlayer { override fun onVideoSizeChanged(videoSize: VideoSize) { super.onVideoSizeChanged(videoSize) - playerDimensionsLoaded?.invoke(Pair(videoSize.width, videoSize.height)) + event(ResizedEvent(height = videoSize.height, width = videoSize.width)) } override fun onRenderedFirstFrame() { super.onRenderedFirstFrame() onRenderFirst() - updatedTime() + updatedTime(source = PlayerEventSource.Player) } - }) - } catch (e: Exception) { - Log.e(TAG, "loadExo error", e) - playerError?.invoke(e) + }.also { playerListener = it }) + } catch (t: Throwable) { + Log.e(TAG, "loadExo error", t) + event(ErrorEvent(t)) } } - private var lastTimeStamps: List = emptyList() - override fun addTimeStamps(timeStamps: List) { + private var lastTimeStamps: List = emptyList() + + override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> exoPlayer?.createMessage { _, _ -> - updatedTime() + updatedTime(source = PlayerEventSource.Player) //if (payload is EpisodeSkip.SkipStamp) // this should always be true // onTimestampInvoked?.invoke(payload) } ?.setLooper(Looper.getMainLooper()) - ?.setPosition(timestamp.startMs) + ?.setPosition(timestamp.timestamp.startMs) //?.setPayload(timestamp) ?.setDeleteAfterDelivery(false) ?.send() } - updatedTime() + updatedTime(source = PlayerEventSource.Player) } fun onRenderFirst() { @@ -1126,27 +1666,13 @@ 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) - playerError?.invoke(InvalidFileException("Too short playback")) - return - } setPreferredSubtitles(currentSubtitles) val format = exoPlayer?.videoFormat val width = format?.width val height = format?.height if (height != null && width != null) { - playerDimensionsLoaded?.invoke(Pair(width, height)) + event(ResizedEvent(width = width, height = height)) updatedTime() exoPlayer?.apply { requestedListeningPercentages?.forEach { percentage -> @@ -1170,37 +1696,36 @@ class CS3IPlayer : IPlayer { val mediaItem = getMediaItem(MimeTypes.VIDEO_MP4, data.uri) val offlineSourceFactory = context.createOfflineSource() - val onlineSourceFactory = createOnlineSource(emptyMap()) val (subSources, activeSubtitles) = getSubSources( - onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subtitleHelper, + subHelper = subtitleHelper, + interceptor = null, ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources) - } catch (e: Exception) { - Log.e(TAG, "loadOfflinePlayer error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "loadOfflinePlayer error", t) + event(ErrorEvent(t)) } } private fun getSubSources( - onlineSourceFactory: HttpDataSource.Factory?, offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, + interceptor: Interceptor?, ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url)) + val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) - .setSelectionFlags(SELECTION_FLAG_DEFAULT) + .setSelectionFlags(0) .build() when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE -> { + SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.EMBEDDED_IN_VIDEO -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) SingleSampleMediaSource.Factory(offlineSourceFactory) @@ -1211,45 +1736,164 @@ class CS3IPlayer : IPlayer { } SubtitleOrigin.URL -> { - if (onlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(onlineSourceFactory.apply { - if (sub.headers.isNotEmpty()) - this.setDefaultRequestProperties(sub.headers) - }) - .createMediaSource(subConfig, TIME_UNSET) - } else { - null - } - } - - SubtitleOrigin.EMBEDDED_IN_VIDEO -> { - if (offlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(offlineSourceFactory) - .createMediaSource(subConfig, TIME_UNSET) - } else { - null - } + val dataSourceFactory = createOnlineSource(sub.headers, interceptor) + activeSubtitles.add(sub) + SingleSampleMediaSource.Factory(dataSourceFactory) + .createMediaSource(subConfig, TIME_UNSET) } } } return Pair(subSources, activeSubtitles) } + /** + * Creates audio media sources from ExtractorLink's audioTracks + * @param audioTracks List of audio tracks from ExtractorLink + * @return List of MediaSource for audio tracks + */ + private fun getAudioSources( + audioTracks: List, + interceptor: Interceptor?, + ): List { + return audioTracks.mapNotNull { audio -> + try { + val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url) + val dataSourceFactory = createOnlineSource(audio.headers, interceptor) + DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) + } catch (e: Exception) { + Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}") + null + } + } + } + override fun isActive(): Boolean { return exoPlayer != null } - private fun loadOnlinePlayer(context: Context, link: ExtractorLink) { + @MainThread + private fun loadTorrent(context: Context, link: ExtractorLink) { + ioSafe { + // we check exoPlayer a lot here, and that is because we don't want to load exo after + // the user has left the player, in the case that the user click back when this is + // happening + try { + if (exoPlayer == null) return@ioSafe + val (newLink, status) = Torrent.transformLink(link) + val hash = status.hash + if (exoPlayer == null) return@ioSafe + runOnMainThread { + if (exoPlayer == null) return@runOnMainThread + releasePlayer() + if (hash != null) { + torrentEventLooper(hash) + } + loadOnlinePlayer(context, newLink) + } + } catch (t: Throwable) { + event(ErrorEvent(t)) + } + } + } + + @SuppressLint("UnsafeOptInUsageError") + @MainThread + private fun loadOnlinePlayer(context: Context, link: ExtractorLink, retry: Boolean = false) { Log.i(TAG, "loadOnlinePlayer $link") try { + val mime = when (link.type) { + ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 + ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD + ExtractorLinkType.VIDEO -> MimeTypes.VIDEO_MP4 + ExtractorLinkType.TORRENT, ExtractorLinkType.MAGNET -> { + // we check settings first, todo cleanup + val default = TvType.entries.toTypedArray() + .sorted() + .filter { it != TvType.NSFW } + .map { it.ordinal } + + val defaultSet = default.map { it.toString() }.toSet() + val currentPrefMedia = try { + PreferenceManager.getDefaultSharedPreferences(context) + .getStringSet( + context.getString(R.string.prefer_media_type_key), + defaultSet + ) + ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } + } catch (_: Throwable) { + null + } ?: default + + if (!currentPrefMedia.contains(TvType.Torrent.ordinal)) { + val errorMessage = context.getString(R.string.torrent_preferred_media) + event(ErrorEvent(ErrorLoadingException(errorMessage))) + return + } + + if (Torrent.hasAcceptedTorrentForThisSession == false) { + val errorMessage = context.getString(R.string.torrent_not_accepted) + event(ErrorEvent(ErrorLoadingException(errorMessage))) + return + } + // load the initial UI, we require an exoPlayer to be alive + if (!retry) { + // this causes a *bug* that restarts all torrents from 0 + // but I would call this a feature + releasePlayer() + loadExo(context, listOf(), listOf()) + } + event( + StatusEvent( + wasPlaying = CSPlayerLoading.IsPlaying, + isPlaying = CSPlayerLoading.IsBuffering + ) + ) + + if (Torrent.hasAcceptedTorrentForThisSession == true) { + loadTorrent(context, link) + return + } + + val builder: AlertDialog.Builder = AlertDialog.Builder(context) + + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + Torrent.hasAcceptedTorrentForThisSession = true + loadTorrent(context, link) + } + + DialogInterface.BUTTON_NEGATIVE -> { + Torrent.hasAcceptedTorrentForThisSession = false + val errorMessage = + context.getString(R.string.torrent_not_accepted) + event(ErrorEvent(ErrorLoadingException(errorMessage))) + } + } + } + + builder.setTitle(R.string.play_torrent_button) + .setMessage(R.string.torrent_info) + // Ensure that the user will not accidentally start a torrent session. + .setCancelable(false).setOnCancelListener { + val errorMessage = context.getString(R.string.torrent_not_accepted) + event(ErrorEvent(ErrorLoadingException(errorMessage))) + } + .setPositiveButton(R.string.ok, dialogClickListener) + .setNegativeButton(R.string.go_back, dialogClickListener) + .show().setDefaultFocus() + + return + } + } + currentLink = link if (ignoreSSL) { // Disables ssl check val sslContext: SSLContext = SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(SSLTrustManager()), java.security.SecureRandom()) + sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom()) sslContext.createSSLEngine() HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession -> true @@ -1257,57 +1901,123 @@ class CS3IPlayer : IPlayer { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) } - val mime = when { - link.isM3u8 -> MimeTypes.APPLICATION_M3U8 - link.isDash -> MimeTypes.APPLICATION_MPD - else -> MimeTypes.VIDEO_MP4 - } - val mediaItems = if (link is ExtractorLinkPlayList) { - link.playlist.map { + val mediaItems = when (link) { + is ExtractorLinkPlayList -> link.playlist.map { MediaItemSlice(getMediaItem(mime, it.url), it.durationUs) } - } else { - listOf( + + is DrmExtractorLink -> { + listOf( + // Single sliced list with unset length + MediaItemSlice( + getMediaItem(mime, link.url), Long.MIN_VALUE, + drm = DrmMetadata( + kid = link.kid, + key = link.key, + uuid = link.uuid.toJavaUuid(), + kty = link.kty, + licenseUrl = link.licenseUrl, + keyRequestParameters = link.keyRequestParameters, + ) + ) + ) + } + + else -> listOf( // Single sliced list with unset length MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE) ) } - val onlineSourceFactory = createOnlineSource(link) + // For DASH or HLS single streams (non-playlist), prefer the player's default + // live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick + // the live/default position when no explicit start position was provided. + if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) { + playbackPosition = TIME_UNSET + } + + val provider = getApiFromNameNull(link.source) + val interceptor: Interceptor? = provider?.getVideoInterceptor(link) + + val onlineSourceFactory = + createVideoSource( + link = link, + engine = tryCreateEngine(context, simpleCacheSize), + interceptor = interceptor + ) + val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( - onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subtitleHelper + subHelper = subtitleHelper, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly + ) + + // Create audio sources from ExtractorLink's audioTracks + val audioSources = getAudioSources( + audioTracks = link.audioTracks, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) - if (simpleCache == null) - simpleCache = getCache(context, simpleCacheSize) - - val cacheFactory = CacheDataSource.Factory().apply { - simpleCache?.let { setCache(it) } - setUpstreamDataSourceFactory(onlineSourceFactory) - } - - loadExo(context, mediaItems, subSources, cacheFactory) - } catch (e: Exception) { - Log.e(TAG, "loadOnlinePlayer error", e) - playerError?.invoke(e) + loadExo( + context = context, + mediaSlices = mediaItems, + subSources = subSources, + audioSources = audioSources, + onlineSource = onlineSourceFactory + ) + } catch (t: Throwable) { + Log.e(TAG, "loadOnlinePlayer error", t) + event(ErrorEvent(t)) } } override fun reloadPlayer(context: Context) { Log.i(TAG, "reloadPlayer") - exoPlayer?.release() + releasePlayer(false) currentLink?.let { loadOnlinePlayer(context, it) } ?: currentDownloadedFile?.let { loadOfflinePlayer(context, it) } } + + private val tracksAnalyticsListener = object : AnalyticsListener { + + override fun onVideoInputFormatChanged( + eventTime: AnalyticsListener.EventTime, + format: Format, + decoderReuseEvaluation: DecoderReuseEvaluation? + ) { + event(TracksChangedEvent()) + } + + override fun onAudioInputFormatChanged( + eventTime: AnalyticsListener.EventTime, + format: Format, + decoderReuseEvaluation: DecoderReuseEvaluation? + ) { + event(TracksChangedEvent()) + } + + override fun onVideoDisabled( + eventTime: AnalyticsListener.EventTime, + decoderCounters: DecoderCounters + ) { + event(TracksChangedEvent()) + } + + override fun onAudioDisabled( + eventTime: AnalyticsListener.EventTime, + decoderCounters: DecoderCounters + ) { + event(TracksChangedEvent()) + } + } + } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt new file mode 100644 index 000000000..c26a4f2df --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/* +* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes. +*/ +package com.lagradost.cloudstream3.ui.player + +import android.text.Html +import android.text.Spanned +import android.text.TextUtils +import androidx.annotation.VisibleForTesting +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Format.CueReplacementBehavior +import androidx.media3.common.text.Cue +import androidx.media3.common.text.Cue.AnchorType +import androidx.media3.common.util.Consumer +import androidx.media3.common.util.Log +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.text.CuesWithTiming +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.text.SubtitleParser.OutputOptions +import com.google.common.base.Preconditions.checkNotNull +import com.google.common.collect.ImmutableList +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** A [SubtitleParser] for SubRip. */ +@UnstableApi +class CustomSubripParser : SubtitleParser { + private val textBuilder: StringBuilder = StringBuilder() + private val tags: ArrayList = ArrayList() + private val parsableByteArray: ParsableByteArray = ParsableByteArray() + + override fun getCueReplacementBehavior(): @CueReplacementBehavior Int { + return CUE_REPLACEMENT_BEHAVIOR + } + + override fun parse( + data: ByteArray, + offset: Int, + length: Int, + outputOptions: OutputOptions, + output: Consumer + ) { + parsableByteArray.reset(data, /* limit= */offset + length) + parsableByteArray.setPosition(offset) + val charset = detectUtfCharset(parsableByteArray) + + val cuesWithTimingBeforeRequestedStartTimeUs: MutableList? = + if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues) + ArrayList() + else + null + var currentLine: String? + while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) { + if (currentLine!!.isEmpty()) { + // Skip blank lines. + continue + } + + // Parse and check the index line. + try { + currentLine.toInt() + } catch (_: NumberFormatException) { + Log.w(TAG, "Skipping invalid index: $currentLine") + continue + } + + // Read and parse the timing line. + currentLine = parsableByteArray.readLine(charset) + if (currentLine == null) { + Log.w(TAG, "Unexpected end") + break + } + + val startTimeUs: Long + val endTimeUs: Long + val matcher = SUBRIP_TIMING_LINE.matcher(currentLine) + if (matcher.matches()) { + startTimeUs = parseTimecode(matcher, /* groupOffset= */1) + endTimeUs = parseTimecode(matcher, /* groupOffset= */6) + } else { + Log.w(TAG, "Skipping invalid timing: $currentLine") + continue + } + + // Read and parse the text and tags. + textBuilder.setLength(0) + tags.clear() + currentLine = parsableByteArray.readLine(charset) + while (!TextUtils.isEmpty(currentLine)) { + if (textBuilder.isNotEmpty()) { + textBuilder.append("
") + } + textBuilder.append(processLine(currentLine!!, tags)) + currentLine = parsableByteArray.readLine(charset) + } + + @Suppress("DEPRECATION") + val text = Html.fromHtml(textBuilder.toString()) + + var alignmentTag: String? = null + for (i in tags.indices) { + val tag = tags[i] + if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) { + alignmentTag = tag + // Subsequent alignment tags should be ignored. + break + } + } + if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) { + output.accept( + CuesWithTiming( + ImmutableList.of(buildCue(text, alignmentTag)), + startTimeUs, /* durationUs= */ + endTimeUs - startTimeUs + ) + ) + } else cuesWithTimingBeforeRequestedStartTimeUs?.add( + CuesWithTiming( + ImmutableList.of(buildCue(text, alignmentTag)), + startTimeUs, /* durationUs= */ + endTimeUs - startTimeUs + ) + ) + } + if (cuesWithTimingBeforeRequestedStartTimeUs != null) { + for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) { + output.accept(cuesWithTiming) + } + } + } + + /** + * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if + * no BOM is found. + */ + private fun detectUtfCharset(data: ParsableByteArray): Charset { + val charset = data.readUtfCharsetFromBom() + return charset ?: StandardCharsets.UTF_8 + } + + /** + * Trims and removes tags from the given line. The removed tags are added to `tags`. + * + * @param line The line to process. + * @param tags A list to which removed tags will be added. + * @return The processed line. + */ + private fun processLine(line: String, tags: ArrayList): String { + var line = line + line = line.trim { it <= ' ' } + + var removedCharacterCount = 0 + val processedLine = StringBuilder(line) + val matcher = SUBRIP_TAG_PATTERN.matcher(line) + while (matcher.find()) { + val tag = matcher.group() + tags.add(tag) + val start = matcher.start() - removedCharacterCount + val tagLength = tag.length + processedLine.replace(start, /* end= */start + tagLength, /* str= */"") + removedCharacterCount += tagLength + } + + return processedLine.toString() + } + + /** + * Build a [Cue] based on the given text and alignment tag. + * + * @param text The text. + * @param alignmentTag The alignment tag, or `null` if no alignment tag is available. + * @return Built cue + */ + private fun buildCue(text: Spanned, alignmentTag: String?): Cue { + val cue = Cue.Builder().setText(text) + if (alignmentTag == null) { + return cue.build() + } + + // Horizontal alignment. + when (alignmentTag) { + ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START) + ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END) + ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + } + + // Vertical alignment. + when (alignmentTag) { + ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END) + ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START) + ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) + else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) + } + + return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor())) + .setLine( + getFractionalPositionForAnchorType(cue.getLineAnchor()), + Cue.LINE_TYPE_FRACTION + ) + .build() + } + + companion object { + /** + * The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this + * implementation. + */ + const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int = + Format.CUE_REPLACEMENT_BEHAVIOR_MERGE + + // Fractional positions for use when alignment tags are present. + private const val START_FRACTION = 0.08f + private const val END_FRACTION = 1 - START_FRACTION + private const val MID_FRACTION = 0.5f + + private const val TAG = "SubripParser" + + // The google devs are useless, this entire class is just to override this + private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?" + private val SUBRIP_TIMING_LINE: Pattern = + Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*") + + // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183]. + private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}") + private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}" + + // Alignment tags for SSA V4+. + private const val ALIGN_BOTTOM_LEFT = "{\\an1}" + private const val ALIGN_BOTTOM_MID = "{\\an2}" + private const val ALIGN_BOTTOM_RIGHT = "{\\an3}" + private const val ALIGN_MID_LEFT = "{\\an4}" + private const val ALIGN_MID_MID = "{\\an5}" + private const val ALIGN_MID_RIGHT = "{\\an6}" + private const val ALIGN_TOP_LEFT = "{\\an7}" + private const val ALIGN_TOP_MID = "{\\an8}" + private const val ALIGN_TOP_RIGHT = "{\\an9}" + + private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long { + val hours = matcher.group(groupOffset + 1) + var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 + timestampMs += checkNotNull(matcher.group(groupOffset + 2)) + .toLong() * 60 * 1000 + timestampMs += checkNotNull(matcher.group(groupOffset + 3)) + .toLong() * 1000 + val millis = matcher.group(groupOffset + 4) + + timestampMs += when (millis?.length) { + null -> 0L + 1 -> millis.toLong() * 100L + 2 -> millis.toLong() * 10L + 3 -> millis.toLong() * 1L + else -> millis.substring(0, 3).toLong() + } + + return timestampMs * 1000 + } + + // TODO(b/289983417): Make package-private again, once it is no longer needed in + // DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed) + @VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE) + fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float { + return when (anchorType) { + Cue.ANCHOR_TYPE_START -> START_FRACTION + Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION + Cue.ANCHOR_TYPE_END -> END_FRACTION + Cue.TYPE_UNSET -> // Should never happen. + throw IllegalArgumentException() + + else -> + throw IllegalArgumentException() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index b830d4e02..61d6f5564 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,36 +1,42 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context +import android.text.Layout import android.util.Log -import androidx.preference.PreferenceManager +import androidx.annotation.OptIn import androidx.media3.common.Format import androidx.media3.common.MimeTypes -import androidx.media3.exoplayer.text.ExoplayerCuesDecoder +import androidx.media3.common.text.Cue +import androidx.media3.common.util.Consumer +import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.text.SubtitleDecoderFactory +import androidx.media3.extractor.text.CuesWithTiming +import androidx.media3.extractor.text.SimpleSubtitleDecoder +import androidx.media3.extractor.text.Subtitle import androidx.media3.extractor.text.SubtitleDecoder -import androidx.media3.extractor.text.SubtitleInputBuffer -import androidx.media3.extractor.text.SubtitleOutputBuffer -import androidx.media3.extractor.text.cea.Cea608Decoder -import androidx.media3.extractor.text.cea.Cea708Decoder -import androidx.media3.extractor.text.dvb.DvbDecoder -import androidx.media3.extractor.text.pgs.PgsDecoder -import androidx.media3.extractor.text.ssa.SsaDecoder -import androidx.media3.extractor.text.subrip.SubripDecoder -import androidx.media3.extractor.text.ttml.TtmlDecoder -import androidx.media3.extractor.text.tx3g.Tx3gDecoder -import androidx.media3.extractor.text.webvtt.Mp4WebvttDecoder -import androidx.media3.extractor.text.webvtt.WebvttDecoder +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.ttml.TtmlParser +import androidx.media3.extractor.text.tx3g.Tx3gParser +import androidx.media3.extractor.text.webvtt.Mp4WebvttParser +import androidx.media3.extractor.text.webvtt.WebvttParser +import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import org.mozilla.universalchardet.UniversalDetector -import java.nio.ByteBuffer +import java.lang.ref.WeakReference 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. - **/ -class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { + */ +@OptIn(UnstableApi::class) +class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { companion object { fun updateForcedEncoding(context: Context) { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -45,12 +51,24 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { } } + 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 - var regexSubtitlesToRemoveCaptions = false - var regexSubtitlesToRemoveBloat = false - var uppercaseSubtitles = false + val style: SaveCaptionStyle get() = SubtitlesFragment.getCurrentSavedStyle() + private val locationRegex = Regex("""\{\\an(\d+)\}""", RegexOption.IGNORE_CASE) val bloatRegex = listOf( Regex( @@ -70,7 +88,6 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { RegexOption.IGNORE_CASE ), ) - val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\d\s]*?[])}]\s*""")) //https://emptycharacter.com/ //https://www.fileformat.info/info/unicode/char/200b/index.htm @@ -80,18 +97,103 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { " " ) } + + 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: SubtitleDecoder? = null - - override fun getName(): String { - return realDecoder?.name ?: this::javaClass.name - } - - override fun dequeueInputBuffer(): SubtitleInputBuffer { - Log.i(TAG, "dequeueInputBuffer") - return realDecoder?.dequeueInputBuffer() ?: SubtitleInputBuffer() - } + private var realDecoder: SubtitleParser? = null private fun getStr(byteArray: ByteArray): Pair { val encoding = try { @@ -125,161 +227,191 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { } } - private fun getStr(input: SubtitleInputBuffer): String? { - try { - val data = input.data ?: return null - data.position(0) - val fullDataArr = ByteArray(data.remaining()) - data.get(fullDataArr) - return trimStr(getStr(fullDataArr).first) - } catch (e: Exception) { - Log.e(TAG, "Failed to parse text returning plain data") - logError(e) - return null - } - } + private fun getSubtitleParser(data: String): SubtitleParser? { + // This way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype - private fun SubtitleInputBuffer.setSubtitleText(text: String) { -// println("Set subtitle text -----\n$text\n-----") - this.data = ByteBuffer.wrap(text.toByteArray(charset(UTF_8))) - } + // First we remove all invisible characters at the start, this is an issue in some subtitle files + // Cntrl is control characters: https://en.wikipedia.org/wiki/Unicode_control_characters + // Cf is formatting characters: https://www.compart.com/en/unicode/category/Cf + val controlCharsRegex = Regex("""[\p{Cntrl}\p{Cf}]""") + val trimmedText = + data.trimStart { it.isWhitespace() || controlCharsRegex.matches(it.toString()) } - override fun queueInputBuffer(inputBuffer: SubtitleInputBuffer) { - Log.i(TAG, "queueInputBuffer") - try { - val inputString = getStr(inputBuffer) - if (realDecoder == null && !inputString.isNullOrBlank()) { - var str: String = inputString - // this way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype - Log.i(TAG, "Got data from queueInputBuffer") - //https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388 - realDecoder = when { - str.startsWith("WEBVTT", ignoreCase = true) -> WebvttDecoder() - str.startsWith(" TtmlDecoder() - (str.startsWith( - "[Script Info]", - ignoreCase = true - ) || str.startsWith("Title:", ignoreCase = true)) -> SsaDecoder(fallbackFormat?.initializationData) - str.startsWith("1", ignoreCase = true) -> SubripDecoder() - fallbackFormat != null -> { - when (val mimeType = fallbackFormat.sampleMimeType) { - MimeTypes.TEXT_VTT -> WebvttDecoder() - MimeTypes.TEXT_SSA -> SsaDecoder(fallbackFormat.initializationData) - MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttDecoder() - MimeTypes.APPLICATION_TTML -> TtmlDecoder() - MimeTypes.APPLICATION_SUBRIP -> SubripDecoder() - MimeTypes.APPLICATION_TX3G -> Tx3gDecoder(fallbackFormat.initializationData) - MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> Cea608Decoder( - mimeType, - fallbackFormat.accessibilityChannel, - Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS - ) - MimeTypes.APPLICATION_CEA708 -> Cea708Decoder( - fallbackFormat.accessibilityChannel, - fallbackFormat.initializationData - ) - MimeTypes.APPLICATION_DVBSUBS -> DvbDecoder(fallbackFormat.initializationData) - MimeTypes.APPLICATION_PGS -> PgsDecoder() - MimeTypes.TEXT_EXOPLAYER_CUES -> ExoplayerCuesDecoder() - else -> null - } - } + //https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388 + val subtitleParser = when { + // "WEBVTT" can be hidden behind invisible characters not filtered by trim + trimmedText.substring(0, 10).contains("WEBVTT", ignoreCase = true) -> WebvttParser() + trimmedText.startsWith(" TtmlParser() + (trimmedText.startsWith( + "[Script Info]", + ignoreCase = true + ) || trimmedText.startsWith( + "Title:", + ignoreCase = true + )) -> SsaParser(fallbackFormat?.initializationData) + + trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() + fallbackFormat != null -> { + when (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_TX3G -> Tx3gParser(fallbackFormat.initializationData) + // These decoders are not converted to parsers yet + // TODO +// MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> Cea608Decoder( +// mimeType, +// fallbackFormat.accessibilityChannel, +// Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS +// ) +// MimeTypes.APPLICATION_CEA708 -> Cea708Decoder( +// fallbackFormat.accessibilityChannel, +// fallbackFormat.initializationData +// ) + MimeTypes.APPLICATION_DVBSUBS -> DvbParser(fallbackFormat.initializationData) + MimeTypes.APPLICATION_PGS -> PgsParser() else -> null } - Log.i( - TAG, - "Decoder selected: $realDecoder" - ) - realDecoder?.let { decoder -> - decoder.dequeueInputBuffer()?.let { buff -> - if (decoder !is SsaDecoder) { - if (regexSubtitlesToRemoveCaptions) - captionRegex.forEach { rgx -> - str = str.replace(rgx, "\n") - } - if (regexSubtitlesToRemoveBloat) - bloatRegex.forEach { rgx -> - str = str.replace(rgx, "\n") - } - } - buff.setSubtitleText(str) - decoder.queueInputBuffer(buff) - Log.i( - TAG, - "Decoder queueInputBuffer successfully" - ) - } - CS3IPlayer.requestSubtitleUpdate?.invoke() - } - } else { - Log.i( - TAG, - "Decoder else queueInputBuffer successfully" + } + + else -> null + } + return subtitleParser + } + + val currentSubtitleCues = mutableListOf() + + + override fun parse( + data: ByteArray, + offset: Int, + length: Int, + outputOptions: SubtitleParser.OutputOptions, + output: Consumer + ) { + val currentStyle = style + val customOutput = Consumer { cue -> + val newCue = + CuesWithTiming(cue.cues, cue.startTimeUs, cue.durationUs) + + // Do not apply the offset to the currentSubtitleCues as those are then used for sync subs + currentSubtitleCues.add( + SubtitleCue( + newCue.startTimeUs / 1000, + newCue.durationUs / 1000, + newCue.cues.map { it.text.toString() }) + ) + + // offset timing for the final + val updatedCues = + CuesWithTiming( + newCue.cues, + newCue.startTimeUs - subtitleOffset.times(1000), + newCue.durationUs ) - if (!inputString.isNullOrBlank()) { - var str: String = inputString - if (realDecoder !is SsaDecoder) { - if (regexSubtitlesToRemoveCaptions) - captionRegex.forEach { rgx -> - str = str.replace(rgx, "\n") - } - if (regexSubtitlesToRemoveBloat) + output.accept(updatedCues) + } + Log.i(TAG, "Parse subtitle, current parser: $realDecoder") + try { + val inputString = getStr(data).first + Log.i(TAG, "Subtitle preview: ${inputString.substring(0, 30)}") + if (inputString.isNotBlank()) { + var str: String = trimStr(inputString) + realDecoder = realDecoder ?: getSubtitleParser(inputString) + Log.i( + TAG, + "Parser selected: $realDecoder" + ) + realDecoder?.let { decoder -> + if (decoder !is SsaParser) { + if (currentStyle.removeBloat) bloatRegex.forEach { rgx -> str = str.replace(rgx, "\n") } - if (uppercaseSubtitles) { + if (currentStyle.upperCase) { str = str.uppercase() } } - inputBuffer.setSubtitleText(str) } - - realDecoder?.queueInputBuffer(inputBuffer) + val array = str.toByteArray() + realDecoder?.parse( + array, + minOf(array.size, offset), + minOf(array.size, length), + outputOptions, + customOutput + ) } } catch (e: Exception) { logError(e) } } - override fun dequeueOutputBuffer(): SubtitleOutputBuffer? { - return realDecoder?.dequeueOutputBuffer() + override fun getCueReplacementBehavior(): Int { + // CUE_REPLACEMENT_BEHAVIOR_REPLACE seems most compatible, change if required + return realDecoder?.cueReplacementBehavior ?: Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE } - override fun flush() { - realDecoder?.flush() - } - - override fun release() { - realDecoder?.release() - } - - override fun setPositionUs(positionUs: Long) { - realDecoder?.setPositionUs(positionUs) + override fun reset() { + currentSubtitleCues.clear() + super.reset() } } /** See https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java */ +@OptIn(UnstableApi::class) class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { + override fun supportsFormat(format: Format): Boolean { -// return SubtitleDecoderFactory.DEFAULT.supportsFormat(format) return listOf( MimeTypes.TEXT_VTT, MimeTypes.TEXT_SSA, MimeTypes.APPLICATION_TTML, MimeTypes.APPLICATION_MP4VTT, MimeTypes.APPLICATION_SUBRIP, - //MimeTypes.APPLICATION_TX3G, + MimeTypes.APPLICATION_TX3G, //MimeTypes.APPLICATION_CEA608, //MimeTypes.APPLICATION_MP4CEA608, //MimeTypes.APPLICATION_CEA708, - //MimeTypes.APPLICATION_DVBSUBS, - //MimeTypes.APPLICATION_PGS, + MimeTypes.APPLICATION_DVBSUBS, + MimeTypes.APPLICATION_PGS, //MimeTypes.TEXT_EXOPLAYER_CUES ).contains(format.sampleMimeType) } - override fun createDecoder(format: Format): SubtitleDecoder { - return CustomDecoder(format) + private var latestDecoder: WeakReference? = null + + fun getSubtitleCues(): List? { + return latestDecoder?.get()?.currentSubtitleCues } -} \ No newline at end of file + + /** + * 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 + latestDecoder = WeakReference(parser) + + return DelegatingSubtitleDecoder( + parser::class.simpleName + "Decoder", parser + ) + } +} + +/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ +@OptIn(UnstableApi::class) +class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) : + SimpleSubtitleDecoder(name) { + + override fun decode(data: ByteArray, length: Int, reset: Boolean): Subtitle { + if (reset) { + parser.reset() + } + return parser.parseToLegacySubtitle(data, 0, length); + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt deleted file mode 100644 index 514aaeab0..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.os.Looper -import androidx.media3.exoplayer.text.SubtitleDecoderFactory -import androidx.media3.exoplayer.text.TextOutput - -class CustomTextRenderer( - offset: Long, - output: TextOutput?, - outputLooper: Looper?, - decoderFactory: SubtitleDecoderFactory = SubtitleDecoderFactory.DEFAULT -) : NonFinalTextRenderer(output, outputLooper, decoderFactory) { - private var offsetPositionUs: Long = 0L - - init { - setRenderOffsetMs(offset) - } - - fun setRenderOffsetMs(offset : Long) { - offsetPositionUs = offset * 1000L - } - - fun getRenderOffsetMs() : Long { - return offsetPositionUs / 1000L - } - - override fun render( positionUs: Long, elapsedRealtimeUs: Long) { - super.render(positionUs + offsetPositionUs, elapsedRealtimeUs + offsetPositionUs) - } -} \ No newline at end of file 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 baf7ed52b..35f8dcfd8 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,95 +1,76 @@ package com.lagradost.cloudstream3.ui.player -import com.lagradost.cloudstream3.AcraApplication.Companion.context +import android.net.Uri +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlin.math.max -import kotlin.math.min +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 class DownloadFileGenerator( - private val episodes: List, - private var currentIndex: Int = 0 -) : IGenerator { + episodes: List +) : VideoGenerator(episodes) { override val hasCache = false + override val canSkipLoading = false - override fun hasNext(): Boolean { - return currentIndex < episodes.size - 1 - } - - override fun hasPrev(): Boolean { - return currentIndex > 0 - } - - override fun next() { - if (hasNext()) - currentIndex++ - } - - override fun prev() { - if (hasPrev()) - currentIndex-- - } - - override fun goto(index: Int) { - // clamps value - currentIndex = min(episodes.size - 1, max(0, index)) - } - - override fun getCurrentId(): Int? { - return episodes[currentIndex].id - } - - override fun getCurrent(offset: Int): Any? { - return episodes.getOrNull(currentIndex + offset) - } - - override fun getAll(): List? { - return null - } + override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, + isCasting: Boolean ): Boolean { - val meta = episodes[currentIndex + offset] - callback(Pair(null, meta)) + val meta = videos.getOrNull(offset) ?: return false - context?.let { ctx -> - val relative = meta.relativePath - val display = meta.displayName - - if (display == null || relative == null) { - return@let - } - VideoDownloadManager.getFolder(ctx, relative, meta.basePath) - ?.forEach { file -> - val name = display.removeSuffix(".mp4") - if (file.first != meta.displayName && file.first.startsWith(name)) { - val realName = file.first.removePrefix(name) - .removeSuffix(".vtt") - .removeSuffix(".srt") - .removeSuffix(".txt") - .trim() - .removePrefix("(") - .removeSuffix(")") - - subtitleCallback( - SubtitleData( - realName.ifBlank { ctx.getString(R.string.default_subtitles) }, - file.second.toString(), - SubtitleOrigin.DOWNLOADED_FILE, - name.toSubtitleMimeType(), - emptyMap() - ) - ) - } + 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) } + } + + if (info != null) { + val newMeta = meta.copy(uri = info.path) + callback(null to newMeta) + } else callback(null to meta) + } else callback(null to meta) + + val ctx = context ?: return true + val relative = meta.relativePath ?: return true + val display = meta.displayName ?: return true + + val cleanDisplay = cleanDisplayName(display) + + 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() + + subtitleCallback( + SubtitleData( + originalName.ifBlank { ctx.getString(R.string.default_subtitles) }, + nameSuffix, + uri.toString(), + SubtitleOrigin.DOWNLOADED_FILE, + name.toSubtitleMimeType(), + emptyMap(), + fromLanguageToTagIETF(originalName, true) + ) + ) + } } return true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index 03405fafd..a086cc16f 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 @@ -1,109 +1,111 @@ package com.lagradost.cloudstream3.ui.player import android.content.Intent -import android.net.Uri import android.os.Bundle import android.util.Log import android.view.KeyEvent import androidx.appcompat.app.AppCompatActivity import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.ExtractorUri -import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.storage.SafeFile - -const val DTAG = "PlayerActivity" +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink +import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat class DownloadedPlayerActivity : AppCompatActivity() { - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - CommonActivity.dispatchKeyEvent(this, event)?.let { - return it - } - return super.dispatchKeyEvent(event) + companion object { + const val TAG = "DownloadedPlayerActivity" } - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - CommonActivity.onKeyDown(this, keyCode, event) + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) - return super.onKeyDown(keyCode, event) - } + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = + CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) override fun onUserLeaveHint() { super.onUserLeaveHint() CommonActivity.onUserLeaveHint(this) } - override fun onBackPressed() { - finish() + 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 playLink(url: String) { - this.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - LinkGenerator( - listOf( - BasicLink(url) - ) - ) - ) - ) - } - - private fun playUri(uri: Uri) { - val name = SafeFile.fromUri(this, uri)?.name() - this.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator( - listOf( - ExtractorUri( - uri = uri, - name = name ?: getString(R.string.downloaded_file) - ) - ) - ) - ) - ) + 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?) { - Log.i(DTAG, "onCreate") - - CommonActivity.loadThemes(this) super.onCreate(savedInstanceState) + CommonActivity.loadThemes(this) CommonActivity.init(this) - + enableEdgeToEdgeCompat() setContentView(R.layout.empty_layout) + Log.i(TAG, "onCreate") + handleIntent(intent) + /** + * 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) { - val extraText = try { // I dont trust android - intent.getStringExtra(Intent.EXTRA_TEXT) - } catch (e: Exception) { - null - } + if ( + intent.action == Intent.ACTION_SEND || + intent.action == Intent.ACTION_OPEN_DOCUMENT || + intent.action == Intent.ACTION_VIEW + ) { + val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) } val cd = intent.clipData val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null val url = item?.text?.toString() - - // idk what I am doing, just hope any of these work - if (item?.uri != null) - playUri(item.uri) - else if (url != null) - playLink(url) - else if (data != null) - playUri(data) - else if (extraText != null) - playLink(extraText) - else { - finish() - return + when { + item?.uri != null -> playUri(this, item.uri) + url != null -> playLink(this, url) + data != null -> playUri(this, data) + extraText != null -> playLink(this, extraText) + else -> finishAndRemoveTask() } } else if (data?.scheme == "content") { - playUri(data) - } else { - finish() - return - } + playUri(this, data) + } else finishAndRemoveTask() } -} \ No newline at end of file + + override fun onResume() { + super.onResume() + CommonActivity.setActivityInstance(this) + } +} 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 7c19e97d3..85db33fc0 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 @@ -1,50 +1,25 @@ package com.lagradost.cloudstream3.ui.player import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.ExtractorLinkType class ExtractorLinkGenerator( private val links: List, private val subtitles: List, -) : IGenerator { - override val hasCache = false - - override fun getCurrentId(): Int? { - return null - } - - override fun hasNext(): Boolean { - return false - } - - override fun getAll(): List? { - return null - } - - override fun hasPrev(): Boolean { - return false - } - - override fun getCurrent(offset: Int): Any? { - return null - } - - override fun goto(index: Int) {} - - override fun next() {} - - override fun prev() {} - +) : NoVideoGenerator(null) { override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int + offset: Int, + isCasting: Boolean ): Boolean { subtitles.forEach(subtitleCallback) links.forEach { - callback.invoke(it to null) + if(sourceTypes.contains(it.type)) { + callback.invoke(it to null) + } } return true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt new file mode 100644 index 000000000..025267cc9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.ui.player + +import android.content.Context +import android.os.Looper +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.text.TextOutput +import androidx.media3.exoplayer.text.TextRenderer +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory + +@UnstableApi +class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { + /** Somehow the nextlib authors decided that we need a text renderer that causes + * "ERROR_CODE_FAILED_RUNTIME_CHECK". + * + * Core issue: https://github.com/anilbeesetti/nextlib/pull/158 + * Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718 + * */ + override fun buildTextRenderers( + context: Context, + output: TextOutput, + outputLooper: Looper, + extensionRendererMode: Int, + out: ArrayList + ) { + out.add(TextRenderer(output, outputLooper)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 0f3c189d8..4ba933e13 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 @@ -3,18 +3,16 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ObjectAnimator 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.content.res.Resources 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.util.DisplayMetrics import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent @@ -22,73 +20,71 @@ import android.view.Surface import android.view.View import android.view.ViewGroup import android.view.WindowManager -import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.view.animation.AnimationUtils +import android.view.animation.DecelerateInterpolator +import android.widget.LinearLayout +import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red +import androidx.core.view.children import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +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.LoadResponse import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding +import com.lagradost.cloudstream3.databinding.SpeedDialogBinding import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.player.GeneratorPlayer.Companion.subsProvidersIsActive import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper -import com.lagradost.cloudstream3.ui.result.setText -import com.lagradost.cloudstream3.ui.result.txt -import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +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.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.getNavigationBarHeight -import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.Vector2 -import kotlin.math.* +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt +import kotlin.math.roundToInt - -const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking -const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage -const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage -const val VERTICAL_MULTIPLIER = 2.0f -const val HORIZONTAL_MULTIPLIER = 2.0f -const val DOUBLE_TAB_MAXIMUM_HOLD_TIME = 200L -const val DOUBLE_TAB_MINIMUM_TIME_BETWEEN = 200L // this also affects the UI show response time -const val DOUBLE_TAB_PAUSE_PERCENTAGE = 0.15 // in both directions private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" // All the UI Logic for the player -open class FullScreenPlayer : AbstractPlayerFragment() { +@OptIn(UnstableApi::class) +open class FullScreenPlayer : AbstractPlayerFragment( + BindingCreator.Bind(FragmentPlayerBinding::bind) +) { + override fun pickLayout(): Int = R.layout.fragment_player protected open var lockRotation = true - protected open var isFullScreenPlayer = true - protected open var isTv = false - protected var playerBinding: PlayerCustomLayoutBinding? = null - // 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 @@ -97,18 +93,13 @@ open class FullScreenPlayer : AbstractPlayerFragment() { **/ protected var currentQualityProfile = 1 - // protected var currentPrefQuality = -// Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell - protected var fastForwardTime = 10000L protected var androidTVInterfaceOffSeekTime = 10000L protected var androidTVInterfaceOnSeekTime = 30000L - protected var swipeHorizontalEnabled = false - protected var swipeVerticalEnabled = false protected var playBackSpeedEnabled = false protected var playerResizeEnabled = false - protected var doubleTapEnabled = false - protected var doubleTapPauseEnabled = true - + protected var playerRotateEnabled = false + protected var rotatedManually = false + private var hideControlsNames = false protected var subtitleDelay set(value) = try { player.setSubtitleOffset(-value) @@ -122,60 +113,115 @@ open class FullScreenPlayer : AbstractPlayerFragment() { 0L } - //private var useSystemBrightness = false - protected var useTrueSystemBrightness = true - private val fullscreenNotch = true //TODO SETTING + private var isShowingEpisodeOverlay: Boolean = false + private var previousPlayStatus: Boolean = false - protected val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics + override fun fixLayout(view: View) = Unit - // screenWidth and screenHeight does always - // refer to the screen while in landscape mode - protected val screenWidth: Int - get() { - return max(displayMetrics.widthPixels, displayMetrics.heightPixels) + /** + * 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 val screenHeight: Int - get() { - return min(displayMetrics.widthPixels, displayMetrics.heightPixels) + 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 var statusBarHeight: Int? = null - private var navigationBarHeight: Int? = null + /** 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 brightnessIcons = listOf( - R.drawable.sun_1, - R.drawable.sun_2, - R.drawable.sun_3, - R.drawable.sun_4, - R.drawable.sun_5, - R.drawable.sun_6, - //R.drawable.sun_7, - // R.drawable.ic_baseline_brightness_1_24, - // R.drawable.ic_baseline_brightness_2_24, - // R.drawable.ic_baseline_brightness_3_24, - // R.drawable.ic_baseline_brightness_4_24, - // R.drawable.ic_baseline_brightness_5_24, - // R.drawable.ic_baseline_brightness_6_24, - // R.drawable.ic_baseline_brightness_7_24, - ) + private fun scheduleMetadataVisibility() { + val metadataScrim = playerBinding?.playerMetadataScrim ?: return + val ctx = metadataScrim.context ?: return - 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, - ) + if (!ctx.shouldShowPlayerMetadata()) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return + } - 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 + if (isLayout(PHONE)) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return + } + + val isPaused = currentPlayerStatus == CSPlayerLoading.IsPaused + val token = ++metadataVisibilityToken + + if (isPaused) { + metadataScrim.postDelayed({ + /** Make sure the user has not interacted with anything */ + if (token != metadataVisibilityToken) return@postDelayed + /** If already visible, then do not rerun the animation */ + if (metadataScrim.isVisible) return@postDelayed + /** Failsafe, as this should only be shown when paused */ + if (currentPlayerStatus != CSPlayerLoading.IsPaused) return@postDelayed + /** We do not want to show the logo in the background when the user is within another screen */ + if (isDialogOpen()) return@postDelayed + + metadataScrim.alpha = 0f + metadataScrim.isVisible = true + metadataScrim.animate() + .alpha(1f) + .setDuration(500L) + .setInterpolator(DecelerateInterpolator()) + .start() + hidePlayerUI() + }, 8000L) + } else { + if (metadataScrim.isVisible) { + metadataScrim.animate() + .alpha(0f) + .setDuration(300L) + .setInterpolator(AccelerateDecelerateInterpolator()) + .withEndAction { + metadataScrim.alpha = 0f // force final state + metadataScrim.isVisible = false + } + .start() + } + } } override fun onDestroyView() { + playerHostView?.releaseOverlayLayoutListener() playerBinding = null super.onDestroyView() } @@ -190,33 +236,57 @@ open class FullScreenPlayer : AbstractPlayerFragment() { open fun openOnlineSubPicker( context: Context, - imdbId: Long?, + loadResponse: LoadResponse?, dismissCallback: (() -> Unit) ) { throw NotImplementedError() } - /** Returns false if the touch is on the status bar or navigation bar*/ - private fun isValidTouch(rawX: Float, rawY: Float): Boolean { - val statusHeight = statusBarHeight ?: 0 - // val navHeight = navigationBarHeight ?: 0 - // nav height is removed because screenWidth already takes into account that - return rawY > statusHeight && rawX < screenWidth //- navHeight + open fun showEpisodesOverlay() { + throw NotImplementedError() + } + + open fun isThereEpisodes(): Boolean { + return false } override fun exitedPipMode() { animateLayoutChanges() } + private fun animateLayoutChangesForSubtitles() = + // Post here as bottomPlayerBar is gone the first frame => bottomPlayerBar.height = 0 + playerBinding?.bottomPlayerBar?.post { + val sView = subView ?: return@post + val sStyle = CustomDecoder.style + val binding = playerBinding ?: return@post + + val move = if (isShowing) minOf( + // We do not want to drag down subtitles if the subtitle elevation is large + -sStyle.elevation.toPx, + // The lib uses Invisible instead of Gone for no reason + binding.previewFrameLayout.height - binding.bottomPlayerBar.height + ) else -sStyle.elevation.toPx + ObjectAnimator.ofFloat(sView, "translationY", move.toFloat()).apply { + duration = 200 + start() + } + } + protected fun animateLayoutChanges() { + if (isLayout(PHONE)) { // isEnabled also disables the onKeyDown + playerBinding?.exoProgress?.isEnabled = isShowing // Prevent accidental clicks/drags + } + if (isShowing) { updateUIVisibility() } else { + toggleEpisodesOverlay(false) playerBinding?.playerHolder?.postDelayed({ updateUIVisibility() }, 200) } val titleMove = if (isShowing) 0f else -50.toPx.toFloat() - playerBinding?.playerVideoTitle?.let { + playerBinding?.playerVideoTitleHolder?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() @@ -228,6 +298,19 @@ open class FullScreenPlayer : AbstractPlayerFragment() { start() } } + playerBinding?.playerVideoInfo?.let { + ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { + duration = 200 + start() + } + } + playerBinding?.playerMetadataScrim?.let { + ObjectAnimator.ofFloat(it, "translationY", 1f).apply { + duration = 200 + start() + } + } + val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() playerBinding?.bottomPlayerBar?.let { ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { @@ -235,27 +318,25 @@ open class FullScreenPlayer : AbstractPlayerFragment() { start() } } - + if (isLayout(PHONE)) { + playerBinding?.playerEpisodesButton?.let { + ObjectAnimator.ofFloat(it, "translationX", if (isShowing) 0f else 50.toPx.toFloat()) + .apply { + duration = 200 + start() + } + } + } val fadeTo = if (isShowing) 1f else 0f val fadeAnimation = AlphaAnimation(1f - fadeTo, fadeTo) fadeAnimation.duration = 100 fadeAnimation.fillAfter = true - 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() - } - } + animateLayoutChangesForSubtitles() val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat() - playerBinding?.apply { playerOpenSource.let { ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { @@ -265,24 +346,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } if (!isLocked) { - playerFfwdHolder.alpha = 1f - playerRewHolder.alpha = 1f - // player_pause_play_holder?.alpha = 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) - playerFfwdHolder.startAnimation(fadeAnimation) - playerRewHolder.startAnimation(fadeAnimation) - playerPausePlay.startAnimation(fadeAnimation) - - /*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) + downloadBothHeader.startAnimation(fadeAnimation) } bottomPlayerBar.startAnimation(fadeAnimation) @@ -292,82 +359,140 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } override fun subtitlesChanged() { + val tracks = player.getVideoTracks() + val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> + track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES + } + // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = - player.getCurrentPreferredSubtitle() == null + isBuiltinSubtitles || tracks.currentTextTracks.isEmpty() } - open fun lockOrientation(activity: Activity) { - val display = - (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay - val rotation = display.rotation + private fun restoreOrientationWithSensor(activity: Activity) { val currentOrientation = activity.resources.configuration.orientation - var orientation = 0 - when (currentOrientation) { - Configuration.ORIENTATION_LANDSCAPE -> orientation = - if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + val orientation = when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - //Configuration.ORIENTATION_PORTRAIT -> orientation = - // if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + Configuration.ORIENTATION_PORTRAIT -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + else -> playerHostView?.dynamicOrientation() ?: return } activity.requestedOrientation = orientation } - private fun updateOrientation() { + private fun toggleOrientationWithSensor(activity: Activity) { + val currentOrientation = activity.resources.configuration.orientation + val orientation: Int = when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + Configuration.ORIENTATION_PORTRAIT -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + else -> playerHostView?.dynamicOrientation() ?: return + } + activity.requestedOrientation = orientation + } + + private fun lockOrientation(activity: Activity) { + val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + @Suppress("DEPRECATION") + (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay + else activity.display!! + val rotation = display.rotation + val currentOrientation = activity.resources.configuration.orientation + val orientation: Int + when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + orientation = + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + else + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + + Configuration.ORIENTATION_PORTRAIT -> + orientation = + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + + else -> orientation = playerHostView?.dynamicOrientation() ?: return + } + activity.requestedOrientation = orientation + } + + private fun updateOrientation(ignoreDynamicOrientation: Boolean = false) { activity?.apply { - if(lockRotation) { - if(isLocked) { + if (lockRotation) { + if (isLocked) { lockOrientation(this) - } - else { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } else { + if (ignoreDynamicOrientation || rotatedManually) { + // Restore when lock is disabled. + restoreOrientationWithSensor(this) + } else { + this.requestedOrientation = + playerHostView?.dynamicOrientation() ?: return@apply + } } } } } - protected fun enterFullscreen() { - if (isFullScreenPlayer) { - activity?.hideSystemUI() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { - val params = activity?.window?.attributes - params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - activity?.window?.attributes = params + private fun setupKeyEventListener() { + keyEventListener = { (event, hasNavigated) -> + when { + event == null -> false + event.action == KeyEvent.ACTION_DOWN && + (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || + event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) -> + playerHostView?.handleVolumeKey(event.keyCode) ?: false + + player.isActive() -> handleKeyEvent(event, hasNavigated) + else -> false } } - updateOrientation() - } - - protected fun exitFullscreen() { - activity?.showSystemUI() - //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 } override fun onResume() { - enterFullscreen() + 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() super.onResume() } + override fun onStop() { + activity?.detachBackPressedCallback("FullScreenPlayer") + super.onStop() + } + override fun onDestroy() { - exitFullscreen() - player.release() - player.releaseCallbacks() + playerHostView?.exitFullscreen() super.onDestroy() } private fun setPlayBackSpeed(speed: Float) { try { - setKey(PLAYBACK_SPEED_KEY, speed) + DataStoreHelper.playBackSpeed = speed playerBinding?.playerSpeedBtt?.text = getString(R.string.player_speed_text_format).format(speed) .replace(".0x", "x") @@ -385,29 +510,30 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun showSubtitleOffsetDialog() { val ctx = context ?: return + // Pause player because the subtitles cannot be continuously updated to follow playback. + player.handleEvent( + CSPlayerEvent.Pause, + PlayerEventSource.UI + ) val binding = SubtitleOffsetBinding.inflate(LayoutInflater.from(ctx), null, false) - val builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - .setView(binding.root) - val dialog = builder.create() + // Use dialog as opposed to alertdialog to get fullscreen + val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply { + setContentView(binding.root) + } + this.selectSubtitlesDialog = dialog dialog.show() - val beforeOffset = subtitleDelay + val isPortrait = + ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + fixSystemBarsPadding(binding.root, fixIme = isPortrait) - /*val applyButton = dialog.findViewById(R.id.apply_btt)!! - val cancelButton = dialog.findViewById(R.id.cancel_btt)!! - val input = dialog.findViewById(R.id.subtitle_offset_input)!! - val sub = dialog.findViewById(R.id.subtitle_offset_subtract)!! - val subMore = dialog.findViewById(R.id.subtitle_offset_subtract_more)!! - val add = dialog.findViewById(R.id.subtitle_offset_add)!! - val addMore = dialog.findViewById(R.id.subtitle_offset_add_more)!! - val subTitle = dialog.findViewById(R.id.subtitle_offset_sub_title)!!*/ + var currentOffset = subtitleDelay binding.apply { subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> text?.toString()?.toLongOrNull()?.let { time -> - subtitleDelay = time + currentOffset = time val str = when { time > 0L -> { txt(R.string.subtitle_offset_extra_hint_later_format, time) @@ -425,7 +551,30 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } subtitleOffsetInput.text = - Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) + Editable.Factory.getInstance()?.newEditable(currentOffset.toString()) + + val subtitles = player.getSubtitleCues().toMutableList() + + subtitleOffsetRecyclerview.isVisible = subtitles.isNotEmpty() + noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() + + val initialSubtitlePosition = (player.getPosition() ?: 0) - currentOffset + val subtitleAdapter = + SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> + val playerPosition = player.getPosition() ?: 0 + subtitleOffsetInput.text = Editable.Factory.getInstance() + ?.newEditable((playerPosition - subtitleCue.startTimeMs).toString()) + }.apply { + submitList(subtitles) + } + + subtitleOffsetRecyclerview.adapter = subtitleAdapter + // Prevent flashing changes when changing items + (subtitleOffsetRecyclerview.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = + false + + val firstSubtitle = subtitleAdapter.getLatestActiveItem(initialSubtitlePosition) + subtitleOffsetRecyclerview.scrollToPosition(firstSubtitle) val buttonChange = 100L val buttonChangeMore = 1000L @@ -450,142 +599,109 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } dialog.setOnDismissListener { - if (isFullScreenPlayer) - activity?.hideSystemUI() + selectSubtitlesDialog = null + activity?.hideSystemUI() } applyBtt.setOnClickListener { + selectSubtitlesDialog = null + subtitleDelay = currentOffset + dialog.dismissSafe(activity) + player.seekTime(1L) + } + resetBtt.setOnClickListener { + selectSubtitlesDialog = null + subtitleDelay = 0 dialog.dismissSafe(activity) player.seekTime(1L) } cancelBtt.setOnClickListener { - subtitleDelay = beforeOffset + selectSubtitlesDialog = null dialog.dismissSafe(activity) } } } + @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 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 act = activity ?: return + val isPlaying = player.getIsPlaying() + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) - 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]) + 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) } } - } - fun resetRewindText() { - playerBinding?.exoRewText?.text = - getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) - } - - fun resetFastForwardText() { - playerBinding?.exoFfwdText?.text = - getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) - } - - private fun rewind() { - try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerRewHolder.alpha = 1f - - val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) - exoRew.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) + binding.speedMinus.setOnClickListener { + setPlayBackSpeed(maxOf((player.getPlaybackSpeed() - 0.1f), 0.1f)) + updateSpeedDialogBinding(binding) } - } - private fun fastForward() { - try { - playerBinding?.apply { - playerCenterMenu.isGone = false - playerFfwdHolder.alpha = 1f - - val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) - exoFfwd.startAnimation(rotateRight) - - val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) - goRight.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} - - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - exoFfwdText.post { - resetFastForwardText() - playerCenterMenu.isGone = !isShowing - playerFfwdHolder.alpha = if (isShowing) 1f else 0f - } - } - }) - exoFfwdText.startAnimation(goRight) - exoFfwdText.text = - getString(R.string.ffw_text_format).format(fastForwardTime / 1000) - } - player.seekTime(fastForwardTime) - } catch (e: Exception) { - logError(e) + binding.speedPlus.setOnClickListener { + setPlayBackSpeed(minOf((player.getPlaybackSpeed() + 0.1f), 2.0f)) + updateSpeedDialogBinding(binding) } + + binding.speedBar.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + setPlayBackSpeed(value) + updateSpeedDialogBinding(binding) + } + } + + val dismiss = DialogInterface.OnDismissListener { + activity?.hideSystemUI() + if (isPlaying) { + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + } + selectSpeedDialog = null + } + + // if (isLayout(PHONE)) { + // val builder = + // BottomSheetDialog(act, R.style.AlertDialogCustom) + // builder.setContentView(binding.root) + // builder.setOnDismissListener(dismiss) + // builder.show() + //} else { + val builder = + AlertDialog.Builder(act, R.style.AlertDialogCustom) + .setView(binding.root) + builder.setOnDismissListener(dismiss) + val dialog = builder.create() + this.selectSpeedDialog = dialog + dialog.show() + //} } private fun onClickChange() { isShowing = !isShowing - if (isShowing) { - playerBinding?.playerIntroPlay?.isGone = true - autoHide() - } - if (isFullScreenPlayer) - activity?.hideSystemUI() + if (isShowing) autoHide() + activity?.hideSystemUI() animateLayoutChanges() - playerBinding?.playerPausePlay?.requestFocus() + if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() } private fun toggleLock() { @@ -594,7 +710,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked - updateOrientation() + playerHostView?.isLocked = isLocked + updateOrientation(true) // set true to ignore auto rotate to stay in current orientation if (isLocked && isShowing) { playerBinding?.playerHolder?.postDelayed({ @@ -605,40 +722,37 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val fadeTo = if (isLocked) 0f else 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) playerBinding?.apply { - val fadeAnimation = AlphaAnimation(playerVideoTitle.alpha, fadeTo).apply { + val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { duration = 100 fillAfter = true } updateUIVisibility() - // MENUS - //centerMenu.startAnimation(fadeAnimation) - playerPausePlay.startAnimation(fadeAnimation) - playerFfwdHolder.startAnimation(fadeAnimation) - playerRewHolder.startAnimation(fadeAnimation) + downloadBothHeader.startAnimation(fadeAnimation) - //if (hasEpisodes) - // player_episodes_button?.startAnimation(fadeAnimation) - //player_media_route_button?.startAnimation(fadeAnimation) - //video_bar.startAnimation(fadeAnimation) + if (hasEpisodes) + playerEpisodesButton.startAnimation(fadeAnimation) + // player_media_route_button?.startAnimation(fadeAnimation) + // video_bar.startAnimation(fadeAnimation) - //TITLE + // TITLE playerVideoTitleRez.startAnimation(fadeAnimation) + playerVideoInfo.startAnimation(fadeAnimation) playerEpisodeFiller.startAnimation(fadeAnimation) - playerVideoTitle.startAnimation(fadeAnimation) + playerVideoTitleHolder.startAnimation(fadeAnimation) playerTopHolder.startAnimation(fadeAnimation) // BOTTOM playerLockHolder.startAnimation(fadeAnimation) - //player_go_back_holder?.startAnimation(fadeAnimation) - + // player_go_back_holder?.startAnimation(fadeAnimation) shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) } updateLockUI() } - fun updateUIVisibility() { + private fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -649,22 +763,23 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } playerBinding?.apply { - playerLockHolder.isGone = isGone playerVideoBar.isGone = isGone - playerPausePlay.isGone = isGone - //player_buffering?.isGone = isGone + playerPausePlayHolderHolder.isGone = + isGone || currentPlayerStatus == CSPlayerLoading.IsBuffering playerTopHolder.isGone = isGone - //player_episodes_button?.isVisible = !isGone && hasEpisodes - playerVideoTitle.isGone = togglePlayerTitleGone -// player_video_title_rez?.isGone = isGone + val showPlayerEpisodes = !isGone && isThereEpisodes() + playerEpisodesButtonRoot.isVisible = showPlayerEpisodes + playerEpisodesButton.isVisible = showPlayerEpisodes + playerVideoTitleHolder.isGone = togglePlayerTitleGone + playerVideoTitleRez.isGone = isGone || playerVideoTitleRez.text.isBlank() playerEpisodeFiller.isGone = isGone playerCenterMenu.isGone = isGone playerLock.isGone = !isShowing - //player_media_route_button?.isClickable = !isGone playerGoBackHolder.isGone = isGone playerSourcesBtt.isGone = isGone + shadowOverlay.isGone = isGone playerSkipEpisode.isClickable = !isGone } } @@ -672,518 +787,293 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun updateLockUI() { playerBinding?.apply { playerLock.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) - if (layout == R.layout.fragment_player) { - val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) - else Color.WHITE - if (color != null) { - playerLock.setTextColor(color) - playerLock.iconTint = ColorStateList.valueOf(color) - playerLock.rippleColor = - ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) - } + val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) + else Color.WHITE + if (color != null) { + playerLock.setTextColor(color) + playerLock.iconTint = ColorStateList.valueOf(color) + playerLock.rippleColor = + ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) } } } - private var currentTapIndex = 0 protected fun autoHide() { - currentTapIndex++ - val index = currentTapIndex - playerBinding?.playerHolder?.postDelayed({ - if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { - onClickChange() - } - }, 2000) + metadataVisibilityToken++ + playerHostView?.scheduleAutoHide() + scheduleMetadataVisibility() } - // 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() + override fun onAutoHideUI() { + if (player.getIsPlaying()) onClickChange() + } + + protected fun hidePlayerUI() { + if (isShowing) { + isShowing = false + animateLayoutChanges() } } - 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 + /** PlayerView.Callbacks touch overrides */ - // 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 + override fun isUIShowing(): Boolean = isShowing - enum class TouchAction { - Brightness, - Volume, - Time, + override fun onSingleTap() { + onClickChange() } - 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 - } - } - } - - 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) - } - } + override fun onTouchDown() { + if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) } @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 + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text + } + } - playerBinding?.apply { - playerIntroPlay.isGone = true + override fun onHidePlayerUI() { + hidePlayerUI() + } - when (event.action) { - MotionEvent.ACTION_DOWN -> { - // validates if the touch is inside of the player area - isCurrentTouchValid = isValidTouch(currentTouch.x, currentTouch.y) - /*if (isCurrentTouchValid && player_episode_list?.isVisible == true) { - player_episode_list?.isVisible = false - } else*/ if (isCurrentTouchValid) { - currentTouchStartTime = System.currentTimeMillis() - currentTouchStart = currentTouch - currentTouchLast = currentTouch - currentTouchStartPlayerTime = player.getPosition() + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + animateLayoutChanges() + } + autoHide() + } - 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) + override fun playerStatusChanged() { + super.playerStatusChanged() + scheduleMetadataVisibility() + } - currentRequestedVolume = currentVolume.toFloat() / maxVolume.toFloat() - } - } - } + // 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() + } - 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) - } - } - } - } - } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) - // see if click is eligible for seek 10s - val holdTime = currentTouchStartTime?.minus(System.currentTimeMillis()) - if (isCurrentTouchValid // is valid - && currentTouchAction == null // no other action like swiping is taking place - && currentLastTouchAction == null // last action was none, this prevents mis input random seek - && holdTime != null - && holdTime < DOUBLE_TAB_MAXIMUM_HOLD_TIME // it is a click not a long hold - ) { - if (!isLocked - && (System.currentTimeMillis() - currentLastTouchEndTime) < DOUBLE_TAB_MINIMUM_TIME_BETWEEN // the time since the last action is short - ) { - currentClickCount++ - - if (currentClickCount >= 1) { // have double clicked - currentDoubleTapIndex++ - if (doubleTapPauseEnabled && isFullScreenPlayer) { // you can pause if your tap is in the middle of the screen - when { - currentTouch.x < screenWidth / 2 - (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { - if (doubleTapEnabled) - rewind() - } - - currentTouch.x > screenWidth / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { - if (doubleTapEnabled) - fastForward() - } - - else -> { - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } - } - } 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 - } - } - } - } + // 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() } } - currentTouchLast = currentTouch + } + + override fun resize(resize: PlayerResize, showToast: Boolean) { + super.resize(resize, showToast) + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + } + + private fun handleKeyDownEvent(keyCode: Int): Boolean? { + // adb shell input keyevent [INT] + when (keyCode) { + KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + player.handleEvent(CSPlayerEvent.SeekForward) + } + + KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { + player.handleEvent(CSPlayerEvent.SeekBack) + } + + KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { + player.handleEvent(CSPlayerEvent.PrevEpisode) + } + + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + player.handleEvent(CSPlayerEvent.Pause) + } + + KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { + player.handleEvent(CSPlayerEvent.Play) + } + + KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { + toggleLock() + } + + KeyEvent.KEYCODE_H -> { + onClickChange() + } + + KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { + player.handleEvent(CSPlayerEvent.ToggleMute) + } + + KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { + showMirrorsDialogue() + } + // OpenSubtitles shortcut + KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { + val context = context + if (subsProvidersIsActive && context != null) { + openOnlineSubPicker(context, null) {} + } + } + + KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { + showSpeedDialog() + } + + KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { + nextResize() + } + + KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { + skipOp() + } + + KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } + + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER -> { // space is not captured due to navigation + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + + // KEYCODE_DPAD_CENTER and KEYCODE_ENTER both act as a "select/confirm" button. + // Some remotes (e.g. LG Magic Remote) send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER. + // When the player UI or a dialog is visible, we let the event pass through (return null) + // so the focused button/item can handle the click normally, rather than always toggling + // play/pause. Only when the UI is hidden do we treat it as a play/pause toggle. + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER -> { + if (isShowing || isDialogOpen()) { + return null + } + // If UI is not shown make click instantly skip to next chapter even if locked + if (timestampShowState) { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } else if (!isLocked) { + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + onClickChange() + } + + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_UP -> { + if (isShowing || isShowingEpisodeOverlay) { + return null + } + onClickChange() + } + + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { + player.seekTime(-androidTVInterfaceOffSeekTime) + return true + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(-androidTVInterfaceOnSeekTime) + return true + } else { + return null + } + } + + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { + player.seekTime(androidTVInterfaceOffSeekTime) + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(androidTVInterfaceOnSeekTime) + } else { + return null + } + } + + KeyEvent.KEYCODE_VOLUME_DOWN, + KeyEvent.KEYCODE_VOLUME_UP -> { + // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR). + if (playerHostView?.handleVolumeKey(keyCode) != true) { + return null + } + } + + KeyEvent.KEYCODE_MENU, + KeyEvent.KEYCODE_SETTINGS -> { + if (isLocked || !isThereEpisodes()) { + return null + } + toggleEpisodesOverlay(true) + } + else -> return null // Avoid capturing all input + } return true } private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() - } 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 - } - } + return false + } + val keyCode = event.keyCode - KeyEvent.KEYCODE_DPAD_UP -> { - if (!isShowing) { - onClickChange() - return true - } - } + if (event.action == KeyEvent.ACTION_DOWN) { + val value = handleKeyDownEvent(keyCode) + if (value != null) { + return value + } + } - KeyEvent.KEYCODE_DPAD_LEFT -> { - 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_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 && isTv) { - onClickChange() - 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() } } + + // netflix capture back and hide ~monke + // This is removed due to inconsistent behavior on A36 vs A22, see https://github.com/recloudstream/cloudstream/issues/1804 + /*KeyEvent.KEYCODE_BACK -> { + if (isShowing && isLayout(TV or EMULATOR)) { + onClickChange() + return true + } + }*/ } return false } protected fun uiReset() { + metadataVisibilityToken++ + playerBinding?.playerMetadataScrim?.let { + it.animate().cancel() + it.alpha = 0f + it.isVisible = false + } isShowing = false - + toggleEpisodesOverlay(false) // if nothing has loaded these buttons should not be visible playerBinding?.apply { playerSkipEpisode.isVisible = false + playerGoForwardRoot.isVisible = false playerTracksBtt.isVisible = false playerSkipOp.isVisible = false shadowOverlay.isVisible = false @@ -1191,8 +1081,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { updateLockUI() updateUIVisibility() animateLayoutChanges() - resetFastForwardText() - resetRewindText() + playerHostView?.gestureHelper?.resetFastForwardText() + playerHostView?.gestureHelper?.resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { @@ -1201,114 +1091,35 @@ open class FullScreenPlayer : AbstractPlayerFragment() { super.onSaveInstanceState(outState) } - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + 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 // init variables - setPlayBackSpeed(getKey(PLAYBACK_SPEED_KEY) ?: 1.0f) + 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.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 - 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 - } - - //player_episodes_button?.setOnClickListener { - // player_episodes_button?.isGone = true - // player_episode_list?.isVisible = true - //} -// - //player_episode_list?.adapter = PlayerEpisodeAdapter { click -> -// - //} + setupKeyEventListener() try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - fastForwardTime = - settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10) - .toLong() * 1000L - androidTVInterfaceOffSeekTime = settingsManager.getInt( ctx.getString(R.string.android_tv_interface_off_seek_key), @@ -1322,36 +1133,23 @@ 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 ) + playerRotateEnabled = settingsManager.getBoolean( + ctx.getString(R.string.rotate_video_key), + false + ) playerResizeEnabled = settingsManager.getBoolean( ctx.getString(R.string.player_resize_enabled_key), true ) - doubleTapEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_enabled_key), - false - ) - - doubleTapPauseEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_pause_enabled_key), - false - ) + hideControlsNames = settingsManager.getBoolean( + ctx.getString(R.string.hide_player_control_names_key), + false + ) val profiles = QualityDataHelper.getProfiles() val type = if (ctx.isUsingMobileData()) @@ -1359,33 +1157,58 @@ open class FullScreenPlayer : AbstractPlayerFragment() { else QualityDataHelper.QualityProfileType.WiFi currentQualityProfile = - profiles.firstOrNull { it.type == type }?.id ?: profiles.firstOrNull()?.id - ?: currentQualityProfile - -// currentPrefQuality = settingsManager.getInt( -// ctx.getString(if (ctx.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), -// currentPrefQuality -// ) - // useSystemBrightness = - // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) + profiles.firstOrNull { it.types.contains(type) }?.id + ?: profiles.firstOrNull()?.id + ?: currentQualityProfile } playerBinding?.apply { playerSpeedBtt.isVisible = playBackSpeedEnabled playerResizeBtt.isVisible = playerResizeEnabled + playerRotateBtt.isVisible = + if (isLayout(TV or EMULATOR)) false else playerRotateEnabled + if (hideControlsNames) { + hideControlsNames() + } } } catch (e: Exception) { logError(e) } + playerBinding?.apply { - playerPausePlay.setOnClickListener { - autoHide() - player.handleEvent(CSPlayerEvent.PlayPauseToggle) + 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) { + text.isSelected = false + 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 + } + } } skipChapterButton.setOnClickListener { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } + playerRotateBtt.setOnClickListener { + autoHide() + toggleRotate() + } + // init clicks playerResizeBtt.setOnClickListener { autoHide() @@ -1407,6 +1230,16 @@ open class FullScreenPlayer : AbstractPlayerFragment() { player.handleEvent(CSPlayerEvent.NextEpisode) } + playerGoForward.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + playerRestart.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.Restart) + } + playerLock.setOnClickListener { autoHide() toggleLock() @@ -1416,18 +1249,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showSubtitleOffsetDialog() } - exoRew.setOnClickListener { - autoHide() - rewind() - } - - exoFfwd.setOnClickListener { - autoHide() - fastForward() - } - playerGoBack.setOnClickListener { - activity?.popCurrentPage() + activity?.popCurrentPage("FullScreenPlayer") } playerSourcesBtt.setOnClickListener { @@ -1438,20 +1261,21 @@ open class FullScreenPlayer : AbstractPlayerFragment() { showTracksDialogue() } - // it is !not! a bug that you cant touch the right side, it does not register inputs on navbar or status bar - playerHolder.setOnTouchListener { callView, event -> - return@setOnTouchListener handleMotionEvent(callView, event) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + playerControlsScroll.setOnScrollChangeListener { _, _, _, _, _ -> + autoHide() + } } + exoProgress.registerPlayerView(playerView) + + @SuppressLint("ClickableViewAccessibility") exoProgress.setOnTouchListener { _, event -> // this makes the bar not disappear when sliding when (event.action) { - MotionEvent.ACTION_DOWN -> { - currentTapIndex++ - } - + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - currentTapIndex++ + playerHostView?.cancelAutoHide() } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { @@ -1460,35 +1284,84 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } return@setOnTouchListener false } + playerEpisodesButton.setOnClickListener { + toggleEpisodesOverlay(show = true) + } } // init UI try { uiReset() - - // init chromecast UI - // removed due to having no use and bugging - //activity?.let { - // if (it.isCastApiAvailable()) { - // try { - // CastButtonFactory.setUpMediaRouteButton(it, player_media_route_button) - // val castContext = CastContext.getSharedInstance(it.applicationContext) - // - // player_media_route_button?.isGone = - // castContext.castState == CastState.NO_DEVICES_AVAILABLE - // castContext.addCastStateListener { state -> - // player_media_route_button?.isGone = - // state == CastState.NO_DEVICES_AVAILABLE - // } - // } catch (e: Exception) { - // logError(e) - // } - // } else { - // // if cast is not possible hide UI - // player_media_route_button?.isGone = true - // } - //} } catch (e: Exception) { logError(e) } } -} \ No newline at end of file + + private fun toggleRotate() { + activity?.let { + toggleOrientationWithSensor(it) + rotatedManually = true + } + } + + private fun PlayerCustomLayoutBinding.hideControlsNames() { + fun iterate(layout: LinearLayout) { + layout.children.forEach { + if (it is MaterialButton) { + it.textSize = 0f + it.iconPadding = 0 + it.iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START + it.setPadding(0, 0, 0, 0) + } else if (it is LinearLayout) { + iterate(it) + } + } + } + iterate(playerLockHolder.parent as LinearLayout) + } + + 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 + 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 animateEpisodesOverlay(show: Boolean) { + playerBinding?.playerEpisodeOverlay?.let { overlay -> + overlay.animate().cancel() + (overlay.parent as? ViewGroup)?.layoutTransition = null // Disable layout transitions + + val offset = 50 * overlay.resources.displayMetrics.density + + overlay.translationX = if (show) offset else 0f + playerBinding?.playerEpisodeOverlay?.isVisible = true + + overlay.animate() + .translationX(if (show) 0f else offset) + .alpha(if (show) 1f else 0f) + .setDuration(300) + .setInterpolator(AccelerateDecelerateInterpolator()).withEndAction { + if (!show) { + playerBinding?.playerEpisodeOverlay?.isGone = true + } + } + .start() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 341b4ad30..17bef3ec0 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,109 +3,213 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Dialog +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.os.Build import android.os.Bundle +import android.text.Spanned import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.* +import android.widget.AbsListView +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast import 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.preference.PreferenceManager +import androidx.lifecycle.viewModelScope import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.MimeTypes -import com.hippo.unifile.UniFile -import com.lagradost.cloudstream3.* +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.CommonActivity.showToast +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding -import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding -import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.isAnimeOp +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.isLiveStream +import com.lagradost.cloudstream3.isMovieType +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType +import com.lagradost.cloudstream3.ui.player.source_priority.LinkSource import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog -import com.lagradost.cloudstream3.ui.result.* -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1 -import com.lagradost.cloudstream3.utils.* +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.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.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.languages +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.storage.SafeFile +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 java.util.* -import kotlin.math.abs +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.Serializable +import java.util.Calendar +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +@OptIn(UnstableApi::class) class GeneratorPlayer : FullScreenPlayer() { companion object { - private var lastUsedGenerator: IGenerator? = null - fun newInstance(generator: IGenerator, syncData: HashMap? = null): Bundle { + 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 { Log.i(TAG, "newInstance = $syncData") - lastUsedGenerator = generator + val uuid = UUID.randomUUID().toString() + generators[uuid] = generator return Bundle().apply { + putString("uuid", uuid) + putInt("index", index) if (syncData != null) putSerializable("syncData", syncData) } } - val subsProviders - get() = subtitleProviders.filter { !it.requiresLogin || it.loginInfo() != null } + val subsProviders = subtitleProviders val subsProvidersIsActive get() = subsProviders.isNotEmpty() } - private var titleRez = 3 private var limitTitle = 0 + private var showTitle = false + private var showName = false + private var showResolution = false + private var showMediaInfo = false private lateinit var viewModel: PlayerGeneratorViewModel //by activityViewModels() private lateinit var sync: SyncViewModel - private var currentLinks: Set> = setOf() - private var currentSubs: Set = setOf() private var currentSelectedLink: Pair? = null private var currentSelectedSubtitles: SubtitleData? = null - private var currentMeta: Any? = null - private var nextMeta: Any? = null - private var isActive: Boolean = false + private val currentMeta: Any? get() = viewModel.state.generatorState?.meta + private val nextMeta: Any? get() = viewModel.state.generatorState?.nextMeta + + private var isPlayerActive: AtomicBoolean = AtomicBoolean(false) private var isNextEpisode: Boolean = false // this is used to reset the watch time private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none + private val allMeta: List? + get() = viewModel.state.generatorState?.allMeta?.filterIsInstance() + ?.map { episode -> + // Refresh all the episodes watch duration + getViewPos(episode.id)?.let { data -> + episode.copy(position = data.position, duration = data.duration) + } ?: episode + } - private var binding: FragmentPlayerBinding? = null + private fun setSubtitles(subtitle: SubtitleData?, userInitiated: Boolean): Boolean { + // If subtitle is changed and user initiated -> Save the language + if (subtitle != currentSelectedSubtitles && userInitiated) { + val subtitleLanguageTagIETF = if (subtitle == null) { + "" // -> No Subtitles + } else { + subtitle.getIETF_tag() + } - private fun startLoading() { - player.release() - currentSelectedSubtitles = null - isActive = false - binding?.overlayLoadingSkipButton?.isVisible = false - binding?.playerLoadingOverlay?.isVisible = true - } + if (subtitleLanguageTagIETF != null) { + Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") + setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) + preferredAutoSelectSubtitles = subtitleLanguageTagIETF + } + } - private fun setSubtitles(sub: SubtitleData?): Boolean { - currentSelectedSubtitles = sub - //Log.i(TAG, "setSubtitles = $sub") - return player.setPreferredSubtitles(sub) + currentSelectedSubtitles = subtitle + //Log.i(TAG, "setSubtitles = $subtitle") + return player.setPreferredSubtitles(subtitle) } override fun embeddedSubtitlesFetched(subtitles: List) { @@ -117,18 +221,26 @@ 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() { + super.playerStatusChanged() + if (player.getIsPlaying()) { + viewModel.forceClearCache = false + } } private fun noSubtitles(): Boolean { - return setSubtitles(null) + return setSubtitles(null, true) } private fun getPos(): Long { - val durPos = DataStoreHelper.getViewPos(viewModel.getId()) ?: return 0L + val durPos = getViewPos(viewModel.state.generatorState?.id) ?: return 0L if (durPos.duration == 0L) return 0L if (durPos.position * 100L / durPos.duration > 95L) { return 0L @@ -136,7 +248,7 @@ class GeneratorPlayer : FullScreenPlayer() { return durPos.position } - var currentVerifyLink: Job? = null + private var currentVerifyLink: Job? = null private fun loadExtractorJob(extractorLink: ExtractorLink?) { currentVerifyLink?.cancel() @@ -150,17 +262,254 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun loadLink(link: Pair?, sameEpisode: Boolean) { - if (link == null) return + // https://github.com/androidx/media/blob/main/libraries/ui/src/main/java/androidx/media3/ui/PlayerNotificationManager.java#L1517 + private fun createBroadcastIntent( + action: String, + context: Context, + instanceId: Int + ): PendingIntent { + val intent: Intent = Intent(action).setPackage(context.packageName) + intent.putExtra(EXTRA_INSTANCE_ID, instanceId) + val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else PendingIntent.FLAG_UPDATE_CURRENT + return PendingIntent.getBroadcast(context, instanceId, intent, pendingFlags) + } + + private var cachedPlayerNotificationManager: PlayerNotificationManager? = null + + private fun getMediaNotification(context: Context): PlayerNotificationManager { + val cache = cachedPlayerNotificationManager + if (cache != null) return cache + return PlayerNotificationManager.Builder( + context, + NOTIFICATION_ID, + CHANNEL_ID.toString() + ) + .setChannelNameResourceId(R.string.player_notification_channel_name) + .setChannelDescriptionResourceId(R.string.player_notification_channel_description) + .setMediaDescriptionAdapter(object : MediaDescriptionAdapter { + override fun getCurrentContentTitle(player: Player): CharSequence { + return when (val meta = currentMeta) { + is ResultEpisode -> { + meta.headerName + } + + is ExtractorUri -> { + meta.headerName ?: meta.name + } + + else -> null + } ?: "Unknown" + } + + override fun createCurrentContentIntent(player: Player): PendingIntent? { + // Open the app without creating a new task to resume playback seamlessly + return PendingIntentCompat.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + 0, + false + ) + } + + override fun getCurrentContentText(player: Player): CharSequence? { + return when (val meta = currentMeta) { + is ResultEpisode -> { + meta.name + } + + is ExtractorUri -> { + if (meta.headerName == null) { + null + } else { + meta.name + } + } + + else -> null + } + } + + override fun getCurrentLargeIcon( + player: Player, + callback: PlayerNotificationManager.BitmapCallback + ): Bitmap? { + ioSafe { + val url = when (val meta = currentMeta) { + is ResultEpisode -> { + meta.poster + } + + else -> null + } + // if we have a poster url try with it first + if (url != null) { + val urlBitmap = context.getImageBitmapFromUrl(url) + if (urlBitmap != null) { + callback.onBitmap(urlBitmap) + return@ioSafe + } + } + + // retry several times with a preview in case the preview generator is slow + repeat(10) { + val preview = this@GeneratorPlayer.player.getPreview(0.5f) + if (preview != null) { + callback.onBitmap(preview) + return@repeat + } + delay(1000L) + } + } + + // return null as we want to use the callback + return null + } + }).setCustomActionReceiver(object : PlayerNotificationManager.CustomActionReceiver { + // we have to use a custom action for stop if we want to exit the player instead of just stopping playback + override fun createCustomActions( + context: Context, + instanceId: Int + ): MutableMap { + return mutableMapOf( + STOP_ACTION to NotificationCompat.Action( + R.drawable.baseline_stop_24, + @SuppressLint("PrivateResource") + context.getString(androidx.media3.ui.R.string.exo_controls_stop_description), + createBroadcastIntent(STOP_ACTION, context, instanceId) + ) + ) + } + + override fun getCustomActions(player: Player): MutableList { + return mutableListOf(STOP_ACTION) + } + + override fun onCustomAction(player: Player, action: String, intent: Intent) { + when (action) { + STOP_ACTION -> { + exitPlayer() + } + } + } + }) + .setPlayActionIconResourceId(R.drawable.ic_baseline_play_arrow_24) + .setPauseActionIconResourceId(R.drawable.netflix_pause) + .setSmallIconResourceId(R.drawable.baseline_headphones_24) + .setStopActionIconResourceId(R.drawable.baseline_stop_24) + .setRewindActionIconResourceId(R.drawable.go_back_30) + .setFastForwardActionIconResourceId(R.drawable.go_forward_30) + .setNextActionIconResourceId(R.drawable.ic_baseline_skip_next_24) + .setPreviousActionIconResourceId(R.drawable.baseline_skip_previous_24) + .build().apply { + setColorized(true) // Color + setUseChronometer(true) // Seekbar + + // Don't show the prev episode button + setUsePreviousAction(false) + setUsePreviousActionInCompactView(false) + + // Don't show the next episode button + setUseNextAction(false) + setUseNextActionInCompactView(false) + + // Show the skip 30s in both modes + setUseFastForwardAction(true) + setUseFastForwardActionInCompactView(true) + + // Only show rewind in expanded + setUseRewindAction(true) + setUseFastForwardActionInCompactView(false) + + // Use custom stop action + setUseStopAction(false) + } + .also { cachedPlayerNotificationManager = it } + } + + override fun playerUpdated(player: Any?) { + super.playerUpdated(player) + + // Cancel the notification when released + if (player == null) { + cachedPlayerNotificationManager?.setPlayer(null) + cachedPlayerNotificationManager = null + return + } + + // setup the notification when starting the player + if (player is ExoPlayer) { + val ctx = context ?: return + getMediaNotification(ctx).apply { + setPlayer(player) + mMediaSession?.platformToken?.let { + setMediaSessionToken(it) + } + } + } + } + + override fun onDownload(event: DownloadEvent) { + super.onDownload(event) + showDownloadProgress(event) + } + + private fun showDownloadProgress(event: DownloadEvent) { + activity?.runOnUiThread { + playerBinding?.downloadedProgress?.apply { + val indeterminate = event.totalBytes <= 0 || event.downloadedBytes <= 0 + isIndeterminate = indeterminate + if (!indeterminate) { + max = (event.totalBytes / 1000).toInt() + progress = (event.downloadedBytes / 1000).toInt() + } + } + playerBinding?.downloadedProgressText.setText( + txt( + R.string.download_size_format, + android.text.format.Formatter.formatShortFileSize( + context, + event.downloadedBytes + ), + android.text.format.Formatter.formatShortFileSize(context, event.totalBytes) + ) + ) + val downloadSpeed = + android.text.format.Formatter.formatShortFileSize(context, event.downloadSpeed) + playerBinding?.downloadedProgressSpeedText?.text = + // todo string fmt + event.connections?.let { connections -> + "%s/s - %d Connections".format(downloadSpeed, connections) + } ?: downloadSpeed + + // don't display when done + playerBinding?.downloadedProgressSpeedText?.isGone = + event.downloadedBytes != 0L && event.downloadedBytes - 1024 >= event.totalBytes + } + } + + private fun loadLink(link: VideoLink?, sameEpisode: Boolean) { + if (link == null) return + isPlayerActive.set(true) // manage UI binding?.playerLoadingOverlay?.isVisible = false + val isTorrent = + link.first?.type == ExtractorLinkType.MAGNET || link.first?.type == ExtractorLinkType.TORRENT + + playerBinding?.downloadHeader?.isVisible = false + playerBinding?.downloadHeaderToggle?.isVisible = isTorrent + if (!isLayout(PHONE)) { + playerBinding?.downloadBothHeader?.isVisible = isTorrent + } + + showDownloadProgress(DownloadEvent(0, 0, 0, null)) + uiReset() currentSelectedLink = link - currentMeta = viewModel.getMeta() - nextMeta = viewModel.getNextMeta() // setEpisodes(viewModel.getAllMeta() ?: emptyList()) - isActive = true setPlayerDimen(null) setTitle() if (!sameEpisode) @@ -170,6 +519,7 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link + val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -178,42 +528,18 @@ class GeneratorPlayer : FullScreenPlayer() { startPosition = if (sameEpisode) null else { if (isNextEpisode) 0L else getPos() }, - currentSubs, + subtitles, (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( - currentSubs, settings = true, downloads = true + subtitles, settings = true, downloads = true ), + preview = true ) } - if (!sameEpisode) - player.addTimeStamps(listOf()) // clear stamps - } - - private fun closestQuality(target: Int?): Qualities { - if (target == null) return Qualities.Unknown - return Qualities.values().minBy { abs(it.value - target) } - } - - private fun getLinkPriority( - qualityProfile: Int, - link: Pair - ): Int { - val (linkData, _) = link - - val qualityPriority = QualityDataHelper.getQualityPriority( - qualityProfile, - closestQuality(linkData?.quality) - ) - val sourcePriority = - QualityDataHelper.getSourcePriority(qualityProfile, linkData?.source) - - // negative because we want to sort highest quality first - return qualityPriority + sourcePriority - } - - private fun sortLinks(qualityProfile: Int): List> { - return currentLinks.sortedBy { - -getLinkPriority(qualityProfile, it) + if (!sameEpisode) { + player.addTimeStamps(emptyList()) // clear stamps + // Resets subtitle delay, as we watch some other content + player.setSubtitleOffset(0) } } @@ -221,6 +547,7 @@ class GeneratorPlayer : FullScreenPlayer() { var episode: Int? = null, var season: Int? = null, var name: String? = null, + var imdbId: String? = null, ) private fun getMetaData(): TempMetaData { @@ -246,28 +573,29 @@ class GeneratorPlayer : FullScreenPlayer() { return meta } + fun getName(entry: AbstractSubtitleEntities.SubtitleEntity, withLanguage: Boolean): String { + if (entry.lang.isBlank() || !withLanguage) { + return entry.name + } + val language = fromTagToLanguageName(entry.lang.trim()) ?: entry.lang + return "$language ${entry.name}" + } + override fun openOnlineSubPicker( - context: Context, imdbId: Long?, dismissCallback: (() -> Unit) + context: Context, loadResponse: LoadResponse?, dismissCallback: (() -> Unit) ) { - val providers = subsProviders + val providers = subsProviders.toList() val isSingleProvider = subsProviders.size == 1 - val dialog = Dialog(context, R.style.AlertDialogCustomBlack) + val dialog = Dialog(context, R.style.DialogFullscreenPlayer) val binding = DialogOnlineSubtitlesBinding.inflate(LayoutInflater.from(context), null, false) dialog.setContentView(binding.root) + fixSystemBarsPadding(binding.root) var currentSubtitles: List = emptyList() var currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null - fun getName(entry: AbstractSubtitleEntities.SubtitleEntity, withLanguage: Boolean): String { - if (entry.lang.isBlank() || !withLanguage) { - return entry.name - } - val language = fromTwoLettersToLanguage(entry.lang.trim()) ?: entry.lang - return "$language ${entry.name}" - } - val layout = R.layout.sort_bottom_single_choice_double_text val arrayAdapter = object : ArrayAdapter(dialog.context, layout) { @@ -293,7 +621,6 @@ class GeneratorPlayer : FullScreenPlayer() { imageViewEnd.setImageDrawable(drawableEnd) } - @SuppressLint("SetTextI18n") override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(context).inflate(layout, null) @@ -306,9 +633,10 @@ class GeneratorPlayer : FullScreenPlayer() { mainTextView?.text = item?.let { getName(it, false) } val language = - item?.let { fromTwoLettersToLanguage(it.lang.trim()) ?: it.lang } ?: "" + item?.let { fromTagToLanguageName(it.lang) ?: it.lang } ?: "" val providerSuffix = if (isSingleProvider || item == null) "" else " · ${item.source}" + @SuppressLint("SetTextI18n") secondaryTextView?.text = language + providerSuffix setHearingImpairedIcon(drawableEnd, position) @@ -323,25 +651,25 @@ class GeneratorPlayer : FullScreenPlayer() { binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE binding.subtitleAdapter.adapter = arrayAdapter - val adapter = - binding.subtitleAdapter.adapter as? ArrayAdapter binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ -> currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener } - var currentLanguageTwoLetters: String = getAutoSelectLanguageISO639_1() + var currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() fun setSubtitlesList(list: List) { currentSubtitles = list - adapter?.clear() - adapter?.addAll(currentSubtitles) + arrayAdapter.clear() + arrayAdapter.addAll(currentSubtitles) } val currentTempMeta = getMetaData() + // bruh idk why it is not correct - val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent)) + val color = + ColorStateList.valueOf(context.colorFromAttribute(androidx.appcompat.R.attr.colorAccent)) binding.searchLoadingBar.progressTintList = color binding.searchLoadingBar.indeterminateTintList = color @@ -385,21 +713,35 @@ class GeneratorPlayer : FullScreenPlayer() { binding.searchLoadingBar.show() ioSafe { val search = - AbstractSubtitleEntities.SubtitleSearch( + SubtitleSearch( query = query ?: return@ioSafe, - imdb = imdbId, + imdbId = loadResponse?.getImdbId(), + tmdbId = loadResponse?.getTMDbId()?.toInt(), + malId = loadResponse?.getMalId()?.toInt(), + aniListId = loadResponse?.getAniListId()?.toInt(), epNumber = currentTempMeta.episode, seasonNumber = currentTempMeta.season, - lang = currentLanguageTwoLetters.ifBlank { null }, + lang = currentLanguageTagIETF.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) + + // TODO Make ui a lot better, like search with tabs val results = providers.amap { - try { - it.search(search) - } catch (e: Exception) { - null + when (val response = Resource.fromResult(it.search(search))) { + is Resource.Success -> { + response.value + } + + is Resource.Loading -> { + emptyList() + } + + is Resource.Failure -> { + showToast(response.errorString) + emptyList() + } } - }.filterNotNull() + } val max = results.maxOfOrNull { it.size } ?: return@ioSafe // very ugly @@ -427,14 +769,22 @@ class GeneratorPlayer : FullScreenPlayer() { }) binding.searchFilter.setOnClickListener { view -> - val lang639_1 = languages.map { it.ISO_639_1 } - activity?.showDialog(languages.map { it.languageName }, - lang639_1.indexOf(currentLanguageTwoLetters), + 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), view?.context?.getString(R.string.subs_subtitle_languages) ?: return@setOnClickListener, true, { }) { index -> - currentLanguageTwoLetters = lang639_1[index] + currentLanguageTagIETF = langTagsIETF[index] binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) } } @@ -443,16 +793,39 @@ class GeneratorPlayer : FullScreenPlayer() { currentSubtitle?.let { currentSubtitle -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> ioSafe { - val url = api.load(currentSubtitle) ?: return@ioSafe - val subtitle = SubtitleData( - name = getName(currentSubtitle, true), - url = url, - origin = SubtitleOrigin.URL, - mimeType = url.toSubtitleMimeType(), - headers = currentSubtitle.headers - ) - runOnMainThread { - addAndSelectSubtitles(subtitle) + when (val apiResource = + Resource.fromResult(api.resource(currentSubtitle))) { + is Resource.Success -> { + val subtitles = apiResource.value.getSubtitles().map { resource -> + SubtitleData( + originalName = resource.name ?: getName( + currentSubtitle, + true + ), + nameSuffix = "", + url = resource.url, + origin = resource.origin, + mimeType = resource.url.toSubtitleMimeType(), + headers = currentSubtitle.headers, + languageCode = currentSubtitle.lang + ) + } + if (subtitles.isEmpty()) { + showToast(R.string.no_subtitles) + return@ioSafe + } + runOnMainThread { + addAndSelectSubtitles(*subtitles.toTypedArray()) + } + } + + is Resource.Failure -> { + showToast(apiResource.errorString) + } + + is Resource.Loading -> { + // not possible + } } } } @@ -490,25 +863,29 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun addAndSelectSubtitles(subtitleData: SubtitleData) { + @MainThread + private fun addAndSelectSubtitles( + vararg subtitleData: SubtitleData + ) { + if (subtitleData.isEmpty()) return val ctx = context ?: return - - val subs = currentSubs + subtitleData + val selectedSubtitle = subtitleData.first() + viewModel.addSubtitles(subtitleData.toSet()) // this is used instead of observe(viewModel._currentSubs), because observe is too slow - player.setActiveSubtitles(subs) + player.setActiveSubtitles(viewModel.state.subtitles) // Save current time as to not reset player to 00:00 player.saveData() player.reloadPlayer(ctx) - setSubtitles(subtitleData) - viewModel.addSubtitles(setOf(subtitleData)) + setSubtitles(selectedSubtitle, false) selectSourceDialog?.dismissSafe() + selectSourceDialog = null showToast( - String.format(ctx.getString(R.string.player_loaded_subtitles), subtitleData.name), + String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), Toast.LENGTH_LONG ) } @@ -516,10 +893,10 @@ class GeneratorPlayer : FullScreenPlayer() { // Open file picker private val subsPathPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - normalSafeApiCall { + safe { // It lies, it can be null if file manager quits. - if (uri == null) return@normalSafeApiCall - val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall + if (uri == null) return@safe + val ctx = context ?: CloudStreamApp.context ?: return@safe // RW perms for the path ctx.contentResolver.takePersistableUriPermission( uri, @@ -534,18 +911,88 @@ class GeneratorPlayer : FullScreenPlayer() { val subtitleData = SubtitleData( name, + "", uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), - emptyMap() + emptyMap(), + null ) addAndSelectSubtitles(subtitleData) } } - 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) = + viewModel.viewModelScope.launch { + // async should not have a race condition if they are on the same group + var hasSelectASubtitle = false + + // first come first served with these subtitles + // we might want to change it to prefer different sources when used multiple times, + // however caching might make this random after the first click too + subsProviders.toList().amap { provider -> + val success = when (val result = Resource.fromResult( + provider.search( + query = query + ) + )) { + is Resource.Failure -> { + // scope might cancel, so we do an extra check + if (this.isActive) { + showToast("${provider.idPrefix}${result.errorString}") + } + return@amap + } + + is Resource.Loading -> { + // unreachable + return@amap + } + + is Resource.Success -> { + result.value + } + } + + // try to add every subtitle until we have added a new subtitle file + for (subtitleEntry in success) { + if (hasSelectASubtitle || !this.isActive) { + break + } + + val subtitleResources = provider.resource(subtitleEntry).getOrNull() ?: continue + + val subtitles = subtitleResources.getSubtitles().map { resource -> + SubtitleData( + originalName = resource.name ?: getName(subtitleEntry, true), + nameSuffix = "", + url = resource.url, + origin = resource.origin, + mimeType = resource.url.toSubtitleMimeType(), + headers = subtitleEntry.headers, + languageCode = subtitleEntry.lang, + ) + } + + // checks for both a race condition and if any of the subs generated is new + if (this.isActive && !viewModel.state.subtitles.containsAll(subtitles) && !hasSelectASubtitle) { + hasSelectASubtitle = true + runOnMainThread { + addAndSelectSubtitles(*subtitles.toTypedArray()) + } + break + } + } + } + // maybe better error here? + if (!hasSelectASubtitle && this.isActive) { + showToast(R.string.no_subtitles) + } + } + override fun showMirrorsDialogue() { try { @@ -553,19 +1000,21 @@ class GeneratorPlayer : FullScreenPlayer() { //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") context?.let { ctx -> val isPlaying = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause) - val currentSubtitles = sortSubs(currentSubs) + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) + val currentSubtitles = sortSubs(viewModel.state.subtitles) - val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) + val sourceDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) val binding = PlayerSelectSourceAndSubsBinding.inflate(LayoutInflater.from(ctx), null, false) sourceDialog.setContentView(binding.root) + fixSystemBarsPadding(binding.root) selectSourceDialog = sourceDialog sourceDialog.show() 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 @@ -578,6 +1027,14 @@ class GeneratorPlayer : FullScreenPlayer() { var shouldDismiss = true + binding.subtitleSettingsBtt.setOnClickListener { + safe { + val subtitlesFragment = SubtitlesFragment() + subtitlesFragment.systemBarsAddPadding = true + subtitlesFragment.show(this.parentFragmentManager, "SubtitleSettings") + } + } + fun dismiss() { if (isPlaying) { player.handleEvent(CSPlayerEvent.Play) @@ -586,6 +1043,8 @@ class GeneratorPlayer : FullScreenPlayer() { } if (subsProvidersIsActive) { + val currentLoadResponse = viewModel.state.generatorState?.response + val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null ) as TextView @@ -596,11 +1055,45 @@ class GeneratorPlayer : FullScreenPlayer() { loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) - openOnlineSubPicker(it.context, null) { + selectSourceDialog = null + openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } } subtitleList.addFooterView(loadFromOpenSubsFooter) + + // subs from 1 button here + val metadata = getMetaData() + val queryName = metadata.name ?: currentLoadResponse?.name + if (queryName != null) { + val currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() + val loadFromFirstSubsFooter: TextView = layoutInflater.inflate( + R.layout.sort_bottom_footer_add_choice, null + ) as TextView + + loadFromFirstSubsFooter.text = + ctx.getString(R.string.player_load_one_subtitle_online) + + loadFromFirstSubsFooter.setOnClickListener { + sourceDialog.dismissSafe(activity) + selectSourceDialog = null + showToast(R.string.loading) + addFirstSub( + SubtitleSearch( + query = queryName, + imdbId = currentLoadResponse?.getImdbId(), + tmdbId = currentLoadResponse?.getTMDbId()?.toInt(), + malId = currentLoadResponse?.getMalId()?.toInt(), + aniListId = currentLoadResponse?.getAniListId()?.toInt(), + epNumber = metadata.episode, + seasonNumber = metadata.season, + lang = currentLanguageTagIETF.ifBlank { null }, + year = viewModel.currentSubtitleYear.value + ) + ) + } + subtitleList.addFooterView(loadFromFirstSubsFooter) + } } var sourceIndex = 0 @@ -608,7 +1101,7 @@ class GeneratorPlayer : FullScreenPlayer() { var sortedUrls = emptyList>() fun refreshLinks(qualityProfile: Int) { - sortedUrls = sortLinks(qualityProfile) + sortedUrls = viewModel.state.sortLinks(qualityProfile) if (sortedUrls.isEmpty()) { sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true @@ -633,6 +1126,16 @@ class GeneratorPlayer : FullScreenPlayer() { sourceIndex = which providerList.setItemChecked(which, true) } + + providerList.setOnItemLongClickListener { _, _, position, _ -> + sortedUrls.getOrNull(position)?.first?.url?.let { + clipboardHelper( + txt(R.string.video_source), + it + ) + } + true + } } } @@ -643,21 +1146,74 @@ 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)) - subsArrayAdapter.addAll(currentSubtitles.map { it.name }) + 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) subtitleList.adapter = subsArrayAdapter subtitleList.choiceMode = AbsListView.CHOICE_MODE_SINGLE - subtitleList.setSelection(subtitleIndex) - subtitleList.setItemChecked(subtitleIndex, true) + 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.setOnItemClickListener { _, _, which, _ -> - if (which > currentSubtitles.size) { + if (which > subtitlesGrouped.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. @@ -668,13 +1224,35 @@ class GeneratorPlayer : FullScreenPlayer() { val child = subtitleList.adapter.getView(which, null, subtitleList) child?.performClick() } else { - subtitleIndex = which + if (subtitleGroupIndex != which) { + subtitleGroupIndex = which + subtitleOptionIndex = + if (subtitleGroupIndex == subtitleGroupIndexStart) { + subtitleOptionIndexStart + } else { + 0 + } + } 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) { @@ -688,16 +1266,28 @@ class GeneratorPlayer : FullScreenPlayer() { binding.profilesClickSettings.setOnClickListener { val activity = activity ?: return@setOnClickListener - QualityProfileDialog( + val dialog = QualityProfileDialog( activity, - R.style.AlertDialogCustomBlack, - currentLinks.mapNotNull { it.first }, + R.style.DialogFullscreenPlayer, + viewModel.state.links.mapNotNull { + it.first?.let { extractorLink -> + LinkSource( + extractorLink + ) + } + }, currentQualityProfile ) { profile -> currentQualityProfile = profile.id setProfileName(profile.id) - refreshLinks(profile.id) - }.show() + } + + dialog.setOnDismissListener { + viewModel.state.clearSortedLinksCache() + refreshLinks(currentQualityProfile) + } + + dialog.show() } binding.subtitlesEncodingFormat.apply { @@ -713,7 +1303,7 @@ class GeneratorPlayer : FullScreenPlayer() { text = prefNames[if (index == -1) 0 else index] } - binding.subtitlesClickSettings.setOnClickListener { + binding.subtitlesEncodingFormat.setOnClickListener { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) @@ -725,17 +1315,20 @@ 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] - ).apply() - + settingsManager.edit { + putString( + ctx.getString(R.string.subtitles_encoding_key), prefValues[it] + ) + } updateForcedEncoding(ctx) dismiss() player.seekTime(-1) // to update subtitles, a dirty trick @@ -743,16 +1336,15 @@ class GeneratorPlayer : FullScreenPlayer() { } binding.applyBtt.setOnClickListener { - var init = false - if (sourceIndex != startSource) { - init = true - } - if (subtitleIndex != subtitleIndexStart) { - init = init || if (subtitleIndex <= 0) { + var init = sourceIndex != startSource + if (subtitleGroupIndex != subtitleGroupIndexStart || subtitleOptionIndex != subtitleOptionIndexStart) { + init = init or if (subtitleGroupIndex <= 0) { noSubtitles() } else { - currentSubtitles.getOrNull(subtitleIndex - 1)?.let { - setSubtitles(it) + subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( + subtitleOptionIndex + )?.let { + setSubtitles(it, true) } ?: false } } @@ -762,6 +1354,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } sourceDialog.dismissSafe(activity) + selectSourceDialog = null } } } catch (e: Exception) { @@ -784,11 +1377,14 @@ class GeneratorPlayer : FullScreenPlayer() { val currentAudioTracks = tracks.allAudioTracks val binding: PlayerSelectTracksBinding = PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) - val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) + val trackDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) + this.selectTrackDialog = trackDialog trackDialog.setContentView(binding.root) trackDialog.show() -// selectTracksDialog = tracksDialog + fixSystemBarsPadding(binding.root) + + // selectTracksDialog = tracksDialog val videosList = binding.videoTracksList val audioList = binding.autoTracksList @@ -831,22 +1427,56 @@ class GeneratorPlayer : FullScreenPlayer() { trackDialog.setOnDismissListener { dismiss() -// selectTracksDialog = null + // selectTracksDialog = null } - var audioIndexStart = currentAudioTracks.indexOf(tracks.currentAudioTrack).takeIf { - it != -1 - } ?: currentVideoTracks.indexOfFirst { - tracks.currentAudioTrack?.id == it.id - } + var audioIndexStart = currentAudioTracks.indexOfFirst { track -> + track.id == tracks.currentAudioTrack?.id && + track.formatIndex == tracks.currentAudioTrack?.formatIndex + }.coerceAtLeast(0) val audioArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) -// audioArrayAdapter.add(ctx.getString(R.string.no_subtitles)) - audioArrayAdapter.addAll(currentAudioTracks.mapIndexed { index, format -> - format.label ?: format.language?.let { fromTwoLettersToLanguage(it) } - ?: index.toString() - }) + + 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(" • ") + + + } + ) audioList.adapter = audioArrayAdapter audioList.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -861,12 +1491,15 @@ class GeneratorPlayer : FullScreenPlayer() { binding.cancelBtt.setOnClickListener { trackDialog.dismissSafe(activity) + this.selectTrackDialog = null } binding.applyBtt.setOnClickListener { val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) player.setPreferredAudioTrack( - currentTrack?.language, currentTrack?.id + currentTrack?.language, + currentTrack?.id, + currentTrack?.formatIndex, ) val currentVideo = currentVideoTracks.getOrNull(videoIndex) @@ -875,8 +1508,8 @@ class GeneratorPlayer : FullScreenPlayer() { if (width != NO_VALUE && height != NO_VALUE) { player.setMaxVideoSize(width, height, currentVideo?.id) } - trackDialog.dismissSafe(activity) + this.selectTrackDialog = null } } } catch (e: Exception) { @@ -884,47 +1517,123 @@ class GeneratorPlayer : FullScreenPlayer() { } } + override fun playerError(exception: Throwable) { + val currentUrl = + currentSelectedLink?.let { it.first?.url ?: it.second?.uri?.toString() } ?: "unknown" + val headers = currentSelectedLink?.first?.headers?.toString() ?: "none" + val referer = currentSelectedLink?.first?.referer ?: "none" + Log.e( + TAG, + "playerError: $currentSelectedLink, " + + "type=${exception::class.java.canonicalName}, " + + "message=${exception.message}, url=$currentUrl, headers=$headers, " + + "referer=$referer, position=${player.getPosition() ?: "unknown"}, " + + "duration=${player.getDuration() ?: "unknown"}, " + + "isPlaying=${player.getIsPlaying()}", exception + ) - override fun playerError(exception: Exception) { - Log.i(TAG, "playerError = $currentSelectedLink") + if (!hasNextMirror()) { + viewModel.forceClearCache = true + } super.playerError(exception) } private fun noLinksFound() { + viewModel.forceClearCache = true + showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) activity?.popCurrentPage() } private fun startPlayer() { - if (isActive) return // we don't want double load when you skip loading + // We don't want double load when you skip loading + if (isPlayerActive.get()) { + return + } - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return } + // Atomic operation to prevent double loading + if (!isPlayerActive.compareAndSet(false, true)) { + return + } loadLink(links.first(), false) + showPlayerMetadata() + } + + private fun showPlayerMetadata() { + val overlay = playerBinding?.playerMetadataScrim ?: return + + val titleView = overlay.findViewById(R.id.player_movie_title) + val logoView = overlay.findViewById(R.id.player_movie_logo) + val metaView = overlay.findViewById(R.id.player_movie_meta) + val descView = overlay.findViewById(R.id.player_movie_overview) + + val load = viewModel.state.generatorState?.response ?: return + val episode = currentMeta as? ResultEpisode + titleView.text = load.name + + bindLogo( + url = load.logoUrl, + headers = load.posterHeaders, + titleView = titleView, + logoView = logoView + ) + + val meta = arrayOf( + load.tags?.takeIf { it.isNotEmpty() }?.joinToString(", "), + load.year?.toString(), + if (!load.type.isMovieType()) + context?.getShortSeasonText( + episode = episode?.episode, + season = episode?.season + ) + else null, + load.score?.let { "⭐ $it" } + ).filterNotNull() + .joinToString(" • ") + + metaView.text = meta + metaView.isVisible = meta.isNotBlank() + + + val description = load.plot + + if (!description.isNullOrBlank()) { + descView.isVisible = true + descView.text = description + } else { + descView.isVisible = false + + } } override fun nextEpisode() { - isNextEpisode = true - player.release() - viewModel.loadLinksNext() + if (viewModel.hasNextEpisode() == true) { + isNextEpisode = true + releasePlayer() + viewModel.loadLinksNext() + } } override fun prevEpisode() { - isNextEpisode = true - player.release() - viewModel.loadLinksPrev() + if (viewModel.hasPrevEpisode() == true) { + isNextEpisode = true + releasePlayer() + viewModel.loadLinksPrev() + } } override fun hasNextMirror(): Boolean { - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val links = sortLinks(currentQualityProfile) + val links = viewModel.state.sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return @@ -947,14 +1656,13 @@ class GeneratorPlayer : FullScreenPlayer() { var maxEpisodeSet: Int? = null var hasRequestedStamps: Boolean = false - override fun playerPositionChanged(posDur: Pair) { + override fun playerPositionChanged(position: Long, duration: Long) { // Don't save livestream data if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return // Don't save NSFW data if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return - val (position, duration) = posDur if (duration <= 0L) return // idk how you achieved this, but div by zero crash if (!hasRequestedStamps) { hasRequestedStamps = true @@ -969,49 +1677,15 @@ class GeneratorPlayer : FullScreenPlayer() { viewModel.loadStamps(duration) } - viewModel.getId()?.let { - DataStoreHelper.setViewPos(it, position, duration) - } - val percentage = position * 100L / duration - val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE - val resumeMeta = if (nextEp) nextMeta else currentMeta - if (resumeMeta == null && nextEp) { - // remove last watched as it is the last episode and you have watched too much - when (val newMeta = currentMeta) { - is ResultEpisode -> { - DataStoreHelper.removeLastWatched(newMeta.parentId) - } - - is ExtractorUri -> { - DataStoreHelper.removeLastWatched(newMeta.parentId) - } - } - } else { - // save resume - when (resumeMeta) { - is ResultEpisode -> { - DataStoreHelper.setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = false - ) - } - - is ExtractorUri -> { - DataStoreHelper.setLastWatched( - resumeMeta.parentId, - resumeMeta.id, - resumeMeta.episode, - resumeMeta.season, - isFromDownload = true - ) - } - } - } + DataStoreHelper.setViewPosAndResume( + viewModel.state.generatorState?.id, + position, + duration, + currentMeta, + nextMeta + ) var isOpVisible = false when (val meta = currentMeta) { @@ -1025,7 +1699,7 @@ class GeneratorPlayer : FullScreenPlayer() { ctx.getString(R.string.episode_sync_enabled_key), true ) ) maxEpisodeSet = meta.episode - sync.modifyMaxEpisode(meta.episode) + sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode) } } @@ -1034,8 +1708,19 @@ class GeneratorPlayer : FullScreenPlayer() { } playerBinding?.playerSkipOp?.isVisible = isOpVisible - playerBinding?.playerSkipEpisode?.isVisible = - !isOpVisible && viewModel.hasNextEpisode() == true + + when { + isLayout(PHONE) -> + playerBinding?.playerSkipEpisode?.isVisible = + !isOpVisible && viewModel.hasNextEpisode() == true + + else -> { + val hasNextEpisode = viewModel.hasNextEpisode() == true + playerBinding?.playerGoForward?.isVisible = hasNextEpisode + playerBinding?.playerGoForwardRoot?.isVisible = hasNextEpisode + } + + } if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { viewModel.preLoadNextLinks() @@ -1046,33 +1731,28 @@ class GeneratorPlayer : FullScreenPlayer() { subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null - val lang = fromTwoLettersToLanguage(langCode) ?: return null if (downloads) { - return subtitles.firstOrNull { sub -> - (sub.origin == SubtitleOrigin.DOWNLOADED_FILE && sub.name == context?.getString( - R.string.default_subtitles - )) - } + sortSubs(subtitles).firstOrNull { + it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( + langCode + ) + }?.let { return it } } - sortSubs(subtitles).firstOrNull { sub -> - val t = sub.name.replace(Regex("[^A-Za-z]"), " ").trim() - (settings) && t == lang || t.startsWith(lang) || t == langCode - }?.let { sub -> - return sub - } + if (!settings) return null - return null + return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } } private fun autoSelectFromSettings(): Boolean { - // auto select subtitle based of settings + // auto select subtitle based on settings val langCode = preferredAutoSelectSubtitles val current = player.getCurrentPreferredSubtitle() Log.i(TAG, "autoSelectFromSettings = $current") context?.let { ctx -> - if (current != null) { - if (setSubtitles(current)) { + // Only use the player preferred subtitle if it matches the available language + if (current != null && (langCode == null || current.matchesLanguageCode(langCode))) { + if (setSubtitles(current, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1080,9 +1760,9 @@ class GeneratorPlayer : FullScreenPlayer() { } } else if (!langCode.isNullOrEmpty()) { getAutoSelectSubtitle( - currentSubs, settings = true, downloads = false + viewModel.state.subtitles, settings = true, downloads = false )?.let { sub -> - if (setSubtitles(sub)) { + if (setSubtitles(sub, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1094,31 +1774,39 @@ class GeneratorPlayer : FullScreenPlayer() { return false } - private fun autoSelectFromDownloads(): Boolean { - if (player.getCurrentPreferredSubtitle() == null) { - getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub -> - context?.let { ctx -> - if (setSubtitles(sub)) { - player.saveData() - player.reloadPlayer(ctx) - player.handleEvent(CSPlayerEvent.Play) - return true - } - } - } + private fun autoSelectFromDownloads() { + if (player.getCurrentPreferredSubtitle() != null) { + return } - return false + val sub = + getAutoSelectSubtitle(viewModel.state.subtitles, settings = false, downloads = true) + ?: return + val ctx = context ?: return + if (!setSubtitles(sub, false)) { + return + } + player.saveData() + player.reloadPlayer(ctx) + player.handleEvent(CSPlayerEvent.Play) } private fun autoSelectSubtitles() { //Log.i(TAG, "autoSelectSubtitles") - normalSafeApiCall { + safe { 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 @@ -1165,8 +1853,6 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } - - @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() @@ -1185,68 +1871,127 @@ class GeneratorPlayer : FullScreenPlayer() { playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false playerBinding?.playerVideoTitle?.text = playerVideoTitle + playerBinding?.offlinePin?.isVisible = viewModel.generator is DownloadFileGenerator } - @SuppressLint("SetTextI18n") fun setPlayerDimen(widthHeight: Pair?) { - val extra = if (widthHeight != null) { - val (width, height) = widthHeight - "${width}x${height}" - } else { - "" + val resolution = widthHeight?.let { "${it.first}x${it.second}" } + val name = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name + val title = getHeaderName() + + val result = listOfNotNull( + title?.takeIf { showTitle && it.isNotBlank() }, + name?.takeIf { showName && it.isNotBlank() }, + resolution?.takeIf { showResolution && it.isNotBlank() }, + ).joinToString(" - ") + + playerBinding?.playerVideoTitleRez?.apply { + text = result + isVisible = result.isNotBlank() } + } - val source = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name ?: "NULL" - val title = when (titleRez) { - 0 -> "" - 1 -> extra - 2 -> source - 3 -> "$source - $extra" + private fun videoCodecName(mime: String?): String? { + val m = mime?.lowercase() ?: return null + return when { + m.contains("avc") || m.contains("h264") -> "AVC" + m.contains("hevc") || m.contains("h265") -> "HEVC" + m.contains("av1") -> "AV1" + m.contains("vp9") -> "VP9" + m.contains("vp8") -> "VP8" + "/" in m -> m.substringAfter("/").uppercase() + else -> m.uppercase() + } + } + + private fun audioCodecName(mime: String?): String { + val m = mime?.lowercase()?.trim().orEmpty() + if (m.isBlank()) return "" + return when { + m.contains("eac3-joc") -> "Dolby Atmos" + m.contains("truehd") -> "TrueHD" + m.contains("eac3") -> "E-AC3" + m.contains("ac-3") || m.contains("ac3") -> "AC3" + m.contains("aac") || m.contains("mp4a") -> "AAC" + m.contains("opus") -> "Opus" + m.contains("vorbis") -> "Vorbis" + m.contains("mp3") -> "MP3" + m.contains("flac") -> "FLAC" + m.contains("dts") -> "DTS" + m.contains("pcm") -> "PCM" + m.contains("alac") -> "ALAC" + m.contains("amr") -> "AMR" + m.contains("/") -> m.substringAfter("/").uppercase().takeIf { it.isNotBlank() } ?: "" else -> "" } - playerBinding?.playerVideoTitleRez?.apply { - text = title - isVisible = title.isNotBlank() + } + + private fun updatePlayerInfo() { + val tracks = player.getVideoTracks() + + val videoTrack = tracks.currentVideoTrack + val audioTrack = tracks.currentAudioTrack + + val ctx = context ?: return + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + showMediaInfo = prefs.getBoolean(ctx.getString(R.string.show_media_info_key), false) + + val videoCodec = videoCodecName(videoTrack?.sampleMimeType) + val audioCodec = audioCodecName(audioTrack?.sampleMimeType) + val languageName = fromTagToLanguageName(audioTrack?.language) + val label = audioTrack?.label + + val channelCount = audioTrack?.channelCount + + val channels = when { + // May be below 1 or null when unknown + channelCount == null || channelCount <= 0 -> "" + channelCount == 1 -> "Mono" + channelCount == 2 -> "Stereo" + channelCount == 6 -> "5.1" + channelCount == 8 -> "7.1" + else -> "${channelCount}ch" + } + + val language = languageName?.takeIf { it.isNotBlank() }?.let { lang -> + label?.takeIf { it.isNotBlank() && !it.equals(lang, true) } + ?.let { lang } + ?: lang + } ?: label?.takeIf { it.isNotBlank() } + + val stats = arrayOf( + videoCodec, + language, + channels, + audioCodec + ).filter { !it.isNullOrBlank() }.joinToString(" • ") + + playerBinding?.playerVideoInfo?.apply { + text = stats + isVisible = showMediaInfo && stats.isNotBlank() } } - override fun playerDimensionsLoaded(widthHeight: Pair) { - setPlayerDimen(widthHeight) + override fun playerDimensionsLoaded(width: Int, height: Int) { + super.playerDimensionsLoaded(width, height) + setPlayerDimen(width to height) } private fun unwrapBundle(savedInstanceState: Bundle?) { Log.i(TAG, "unwrapBundle = $savedInstanceState") savedInstanceState?.let { bundle -> - sync.addSyncs(bundle.getSerializable("syncData") as? HashMap?) + sync.addSyncs(bundle.getSafeSerializable>("syncData")) } } - 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 - isTv = isTvSettings() - layout = if (isTv) R.layout.fragment_player_tv else R.layout.fragment_player - - viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] - sync = ViewModelProvider(this)[SyncViewModel::class.java] - - viewModel.attachGenerator(lastUsedGenerator) - unwrapBundle(savedInstanceState) - unwrapBundle(arguments) - - val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null - binding = FragmentPlayerBinding.bind(root) - return root - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } - - var timestampShowState = false + /** + * This is used instead of layout-television to follow the + * settings and some TV devices are not classified as TV + * for some reason. + */ + override fun pickLayout(): Int = + if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player var skipAnimator: ValueAnimator? = null var skipIndex = 0 @@ -1267,6 +2012,12 @@ class GeneratorPlayer : FullScreenPlayer() { skipAnimator?.cancel() isVisible = true + /** Focus instantly to make the focus color appear instantly */ + if (show && !isShowing) { + // Automatically request focus if the menu is not opened + playerBinding?.skipChapterButton?.requestFocus() + } + // just in case val lay = layoutParams lay.width = from @@ -1275,12 +2026,7 @@ class GeneratorPlayer : FullScreenPlayer() { from, to ).apply { addListener(onEnd = { - if (show) { - if (!isShowing) { - // Automatically request focus if the menu is not opened - playerBinding?.skipChapterButton?.requestFocus() - } - } else { + if (!show) { playerBinding?.skipChapterButton?.isVisible = false if (!isShowing) { // Automatically return focus to play pause @@ -1300,11 +2046,11 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { + override fun onTimestampSkipped(timestamp: VideoSkipStamp) { displayTimeStamp(false) } - override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { + override fun onTimestamp(timestamp: VideoSkipStamp?) { if (timestamp != null) { playerBinding?.skipChapterButton?.setText(timestamp.uiText) displayTimeStamp(true) @@ -1318,25 +2064,143 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - var langFilterList = listOf() - var filterSubByLang = false + override fun isThereEpisodes(): Boolean { + // Checks if there is a second episode of type ResultEpisode + // => There exists more than 1 episode, and they are all ResultEpisode + return viewModel.state.generatorState?.allMeta?.getOrNull(1) as? ResultEpisode != null + } + + override fun showEpisodesOverlay() { + try { + playerBinding?.apply { + playerEpisodeList.setRecycledViewPool(EpisodeAdapter.sharedPool) + playerEpisodeList.adapter = EpisodeAdapter( + false, + { episodeClick -> + if (episodeClick.action == ACTION_CLICK_DEFAULT) { + isNextEpisode = false + releasePlayer() + playerEpisodeOverlay.isGone = true + episodeClick.position?.let { viewModel.loadThisEpisode(it) } + } + }, + { downloadClickEvent -> + DownloadButtonSetup.handleDownloadClick(downloadClickEvent) + } + ) + playerEpisodeList.setLinearListLayout( + isHorizontal = false, + nextUp = FOCUS_SELF, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + val episodes = allMeta ?: emptyList() + (playerEpisodeList.adapter as? EpisodeAdapter)?.submitList(episodes) + + // Scroll to current episode + viewModel.state.generatorState?.index?.let { index -> + playerEpisodeList.scrollToPosition(index) + // Ensure focus on tv + if (isLayout(TV)) { + playerEpisodeList.post { + val viewHolder = + playerEpisodeList.findViewHolderForAdapterPosition(index) + viewHolder?.itemView?.requestFocus() + viewHolder?.itemView?.let { itemView -> + itemView.isFocusableInTouchMode = true + itemView.requestFocus() + } + } + } + } + + // update overlay season title + var lastTopIndex = -1 + playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + val layoutManager = + recyclerView.layoutManager as? LinearLayoutManager ?: return + val topIndex = layoutManager.findFirstCompletelyVisibleItemPosition() + if (topIndex != RecyclerView.NO_POSITION && topIndex != lastTopIndex) { + @Suppress("AssignedValueIsNeverRead") + lastTopIndex = topIndex + val topItem = episodes.getOrNull(topIndex) + topItem?.let { + playerEpisodeOverlayTitle.setText( + ResultViewModel2.seasonToTxt( + topItem.seasonData, + topItem.seasonIndex + ) + ) + } + } + } + }) + } + } catch (e: Exception) { + logError(e) + } + } + + @MainThread + fun releasePlayer() { + player.release() + currentSelectedSubtitles = null + currentSelectedLink = null + isPlayerActive.set(false) + binding?.overlayLoadingSkipButton?.isVisible = false + binding?.playerLoadingOverlay?.isVisible = true + uiReset() + } + + fun exitPlayer() { + playerHostView?.exitFullscreen() + player.release() + activity?.popCurrentPage() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt("index", viewModel.episodeIndex) + super.onSaveInstanceState(outState) + } + + override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { + viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] + sync = ViewModelProvider(this)[SyncViewModel::class.java] + + val uuid = savedInstanceState?.getString("uuid") ?: arguments?.getString("uuid") + val index = savedInstanceState?.getInt("index") ?: arguments?.getInt("index") + val generator = generators[uuid] + + unwrapBundle(savedInstanceState) + unwrapBundle(arguments) + + super.onBindingCreated(binding, savedInstanceState) + + // Avoid showing no links found + if (generator == null || index == null) { + exitPlayer() + return + } + viewModel.attachGenerator(generator, index) context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - titleRez = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_rez_key), 3) - limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_limit_title_key), 0) + showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) + showResolution = + settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) + showMediaInfo = + settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) + limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) updateForcedEncoding(ctx) - - filterSubByLang = + viewModel.filterSubByLang = settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) - if (filterSubByLang) { + if (viewModel.filterSubByLang) { val langFromPrefMedia = settingsManager.getStringSet( this.getString(R.string.provider_lang_key), mutableSetOf("en") ) - langFilterList = langFromPrefMedia?.mapNotNull { - fromTwoLettersToLanguage(it)?.lowercase() ?: return@mapNotNull null + viewModel.langFilterList = langFromPrefMedia?.mapNotNull { + fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null } ?: listOf() } } @@ -1346,29 +2210,60 @@ class GeneratorPlayer : FullScreenPlayer() { sync.updateUserData() - preferredAutoSelectSubtitles = getAutoSelectLanguageISO639_1() + preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() - if (currentSelectedLink == null) { + val selectedLink = currentSelectedLink + if (selectedLink == null) { viewModel.loadLinks() + } else { + // Recreated view, so we need to recreate the + loadLink(selectedLink, true) } - binding?.overlayLoadingSkipButton?.setOnClickListener { - startPlayer() + binding.overlayLoadingSkipButton.setOnClickListener { + // Mark as "success" early + viewModel.modifyState { + copy(loading = Resource.Success(Unit)) + } } - binding?.playerLoadingGoBack?.setOnClickListener { - player.release() - activity?.popCurrentPage() + binding.playerLoadingGoBack.setOnClickListener { + exitPlayer() } - observe(viewModel.currentStamps) { stamps -> + playerBinding?.downloadHeader?.setOnClickListener { + it?.isVisible = false + } + + playerBinding?.downloadHeaderToggle?.setOnClickListener { + playerBinding?.downloadHeader?.let { + it.isVisible = !it.isVisible + } + } + + observe(viewModel.currentStamps) { (stamps, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe player.addTimeStamps(stamps) } - observe(viewModel.loadingLinks) { - when (it) { + observe(viewModel.currentSubtitles) { (subtitles, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe + player.setActiveSubtitles(subtitles) + + // If the file is downloaded then do not select auto select the subtitles + // Downloaded subtitles cannot be selected immediately after loading since + // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles + // Resulting in unselecting the downloaded subtitle + if (subtitles.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { + autoSelectSubtitles() + } + } + observe(viewModel.loadingLinks) { (loading, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe + + when (loading) { is Resource.Loading -> { - startLoading() + releasePlayer() } is Resource.Success -> { @@ -1380,21 +2275,31 @@ class GeneratorPlayer : FullScreenPlayer() { } is Resource.Failure -> { - showToast(it.errorString, Toast.LENGTH_LONG) + showToast(loading.errorString, Toast.LENGTH_LONG) startPlayer() } } } - observe(viewModel.currentLinks) { - currentLinks = it - val turnVisible = it.isNotEmpty() - val wasGone = binding?.overlayLoadingSkipButton?.isGone == true - binding?.overlayLoadingSkipButton?.isVisible = turnVisible + observe(viewModel.currentLinks) { (links, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe - normalSafeApiCall { - if (currentLinks.any { link -> - getLinkPriority(currentQualityProfile, link) >= + val turnVisible = links.isNotEmpty() && viewModel.generator?.canSkipLoading == true + val wasGone = binding.overlayLoadingSkipButton.isGone + + binding.overlayLoadingSkipButton.apply { + isVisible = turnVisible + if (links.isEmpty()) { + setText(R.string.skip_loading) + } else { + @SuppressLint("SetTextI18n") + text = "${context.getString(R.string.skip_loading)} (${links.size})" + } + } + + safe { + if (!isPlayerActive.get() && viewModel.state.links.any { link -> + getLinkPriority(currentQualityProfile, link.first) >= QualityDataHelper.AUTO_SKIP_PRIORITY } ) { @@ -1403,34 +2308,15 @@ class GeneratorPlayer : FullScreenPlayer() { } if (turnVisible && wasGone) { - binding?.overlayLoadingSkipButton?.requestFocus() - } - } - - observe(viewModel.currentSubs) { set -> - val setOfSub = mutableSetOf() - if (langFilterList.isNotEmpty() && filterSubByLang) { - Log.i("subfilter", "Filtering subtitle") - langFilterList.forEach { lang -> - Log.i("subfilter", "Lang: $lang") - setOfSub += set.filter { - it.name.contains(lang, ignoreCase = true) || - it.origin != SubtitleOrigin.URL - } - } - currentSubs = setOfSub - } else { - currentSubs = set - } - player.setActiveSubtitles(set) - - // If the file is downloaded then do not select auto select the subtitles - // Downloaded subtitles cannot be selected immediately after loading since - // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles - // Resulting in unselecting the downloaded subtitle - if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { - autoSelectSubtitles() + binding.overlayLoadingSkipButton.requestFocus() } } } } + +@Suppress("DEPRECATION") +inline fun Bundle.getSafeSerializable(key: String): T? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable( + key, + T::class.java + ) 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 a1287e6aa..3ab46ce21 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt @@ -1,27 +1,51 @@ package com.lagradost.cloudstream3.ui.player import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.ExtractorLinkType -interface IGenerator { - val hasCache: Boolean +val LOADTYPE_INAPP = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8, + ExtractorLinkType.TORRENT, + ExtractorLinkType.MAGNET +) - fun hasNext(): Boolean - fun hasPrev(): Boolean - fun next() - fun prev() - fun goto(index: Int) +val LOADTYPE_INAPP_DOWNLOAD = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.M3U8 +) - 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 +val LOADTYPE_CHROMECAST = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 +) - /* not safe, must use try catch */ - suspend fun generateLinks( +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 +} + +abstract class VideoGenerator(val videos: List) { + abstract val hasCache: Boolean + abstract val canSkipLoading: Boolean + abstract fun getId(index : Int) : Int? + + fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex + fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 + + @Throws + abstract suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset : Int = 0, + offset: Int, + isCasting: Boolean ): Boolean -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 3038cb8d2..034237266 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 @@ -1,32 +1,13 @@ 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.ExtractorUri - -enum class PlayerEventType(val value: Int) { - //Stop(-1), - 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), -} +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp enum class CSPlayerEvent(val value: Int) { Pause(0), @@ -39,15 +20,140 @@ enum class CSPlayerEvent(val value: Int) { PrevEpisode(6), PlayPauseToggle(7), ToggleMute(8), + Restart(9), + PlayAsAudio(10), } enum class CSPlayerLoading { IsPaused, IsPlaying, IsBuffering, - //IsDone, + IsEnded, } +enum class PlayerEventSource { + /** This event was invoked from the user pressing some button or selecting something */ + UI, + + /** This event was invoked automatically */ + Player, + + /** This event was invoked from a external sync tool like WatchTogether */ + Sync, +} + +abstract class PlayerEvent { + abstract val source: PlayerEventSource +} + +/** this is used to update UI based of the current time, + * using requestedListeningPercentages as well as saving time */ +data class PositionEvent( + override val source: PlayerEventSource, + val fromMs: Long, + val toMs: Long, + /** duration of the entire video */ + val durationMs: Long, +) : PlayerEvent() { + /** how many ms (+-) we have skipped */ + val seekMs : Long get() = toMs - fromMs +} + +/** player error when rendering or misc, used to display toast or log */ +data class ErrorEvent( + val error: Throwable, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** Event when timestamps appear, null when it should disappear */ +data class TimestampInvokedEvent( + val timestamp: VideoSkipStamp, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */ +data class TimestampSkippedEvent( + val timestamp: VideoSkipStamp, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** this is used by the player to load the next or prev episode */ +data class EpisodeSeekEvent( + /** -1 = prev, 1 = next, will never be 0, atm the user cant seek more than +-1 */ + val offset: Int, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() { + init { + assert(offset != 0) + } +} + +/** Event when the video is resized aka changed resolution or mirror */ +data class ResizedEvent( + val height: Int, + val width: Int, + override val source: PlayerEventSource = PlayerEventSource.Player, +) : PlayerEvent() + +/** Event when the player status update, along with the previous status (for animation)*/ +data class StatusEvent( + val wasPlaying: CSPlayerLoading, + val isPlaying: CSPlayerLoading, + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event when tracks are changed, used for UI changes */ +data class TracksChangedEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event from player to give all embedded subtitles */ +data class EmbeddedSubtitlesFetchedEvent( + val tracks: List, + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** on attach player to view */ +data class PlayerAttachedEvent( + val player: Any?, + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event from player to inform that subtitles have updated in some way */ +data class SubtitlesUpdatedEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** current player starts, asking for all other programs to shut the fuck up */ +data class RequestAudioFocusEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Pause event, separate from StatusEvent */ +data class PauseEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Play event, separate from StatusEvent */ +data class PlayEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Event when the player video has ended, up to the settings on what to do when that happens */ +data class VideoEndedEvent( + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + +/** Used for torrent to pre-download a video before playing it */ +data class DownloadEvent( + val downloadedBytes: Long, + val totalBytes: Long, + /** bytes / sec */ + val downloadSpeed: Long, + val connections: Int?, + + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() interface Track { /** @@ -56,48 +162,48 @@ interface Track { **/ val id: String? val label: String? - - // val isCurrentlyPlaying: Boolean val language: String? + val sampleMimeType : String? } data class VideoTrack( override val id: String?, override val label: String?, -// override val isCurrentlyPlaying: Boolean, 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 isCurrentlyPlaying: Boolean, 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?, +) : Track + + data class CurrentTracks( val currentVideoTrack: VideoTrack?, val currentAudioTrack: AudioTrack?, + val currentTextTracks: List, val allVideoTracks: List, val allAudioTracks: List, + val allTextTracks: List, ) -class InvalidFileException(msg: String) : Exception(msg) - //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 -const val STATE_RESUME_WINDOW = "resumeWindow" -const val STATE_RESUME_POSITION = "resumePosition" -const val STATE_PLAYER_FULLSCREEN = "playerFullscreen" -const val STATE_PLAYER_PLAYING = "playerOnPlay" const val ACTION_MEDIA_CONTROL = "media_control" const val EXTRA_CONTROL_TYPE = "control_type" -const val PLAYBACK_SPEED = "playback_speed" -const val RESIZE_MODE_KEY = "resize_mode" // Last used resize mode -const val PLAYBACK_SPEED_KEY = "playback_speed" // Last used playback speed -const val PREFERRED_SUBS_KEY = "preferred_subtitles" // Last used resize mode -//const val PLAYBACK_FASTFORWARD = "playback_fastforward" // Last used resize mode /** Abstract Exoplayer logic, can be expanded to other players */ interface IPlayer { @@ -105,30 +211,22 @@ interface IPlayer { fun setPlaybackSpeed(speed: Float) fun getIsPlaying(): Boolean + /** Current player duration in milliseconds */ fun getDuration(): Long? + /** Current player position in milliseconds */ fun getPosition(): Long? - fun seekTime(time: Long) - fun seekTo(time: Long) + fun seekTime(time: Long, source: PlayerEventSource = PlayerEventSource.UI) + fun seekTo(time: Long, source: PlayerEventSource = PlayerEventSource.UI) fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms + @AnyThread fun initCallbacks( - playerUpdated: (Any?) -> Unit, // attach player to view - updateIsPlaying: ((Pair) -> Unit)? = null, // (wasPlaying, isPlaying) - requestAutoFocus: (() -> Unit)? = null, // current player starts, asking for all other programs to shut the fuck up - playerError: ((Exception) -> Unit)? = null, // player error when rendering or misc, used to display toast or log - playerDimensionsLoaded: ((Pair) -> Unit)? = null, // (with, height), for UI - requestedListeningPercentages: List? = null, // this is used to request when the player should report back view percentage - playerPositionChanged: ((Pair) -> Unit)? = null,// (position, duration) this is used to update UI based of the current time - nextEpisode: (() -> Unit)? = null, // this is used by the player to load the next episode - prevEpisode: (() -> Unit)? = null, // this is used by the player to load the previous episode - subtitlesUpdates: (() -> Unit)? = null, // callback from player to inform that subtitles have updated in some way - embeddedSubtitlesFetched: ((List) -> Unit)? = null, // callback from player to give all embedded subtitles - onTracksInfoChanged: (() -> Unit)? = null, // Callback when tracks are changed, used for UI changes - onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null, // Callback when timestamps appear, null when it should disappear - onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null, // callback for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) + @MainThread eventHandler: ((PlayerEvent) -> Unit), + /** this is used to request when the player should report back view percentage */ + requestedListeningPercentages: List? = null, ) fun releaseCallbacks() @@ -136,7 +234,7 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() - fun addTimeStamps(timeStamps: List) + fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, @@ -146,16 +244,20 @@ interface IPlayer { startPosition: Long? = null, subtitles: Set, subtitle: SubtitleData?, - autoPlay: Boolean? = true + autoPlay: Boolean? = true, + preview : Boolean = true, ) fun reloadPlayer(context: Context) + fun getPreview(fraction : Float) : Bitmap? + fun hasPreview() : Boolean + fun setActiveSubtitles(subtitles: Set) fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing fun getCurrentPreferredSubtitle(): SubtitleData? - fun handleEvent(event: CSPlayerEvent) + fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI) fun onStop() fun onPause() @@ -185,5 +287,8 @@ interface IPlayer { fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null) /** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */ - fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null) -} \ No newline at end of file + fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, formatIndex: Int? = null) + + /** Get the current subtitle cues, for use with syncing */ + fun getSubtitleCues(): List +} 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 0b5608573..db06e26e9 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 @@ -1,9 +1,32 @@ 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.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.utils.* -import java.net.URI +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( + val uri: Uri, + val name: String, + + val basePath: String? = null, + val relativePath: String? = null, + val displayName: String? = null, + + val id: Int? = null, + val parentId: Int? = null, + val episode: Int? = null, + val season: Int? = null, + val headerName: String? = null, + val tvType: TvType? = null, +) /** * Used to open the player more easily with the LinkGenerator @@ -12,49 +35,23 @@ data class BasicLink( val url: String, val name: String? = null, ) + class LinkGenerator( private val links: List, private val extract: Boolean = true, - private val referer: String? = null, - private val isM3u8: Boolean? = null -) : IGenerator { - override val hasCache = false - - 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() {} - + private val refererUrl: String? = null, + id: Int? +) : NoVideoGenerator(id) { override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int + offset: Int, + isCasting: Boolean ): Boolean { links.amap { link -> - if (!extract || !loadExtractor(link.url, referer, { + if (!extract || !loadExtractor(link.url, refererUrl, { subtitleCallback(PlayerSubtitleHelper.getSubtitleData(it)) }) { callback(it to null) @@ -62,19 +59,43 @@ class LinkGenerator( // if don't extract or if no extractor found simply return the link callback( - ExtractorLink( + newExtractorLink( "", link.name ?: link.url, unshortenLinkSafe(link.url), // unshorten because it might be a raw link - referer ?: "", - Qualities.Unknown.value, isM3u8 ?: normalSafeApiCall { - URI(link.url).path?.substringAfterLast(".")?.contains("m3u") - } ?: false - ) to null + type = INFER_TYPE, + ) { + this.referer = refererUrl ?: "" + this.quality = Qualities.Unknown.value + } 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/NonFinalTextRenderer.java b/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java deleted file mode 100644 index b5ecfe8fa..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java +++ /dev/null @@ -1,455 +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. - */ -package com.lagradost.cloudstream3.ui.player; - -import static androidx.media3.common.text.Cue.DIMEN_UNSET; -import static androidx.media3.common.text.Cue.LINE_TYPE_NUMBER; -import static androidx.media3.common.util.Assertions.checkNotNull; -import static androidx.media3.common.util.Assertions.checkState; -import static java.lang.annotation.ElementType.TYPE_USE; - -import android.os.Handler; -import android.os.Handler.Callback; -import android.os.Looper; -import android.os.Message; - -import androidx.annotation.IntDef; -import androidx.annotation.Nullable; -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.text.Cue; -import androidx.media3.common.text.CueGroup; -import androidx.media3.common.MimeTypes; -import androidx.media3.common.util.Log; -import androidx.media3.common.util.Util; -import androidx.media3.exoplayer.BaseRenderer; -import androidx.media3.exoplayer.FormatHolder; -import androidx.media3.exoplayer.RendererCapabilities; -import androidx.media3.exoplayer.source.SampleStream; -import androidx.media3.exoplayer.text.SubtitleDecoderFactory; -import androidx.media3.exoplayer.text.TextOutput; -import androidx.media3.extractor.text.Subtitle; -import androidx.media3.extractor.text.SubtitleDecoder; -import androidx.media3.extractor.text.SubtitleDecoderException; -import androidx.media3.extractor.text.SubtitleInputBuffer; -import androidx.media3.extractor.text.SubtitleOutputBuffer; - -import java.lang.annotation.Documented; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; -// DO NOT CONVERT TO KOTLIN AUTOMATICALLY, IT FUCKS UP AND DOES NOT DISPLAY SUBS FOR SOME REASON -// IF YOU CHANGE THE CODE MAKE SURE YOU GET THE CUES CORRECT! - -/** - * A renderer for text. - * - *

{@link Subtitle}s are decoded from sample data using {@link SubtitleDecoder} instances - * obtained from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s - * is delegated to a {@link TextOutput}. - */ -public class NonFinalTextRenderer extends BaseRenderer implements Callback { - - private static final String TAG = "TextRenderer"; - - /** - * @param trackType The track type that the renderer handles. One of the {@link C} {@code - * TRACK_TYPE_*} constants. - * @param outputHandler - */ - public NonFinalTextRenderer(int trackType, @Nullable Handler outputHandler) { - super(trackType); - this.outputHandler = outputHandler; - } - - @Documented - @Retention(RetentionPolicy.SOURCE) - @Target(TYPE_USE) - @IntDef({ - REPLACEMENT_STATE_NONE, - REPLACEMENT_STATE_SIGNAL_END_OF_STREAM, - REPLACEMENT_STATE_WAIT_END_OF_STREAM - }) - private @interface ReplacementState { - } - - /** - * The decoder does not need to be replaced. - */ - private static final int REPLACEMENT_STATE_NONE = 0; - /** - * The decoder needs to be replaced, but we haven't yet signaled an end of stream to the existing - * decoder. We need to do so in order to ensure that it outputs any remaining buffers before we - * release it. - */ - private static final int REPLACEMENT_STATE_SIGNAL_END_OF_STREAM = 1; - /** - * The decoder needs to be replaced, and we've signaled an end of stream to the existing decoder. - * We're waiting for the decoder to output an end of stream signal to indicate that it has output - * any remaining buffers before we release it. - */ - private static final int REPLACEMENT_STATE_WAIT_END_OF_STREAM = 2; - - private static final int MSG_UPDATE_OUTPUT = 0; - - @Nullable - private final Handler outputHandler; - private TextOutput output = null; - private SubtitleDecoderFactory decoderFactory = null; - private FormatHolder formatHolder = null; - - private boolean inputStreamEnded; - private boolean outputStreamEnded; - private boolean waitingForKeyFrame; - private @ReplacementState int decoderReplacementState; - @Nullable - private Format streamFormat; - @Nullable - private SubtitleDecoder decoder; - @Nullable - private SubtitleInputBuffer nextInputBuffer; - @Nullable - private SubtitleOutputBuffer subtitle; - @Nullable - private SubtitleOutputBuffer nextSubtitle; - private int nextSubtitleEventIndex; - private long finalStreamEndPositionUs; - - /** - * @param output The output. - * @param outputLooper The looper associated with the thread on which the output should be called. - * If the output makes use of standard Android UI components, then this should normally be the - * looper associated with the application's main thread, which can be obtained using {@link - * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called - * directly on the player's internal rendering thread. - */ - public NonFinalTextRenderer(TextOutput output, @Nullable Looper outputLooper) { - this(output, outputLooper, SubtitleDecoderFactory.DEFAULT); - } - - /** - * @param output The output. - * @param outputLooper The looper associated with the thread on which the output should be called. - * If the output makes use of standard Android UI components, then this should normally be the - * looper associated with the application's main thread, which can be obtained using {@link - * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called - * directly on the player's internal rendering thread. - * @param decoderFactory A factory from which to obtain {@link SubtitleDecoder} instances. - */ - public NonFinalTextRenderer( - TextOutput output, @Nullable Looper outputLooper, SubtitleDecoderFactory decoderFactory) { - super(C.TRACK_TYPE_TEXT); - this.output = checkNotNull(output); - this.outputHandler = - outputLooper == null ? null : Util.createHandler(outputLooper, /* callback= */ this); - this.decoderFactory = decoderFactory; - formatHolder = new FormatHolder(); - finalStreamEndPositionUs = C.TIME_UNSET; - } - - @Override - public String getName() { - return TAG; - } - - @Override - public @Capabilities int supportsFormat(Format format) { - if (decoderFactory.supportsFormat(format)) { - return RendererCapabilities.create( - format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM); - } else if (MimeTypes.isText(format.sampleMimeType)) { - return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_SUBTYPE); - } else { - return RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE); - } - } - - /** - * Sets the position at which to stop rendering the current stream. - * - *

Must be called after {@link #setCurrentStreamFinal()}. - * - * @param streamEndPositionUs The position to stop rendering at or {@link C#LENGTH_UNSET} to - * render until the end of the current stream. - */ - // TODO(internal b/181312195): Remove this when it's no longer needed once subtitles are decoded - // on the loading side of SampleQueue. - public void setFinalStreamEndPositionUs(long streamEndPositionUs) { - checkState(isCurrentStreamFinal()); - this.finalStreamEndPositionUs = streamEndPositionUs; - } - - @Override - protected void onStreamChanged(Format[] formats, long startPositionUs, long offsetUs) { - streamFormat = formats[0]; - if (decoder != null) { - decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM; - } else { - initDecoder(); - } - } - - @Override - protected void onPositionReset(long positionUs, boolean joining) { - clearOutput(); - inputStreamEnded = false; - outputStreamEnded = false; - finalStreamEndPositionUs = C.TIME_UNSET; - if (decoderReplacementState != REPLACEMENT_STATE_NONE) { - replaceDecoder(); - } else { - releaseBuffers(); - checkNotNull(decoder).flush(); - } - } - - @Override - public void render(long positionUs, long elapsedRealtimeUs) { - if (isCurrentStreamFinal() - && finalStreamEndPositionUs != C.TIME_UNSET - && positionUs >= finalStreamEndPositionUs) { - releaseBuffers(); - outputStreamEnded = true; - } - - if (outputStreamEnded) { - return; - } - - if (nextSubtitle == null) { - checkNotNull(decoder).setPositionUs(positionUs); - try { - nextSubtitle = checkNotNull(decoder).dequeueOutputBuffer(); - } catch (SubtitleDecoderException e) { - handleDecoderError(e); - return; - } - } - - if (getState() != STATE_STARTED) { - return; - } - - boolean textRendererNeedsUpdate = false; - if (subtitle != null) { - // We're iterating through the events in a subtitle. Set textRendererNeedsUpdate if we - // advance to the next event. - long subtitleNextEventTimeUs = getNextEventTime(); - while (subtitleNextEventTimeUs <= positionUs) { - nextSubtitleEventIndex++; - subtitleNextEventTimeUs = getNextEventTime(); - textRendererNeedsUpdate = true; - } - } - if (nextSubtitle != null) { - SubtitleOutputBuffer nextSubtitle = this.nextSubtitle; - if (nextSubtitle.isEndOfStream()) { - if (!textRendererNeedsUpdate && getNextEventTime() == Long.MAX_VALUE) { - if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) { - replaceDecoder(); - } else { - releaseBuffers(); - outputStreamEnded = true; - } - } - } else if (nextSubtitle.timeUs <= positionUs) { - // Advance to the next subtitle. Sync the next event index and trigger an update. - if (subtitle != null) { - subtitle.release(); - } - nextSubtitleEventIndex = nextSubtitle.getNextEventTimeIndex(positionUs); - subtitle = nextSubtitle; - this.nextSubtitle = null; - textRendererNeedsUpdate = true; - } - } - - if (textRendererNeedsUpdate) { - // If textRendererNeedsUpdate then subtitle must be non-null. - checkNotNull(subtitle); - // textRendererNeedsUpdate is set and we're playing. Update the renderer. - updateOutput(subtitle.getCues(positionUs)); - } - - if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) { - return; - } - - try { - while (!inputStreamEnded) { - @Nullable SubtitleInputBuffer nextInputBuffer = this.nextInputBuffer; - if (nextInputBuffer == null) { - nextInputBuffer = checkNotNull(decoder).dequeueInputBuffer(); - if (nextInputBuffer == null) { - return; - } - this.nextInputBuffer = nextInputBuffer; - } - if (decoderReplacementState == REPLACEMENT_STATE_SIGNAL_END_OF_STREAM) { - nextInputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); - checkNotNull(decoder).queueInputBuffer(nextInputBuffer); - this.nextInputBuffer = null; - decoderReplacementState = REPLACEMENT_STATE_WAIT_END_OF_STREAM; - return; - } - // Try and read the next subtitle from the source. - @SampleStream.ReadDataResult int result = readSource(formatHolder, nextInputBuffer, /* readFlags= */ 0); - if (result == C.RESULT_BUFFER_READ) { - if (nextInputBuffer.isEndOfStream()) { - inputStreamEnded = true; - waitingForKeyFrame = false; - } else { - @Nullable Format format = formatHolder.format; - if (format == null) { - // We haven't received a format yet. - return; - } - nextInputBuffer.subsampleOffsetUs = format.subsampleOffsetUs; - nextInputBuffer.flip(); - waitingForKeyFrame &= !nextInputBuffer.isKeyFrame(); - } - if (!waitingForKeyFrame) { - checkNotNull(decoder).queueInputBuffer(nextInputBuffer); - this.nextInputBuffer = null; - } - } else if (result == C.RESULT_NOTHING_READ) { - return; - } - } - } catch (SubtitleDecoderException e) { - handleDecoderError(e); - } - } - - @Override - protected void onDisabled() { - streamFormat = null; - finalStreamEndPositionUs = C.TIME_UNSET; - clearOutput(); - releaseDecoder(); - } - - @Override - public boolean isEnded() { - return outputStreamEnded; - } - - @Override - public boolean isReady() { - // Don't block playback whilst subtitles are loading. - // Note: To change this behavior, it will be necessary to consider [Internal: b/12949941]. - return true; - } - - private void releaseBuffers() { - nextInputBuffer = null; - nextSubtitleEventIndex = C.INDEX_UNSET; - if (subtitle != null) { - subtitle.release(); - subtitle = null; - } - if (nextSubtitle != null) { - nextSubtitle.release(); - nextSubtitle = null; - } - } - - private void releaseDecoder() { - releaseBuffers(); - checkNotNull(decoder).release(); - decoder = null; - decoderReplacementState = REPLACEMENT_STATE_NONE; - } - - private void initDecoder() { - waitingForKeyFrame = true; - decoder = decoderFactory.createDecoder(checkNotNull(streamFormat)); - } - - private void replaceDecoder() { - releaseDecoder(); - initDecoder(); - } - - private long getNextEventTime() { - if (nextSubtitleEventIndex == C.INDEX_UNSET) { - return Long.MAX_VALUE; - } - checkNotNull(subtitle); - return nextSubtitleEventIndex >= subtitle.getEventTimeCount() - ? Long.MAX_VALUE - : subtitle.getEventTime(nextSubtitleEventIndex); - } - - private void updateOutput(List cues) { - if (outputHandler != null) { - outputHandler.obtainMessage(MSG_UPDATE_OUTPUT, cues).sendToTarget(); - } else { - invokeUpdateOutputInternal(cues); - } - } - - private void clearOutput() { - updateOutput(Collections.emptyList()); - } - - @SuppressWarnings("unchecked") - @Override - public boolean handleMessage(Message msg) { - switch (msg.what) { - case MSG_UPDATE_OUTPUT: - invokeUpdateOutputInternal((List) msg.obj); - return true; - default: - throw new IllegalStateException(); - } - } - - private void invokeUpdateOutputInternal(List cues) { - // See https://github.com/google/ExoPlayer/issues/7934 - // SubripDecoder texts tend to be DIMEN_UNSET which pushes up the - // subs unlike WEBVTT which creates an inconsistency - - List fixedCues = cues.stream().map( - cue -> { - Cue.Builder builder = cue.buildUpon(); - - if (cue.line == DIMEN_UNSET) - builder.setLine(-1f, LINE_TYPE_NUMBER); - - return builder.setSize(DIMEN_UNSET).build(); - } - ).collect(Collectors.toList()); - - output.onCues(fixedCues); - output.onCues(new CueGroup(fixedCues, 0L)); - } - - /** - * Called when {@link #decoder} throws an exception, so it can be logged and playback can - * continue. - * - *

Logs {@code e} and resets state to allow decoding the next sample. - */ - private void handleDecoderError(SubtitleDecoderException e) { - Log.e(TAG, "Subtitle decoding failed. streamFormat=" + streamFormat, e); - clearOutput(); - replaceDecoder(); - } -} 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 new file mode 100644 index 000000000..dcf976612 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt @@ -0,0 +1,95 @@ +package com.lagradost.cloudstream3.ui.player + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.core.content.ContextCompat.getString +import androidx.navigation.NavOptions +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.safefile.SafeFile + +object OfflinePlaybackHelper { + /** + * Pop any existing player off the nav back stack before pushing the new one, + * keeping the stack flat (at most one player at a time). This prevents an + * OOM when many files are opened in sequence via DownloadedPlayerActivity. + */ + private val replacePlayerNavOptions = NavOptions.Builder() + .setPopUpTo(R.id.navigation_player, inclusive = true, saveState = false) + .build() + + fun playLink(activity: Activity, url: String) { + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + LinkGenerator( + listOf( + BasicLink(url) + ), id = url.hashCode() + ), 0 + ), + replacePlayerNavOptions + ) + } + + // See CloudStreamPackage + fun playIntent(activity: Activity, intent: Intent?): Boolean { + if (intent == null) return false + val links = intent.getStringArrayExtra(CloudStreamPackage.LINKS_EXTRA) + ?.mapNotNull { tryParseJson(it) } ?: emptyList() + if (links.isEmpty()) return false + val subs = intent.getStringArrayExtra(CloudStreamPackage.SUBTITLE_EXTRA) + ?.mapNotNull { tryParseJson(it) } ?: emptyList() + + val id = intent.getIntExtra(CloudStreamPackage.ID_EXTRA, -1) + //val title = intent.getStringExtra(CloudStreamPackage.TITLE_EXTRA) // unused + val pos = intent.getLongExtra(CloudStreamPackage.POSITION_EXTRA, -1L) + val dur = intent.getLongExtra(CloudStreamPackage.DURATION_EXTRA, -1L) + + if (id != -1 && pos != -1L) { + val duration = if (dur != -1L) { + dur + } else DataStoreHelper.getViewPos(id)?.duration ?: pos + DataStoreHelper.setViewPos(id, pos, duration) + } + + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + MinimalLinkGenerator( + links, + subs, + if (id != -1) id else null, + ), 0 + ), + replacePlayerNavOptions + ) + return true + } + + fun playUri(activity: Activity, uri: Uri) { + if (uri.scheme == "magnet") { + playLink(activity, uri.toString()) + return + } + val name = SafeFile.fromUri(activity, uri)?.name() + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + DownloadFileGenerator( + listOf( + ExtractorUri( + uri = uri, + 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() + ) + ) + ), 0 + ), + replacePlayerNavOptions + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt new file mode 100644 index 000000000..f011ef37b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt @@ -0,0 +1,10 @@ +package com.lagradost.cloudstream3.ui.player + +import android.text.TextPaint +import android.text.style.CharacterStyle +import androidx.annotation.Px + +// source: https://github.com/androidx/media/pull/1840 +class OutlineSpan(@Px val outlineWidth : Float) : CharacterStyle() { + override fun updateDrawState(tp: TextPaint?) { tp?.strokeWidth = outlineWidth } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index 1b13b5196..e3c390d50 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 @@ -5,184 +5,395 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.videoskip.SkipAPI +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.jetbrains.annotations.Contract +import java.util.concurrent.ConcurrentHashMap + +typealias VideoLink = Pair + +data class GeneratorState( + val meta: Any?, + val nextMeta: Any?, + val allMeta: List<*>?, + val response: LoadResponse?, + val index: Int, + val id: Int?, +) + +/** Immutable state of all current links relevant to displaying the video */ +// @MustUseReturnValues +// @Immutable +data class VideoState( + val subtitles: PersistentSet = persistentSetOf(), + val links: PersistentSet = persistentSetOf(), + val stamps: PersistentList = persistentListOf(), + val loading: Resource = Resource.Loading(), + val generatorState: GeneratorState? = null, + val instance: Int, +) { + /** + * This acts as a local cache for sorted links that are not copied over by the copy constructor. + * + * sortedBy is not exactly expensive, but each hasNextMirror does it again, so this alleviates unnecessary recomputation + * */ + private val sortedLinks: ConcurrentHashMap> = ConcurrentHashMap() + + fun clearSortedLinksCache() = sortedLinks.clear() + + // Modifying sortedLinks is not considered a "visible" side effect, and rerunning it does not change the result + // It is by all standards, idempotent and by extension also pure as it has no "visible" side effect + /** Returns .links in the sorted order according to the qualityProfile. + * Use .links if order is not needed */ + @Contract(pure = true) + fun sortLinks(qualityProfile: Int): List { + return sortedLinks[qualityProfile] ?: links.sortedBy { link -> + // negative because we want to sort highest quality first + -getLinkPriority(qualityProfile, link.first) + }.also { value -> sortedLinks[qualityProfile] = value } + } + + @Contract(pure = true) + fun add(item: SubtitleData): VideoState = copy(subtitles = subtitles.add(item)) + + @Contract(pure = true) + fun add(item: VideoLink): VideoState = copy(links = links.add(item)) + + @Contract(pure = true) + fun add(item: VideoSkipStamp): VideoState = copy(stamps = stamps.add(item)) + + @JvmName("addSubtitleData") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(subtitles = subtitles.addAll(items)) + + @JvmName("addVideoLink") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(links = links.addAll(items)) + + @JvmName("addVideoSkipStamp") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(stamps = stamps.addAll(items)) + + @Contract(pure = true) + fun set(item: SubtitleData): VideoState = copy(subtitles = persistentSetOf(item)) + + @Contract(pure = true) + fun set(item: VideoLink): VideoState = copy(links = persistentSetOf(item)) + + @Contract(pure = true) + fun set(item: VideoSkipStamp): VideoState = copy(stamps = persistentListOf(item)) + + @JvmName("setSubtitleData") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(subtitles = items.toPersistentSet()) + + @JvmName("setVideoLink") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(links = items.toPersistentSet()) + + @JvmName("setVideoSkipStamp") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(stamps = items.toPersistentList()) +} + +data class VideoLive( + val value: T, + val instance: Int, +) class PlayerGeneratorViewModel : ViewModel() { companion object { - val TAG = "PlayViewGen" + const val TAG = "PlayViewGen" } - private var generator: IGenerator? = null + @Volatile + var generator: VideoGenerator<*>? = null - private val _currentLinks = MutableLiveData>>(setOf()) - val currentLinks: LiveData>> = _currentLinks + @Volatile + var episodeIndex: Int = 0 - private val _currentSubs = MutableLiveData>(setOf()) - val currentSubs: LiveData> = _currentSubs + /** + * The state of the video player, only modify it by modifyState to make sure observe is called, + * and avoid concurrency issues. + * + * This value can be used without Synchronized or locking when reading, as all fields are immutable. + * */ + @Volatile + var state = VideoState(instance = 0) + private set - private val _loadingLinks = MutableLiveData>() - val loadingLinks: LiveData> = _loadingLinks + private val _currentLinks = + MutableLiveData>>>(null) + val currentLinks: LiveData>>> = _currentLinks - private val _currentStamps = MutableLiveData>(emptyList()) - val currentStamps: LiveData> = _currentStamps + 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 _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear + /** + * Save the Episode ID to prevent starting multiple link loading Jobs when preloading links. + */ + private var currentLoadingEpisodeId: Int? = null + + var forceClearCache = false + fun setSubtitleYear(year: Int?) { _currentSubtitleYear.postValue(year) } - fun getId(): Int? { - return generator?.getCurrentId() - } - - fun loadLinks(episode: Int) { - generator?.goto(episode) - loadLinks() - } - fun loadLinksPrev() { Log.i(TAG, "loadLinksPrev") - if (generator?.hasPrev() == true) { - generator?.prev() + if (generator?.hasPrev(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun loadLinksNext() { Log.i(TAG, "loadLinksNext") - if (generator?.hasNext() == true) { - generator?.next() + if (generator?.hasNext(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun hasNextEpisode(): Boolean? { - return generator?.hasNext() + return generator?.hasNext(episodeIndex) + } + + fun hasPrevEpisode(): Boolean? { + return generator?.hasPrev(episodeIndex) } fun preLoadNextLinks() { + val id = generator?.getId(episodeIndex) + // Do not preload if already loading + if (id == currentLoadingEpisodeId) return + Log.i(TAG, "preLoadNextLinks") currentJob?.cancel() - currentJob = viewModelScope.launchSafe { - if (generator?.hasCache == true && generator?.hasNext() == true) { - safeApiCall { - generator?.generateLinks( - clearCache = false, - isCasting = false, - {}, - {}, - offset = 1 - ) + currentLoadingEpisodeId = id + + currentJob = viewModelScope.launch { + try { + if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) { + safeApiCall { + generator?.generateLinks( + sourceTypes = LOADTYPE_INAPP, + clearCache = false, + isCasting = false, + callback = {}, + subtitleCallback = {}, + offset = episodeIndex + 1 + ) + } + } + } catch (t: Throwable) { + logError(t) + } finally { + if (currentLoadingEpisodeId == id) { + currentLoadingEpisodeId = null } } } } - fun getMeta(): Any? { - return normalSafeApiCall { generator?.getCurrent() } + fun loadThisEpisode(index: Int) { + episodeIndex = index + loadLinks() } - 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 - } + fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) { + Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index") + generator = newGenerator + episodeIndex = index } /** * If duplicate nothing will happen * */ fun addSubtitles(file: Set) { - 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) - } + val validFile = file.filter(::isValidSubtitle) + if (validFile.isNotEmpty()) + modifyState { + add(validFile) + } } private var currentJob: Job? = null private var currentStampJob: Job? = null fun loadStamps(duration: Long) { - //currentStampJob?.cancel() currentStampJob = ioSafe { - val meta = generator?.getCurrent() - val page = (generator as? RepoLinkGenerator?)?.page - if (page != null && meta is ResultEpisode) { - _currentStamps.postValue(listOf()) - _currentStamps.postValue( - EpisodeSkip.getStamps( - page, - meta, - duration, - hasNextEpisode() ?: false - ) - ) + val genState = state.generatorState ?: return@ioSafe + val meta = genState.meta + val page = genState.response + val id = genState.id + if (page == null || meta !is ResultEpisode) { + return@ioSafe + } + val stamps = SkipAPI.videoStamps( + page, + meta, + duration, + hasNextEpisode() ?: false + ) + + /** Avoid adding stamps to the wrong video */ + modifyState { + if (id != this.generatorState?.id) { + this + } else { + set(stamps) + } } } } - fun loadLinks(clearCache: Boolean = false, isCasting: Boolean = false) { - Log.i(TAG, "loadLinks") + 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") currentJob?.cancel() + val index = episodeIndex - 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(clearCache = clearCache, isCasting = isCasting, { - currentLinks.add(it) - // Clone to prevent ConcurrentModificationException - normalSafeApiCall { - // Extra normalSafeApiCall since .toSet() iterates. - _currentLinks.postValue(currentLinks.toSet()) - } - }, { - currentSubs.add(it) - normalSafeApiCall { - _currentSubs.postValue(currentSubs.toSet()) - } - }) - } - - _loadingLinks.postValue(loadingState) - _currentLinks.postValue(currentLinks) - _currentSubs.postValue( - currentSubs.union(_currentSubs.value ?: emptySet()) + // Clear old data and reset the state + modifyState { + VideoState( + loading = Resource.Loading(), + generatorState = generator?.let { gen -> + GeneratorState( + meta = gen.videos.getOrNull(index), + nextMeta = gen.videos.getOrNull(index + 1), + id = gen.getId(index), + response = (gen as? RepoLinkGenerator)?.page, + index = index, + allMeta = gen.videos + ) + }, + instance = instance + 1 ) } + currentJob = viewModelScope.launchSafe { + // Load more data + 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 new file mode 100644 index 000000000..1c7086d12 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -0,0 +1,1220 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Matrix +import android.media.AudioManager +import android.media.audiofx.LoudnessEnhancer +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation +import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight +import com.lagradost.cloudstream3.utils.Vector2 +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.roundToInt + +/** + * Handles all gesture, volume, brightness, speed-up, zoom, and hardware-key-event input for a + * [PlayerView]. Keeps these separate from the player-view setup and lifecycle + * code in [PlayerView] itself. + * + * Instantiated and owned by [PlayerView]; accessed from host fragments via the delegate + * properties [PlayerView] exposes. + */ +@OptIn(UnstableApi::class) +class PlayerGestureHelper(private val playerView: PlayerView) { + + companion object { + /** Swipe-seek constants */ + const val MINIMUM_SEEK_TIME = 7000L + const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height + const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height + const val VERTICAL_MULTIPLIER = 2.0f + const val HORIZONTAL_MULTIPLIER = 2.0f + + /** Double-tap constants */ + /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ + const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L + /** Time window (ms) between taps to count as a double-tap. + * Also determines how long a single-tap is delayed before firing. */ + const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L + /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ + const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 + + /** Zoom constants */ + /** Minimum zoom; allows zooming out past 100% but snaps back. */ + const val MINIMUM_ZOOM = 0.95f + /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ + const val ZOOM_SNAP_SENSITIVITY = 0.07f + /** Maximum zoom to prevent the user from getting lost. */ + const val MAXIMUM_ZOOM = 4.0f + + /** Extracts translation and uniform scale from a matrix with no rotation. */ + fun matrixToTranslationAndScale(matrix: Matrix): Triple { + val points = floatArrayOf(0f, 0f, 1f, 1f) + matrix.mapPoints(points) + val translationX = points[0] + val translationY = points[1] + val scale = points[2] - translationX + return Triple(translationX, translationY, scale) + } + } + + private val context: Context get() = playerView.context + + /** Set true by the host when the player occupies the full screen. + * Controls whether hardware volume-key overrides are active (phones/emulators only). */ + var isFullScreen: Boolean = false + + /** Volume state */ + var currentRequestedVolume: Float = 0.0f + var isVolumeLocked: Boolean = false + var hasShownVolumeToast: Boolean = false + private var loudnessEnhancer: LoudnessEnhancer? = null + private var progressBarLeftHideRunnable: Runnable? = null + + /** Brightness state */ + var currentRequestedBrightness: Float = 1.0f + var currentExtraBrightness: Float = 0.0f + var isBrightnessLocked: Boolean = false + var hasShownBrightnessToast: Boolean = false + /** When true, read/write system brightness via [Settings.System.SCREEN_BRIGHTNESS]. + * Automatically falls back to window-attribute brightness if the permission is missing. */ + var useTrueSystemBrightness: Boolean = true + /** White overlay inflated into exo_content_frame; alpha encodes extra brightness (0–1). */ + var brightnessOverlay: View? = null + private var progressBarRightHideRunnable: Runnable? = null + + /** Gesture settings (read from prefs in initialize) */ + var swipeVerticalEnabled: Boolean = true + var swipeHorizontalEnabled: Boolean = false + var extraBrightnessEnabled: Boolean = false + var speedupEnabled: Boolean = false + + /** Hold / speed-up */ + val holdHandler = Handler(Looper.getMainLooper()) + var hasTriggeredSpeedUp = false + val holdRunnable = Runnable { + playerView.player.setPlaybackSpeed(2.0f) + showOrHideSpeedUp(true) + playerView.callbacks?.onHoldSpeedUp(true) + hasTriggeredSpeedUp = true + } + + enum class TouchAction { Brightness, Volume, Time } + + /** Mirrors the host's lock state; suppresses gesture interactions when true. */ + var isLocked: Boolean = false + + /** Touch tracking */ + var isCurrentTouchValid = false + private set + private var currentTouchStart: Vector2? = null + private var currentTouchLast: Vector2? = null + /** Current in-progress swipe action, null when no swipe is active. */ + var currentTouchAction: TouchAction? = null + /** Action from the previous touch sequence; guards against mis-detected double-taps after swipes. */ + var currentLastTouchAction: TouchAction? = null + /** The time in the player when you first click. */ + private var currentTouchStartPlayerTime: Long? = null + /** The system time when you first click. */ + private var currentTouchStartTime: Long? = null + /** Whether the player UI was visible when the current swipe gesture began. */ + var uiShowingBeforeGesture: Boolean = false + + /** Icons */ + private val brightnessIcons = listOf( + R.drawable.sun_1, R.drawable.sun_2, R.drawable.sun_3, + R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, R.drawable.sun_7, + ) + private val volumeIcons = listOf( + R.drawable.ic_baseline_volume_mute_24, + R.drawable.ic_baseline_volume_down_24, + R.drawable.ic_baseline_volume_up_24, + ) + + /** Double-tap / tap state */ + + /** Whether double-tapping left/right seeks backward/forward. */ + var doubleTapEnabled: Boolean = false + + /** Whether double-tapping the center of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ + var doubleTapPauseEnabled: Boolean = false + + /** Seek distance (ms) for each double-tap seek. Read from prefs in [initialize]. */ + var fastForwardTime: Long = 10_000L + + /** Monotonically-incremented token; cancels any pending single-tap runnable when a double-tap arrives. */ + private var doubleTapToken = 0 + + /** Number of consecutive taps in the current double-tap window. */ + private var tapCount = 0 + + /** System time of the most-recent touch end. Updated by callers at the end of every ACTION_UP. */ + var lastTouchEndTime: Long = 0L + + /** Zoom state */ + + /** Optional view for showing the snap-hint outline during zoom (set by FullScreenPlayer). */ + var videoOutline: View? = null + + /** Current zoom+pan matrix, or null when no zoom is active. */ + var zoomMatrix: Matrix? = null + + /** The matrix the zoom will animate to after the user lifts fingers. */ + var desiredMatrix: Matrix? = null + + /** Running snap-back animation, or null. */ + var matrixAnimation: ValueAnimator? = null + + private var scaleGestureDetector: ScaleGestureDetector? = null + + /** Midpoint of the two-finger pan, null when no pan is active. */ + var lastPan: Vector2? = null + + private var overlayLayoutListener: View.OnLayoutChangeListener? = null + + /** Called from [PlayerView.initialize] after views are bound. */ + fun initialize() { + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) + swipeHorizontalEnabled = sm.getBoolean(context.getString(R.string.swipe_enabled_key), true) + extraBrightnessEnabled = sm.getBoolean(context.getString(R.string.extra_brightness_key), false) + speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) + doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) + doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) + fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L + } catch (_: Exception) { + } + + // Inject the brightness overlay into the ExoPlayer content frame so it sits + // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. + safe { + val pkg = context.packageName + @SuppressLint("DiscouragedApi") + val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) + val contentFrame = playerView.exoPlayerView?.findViewById(contentId) + if (contentFrame != null) { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + brightnessOverlay = LayoutInflater.from(context) + .inflate(R.layout.extra_brightness_overlay, contentFrame, false) + contentFrame.addView(brightnessOverlay) + } + } + + setupTouchGestures() + } + + /** Called from [PlayerView.release]. */ + fun release() { + safe { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + } + brightnessOverlay = null + loudnessEnhancer?.release() + loudnessEnhancer = null + holdHandler.removeCallbacksAndMessages(null) + clearZoomState() + releaseOverlayLayoutListener() + } + + /** Key-event listener */ + + /** + * Registers the basic volume-key listener on [keyEventListener]. + * Called from [PlayerView.initialize] and from the host fragment's onResume. + */ + fun setupKeyEventListener() { + keyEventListener = { (event, _) -> + if (event != null && event.action == KeyEvent.ACTION_DOWN) + handleVolumeKey(event.keyCode) + else false + } + } + + /** Nulls [keyEventListener]. Called from the host fragment's onPause. */ + fun releaseKeyEventListener() { + keyEventListener = null + } + + /** Speed-up */ + + fun showOrHideSpeedUp(show: Boolean) { + playerView.playerSpeedupButton?.let { btn -> + btn.clearAnimation() + btn.alpha = if (show) 0f else 1f + btn.isVisible = show + btn.animate() + .alpha(if (show) 1f else 0f) + .setDuration(200L) + .withEndAction { if (!show) btn.isVisible = false } + .start() + } + } + + /** Volume helpers */ + + /** + * Syncs [currentRequestedVolume] with the current system stream volume. + * + * This is here to make returning to the player less jarring, if we change the volume outside + * the app. Note that this will make it a bit wierd when using loudness in PiP, then returning + * however that is the cost of correctness. + */ + fun verifyVolume() { + ((context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { am -> + val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (cur < max || currentRequestedVolume <= 1.0f) { + currentRequestedVolume = cur.toFloat() / max.toFloat() + loudnessEnhancer?.release() + loudnessEnhancer = null + } + } + } + + /** + * Handles a hardware volume key press. + * Only active on phones/emulators when [isFullScreen] is true. + * + * @return true if the key was consumed (suppresses the system volume UI). + */ + fun handleVolumeKey(keyCode: Int): Boolean { + /** + * Some TVs do not support volume boosting, and overriding + * the volume buttons can be inconvenient for TV users. + * Since boosting volume is mainly useful on phones and emulators, + * we limit this feature to those devices. + */ + if (!isLayout(PHONE or EMULATOR) || !isFullScreen) return false + if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return false + verifyVolume() + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isVolumeLocked = currentRequestedVolume < 1.0f + // +- 5% + handleVolumeAdjustment(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 0.05f else -0.05f, fromButton = true) + return true + } + + fun handleVolumeAdjustment(delta: Float, fromButton: Boolean) { + val am = (context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return + val curStep = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxStep = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + + val cur = currentRequestedVolume + val locked = isVolumeLocked + val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) + val nextStep = (next * maxStep.toFloat()).roundToInt().coerceIn(0, maxStep) + + // Show toast + if (fromButton) { + // For button related request we only show a toast when we exceeded the volume. + if (cur <= 1.0f && next > 1.0f && !hasShownVolumeToast) { + showToast(R.string.volume_exceeded_100) + hasShownVolumeToast = true + } + } else { + val raw = cur + delta + // For swipes, we show toast that we need to swipe again. + if (raw > 1.0 && locked && !hasShownVolumeToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownVolumeToast = true + } + } + + // Set the current volume step. + if (nextStep != curStep) am.setStreamVolume(AudioManager.STREAM_MUSIC, nextStep, 0) + + var hasBoostError = false + // Apply loudness enhancer for volumes > 100%, removes it if less. + if (next > 1.0f) { + val boost = ((next - 1.0f) * 1000).toInt() + val existing = loudnessEnhancer + if (existing != null) { + existing.setTargetGain(boost) + } else { + val sessionId = (playerView.exoPlayerView?.player as? ExoPlayer)?.audioSessionId + if (sessionId != null && sessionId != AudioManager.ERROR) { + try { + loudnessEnhancer = LoudnessEnhancer(sessionId).apply { + setTargetGain(boost); enabled = true + } + } catch (t: Throwable) { logError(t); hasBoostError = true } + } + } + } else { + loudnessEnhancer?.release(); loudnessEnhancer = null + } + + currentRequestedVolume = next + + val leftHolder = playerView.playerProgressbarLeftHolder ?: return + val level1 = playerView.playerProgressbarLeftLevel1 ?: return + val level2 = playerView.playerProgressbarLeftLevel2 ?: return + val icon = playerView.playerProgressbarLeftIcon ?: return + + if (next > 1.0f) { + // Change color to show that LoudnessEnhancer broke + // this is not a real fix, but solves the crash issue. + level2.progressTintList = ColorStateList.valueOf( + ContextCompat.getColor(context, if (hasBoostError) R.color.colorPrimaryRed else R.color.colorPrimaryOrange) + ) + } + // Max is set high to make it smooth. + level1.max = 100_000 + level1.progress = (next * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.max = 100_000 + level2.progress = if (next > 1.0f) ((next - 1.0) * 100_000f).toInt().coerceIn(2_000, 100_000) else 0 + level2.isVisible = next > 1.0f + // Calculate the clamped index for the volume icon based on the requested volume. + val iconIdx = (next * volumeIcons.lastIndex).roundToInt().coerceIn(0, volumeIcons.lastIndex) + icon.setImageResource(volumeIcons[iconIdx]) + + if (!leftHolder.isVisible || leftHolder.alpha < 1f) { + leftHolder.animate().cancel(); leftHolder.alpha = 1f; leftHolder.isVisible = true + } + progressBarLeftHideRunnable?.let { leftHolder.removeCallbacks(it) } + progressBarLeftHideRunnable = Runnable { + leftHolder.animate().cancel() + leftHolder.animate().alpha(0f).setDuration(300).withEndAction { leftHolder.isVisible = false }.start() + } + // Show the progress bar for 1.5 seconds. + leftHolder.postDelayed(progressBarLeftHideRunnable, 1500) + } + + /** Brightness helpers */ + + /** + * Reads from [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun getBrightness(): Float? { + return if (useTrueSystemBrightness) { + try { + Settings.System.getInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) / 255f + } catch (_: Exception) { + // Because true system brightness requires + // permission, this is a lazy way to check + // as it will throw an error if we do not have it. + useTrueSystemBrightness = false + getBrightness() + } + } else { + try { + (context as? Activity)?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } + } catch (e: Exception) { + logError(e) + null + } + } + } + + /** + * Sets [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun setBrightness(brightness: Float) { + if (useTrueSystemBrightness) { + try { + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE, + Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL + ) + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) + ) + } catch (_: Exception) { + useTrueSystemBrightness = false + setBrightness(brightness) + } + } else { + try { + val lp = (context as? Activity)?.window?.attributes ?: return + // Use 0.004f instead of 0: on some devices a value too close to 0 causes the + // system to override with its own brightness, making fine-tuning impossible. + lp.screenBrightness = brightness.coerceIn(0.004f, 1.0f) + (context as? Activity)?.window?.attributes = lp + } catch (e: Exception) { + logError(e) + } + } + } + + fun handleBrightnessAdjustment(verticalAddition: Float) { + val lastBrightness = currentRequestedBrightness + val raw = currentRequestedBrightness + verticalAddition + val next = raw.coerceIn(0.0f, if (extraBrightnessEnabled && !isBrightnessLocked) 2.0f else 1.0f) + + if (extraBrightnessEnabled && isBrightnessLocked && raw > 1.0f && !hasShownBrightnessToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownBrightnessToast = true + } + + currentRequestedBrightness = next + if (lastBrightness != currentRequestedBrightness) setBrightness(currentRequestedBrightness) + + currentExtraBrightness = if (extraBrightnessEnabled && next > 1.0f) min(2.0f, next) - 1.0f else 0.0f + brightnessOverlay?.alpha = currentExtraBrightness + playerView.callbacks?.onBrightnessExtra(currentExtraBrightness) + + val rightHolder = playerView.playerProgressbarRightHolder ?: return + val level1 = playerView.playerProgressbarRightLevel1 ?: return + val level2 = playerView.playerProgressbarRightLevel2 ?: return + val icon = playerView.playerProgressbarRightIcon ?: return + + level1.max = 100_000 + level1.progress = max(2_000, (min(1.0f, next) * 100_000f).toInt()) + + if (extraBrightnessEnabled) { + level2.max = 100_000 + level2.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.isVisible = next > 1.0f + } + + icon.setImageResource( + // Clamp the value in case of extra brightness. + brightnessIcons[min(brightnessIcons.lastIndex, max(0, round(next * brightnessIcons.lastIndex).toInt()))] + ) + + if (!rightHolder.isVisible || rightHolder.alpha < 1f) { + rightHolder.animate().cancel(); rightHolder.alpha = 1f; rightHolder.isVisible = true + } + progressBarRightHideRunnable?.let { rightHolder.removeCallbacks(it) } + progressBarRightHideRunnable = Runnable { + rightHolder.animate().cancel() + rightHolder.animate().alpha(0f).setDuration(300).withEndAction { rightHolder.isVisible = false }.start() + } + rightHolder.postDelayed(progressBarRightHideRunnable, 1500) + } + + /** Zoom helpers */ + + /** + * Returns the current zoom matrix, accounting for RESIZE_MODE_ZOOM which already has + * an implicit zoom applied. + * + * This is different from `zoomMatrix ?: Matrix()` + * because it allows used to start zooming at different resizeModes. + * + * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM + * 100% will make the zoom snap to less zoomed in then you already are. + */ + fun currentZoomMatrix(): Matrix { + val current = zoomMatrix + if (current != null) return current + + val exoView = playerView.exoPlayerView + val videoView = exoView?.videoSurfaceView + + if (exoView == null || videoView == null || + exoView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { + return Matrix() + } + + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f) { + return Matrix() + } + + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = max(initAspect, 1f / initAspect) + return Matrix().apply { postScale(aspect, aspect) } + } + + /** + * Applies [newMatrix] (scale + translation only) to the video surface view. + * + * @param newMatrix The new zoom matrix + * @param animation If this zoom is part of an animation, as then it will not auto zoom after we are done. + */ + fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { + val exoView = playerView.exoPlayerView ?: return + if (!animation) { + matrixAnimation?.cancel() + matrixAnimation = null + } + val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) + + if (exoView.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { + exoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + + val videoView = exoView.videoSurfaceView ?: return + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + // Sanity check + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || scale <= 0.01f) return + + // Calculate the scaled aspect ratio as the view height is not real, check the debugger + // and you will see videoView.height > screen.height. + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = min(initAspect, 1f / initAspect) + val scaledAspect = scale * aspect + + // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight. + val maxTransX = max(0f, videoWidth * scaledAspect - playerWidth) * 0.5f + val maxTransY = max(0f, videoHeight * scaledAspect - playerHeight) * 0.5f + + // Correct the translation to clamp within the viewing area. + val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) + val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) + + // Set the transform to the correct x and y. + newMatrix.postTranslate( + expectedTranslationX - translationX, + expectedTranslationY - translationY + ) + zoomMatrix = newMatrix + + if (!animation) { + // If we are not in an animation, set up the values for the animation. + if ((scaledAspect - 1f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { + // We are within the correct scaling, so center and fit it. + videoOutline?.isVisible = true + val desired = Matrix() + desired.setScale(1f / aspect, 1f / aspect) + desiredMatrix = desired + } else if (scale < 1f) { + // We have zoomed too far, zoom to 100%. + videoOutline?.isVisible = false + desiredMatrix = Matrix() + } else { + // Keep the same scaling after zoom. + videoOutline?.isVisible = false + desiredMatrix = null + } + } + + // Finally set the actual scale + translation. + videoView.scaleX = scaledAspect + videoView.scaleY = scaledAspect + videoView.translationX = expectedTranslationX + videoView.translationY = expectedTranslationY + updateBrightnessOverlayBounds() + } + + /** + * Clears all zoom state and resets the video surface view to 1:1 scale. + * Does NOT change the ExoPlayer resize mode - call [PlayerView.resize] separately. + */ + fun clearZoomState() { + matrixAnimation?.cancel() + matrixAnimation = null + zoomMatrix = null + desiredMatrix = null + scaleGestureDetector = null + lastPan = null + playerView.exoPlayerView?.videoSurfaceView?.apply { + scaleX = 1f + scaleY = 1f + translationX = 0f + translationY = 0f + } + } + + /** + * Resets zoom to fit mode if any zoom is currently active. + * Calls [PlayerView.resize] to update the ExoPlayer resize mode. + */ + fun resetZoomToDefault() { + if (zoomMatrix != null) { + clearZoomState() + playerView.resize(PlayerResize.Fit, false) + } + } + + private fun createScaleGestureDetector(ctx: Context) { + scaleGestureDetector = ScaleGestureDetector( + ctx, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val matrix = currentZoomMatrix() + val (_, _, scale) = matrixToTranslationAndScale(matrix) + // Clamp scale of the zoom, do it here as it is easier than doing it within applyZoomMatrix. + val newScale = (scale * detector.scaleFactor).coerceIn(MINIMUM_ZOOM, MAXIMUM_ZOOM) + // This is how much we should scale it with to prevent infinite scaling. + val actualScaleFactor = newScale / scale + // Scale around the focus point, this is more natural than just zoom. + val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f + val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f + matrix.postScale(actualScaleFactor, actualScaleFactor, pivotX, pivotY) + applyZoomMatrix(matrix, false) + return true + } + } + ) + } + + /** + * Processes a two-finger zoom/pan gesture event. + * Handles scale detection, panning, and the snap-back animation after finger lift. + * + * @param event The motion event (should have pointerCount >= 2 or [lastPan] != null). + * @param ctx Context used to create the [ScaleGestureDetector] on first call. + * @param onFirstPointerDown Called on [MotionEvent.ACTION_POINTER_DOWN] (e.g. hide player UI). + * @param onGestureEnd Called when the gesture ends (e.g. reset caller touch state). + * @return Always true (event consumed). + */ + fun handleZoomPanGesture( + event: MotionEvent, + ctx: Context, + onFirstPointerDown: () -> Unit, + onGestureEnd: () -> Unit + ): Boolean { + if (scaleGestureDetector == null) createScaleGestureDetector(ctx) + scaleGestureDetector?.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + onFirstPointerDown() + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount >= 2) { + val newPan = Vector2( + (event.getX(0) + event.getX(1)) / 2f, + (event.getY(0) + event.getY(1)) / 2f + ) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan + } + } + + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> { + lastPan = null + videoOutline?.isVisible = false + matrixAnimation?.cancel() + matrixAnimation = null + + // Snap to desired matrix after zoom gesture ends + matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 0 + duration = 200 + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + addUpdateListener { anim -> + val v = anim.animatedValue as Float + val vInv = 1f - v + val m = Matrix() + m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) + m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) + applyZoomMatrix(m, true) + } + start() + } + + onGestureEnd() + } + } + return true + } + + /** + * Resizes and repositions [brightnessOverlay] to exactly match the visible video surface, + * accounting for zoom scale and translation. + */ + fun updateBrightnessOverlayBounds() { + val overlay = brightnessOverlay ?: return + val pv = playerView.exoPlayerView ?: return + val video = pv.videoSurfaceView ?: return + + // Compute accurate transformed bounding box of the video view after scale+translation. + val vw = video.width.toFloat() + val vh = video.height.toFloat() + val sx = video.scaleX + val sy = video.scaleY + if (vw <= 0f || vh <= 0f) return + + // Pivot defaults to center if not set. + val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f + val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f + // Use view position (includes translation) as base; avoid double-counting translation. + val tx = video.x + val ty = video.y + + // Transform function for a local point (lx,ly). + fun transform(lx: Float, ly: Float): Pair { + val gx = tx + pivotX + (lx - pivotX) * sx + val gy = ty + pivotY + (ly - pivotY) * sy + return Pair(gx, gy) + } + + val p0 = transform(0f, 0f); val p1 = transform(vw, 0f) + val p2 = transform(0f, vh); val p3 = transform(vw, vh) + + val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) + val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) + val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) + val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) + + val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) + val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) + + val lp = overlay.layoutParams + if (lp == null) { + overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) + } else if (lp.width != newW || lp.height != newH) { + lp.width = newW; lp.height = newH + overlay.layoutParams = lp + } + + overlay.scaleX = 1f; overlay.scaleY = 1f + overlay.x = minX; overlay.y = minY + } + + /** + * Attaches a persistent layout-change listener to the ExoPlayer view so + * [updateBrightnessOverlayBounds] is called on every layout pass (orientation change, + * aspect-ratio change, zoom, PiP transition, etc.). + */ + fun requestUpdateBrightnessOverlayOnNextLayout() { + val exoView = playerView.exoPlayerView ?: return + overlayLayoutListener?.let { exoView.removeOnLayoutChangeListener(it) } + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + safe { updateBrightnessOverlayBounds() } + } + overlayLayoutListener = listener + exoView.addOnLayoutChangeListener(listener) + } + + /** Removes the overlay layout listener registered by [requestUpdateBrightnessOverlayOnNextLayout]. */ + fun releaseOverlayLayoutListener() { + overlayLayoutListener?.let { playerView.exoPlayerView?.removeOnLayoutChangeListener(it) } + overlayLayoutListener = null + } + + /** Rewind / fast-forward animations */ + + /** Resets the rewind button label to the standard "–Xs" format. */ + fun resetRewindText() { + playerView.exoRewText?.text = context.getString(R.string.rew_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** Resets the fast-forward button label to the standard "+Xs" format. */ + fun resetFastForwardText() { + playerView.exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** + * Fades playerRewHolder, playerFfwdHolder, and playerPausePlay to [fadeTo] (0f or 1f). + * Always resets the holder alphas to 1f first so any stale fillAfter state is cleared. + * Called from host fragments' show/hide control animations so both GeneratorPlayer and trailer share + * the same fade logic. + */ + fun animateCenterControls(fadeTo: Float) { + val from = if (fadeTo > 0.5f) 0f else 1f + fun makeAnim() = AlphaAnimation(from, fadeTo).apply { duration = 100; fillAfter = true } + // Each view needs its own Animation instance; sharing one causes fillAfter to + // not hold reliably across all views once any of them restarts the animation. + playerView.playerRewHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerFfwdHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerPausePlay?.startAnimation(makeAnim()) + } + + /** Plays the rewind animation and seeks back by [fastForwardTime]. */ + fun rewind() { + try { + val rewHolder = playerView.playerRewHolder ?: return + val rew = playerView.playerRew + val rewText = playerView.exoRewText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + // Only expose the parent chain when controls are currently hidden. + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + rewHolder.alpha = 1f + + rew?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_left)) + val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) + goLeft.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + rewText?.post { + resetRewindText() + // Restore parent chain only if we changed it and controls are still hidden. + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + rewHolder.alpha = 0f + } + } + } + }) + rewText?.startAnimation(goLeft) + rewText?.text = context.getString(R.string.rew_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(-fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Plays the fast-forward animation and seeks forward by [fastForwardTime]. */ + fun fastForward() { + try { + val ffwdHolder = playerView.playerFfwdHolder ?: return + val ffwd = playerView.playerFfwd + val ffwdText = playerView.exoFfwdText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + ffwdHolder.alpha = 1f + + ffwd?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_right)) + val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) + goRight.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + ffwdText?.post { + resetFastForwardText() + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + ffwdHolder.alpha = 0f + } + } + } + }) + ffwdText?.startAnimation(goRight) + ffwdText?.text = context.getString(R.string.ffw_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Double-tap detection */ + + /** + * Call when a valid tap is detected (short hold, minimal movement, valid touch area). + * Routes to double-tap seeking/pausing or schedules a delayed single-tap callback. + * + * Updates [lastTouchEndTime] when a confirmed tap (single or double) is recorded. + * + * @param x X coordinate of the tap in the view's coordinate space. + * @param viewWidth Width of the view (used to compute left/center/right zones). + * @param isLocked Whether player controls are locked (suppresses double-tap seek). + * @param onSingleTap Invoked when it is determined to be a single tap; may be deferred. + * @return true if a double-tap action was performed. + */ + fun onTapDetected(x: Float, viewWidth: Int, isLocked: Boolean, onSingleTap: () -> Unit): Boolean { + val anyDoubleTap = doubleTapEnabled || doubleTapPauseEnabled + if (!anyDoubleTap) { + onSingleTap() + return false + } + + val timeSinceLast = System.currentTimeMillis() - lastTouchEndTime + return if (!isLocked && timeSinceLast < DOUBLE_TAP_MINIMUM_TIME_BETWEEN) { + /** Double-tap */ + tapCount++ + doubleTapToken++ // cancel any pending single-tap runnable + if (doubleTapPauseEnabled) { + when { + x < viewWidth / 2f - (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) rewind() + } + x > viewWidth / 2f + (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) fastForward() + } + else -> { + playerView.player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + } else if (doubleTapEnabled) { + if (x < viewWidth / 2f) rewind() else fastForward() + } + true + } else { + /** Single tap (first tap, or too slow for double-tap) */ + tapCount = 0 + val token = ++doubleTapToken + playerView.playerHolder?.postDelayed({ + if (token == doubleTapToken) { + onSingleTap() + } + }, DOUBLE_TAP_MINIMUM_TIME_BETWEEN) + false + } + } + + /** Seek time helpers */ + + private fun calculateNewTime(startTime: Long?, touchStart: Vector2?, touchEnd: Vector2?): Long? { + if (touchStart == null || touchEnd == null || startTime == null) return null + val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() + val duration = playerView.player.getDuration() ?: return null + return max(min(startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), duration), 0) + } + + private fun forceLetters(inp: Long, letters: Int = 2): String { + val added = letters - inp.toString().length + return if (added > 0) "0".repeat(added) + inp.toString() else inp.toString() + } + + private fun convertTimeToString(sec: Long): String { + val rsec = sec % 60L + val min = ceil((sec - rsec) / 60.0).toInt() + val rmin = min % 60L + val h = ceil((min - rmin) / 60.0).toLong() + // int rh = h;// h % 24; + return (if (h > 0) forceLetters(h) + ":" else "") + + (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + + forceLetters(rsec) + } + + /** Touch gestures */ + + fun setupTouchGestures() { + val holder = playerView.playerHolder ?: return + @SuppressLint("ClickableViewAccessibility") + holder.setOnTouchListener(::handleGesture) + } + + private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val holder = playerView.playerHolder ?: return true + val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom + val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right + return validHeight && validWidth + } + + return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation + } + + private fun handleGesture(view: View, event: MotionEvent): Boolean { + val currentTouch = Vector2(event.x, event.y) + val startTouch = currentTouchStart + + /** Two-finger zoom/pan (fullscreen, unlocked) */ + if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked + && !hasTriggeredSpeedUp && currentTouchAction == null) { + holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. + isCurrentTouchValid = false // Prevent other touches + return handleZoomPanGesture( + event = event, + ctx = view.context, + onFirstPointerDown = { + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + }, + onGestureEnd = { + currentTouchStart = null + currentLastTouchAction = null + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + } + ) + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + isCurrentTouchValid = isValidTouch(event.rawX, event.rawY) + if (isCurrentTouchValid) { + playerView.callbacks?.onTouchDown() + hasTriggeredSpeedUp = false + if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + holdHandler.postDelayed(holdRunnable, 500) + } + isVolumeLocked = currentRequestedVolume < 1.0f + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isBrightnessLocked = currentRequestedBrightness < 1.0f + if (currentRequestedBrightness <= 1.0f) hasShownBrightnessToast = false + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = playerView.player.getPosition() + getBrightness()?.let { currentRequestedBrightness = it + currentExtraBrightness } + verifyVolume() + } + return true + } + + MotionEvent.ACTION_MOVE -> { + if (hasTriggeredSpeedUp) return true + if (!isCurrentTouchValid) return true + + if (currentTouchAction == null && startTouch != null) { + val diffFromStart = startTouch - currentTouch + if (swipeVerticalEnabled) { + if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + currentTouchAction = if ((startTouch.x) >= view.width / 2f) + TouchAction.Volume else TouchAction.Brightness + } + } + if (swipeHorizontalEnabled && !isLocked) { + if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + currentTouchAction = TouchAction.Time + } + } + } + + val lastTouch = currentTouchLast + if (lastTouch != null) { + val diffFromLast = lastTouch - currentTouch + val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / view.height.toFloat() + when (currentTouchAction) { + TouchAction.Time -> { + // This simply updates UI as the seek logic happens on release + // startTime is rounded to make the UI sync in a nice way. + val startTime = currentTouchStartPlayerTime?.div(1000L)?.times(1000L) + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { newMs -> + val skipMs = newMs - startTime + playerView.callbacks?.onSeekPreviewText( + "${convertTimeToString(newMs / 1000)} [${ + if (abs(skipMs) < 1000) "" else if (skipMs > 0) "+" else "-" + }${convertTimeToString(abs(skipMs / 1000))}]" + ) + } + } + } + TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) + TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) + null -> Unit + } + if (currentTouchAction != TouchAction.Time) { + playerView.callbacks?.onSeekPreviewText(null) + } + } + currentTouchLast = currentTouch + return true + } + + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + holdHandler.removeCallbacks(holdRunnable) + if (hasTriggeredSpeedUp) { + playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) + showOrHideSpeedUp(false) + playerView.callbacks?.onHoldSpeedUp(false) + hasTriggeredSpeedUp = false + } + + if (isCurrentTouchValid) { + // Horizontal seek on release + if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time && !isLocked) { + val startTime = currentTouchStartPlayerTime + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> + if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { + playerView.player.seekTo(seekTo, PlayerEventSource.UI) + } + } + } + } + // Tap detection: only fire if the finger was held briefly (not a long-press). + val holdTime = currentTouchStartTime?.let { System.currentTimeMillis() - it } + if (currentTouchAction == null && currentLastTouchAction == null + && !hasTriggeredSpeedUp + && (holdTime == null || holdTime < DOUBLE_TAP_MAXIMUM_HOLD_TIME)) { + onTapDetected( + x = currentTouch.x, + viewWidth = view.width, + isLocked = isLocked, + onSingleTap = { playerView.callbacks?.onSingleTap() } + ) + } + } + + playerView.callbacks?.onSeekPreviewText(null) + val hadSwipe = currentTouchAction != null || currentLastTouchAction != null + playerView.callbacks?.onGestureEnd(hadSwipe, uiShowingBeforeGesture) + + // Reset touch + lastTouchEndTime = System.currentTimeMillis() + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + uiShowingBeforeGesture = false + return true + } + } + return false + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt index 938572349..0db06499e 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,113 +1,205 @@ package com.lagradost.cloudstream3.ui.player import android.app.Activity +import android.app.AppOpsManager import android.app.PendingIntent import android.app.PictureInPictureParams import android.app.RemoteAction +import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.drawable.Icon import android.os.Build import android.util.Rational import androidx.annotation.RequiresApi import androidx.annotation.StringRes +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe import kotlin.math.roundToInt -class PlayerPipHelper { - companion object { - @RequiresApi(Build.VERSION_CODES.O) - private fun getPen(activity: Activity, code: Int): PendingIntent { - return PendingIntent.getBroadcast( - activity, - code, - Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code), - PendingIntent.FLAG_IMMUTABLE - ) - } - - @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.go_back_30, - R.string.go_back_30, - CSPlayerEvent.SeekBack - ) - ) - - if (isPlaying) { - actions.add( - getRemoteAction( - activity, - R.drawable.netflix_pause, - R.string.pause, - CSPlayerEvent.Pause - ) - ) - } else { - actions.add( - getRemoteAction( - activity, - R.drawable.ic_baseline_play_arrow_24, - R.string.pause, - CSPlayerEvent.Play - ) - ) - } - - 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() - ) - } +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 } } -} \ No newline at end of file + + /** 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( + activity, + R.drawable.baseline_headphones_24, + R.string.audio_singular, + CSPlayerEvent.PlayAsAudio + ) + ) + /*actions.add( + getRemoteAction( + activity, + R.drawable.go_back_30, + R.string.go_back_30, + CSPlayerEvent.SeekBack + ) + )*/ + + if (status == CSPlayerLoading.IsPlaying) { + actions.add( + getRemoteAction( + activity, + R.drawable.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() + ) + } + } + +} 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 e532d1a3e..ee6170aa5 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 @@ -4,14 +4,14 @@ import android.util.Log import android.util.TypedValue import android.view.ViewGroup import android.widget.FrameLayout +import androidx.annotation.OptIn import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveBloat -import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveCaptions -import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.uppercaseSubtitles import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.fromSaveToStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.UIHelper.toPx enum class SubtitleStatus { @@ -27,24 +27,54 @@ enum class SubtitleOrigin { } /** - * @param name To be displayed in the player + * @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 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" * */ data class SubtitleData( - val name: String, + val originalName: String, + val nameSuffix: String, val url: String, val origin: SubtitleOrigin, val mimeType: String, - val headers: Map + val headers: Map, + val languageCode: String?, ) { /** Internal ID for exoplayer, unique for each link*/ fun getId(): String { return if (origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) url else "$url|$name" } + + /** Returns true if langCode is the same as the IETF tag */ + fun matchesLanguageCode(langCode: String): Boolean { + return getIETF_tag() == langCode + } + + /** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */ + fun getIETF_tag(): String? { + return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true) + } + + val name = "$originalName $nameSuffix" + + /** + * Gets the URL, but tries to fix it if it is malformed. + */ + 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 + } + return fixedSubUrl + } } +@OptIn(UnstableApi::class) class PlayerSubtitleHelper { private var activeSubtitles: Set = emptySet() private var allSubtitles: Set = emptySet() @@ -61,8 +91,7 @@ class PlayerSubtitleHelper { allSubtitles = list } - private var subStyle: SaveCaptionStyle? = null - private var subtitleView: SubtitleView? = null + var subtitleView: SubtitleView? = null companion object { fun String.toSubtitleMimeType(): String { @@ -76,11 +105,13 @@ class PlayerSubtitleHelper { fun getSubtitleData(subtitleFile: SubtitleFile): SubtitleData { return SubtitleData( - name = subtitleFile.lang, + originalName = subtitleFile.lang, + nameSuffix = "", url = subtitleFile.url, origin = SubtitleOrigin.URL, mimeType = subtitleFile.url.toSubtitleMimeType(), - headers = emptyMap() + headers = subtitleFile.headers ?: emptyMap(), + languageCode = subtitleFile.langTag ?: subtitleFile.lang ) } } @@ -96,21 +127,9 @@ class PlayerSubtitleHelper { } fun setSubStyle(style: SaveCaptionStyle) { - regexSubtitlesToRemoveBloat = style.removeBloat - uppercaseSubtitles = style.upperCase - regexSubtitlesToRemoveCaptions = style.removeCaptions - subtitleView?.context?.let { ctx -> - subStyle = style - Log.i(TAG, "SET STYLE = $style") - subtitleView?.setStyle(ctx.fromSaveToStyle(style)) - subtitleView?.translationY = -style.elevation.toPx.toFloat() - val size = style.fixedTextSize - if (size != null) { - subtitleView?.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) - } else { - subtitleView?.setUserDefaultTextSize() - } - } + Log.i(TAG, "SET STYLE = $style") + subtitleView?.translationY = -style.elevation.toPx.toFloat() + setSubtitleViewStyle(subtitleView, style, true) } fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt new file mode 100644 index 000000000..0e6f1a367 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -0,0 +1,842 @@ +package com.lagradost.cloudstream3.ui.player + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimatedVectorDrawable +import android.media.metrics.PlaybackErrorEvent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.text.format.DateUtils +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.RelativeLayout +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.annotation.OptIn +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar +import androidx.preference.PreferenceManager +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.github.rubensousa.previewseekbar.PreviewBar +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import com.lagradost.cloudstream3.CommonActivity.isInPIPMode +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI +import com.lagradost.cloudstream3.utils.UserPreferenceDelegate +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import java.net.SocketTimeoutException + +/** + * Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event + * dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper] + * ([PlayerGestureHelper]), which is exposed via delegate properties for easier access. + */ +@OptIn(UnstableApi::class) +class PlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + companion object { + private const val TAG = "PlayerView" + } + + /** All gesture, volume, brightness and key-event logic lives here. */ + val gestureHelper = PlayerGestureHelper(this) + + /** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */ + var isFullScreen: Boolean + get() = gestureHelper.isFullScreen + set(value) { gestureHelper.isFullScreen = value } + + var isLocked: Boolean + get() = gestureHelper.isLocked + set(value) { gestureHelper.isLocked = value } + + var videoOutline: View? + get() = gestureHelper.videoOutline + set(value) { gestureHelper.videoOutline = value } + + /** Delegate methods */ + fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode) + fun verifyVolume() = gestureHelper.verifyVolume() + fun setupKeyEventListener() = gestureHelper.setupKeyEventListener() + fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener() + fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout() + fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener() + + /** Callbacks */ + + /** Host-fragment-level callbacks invoked by [mainCallback]. */ + interface Callbacks { + fun nextEpisode() {} + fun prevEpisode() {} + fun playerPositionChanged(position: Long, duration: Long) {} + fun playerStatusChanged() {} + fun playerDimensionsLoaded(width: Int, height: Int) {} + fun subtitlesChanged() {} + fun embeddedSubtitlesFetched(subtitles: List) {} + fun onTracksInfoChanged() {} + fun onTimestamp(timestamp: VideoSkipStamp?) {} + fun onTimestampSkipped(timestamp: VideoSkipStamp) {} + fun exitedPipMode() {} + fun hasNextMirror(): Boolean = false + fun nextMirror() {} + fun onDownload(event: DownloadEvent) {} + fun playerError(exception: Throwable) {} + /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ + fun playerUpdated(player: Any?) {} + /** Called on a short single-tap on empty player area (no swipe, no double-tap). */ + fun onSingleTap() {} + /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ + fun onHoldSpeedUp(show: Boolean) {} + /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ + fun onBrightnessExtra(alpha: Float) {} + + /** Touch event callbacks */ + + /** Returns whether the player UI (controls overlay) is currently visible. */ + fun isUIShowing(): Boolean = false + /** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */ + fun onTouchDown() {} + /** Called with seek-preview text during a horizontal-swipe, or null to clear it. */ + fun onSeekPreviewText(text: String?) {} + /** Called when a swipe gesture begins; hide the player UI if desired. */ + fun onHidePlayerUI() {} + /** + * Called at the end of each touch sequence. + * @param hadSwipe true if a swipe (brightness/volume/time) was in progress. + * @param wasUiShowing true if the UI was visible when the swipe began. + */ + fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {} + /** + * Called when the auto-hide timer fires: UI is showing, no touch is active. + * Implement to hide the player controls. + */ + fun onAutoHideUI() {} + } + + var callbacks: Callbacks? = null + + /** Player state */ + + var player: IPlayer = CS3IPlayer() + var resizeMode: Int = 0 + var hasPipModeSupport: Boolean = true + var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering + var mMediaSession: MediaSession? = null + private var pipReceiver: BroadcastReceiver? = null + + /** Auto-hide */ + private var autoHideToken = 0 + private val autoHideHandler = Handler(Looper.getMainLooper()) + + /** View references (populated by bindViews) */ + + var subView: SubtitleView? = null + var playerPausePlayHolderHolder: FrameLayout? = null + var playerPausePlay: ImageView? = null + var playerBuffering: ProgressBar? = null + /** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */ + var exoPlayerView: androidx.media3.ui.PlayerView? = null + var piphide: FrameLayout? = null + var subtitleHolder: FrameLayout? = null + internal var playerRew: View? = null + internal var playerFfwd: View? = null + internal var exoRewText: TextView? = null + internal var exoFfwdText: TextView? = null + internal var playerCenterMenu: View? = null + internal var playerRewHolder: View? = null + internal var playerFfwdHolder: View? = null + internal var playerVideoHolder: View? = null + var playerProgressbarLeftHolder: RelativeLayout? = null + var playerProgressbarLeftIcon: ImageView? = null + var playerProgressbarLeftLevel1: ProgressBar? = null + var playerProgressbarLeftLevel2: ProgressBar? = null + var playerProgressbarRightHolder: RelativeLayout? = null + var playerProgressbarRightIcon: ImageView? = null + var playerProgressbarRightLevel1: ProgressBar? = null + var playerProgressbarRightLevel2: ProgressBar? = null + /** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */ + internal var playerSpeedupButton: View? = null + var playerHolder: FrameLayout? = null + private var exoDuration: TextView? = null + private var timeLeft: TextView? = null + private var exoPosition: TextView? = null + private var timeLive: View? = null + private var exoProgress: LivePreviewTimeBar? = null + + /** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */ + var seekTime: Long = 10_000L + + /** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */ + var isVerticalOrientation: Boolean = false + + /** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */ + var autoPlayerRotateEnabled: Boolean = false + + var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + + // Kept so SubtitlesFragment can unsubscribe the exact same reference. + private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged + + /** View discovery */ + + /** + * Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply + * remain null, all usage is null-safe. + */ + fun bindViews(root: View) { + exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) + exoFfwdText = root.findViewById(R.id.exo_ffwd_text) + exoPlayerView = root.findViewById(R.id.player_view) + exoPosition = root.findViewById(R.id.exo_position) + exoRewText = root.findViewById(R.id.exo_rew_text) + piphide = root.findViewById(R.id.piphide) + playerBuffering = root.findViewById(R.id.player_buffering) + playerCenterMenu = root.findViewById(R.id.player_center_menu) + playerFfwd = root.findViewById(R.id.player_ffwd) + playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) + playerHolder = root.findViewById(R.id.player_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) + playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) + playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) + playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) + playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder) + playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) + playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) + playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) + playerRew = root.findViewById(R.id.player_rew) + playerRewHolder = root.findViewById(R.id.player_rew_holder) + playerSpeedupButton = root.findViewById(R.id.player_speedup_button) + playerVideoHolder = root.findViewById(R.id.player_video_holder) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + timeLeft = root.findViewById(R.id.time_left) + timeLive = root.findViewById(R.id.time_live) + } + + /** + * Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener, + * player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper]. + */ + fun initialize() { + resizeMode = DataStoreHelper.resizeMode + resize(resizeMode, false) + + player.releaseCallbacks() + player.initCallbacks( + eventHandler = ::mainCallback, + requestedListeningPercentages = listOf( + SKIP_OP_VIDEO_PERCENTAGE, + PRELOAD_NEXT_EPISODE_PERCENTAGE, + NEXT_WATCH_EPISODE_PERCENTAGE, + UPDATE_SYNC_PROGRESS_PERCENTAGE, + ), + ) + + if (player is CS3IPlayer) { + // Preview bar + val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress) + exoProgress = progressBar as? LivePreviewTimeBar + val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView) + val previewFrameLayout: FrameLayout? = + exoPlayerView?.findViewById(R.id.previewFrameLayout) + + /** Hide the previewFrameLayout on TV to make the skip op button not float, + * as previewFrameLayout is normally invisible */ + if(isLayout(TV)) { + previewFrameLayout?.isVisible = false + } + + if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) { + var resume = false + progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { + override fun onScrubStart(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + val hasPreview = cs3.hasPreview() + progressBar.isPreviewEnabled = hasPreview + resume = cs3.getIsPlaying() + if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + // No clashing UI + if (hasPreview) subView?.isVisible = false + } + + override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {} + + override fun onScrubStop(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + // Delay to prevent the small flicker of subtitle before seeking. + subView?.postDelayed({ + // If we are not scrubbing then show subtitles again. + if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { + subView?.isVisible = true + } + }, 200) + } + }) + progressBar.attachPreviewView(previewFrameLayout) + progressBar.setPreviewLoader { currentPosition, max -> + val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader + val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat())) + previewImageView.isGone = bitmap == null + previewImageView.setImageBitmap(bitmap) + } + } + + subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) + (player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style) + (player as? CS3IPlayer)?.let { + (it.imageGenerator as? PreviewGenerator)?.params = + ImageParams.new16by9(screenWidth) + } + + /** + * This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player + * and once by the UI even if it should only be registered once by the UI. + */ + exoPlayerView?.findViewById(R.id.exo_progress) + ?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) return + val playerDuration = player.getDuration() ?: return + val playerPosition = player.getPosition() ?: return + mainCallback( + PositionEvent( + source = PlayerEventSource.UI, + durationMs = playerDuration, + fromMs = playerPosition, + toMs = position + ) + ) + } + }) + + // Read seek time and rotation settings. + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10) + .toLong() * 1000L + autoPlayerRotateEnabled = sm.getBoolean( + context.getString(R.string.auto_rotate_video_key), true + ) + } catch (_: Exception) { + } + + val seekSecs = (seekTime / 1000).toInt() + exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) + exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs) + + playerPausePlay?.setOnClickListener { + scheduleAutoHide() + if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI) + } else { + player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + playerRew?.setOnClickListener { + scheduleAutoHide() + gestureHelper.rewind() + } + playerFfwd?.setOnClickListener { + scheduleAutoHide() + gestureHelper.fastForward() + } + + SubtitlesFragment.applyStyleEvent += subStyleListener + + try { + val ctx = context + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val cs3 = player as? CS3IPlayer ?: return + cs3.cacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L + cs3.simpleCacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L + cs3.videoBufferMs = + settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L + } catch (e: Exception) { + logError(e) + } + + // Duration toggle click listeners + exoDuration?.setOnClickListener { setRemainingTimeCounter(true) } + timeLeft?.setOnClickListener { setRemainingTimeCounter(false) } + // Keep remaining-time text in sync with playback position + exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } + + // Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener) + gestureHelper.initialize() + setupKeyEventListener() + + // Apply duration-mode display (remaining time vs elapsed); TV always shows remaining + setRemainingTimeCounter(durationMode || isLayout(TV)) + } + } + + /** Lifecycle delegation */ + + var fullscreenNotch: Boolean = true // TODO SETTING + + fun enterFullscreen(updateOrientation: () -> Unit = {}) { + val activity = context as? Activity + if (isFullScreen) { + activity?.hideSystemUI() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { + val params = activity?.window?.attributes + params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + activity?.window?.attributes = params + } + } + updateOrientation() + } + + fun exitFullscreen() { + val activity = context as? Activity + gestureHelper.resetZoomToDefault() + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + // Simply resets brightness and notch settings that might have been overridden. + val lp = activity?.window?.attributes + lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity?.window?.attributes = lp + activity?.showSystemUI() + } + + fun onStop() { + player.onStop() + } + + fun onResume(ctx: Context) { + player.onResume(ctx) + } + + /** Releases all player resources. */ + fun release() { + player.release() + player.releaseCallbacks() + player = CS3IPlayer() + + // keyEventListener is deregistered in onPause so that the incoming player's + // onResume can register its own listener without racing against release(). + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + CSPlayerLoading.IsPaused, + false, + null + ) + + mMediaSession?.release() + mMediaSession = null + exoPlayerView?.player = null + + SubtitlesFragment.applyStyleEvent -= subStyleListener + + gestureHelper.release() + autoHideHandler.removeCallbacksAndMessages(null) + + keepScreenOn(false) + } + + fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + activity: Activity? + ) { + try { + isInPIPMode = isInPictureInPictureMode + if (isInPictureInPictureMode) { + // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. + piphide?.isVisible = false + pipReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (ACTION_MEDIA_CONTROL != intent.action) return + player.handleEvent( + CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)], + source = PlayerEventSource.UI + ) + } + } + val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + activity?.registerReceiver(pipReceiver, filter) + } + val isPlaying = player.getIsPlaying() + val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + updateIsPlaying(status, status) + } else { + // Restore the full-screen UI. + piphide?.isVisible = true + callbacks?.exitedPipMode() + pipReceiver?.let { + // Prevents java.lang.IllegalArgumentException: Receiver not registered + safe { activity?.unregisterReceiver(it) } + } + activity?.hideSystemUI() + hideKeyboard(this) + } + } catch (e: Exception) { + logError(e) + } + } + + /** Player UI helpers */ + + private fun keepScreenOn(on: Boolean) { + val window = (context as? Activity)?.window ?: return + if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) { + val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying + val isBuffering = CSPlayerLoading.IsBuffering == isPlaying + currentPlayerStatus = isPlaying + + keepScreenOn(isPlayingRightNow || isBuffering) + + if (isBuffering) { + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true + } else { + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false + + if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) + } else if (wasPlaying != isPlaying) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play + ) + val drawable = playerPausePlay?.drawable + var startedAnimation = false + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true } + } + if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true } + if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true } + // Somehow the phone is wacked + if (!startedAnimation) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } else { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + isPlaying, + hasPipModeSupport, + player.getAspectRatio() + ) + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + (context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + } + } + + private fun playerUpdated(player: Any?) { + if (player is ExoPlayer) { + mMediaSession?.release() + mMediaSession = MediaSession.Builder(context, player) + // Ensure unique ID for concurrent players. + .setId(System.currentTimeMillis().toString()) + .build() + + // Necessary for multiple combined videos. + @Suppress("DEPRECATION") + exoPlayerView?.setShowMultiWindowTimeBar(true) + exoPlayerView?.player = player + exoPlayerView?.performClick() + } + callbacks?.playerUpdated(player) + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed. + player.seekTime(-1) + } + + /** Error handling */ + + @MainThread + fun playerError(exception: Throwable) { + fun showErrorToast(message: String) { + if (callbacks?.hasNextMirror() == true) { + showToast(message, Toast.LENGTH_SHORT) + callbacks?.nextMirror() + } else { + showToast( + context.getString(R.string.no_links_found_toast) + "\n" + message, + Toast.LENGTH_LONG + ) + (context as? FragmentActivity)?.popCurrentPage() + } + } + + when (exception) { + is PlaybackException -> { + val msg = exception.message ?: "" + val errorName = exception.errorCodeName + when (val code = exception.errorCode) { + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> + showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_REMOTE_ERROR, + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_TIMEOUT, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> + showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg") + + PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, + PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> + showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> + showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> + showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg") + + else -> + showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg") + } + } + + is SocketTimeoutException -> + showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}") + + is ErrorLoadingException -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + + else -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + } + } + + /** Resize */ + + fun nextResize() { + resizeMode = (resizeMode + 1) % PlayerResize.entries.size + resize(resizeMode, true) + } + + fun resize(resize: Int, showToast: Boolean) { + // Clear all zoom state before applying the new resize mode + gestureHelper.clearZoomState() + resize(PlayerResize.entries[resize], showToast) + } + + fun resize(resize: PlayerResize, showToast: Boolean) { + DataStoreHelper.resizeMode = resize.ordinal + val type = when (resize) { + PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL + PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + exoPlayerView?.resizeMode = type + if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT) + } + + /** Orientation */ + + /** + * Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation] + * and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape. + * Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation. + */ + fun dynamicOrientation(): Int { + if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + return if (autoPlayerRotateEnabled && isVerticalOrientation) + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + + /** Event dispatch */ + + /** + * This receives the events from the player, if you want to append functionality + * you do it here, do note that this only receives events for UI changes, + * and returning early WON'T stop it from changing in e.g. the player time + * or pause status. + */ + @MainThread + fun mainCallback(event: PlayerEvent) { + // We don't want to spam DownloadEvent. + if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event") + when (event) { + is DownloadEvent -> callbacks?.onDownload(event) + is ResizedEvent -> { + // Skip 0x0 dimensions that the player emits when going to STATE_IDLE + // to avoid incorrectly resetting the auto-detected orientation. + if (event.width > 0 && event.height > 0) { + // TV never rotates; otherwise track whether the video is portrait. + isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width + } + callbacks?.playerDimensionsLoaded(event.width, event.height) + } + is PlayerAttachedEvent -> playerUpdated(event.player) + is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() + is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) + is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) + is TracksChangedEvent -> callbacks?.onTracksInfoChanged() + is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks) + is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error) + is RequestAudioFocusEvent -> requestAudioFocus() + is EpisodeSeekEvent -> when (event.offset) { + -1 -> callbacks?.prevEpisode() + 1 -> callbacks?.nextEpisode() + } + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + scheduleAutoHide() + callbacks?.playerStatusChanged() + } + is PositionEvent -> callbacks?.playerPositionChanged( + position = event.toMs, + duration = event.durationMs + ) + is VideoEndedEvent -> { + // Only play next episode if autoplay is on (default). + val ctx = context + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true + ) { + player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player) + } + } + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } + + /** Duration display */ + + fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + exoDuration?.isInvisible = showRemaining + timeLeft?.isVisible = showRemaining + if (showRemaining) updateRemainingTime() + } + + fun updateRemainingTime() { + val duration = player.getDuration() + val position = player.getPosition() + + if (exoProgress?.isAtLiveEdge() == true) { + timeLeft?.alpha = 0f + exoDuration?.alpha = 0f + timeLive?.isVisible = true + } else { + timeLeft?.alpha = 1f + exoDuration?.alpha = 1f + timeLive?.isVisible = false + } + + if (duration != null && duration > 1 && position != null) { + val remainingTimeSeconds = (duration - position + 500) / 1000 + @SuppressLint("SetTextI18n") + timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" + } + } + + /** Auto-hide */ + + /** + * Schedules a delayed auto-hide of the player UI after [delayMs] ms. + * Any previously pending hide is canceled first. + * The hide fires only when no touch is active and [Callbacks.isUIShowing] is true; + * the actual hide action is delegated to [Callbacks.onAutoHideUI]. + */ + fun scheduleAutoHide(delayMs: Long = 3000L) { + val token = ++autoHideToken + autoHideHandler.removeCallbacksAndMessages(null) + autoHideHandler.postDelayed({ + if (token != autoHideToken) return@postDelayed + if (gestureHelper.isCurrentTouchValid) return@postDelayed + if (callbacks?.isUIShowing() != true) return@postDelayed + callbacks?.onAutoHideUI() + }, delayMs) + } + + /** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */ + fun cancelAutoHide() { + autoHideToken++ + autoHideHandler.removeCallbacksAndMessages(null) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt new file mode 100644 index 000000000..2893bcc47 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt @@ -0,0 +1,546 @@ +package com.lagradost.cloudstream3.ui.player + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +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.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.log2 + +const val MAX_LOD = 6 +const val MIN_LOD = 3 + +data class ImageParams( + val width: Int, + val height: Int, +) { + companion object { + val DEFAULT = ImageParams(200, 320) + fun new16by9(width: Int): ImageParams { + if (width < 100) { + return DEFAULT + } + return ImageParams( + width / 4, + (width * 9) / (4 * 16) + ) + } + } + + init { + assert(width > 0 && height > 0) + } +} + +interface IPreviewGenerator { + fun hasPreview(): Boolean + fun getPreviewImage(fraction: Float): Bitmap? + fun release() + + var params: ImageParams + + var durationMs: Long + var loadedImages: Int + + companion object { + fun new(): IPreviewGenerator { + val userDisabled = CloudStreamApp.context?.let { ctx -> + PreferenceManager.getDefaultSharedPreferences(ctx)?.getBoolean( + 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) { + empty() + } else { + PreviewGenerator() + } + } + + fun empty(): IPreviewGenerator { + return NoPreviewGenerator() + } + } +} + +private fun rescale(image: Bitmap, params: ImageParams): Bitmap { + if (image.width <= params.width && image.height <= params.height) return image + val new = image.scale(params.width, params.height) + // throw away the old image + if (new != image) { + image.recycle() + } + return new +} + +/** rescale to not take up as much memory */ +private fun MediaMetadataRetriever.image(timeUs: Long, params: ImageParams): Bitmap? { + /*if (timeUs <= 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + val primary = this.primaryImage + if (primary != null) { + return rescale(primary, params) + } + } catch (t: Throwable) { + logError(t) + } + }*/ + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + this.getScaledFrameAtTime( + timeUs, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC, + params.width, + params.height + ) + } else { + return rescale(this.getFrameAtTime(timeUs) ?: return null, params) + } +} + +/** PreviewGenerator that hides the implementation details of the sub generators that is used, used for source switch cache */ +class PreviewGenerator : IPreviewGenerator { + + /** the most up to date generator, will always mirror the actual source in the player */ + private var currentGenerator: IPreviewGenerator = NoPreviewGenerator() + + /** the longest generated preview of the same episode */ + private var lastGenerator: IPreviewGenerator = NoPreviewGenerator() + + /** always NoPreviewGenerator, used as a cache for nothing */ + private val dummy: IPreviewGenerator = NoPreviewGenerator() + + /** if the current generator is the same as the last by checking time */ + private fun isSameLength(): Boolean = + currentGenerator.durationMs.minus(lastGenerator.durationMs).absoluteValue < 10_000L + + /** use the backup if the current generator is init or if they have the same length */ + private val backupGenerator: IPreviewGenerator + get() { + if (currentGenerator.durationMs == 0L || isSameLength()) { + return lastGenerator + } + return dummy + } + + override fun hasPreview(): Boolean { + return currentGenerator.hasPreview() || backupGenerator.hasPreview() + } + + override fun getPreviewImage(fraction: Float): Bitmap? { + return try { + currentGenerator.getPreviewImage(fraction) ?: backupGenerator.getPreviewImage(fraction) + } catch (t: Throwable) { + logError(t) + null + } + } + + override fun release() { + lastGenerator.release() + currentGenerator.release() + lastGenerator = NoPreviewGenerator() + currentGenerator = NoPreviewGenerator() + } + + override var params: ImageParams = ImageParams.DEFAULT + set(value) { + field = value + lastGenerator.params = value + backupGenerator.params = value + currentGenerator.params = value + } + + override var durationMs: Long + get() = currentGenerator.durationMs + set(_) {} + override var loadedImages: Int + get() = currentGenerator.loadedImages + set(_) {} + + fun clear(keepCache: Boolean) { + if (keepCache) { + if (!isSameLength() || currentGenerator.loadedImages >= lastGenerator.loadedImages || lastGenerator.durationMs == 0L) { + // the current generator is better than the last generator, therefore keep the current + // or the lengths are not the same, therefore favoring the more recent selection + + // if they are the same we favor the current generator + lastGenerator.release() + lastGenerator = currentGenerator + } else { + // otherwise just keep the last generator and throw away the current generator + currentGenerator.release() + } + } else { + // we switched the episode, therefore keep nothing + lastGenerator.release() + lastGenerator = NoPreviewGenerator() + currentGenerator.release() + // we assume that we set currentGenerator right after this, so currentGenerator != NoPreviewGenerator + } + } + + fun load(link: ExtractorLink, keepCache: Boolean) { + clear(keepCache) + + when (link.type) { + ExtractorLinkType.M3U8 -> { + currentGenerator = M3u8PreviewGenerator(params).apply { + load(url = link.url, headers = link.getAllHeaders()) + } + } + + ExtractorLinkType.VIDEO -> { + currentGenerator = Mp4PreviewGenerator(params).apply { + load(url = link.url, headers = link.getAllHeaders()) + } + } + + else -> { + Log.i("PreviewImg", "unsupported format for $link") + } + } + } + + fun load(context: Context, link: ExtractorUri, keepCache: Boolean) { + clear(keepCache) + currentGenerator = Mp4PreviewGenerator(params).apply { + load(keepCache = keepCache, context = context, uri = link.uri) + } + } +} + +@Suppress("UNUSED_PARAMETER") +private class NoPreviewGenerator : IPreviewGenerator { + override fun hasPreview(): Boolean = false + override fun getPreviewImage(fraction: Float): Bitmap? = null + override fun release() = Unit + override var params: ImageParams + get() = ImageParams(0, 0) + set(value) {} + override var durationMs: Long = 0L + override var loadedImages: Int = 0 +} + +private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewGenerator { + // generated images 1:1 to idx of hsl + private var images: Array = arrayOf() + + companion object { + private const val TAG = "PreviewImgM3u8" + } + + + // 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() + + // how many images has been generated + override var loadedImages: Int = 0 + + // how many images we can generate in total, == hsl.size ?: 0 + private var totalImages: Int = 0 + + override fun hasPreview(): Boolean { + return totalImages > 0 && loadedImages >= minOf(totalImages, 4) + } + + override fun getPreviewImage(fraction: Float): Bitmap? { + var bestIdx = -1 + var bestDiff = Double.MAX_VALUE + synchronized(images) { + // just find the best one in a for loop, we don't care about bin searching rn + for (i in images.indices) { + val diff = prefixSum[i].minus(fraction).absoluteValue + if (diff > bestDiff) { + break + } + if (images[i] != null) { + bestIdx = i + bestDiff = diff + } + } + return images.getOrNull(bestIdx) + } + /* + val targetIndex = prefixSum.binarySearch(target) + var ret = images[targetIndex] + if (ret != null) { + return ret + } + for (i in 0..images.size) { + ret = images.getOrNull(i+targetIndex) ?: + }*/ + } + + private fun clear() { + synchronized(images) { + currentJob?.cancel() + // for (i in images.indices) { + // images[i]?.recycle() + // } + images = arrayOf() + prefixSum = arrayOf() + loadedImages = 0 + totalImages = 0 + } + } + + override fun release() { + clear() + images = arrayOf() + } + + override var durationMs: Long = 0L + + private var currentJob: Job? = null + fun load(url: String, headers: Map) { + clear() + currentJob?.cancel() + currentJob = ioSafe { + withContext(Dispatchers.IO) { + Log.i(TAG, "Loading with url = $url headers = $headers") + //tmpFile = + // File.createTempFile("video", ".ts", context.cacheDir).apply { + // deleteOnExit() + // } + val retriever = MediaMetadataRetriever() + val hsl = M3u8Helper2.hslLazy( + M3u8Helper.M3u8Stream( + streamUrl = url, + headers = headers + ), + selectBest = false, + requireAudio = false, + ) + + // no support for encryption atm + if (hsl.isEncrypted) { + Log.i(TAG, "m3u8 is encrypted") + totalImages = 0 + return@withContext + } + + // total duration of the entire m3u8 in seconds + val duration = hsl.allTsLinks.sumOf { it.time ?: 0.0 } + durationMs = (duration * 1000.0).toLong() + val durationInv = 1.0 / duration + + // if the total duration is less then 10s then something is very wrong or + // too short playback to matter + if (duration <= 10.0) { + totalImages = 0 + return@withContext + } + + totalImages = hsl.allTsLinks.size + + // we cant init directly as it is no guarantee of in order + prefixSum = Array(hsl.allTsLinks.size + 1) { 0.0 } + var runningSum = 0.0 + for (i in hsl.allTsLinks.indices) { + runningSum += (hsl.allTsLinks[i].time ?: 0.0) + prefixSum[i + 1] = runningSum * durationInv + } + synchronized(images) { + images = Array(hsl.size) { null } + loadedImages = 0 + } + + val maxLod = ceil(log2(duration)).toInt().coerceIn(MIN_LOD, MAX_LOD) + val count = hsl.allTsLinks.size + for (l in 1..maxLod) { + val items = (1 shl (l - 1)) + for (i in 0 until items) { + val index = (count.div(1 shl l) + (i * count) / items).coerceIn(0, hsl.size) + if (synchronized(images) { images[index] } != null) { + continue + } + Log.i(TAG, "Generating preview for $index") + + val ts = hsl.allTsLinks[index] + try { + retriever.setDataSource(ts.url, hsl.headers) + if (!isActive) { + return@withContext + } + val img = retriever.image(0, params) + if (!isActive) { + return@withContext + } + if (img == null || img.width <= 1 || img.height <= 1) continue + synchronized(images) { + images[index] = img + loadedImages += 1 + } + } catch (t: Throwable) { + logError(t) + continue + } + } + } + + } + } + } +} + +private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGenerator { + // lod = level of detail where the number indicates how many ones there is + // 2^(lod-1) = images + private var loadedLod = 0 + override var loadedImages = 0 + private var images = Array((1 shl MAX_LOD) - 1) { + null + } + + companion object { + private const val TAG = "PreviewImgMp4" + } + + override fun hasPreview(): Boolean { + synchronized(images) { + return loadedLod >= MIN_LOD + } + } + + override fun getPreviewImage(fraction: Float): Bitmap? { + synchronized(images) { + if (loadedLod < MIN_LOD) { + Log.i(TAG, "Requesting preview for $fraction but $loadedLod < $MIN_LOD") + return null + } + Log.i(TAG, "Requesting preview for $fraction") + + var bestIdx = 0 + var bestDiff = 0.5f.minus(fraction).absoluteValue + + // this should be done mathematically, but for now we just loop all images + for (l in 1..loadedLod + 1) { + val items = (1 shl (l - 1)) + for (i in 0 until items) { + val idx = items - 1 + i + if (idx > loadedImages) { + break + } + if (images[idx] == null) { + continue + } + val currentFraction = + (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) + val diff = currentFraction.minus(fraction).absoluteValue + if (diff < bestDiff) { + bestDiff = diff + bestIdx = idx + } + } + } + Log.i(TAG, "Best diff found at ${bestDiff * 100}% diff (${bestIdx})") + return images[bestIdx] + } + } + + // also check out https://github.com/wseemann/FFmpegMediaMetadataRetriever + private val retriever: MediaMetadataRetriever = MediaMetadataRetriever() + + private fun clear(keepCache: Boolean) { + if (keepCache) return + synchronized(images) { + loadedLod = 0 + loadedImages = 0 + // for (i in images.indices) { + // images[i]?.recycle() + // images[i] = null + //} + images.fill(null) + } + } + + private var currentJob: Job? = null + fun load(url: String, headers: Map) { + currentJob?.cancel() + currentJob = ioSafe { + Log.i(TAG, "Loading with url = $url headers = $headers") + clear(true) + retriever.setDataSource(url, headers) + start(this) + } + } + + fun load(keepCache: Boolean, context: Context, uri: Uri) { + currentJob?.cancel() + currentJob = ioSafe { + Log.i(TAG, "Loading with uri = $uri") + clear(keepCache) + retriever.setDataSource(context, uri) + start(this) + } + } + + override fun release() { + currentJob?.cancel() + clear(false) + } + + override var durationMs: Long = 0L + + @Throws + @WorkerThread + private fun start(scope: CoroutineScope) { + Log.i(TAG, "Started loading preview") + + val durationMs = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() + ?: throw IllegalArgumentException("Bad video duration") + this.durationMs = durationMs + val durationUs = (durationMs * 1000L).toFloat() + //val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: throw IllegalArgumentException("Bad video width") + //val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: throw IllegalArgumentException("Bad video height") + + // log2 # 10s durations in the video ~= how many segments we have + val maxLod = ceil(log2((durationMs / 10_000).toFloat())).toInt().coerceIn(MIN_LOD, MAX_LOD) + + for (l in 1..maxLod) { + val items = (1 shl (l - 1)) + for (i in 0 until items) { + val idx = items - 1 + i // as sum(prev) = cur-1 + // frame = 100 / 2^lod + i * 100 / 2^(lod-1) = duration % where lod is one indexed + val fraction = (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) + Log.i(TAG, "Generating preview for ${fraction * 100}%") + val frame = durationUs * fraction + val img = retriever.image(frame.toLong(), params) + if (!scope.isActive) return + if (img == null || img.width <= 1 || img.height <= 1) continue + synchronized(images) { + images[idx] = img + loadedImages = maxOf(loadedImages, idx) + } + } + + synchronized(images) { + loadedLod = maxOf(loadedLod, l) + } + } + } +} \ No newline at end of file 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 2ce53ea5d..0668a194b 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 @@ -2,149 +2,159 @@ package com.lagradost.cloudstream3.ui.player import android.util.Log import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +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.ExtractorUri -import kotlin.math.max -import kotlin.math.min +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +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, +) class RepoLinkGenerator( - private val episodes: List, - private var currentIndex: Int = 0, + episodes: List, val page: LoadResponse? = null, -) : IGenerator { +) : VideoGenerator(episodes) { companion object { const val TAG = "RepoLink" - val cache: HashMap, Pair, MutableSet>> = + val cache: HashMap, Cache> = hashMapOf() } override val hasCache = true - - override fun hasNext(): Boolean { - return currentIndex < episodes.size - 1 - } - - override fun hasPrev(): Boolean { - return currentIndex > 0 - } - - override fun next() { - Log.i(TAG, "next") - if (hasNext()) - currentIndex++ - } - - override fun prev() { - Log.i(TAG, "prev") - if (hasPrev()) - currentIndex-- - } - - override fun goto(index: Int) { - Log.i(TAG, "goto $index") - // clamps value - currentIndex = min(episodes.size - 1, max(0, index)) - } - - override fun getCurrentId(): Int { - return episodes[currentIndex].id - } - - override fun getCurrent(offset: Int): Any? { - return episodes.getOrNull(currentIndex + offset) - } - - override fun getAll(): List { - return episodes - } + override val canSkipLoading = true + override fun getId(index: Int): Int? = videos.getOrNull(index)?.id // this is a simple array that is used to instantly load links if they are already loaded //var linkCache = Array>(size = episodes.size, init = { setOf() }) //var subsCache = Array>(size = episodes.size, init = { setOf() }) + @Throws override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, + isCasting: Boolean, ): Boolean { - val index = currentIndex - val current = episodes.getOrNull(index + offset) ?: return false + val current = videos.getOrNull(offset) ?: return false - val (currentLinkCache, currentSubsCache) = if (clearCache) { - Pair(mutableSetOf(), mutableSetOf()) - } else { - cache[Pair(current.apiName, current.id)] ?: Pair(mutableSetOf(), mutableSetOf()) + 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 = if (clearCache) mutableSetOf() else linkCache[index].toMutableSet() - //val currentSubsCache = if (clearCache) mutableSetOf() else subsCache[index].toMutableSet() + // 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 currentLinks = mutableSetOf() // makes all urls unique - val currentSubsUrls = mutableSetOf() // makes all subs urls unique - val currentSubsNames = mutableSetOf() // makes all subs names unique + synchronized(currentCache) { + val outdatedCache = + unixTime - currentCache.lastCachedTimestamp > 60 * 20 // 20 minutes - currentLinkCache.forEach { link -> - currentLinks.add(link.url) - callback(Pair(link, null)) - } + 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" + ) + } - currentSubsCache.forEach { sub -> - currentSubsUrls.add(sub.url) - currentSubsNames.add(sub.name) - subtitleCallback(sub) - } + // call all callbacks + currentCache.linkCache.forEach { link -> + currentLinksUrls.add(link.url) + if (sourceTypes.contains(link.type)) { + callback(link to null) + } + } - // this stops all execution if links are cached - // no extra get requests - if (currentLinkCache.size > 0) { - return true + currentCache.subtitleCache.forEach { sub -> + currentSubsUrls.add(sub.url) + lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet() + subtitleCallback(sub) + } + + // this stops all execution if links are cached + // no extra get requests + if (currentCache.saturated) { + return true + } } val result = APIRepository( getApiFromNameNull(current.apiName) ?: throw Exception("This provider does not exist") - ).loadLinks(current.data, - isCasting, - { file -> + ).loadLinks( + current.data, + isCasting = isCasting, + subtitleCallback = { file -> + Log.d(TAG, "Loaded SubtitleFile: $file") val correctFile = PlayerSubtitleHelper.getSubtitleData(file) - if (!currentSubsUrls.contains(correctFile.url)) { - currentSubsUrls.add(correctFile.url) + if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) { + return@loadLinks + } - // 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" - } + // 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() - currentSubsNames.add(name) - val updatedFile = correctFile.copy(name = name) + val updatedFile = + correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") - if (!currentSubsCache.contains(updatedFile)) { + synchronized(currentCache) { + if (currentCache.subtitleCache.add(updatedFile)) { subtitleCallback(updatedFile) - currentSubsCache.add(updatedFile) - //subsCache[index] = currentSubsCache + currentCache.lastCachedTimestamp = unixTime } } }, - { link -> + callback = { link -> Log.d(TAG, "Loaded ExtractorLink: $link") - if (!currentLinks.contains(link.url)) { - if (!currentLinkCache.contains(link)) { - currentLinks.add(link.url) - callback(Pair(link, null)) - currentLinkCache.add(link) - //linkCache[index] = currentLinkCache + if (link.url.isBlank() || !currentLinksUrls.add(link.url)) { + return@loadLinks + } + + synchronized(currentCache) { + if (currentCache.linkCache.add(link)) { + if (sourceTypes.contains(link.type)) { + callback(Pair(link, null)) + } + + currentCache.linkCache.add(link) + currentCache.lastCachedTimestamp = unixTime } } } ) - cache[Pair(current.apiName, current.id)] = Pair(currentLinkCache, currentSubsCache) + + synchronized(currentCache) { + currentCache.saturated = currentCache.linkCache.isNotEmpty() + currentCache.lastCachedTimestamp = 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 new file mode 100644 index 000000000..824b5d1a2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt @@ -0,0 +1,114 @@ +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 new file mode 100644 index 000000000..fa65c322e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt @@ -0,0 +1,104 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ObjectAnimator +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import androidx.core.view.isInvisible +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 kotlin.math.roundToInt + +data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: List) { + val endTimeMs = startTimeMs + durationMs +} + +class SubtitleOffsetItemAdapter( + private var currentTimeMs: Long, + val clickCallback: (SubtitleCue) -> Unit +) : + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.startTimeMs == b.startTimeMs + })) { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = SubtitleOffsetItemBinding.inflate(inflater, parent, false) + return ViewHolderState(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..= it.value.startTimeMs + }?.index ?: 0 + } + + fun updateTime(timeMs: Long) { + val previousTime = currentTimeMs + currentTimeMs = timeMs + + val earlyTime = minOf(previousTime, timeMs) + val lateTime = maxOf(previousTime, timeMs) + + // TODO Add binary search and notifyItemRangeChanged + val affectedItems = immutableCurrentList.withIndex().filter { cue -> + // Padding is required in the range because changes can be done within one single subtitle range, + // and that subtitle needs to be updated + cue.value.startTimeMs in (earlyTime - cue.value.durationMs)..(lateTime + cue.value.durationMs) + } + + affectedItems.forEach { item -> + // This could likely be a range + this.notifyItemChanged(item.index) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/Torrent.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/Torrent.kt new file mode 100644 index 000000000..2e554f75e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/Torrent.kt @@ -0,0 +1,392 @@ +package com.lagradost.cloudstream3.ui.player + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.api.Log +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import torrServer.TorrServer +import java.io.File +import java.net.ConnectException +import java.net.URLEncoder + +object Torrent { + var hasAcceptedTorrentForThisSession: Boolean? = null + private const val TORRENT_SERVER_PATH: String = "torrent_tmp" + private const val TIMEOUT: Long = 3 + private const val TAG: String = "Torrent" + + /** Cleans up both old aria2c files and newer go server, (even if the new is also self cleaning) */ + @Throws + fun deleteAllFiles(): Boolean { + val act = CommonActivity.activity ?: return false + val defaultDirectory = "${act.cacheDir.path}/$TORRENT_SERVER_PATH" + return File(defaultDirectory).deleteRecursively() + } + + private var TORRENT_SERVER_URL = "" // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/main/server.go#L23 + + /** Returns true if the server is up */ + private suspend fun echo(): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return false + } + return try { + app.get( + "$TORRENT_SERVER_URL/echo", + ).text.isNotEmpty() + } catch (e: ConnectException) { + // `Failed to connect to /127.0.0.1:8090` if the server is down + false + } catch (t: Throwable) { + logError(t) + false + } + } + + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/shutdown.go#L22 + /** Gracefully shutdown the server. + * should not be used because I am unable to start it again, and the stopTorrentServer() crashes the app */ + suspend fun shutdown(): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return false + } + return try { + app.get( + "$TORRENT_SERVER_URL/shutdown", + ).isSuccessful + } catch (t: Throwable) { + logError(t) + false + } + } + + /** Lists all torrents by the server */ + @Throws + private suspend fun list(): Array { + if(TORRENT_SERVER_URL.isEmpty()) { + throw ErrorLoadingException("Not initialized") + } + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "list", + ), + timeout = TIMEOUT, + headers = emptyMap() + ).parsed>() + } + + /** Drops a single torrent, (I think) this means closing the stream. Returns returns if it is successful */ + private suspend fun drop(hash: String): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return false + } + return try { + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "drop", + hash = hash + ), + timeout = TIMEOUT, + headers = emptyMap() + ).isSuccessful + } catch (t: Throwable) { + logError(t) + false + } + } + + /** Removes a single torrent from the server registry */ + private suspend fun rem(hash: String): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return false + } + return try { + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "rem", + hash = hash + ), + timeout = TIMEOUT, + headers = emptyMap() + ).isSuccessful + } catch (t: Throwable) { + logError(t) + false + } + } + + + /** Removes all torrents from the server, and returns if it is successful */ + suspend fun clearAll(): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return true + } + return try { + val items = list() + var allSuccess = true + for (item in items) { + val hash = item.hash + if (hash == null) { + Log.i(TAG, "No hash on ${item.name}") + allSuccess = false + continue + } + if (drop(hash)) { + Log.i(TAG, "Successfully dropped ${item.name}") + } else { + Log.i(TAG, "Failed to drop ${item.name}") + allSuccess = false + continue + } + if (rem(hash)) { + Log.i(TAG, "Successfully removed ${item.name}") + } else { + Log.i(TAG, "Failed to remove ${item.name}") + allSuccess = false + continue + } + } + allSuccess + } catch (t: Throwable) { + logError(t) + false + } + } + + /** Gets all the metadata of a torrent, will throw if that hash does not exists + * https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/torrents.go#L126 */ + @Throws + suspend fun get( + hash: String, + ): TorrentStatus { + if(TORRENT_SERVER_URL.isEmpty()) { + throw ErrorLoadingException("Not initialized") + } + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "get", + hash = hash, + ), + timeout = TIMEOUT, + headers = emptyMap() + ).parsed() + } + + /** Adds a torrent to the server, this is needed for us to get the hash for further modification, as well as start streaming it*/ + @Throws + private suspend fun add(url: String): TorrentStatus { + if(TORRENT_SERVER_URL.isEmpty()) { + throw ErrorLoadingException("Not initialized") + } + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "add", + link = url, + ), + headers = emptyMap() + ).parsed() + } + + /** Spins up the torrent server. */ + private suspend fun setup(dir: String): Boolean { + go.Seq.load() + if (echo()) { + return true + } + val port = TorrServer.startTorrentServer(dir, 0) + if(port < 0) { + return false + } + TORRENT_SERVER_URL = "http://127.0.0.1:$port" + TorrServer.addTrackers(trackers.joinToString(separator = ",\n")) + return echo() + } + + /** Transforms a torrent link into a streamable link via the server */ + @Throws + suspend fun transformLink(link: ExtractorLink): Pair { + val act = CommonActivity.activity ?: throw IllegalArgumentException("No activity") + val defaultDirectory = "${act.cacheDir.path}/$TORRENT_SERVER_PATH" + File(defaultDirectory).mkdir() + if (!setup(defaultDirectory)) { + throw ErrorLoadingException("Unable to setup the torrent server") + } + val status = add(link.url) + + return newExtractorLink( + source = link.source, + name = link.name, + url = status.streamUrl(link.url), + type = ExtractorLinkType.VIDEO + ) { + this.referer = "" + this.quality = link.quality + } to status + } + + private val trackers = listOf( + "udp://tracker.opentrackr.org:1337/announce", + "https://tracker2.ctix.cn/announce", + "https://tracker1.520.jp:443/announce", + "udp://opentracker.i2p.rocks:6969/announce", + "udp://open.tracker.cl:1337/announce", + "udp://open.demonii.com:1337/announce", + "http://tracker.openbittorrent.com:80/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://open.stealth.si:80/announce", + "udp://exodus.desync.com:6969/announce", + "udp://tracker-udp.gbitt.info:80/announce", + "udp://explodie.org:6969/announce", + "https://tracker.gbitt.info:443/announce", + "http://tracker.gbitt.info:80/announce", + "udp://uploads.gamecoast.net:6969/announce", + "udp://tracker1.bt.moack.co.kr:80/announce", + "udp://tracker.tiny-vps.com:6969/announce", + "udp://tracker.theoks.net:6969/announce", + "udp://tracker.dump.cl:6969/announce", + "udp://tracker.bittor.pw:1337/announce", + "https://tracker1.520.jp:443/announce", + "udp://opentracker.i2p.rocks:6969/announce", + "udp://open.tracker.cl:1337/announce", + "udp://open.demonii.com:1337/announce", + "http://tracker.openbittorrent.com:80/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://open.stealth.si:80/announce", + "udp://exodus.desync.com:6969/announce", + "udp://tracker-udp.gbitt.info:80/announce", + "udp://explodie.org:6969/announce", + "https://tracker.gbitt.info:443/announce", + "http://tracker.gbitt.info:80/announce", + "udp://uploads.gamecoast.net:6969/announce", + "udp://tracker1.bt.moack.co.kr:80/announce", + "udp://tracker.tiny-vps.com:6969/announce", + "udp://tracker.theoks.net:6969/announce", + "udp://tracker.dump.cl:6969/announce", + "udp://tracker.bittor.pw:1337/announce" + ) + + + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/torrents.go#L18 + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/main/web/api/route.go#L7 + data class TorrentRequest( + @JsonProperty("action") + val action: String, + @JsonProperty("hash") + val hash: String = "", + @JsonProperty("link") + val link: String = "", + @JsonProperty("title") + val title: String = "", + @JsonProperty("poster") + val poster: String = "", + @JsonProperty("data") + val data: String = "", + @JsonProperty("save_to_db") + val saveToDB: Boolean = false, + ) + + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/torr/state/state.go#L33 + // omitempty = nullable + data class TorrentStatus( + @JsonProperty("title") + var title: String, + @JsonProperty("poster") + var poster: String, + @JsonProperty("data") + var data: String?, + @JsonProperty("timestamp") + var timestamp: Long, + @JsonProperty("name") + var name: String?, + @JsonProperty("hash") + var hash: String?, + @JsonProperty("stat") + var stat: Int, + @JsonProperty("stat_string") + var statString: String, + @JsonProperty("loaded_size") + var loadedSize: Long?, + @JsonProperty("torrent_size") + var torrentSize: Long?, + @JsonProperty("preloaded_bytes") + var preloadedBytes: Long?, + @JsonProperty("preload_size") + var preloadSize: Long?, + @JsonProperty("download_speed") + var downloadSpeed: Double?, + @JsonProperty("upload_speed") + var uploadSpeed: Double?, + @JsonProperty("total_peers") + var totalPeers: Int?, + @JsonProperty("pending_peers") + var pendingPeers: Int?, + @JsonProperty("active_peers") + var activePeers: Int?, + @JsonProperty("connected_seeders") + var connectedSeeders: Int?, + @JsonProperty("half_open_peers") + var halfOpenPeers: Int?, + @JsonProperty("bytes_written") + var bytesWritten: Long?, + @JsonProperty("bytes_written_data") + var bytesWrittenData: Long?, + @JsonProperty("bytes_read") + var bytesRead: Long?, + @JsonProperty("bytes_read_data") + var bytesReadData: Long?, + @JsonProperty("bytes_read_useful_data") + var bytesReadUsefulData: Long?, + @JsonProperty("chunks_written") + var chunksWritten: Long?, + @JsonProperty("chunks_read") + var chunksRead: Long?, + @JsonProperty("chunks_read_useful") + var chunksReadUseful: Long?, + @JsonProperty("chunks_read_wasted") + var chunksReadWasted: Long?, + @JsonProperty("pieces_dirtied_good") + var piecesDirtiedGood: Long?, + @JsonProperty("pieces_dirtied_bad") + var piecesDirtiedBad: Long?, + @JsonProperty("duration_seconds") + var durationSeconds: Double?, + @JsonProperty("bit_rate") + var bitRate: String?, + @JsonProperty("file_stats") + var fileStats: List?, + @JsonProperty("trackers") + var trackers: List?, + ) { + fun streamUrl(url: String): String { + val fileName = + this.fileStats?.first { !it.path.isNullOrBlank() }?.path + ?: throw ErrorLoadingException("Null path") + + val index = url.substringAfter("index=").substringBefore("&").toIntOrNull() ?: 0 + + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/stream.go#L18 + return "$TORRENT_SERVER_URL/stream/${ + URLEncoder.encode(fileName, "utf-8") + }?link=${this.hash}&index=$index&play" + } + } + + data class TorrentFileStat( + @JsonProperty("id") + val id: Int?, + @JsonProperty("path") + val path: String?, + @JsonProperty("length") + val length: Long?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt new file mode 100644 index 000000000..b3873bd32 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt @@ -0,0 +1,678 @@ +@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 new file mode 100644 index 000000000..5937b1973 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt @@ -0,0 +1,3242 @@ +@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 new file mode 100644 index 000000000..52cd4361b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import com.lagradost.cloudstream3.mvvm.debugWarning +import java.util.WeakHashMap + +object LiveHelper { + private val liveManagers = WeakHashMap>() + + @OptIn(UnstableApi::class) + fun registerPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper registerPlayer called with null player!" } + return + } + + // Prevent duplicates + if (liveManagers.contains(player)) { + return + } + + val liveManager = LiveManager(player) + val listener = object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + val window = Timeline.Window() + timeline.getWindow(player.currentMediaItemIndex, window) + if (window.isDynamic) { + liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs)) + } + super.onTimelineChanged(timeline, reason) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs) + + // Seek back to the optimal live spot + if (timeAheadOfLive > 100) { + player.seekTo(newPosition.positionMs - timeAheadOfLive) + } + } + } + + synchronized(liveManagers) { + player.addListener(listener) + liveManagers[player] = liveManager to listener + } + } + + fun unregisterPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper unregisterPlayer called with null player!" } + return + } + + // Prevent duplicates + if (!liveManagers.contains(player)) { + return + } + + synchronized(liveManagers) { + liveManagers[player]?.let { (_, listener) -> + player.removeListener(listener) + } + liveManagers.remove(player) + } + } + + fun getLiveManager(player: Player?) = liveManagers[player]?.first +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt new file mode 100644 index 000000000..8d848d46a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.media3.common.C +import androidx.media3.common.Player +import java.lang.ref.WeakReference + +// How much margin from the live point is still considered "live" +const val LIVE_MARGIN = 6_000L + +// How many ms should we be behind the real live point? +// Too low, and we cannot pre-buffer +// Too high, and we are no longer live +const val PREFERRED_LIVE_OFFSET = 5_000L + +// An extra offset from the optimal calculated timestamp +// This is to account for chunk updates not always being the same size +const val CHUNK_VARIANCE = 3000L + +// A livestream chunk from the player, the time we get it and the duration can be used to calculate +// the expected live timestamp. +class LivestreamChunk( + durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis() +) { + // We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point. + // If we are ahead of the middle point we will reach the end before the new chunk is expected to be released. + val targetPosition = maxOf(0,minOf( + durationMs - PREFERRED_LIVE_OFFSET, + durationMs / 2 - CHUNK_VARIANCE + )) + + fun isPositionLive(position: Long): Boolean { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET + // println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive") + return withinLive + } + + fun getTimeAheadOfLive(position: Long): Long { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + // println("Ahead of live: ${position-livePosition}") + return position - livePosition + } +} + +// There are two types of livestreams we need to manage +// 1. A livestream with no history, a continually sliding window. +// This livestream has no currentLiveOffset, which means we need to calculate +// the real live point based on when we receive the latest update and the size of that update. +// 2. A livestream with history. +// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point. +// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations. +class LiveManager { + private var _currentPlayer: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayer?.get() + + constructor(player: Player?) { + _currentPlayer = WeakReference(player) + } + + private var lastLivestreamChunk: LivestreamChunk? = null + + fun submitLivestreamChunk(chunk: LivestreamChunk) { + lastLivestreamChunk = chunk + } + + /** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */ + fun getTimeAheadOfLive(position: Long): Long { + val player = currentPlayer ?: return 0 + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0 + + // If the currentLiveOffset is wrong we fall back to manual calculations + val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + val relativeOffset = player.currentLiveOffset - player.currentPosition + position + PREFERRED_LIVE_OFFSET - relativeOffset + } else { + lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0 + } + + // Ensure min of 0 + return maxOf(0, ahead) + } + + /** Check if the stream is currently at the expected live edge, with margins */ + fun isAtLiveEdge(): Boolean { + val player = currentPlayer ?: return false + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false + + // If the currentLiveOffset is wrong we fall back to manual calculations + return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET + } else { + lastLivestreamChunk?.isPositionLive(player.currentPosition) == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt new file mode 100644 index 000000000..3001281fd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt @@ -0,0 +1,38 @@ +package com.lagradost.cloudstream3.ui.player.live + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView +import androidx.media3.ui.R +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import java.lang.ref.WeakReference + + +@OptIn(UnstableApi::class) +class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) { + + private var _currentPlayerView: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayerView?.get()?.player + + fun registerPlayerView(player: PlayerView?) { + _currentPlayerView = WeakReference(player) + val controller = + _currentPlayerView?.get()?.findViewById(R.id.exo_controller) + + controller?.setProgressUpdateListener { position, bufferedPosition -> + currentPlayer?.let { player -> + if (isAtLiveEdge()) { + setPosition(player.duration) + } + } + } + } + + fun isAtLiveEdge(): Boolean { + return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt index fb60ccce6..11dd39105 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt @@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.ui.player.source_priority import android.view.LayoutInflater import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.PlayerPrioritizeItemBinding -import com.lagradost.cloudstream3.utils.AppUtils +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState data class SourcePriority( val data: T, @@ -12,46 +12,41 @@ data class SourcePriority( var priority: Int ) -class PriorityAdapter(override val items: MutableList>) : - AppUtils.DiffAdapter>(items) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return PriorityViewHolder( - PlayerPrioritizeItemBinding.inflate(LayoutInflater.from(parent.context),parent,false), - //LayoutInflater.from(parent.context).inflate(R.layout.player_prioritize_item, parent, false) +class PriorityAdapter() : + NoStateAdapter>() { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + PlayerPrioritizeItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PriorityViewHolder -> holder.bind(items[position]) + override fun onBindContent( + holder: ViewHolderState, + item: SourcePriority, + position: Int + ) { + val binding = holder.view as? PlayerPrioritizeItemBinding ?: return + binding.priorityText.text = item.name + + fun updatePriority() { + binding.priorityNumber.text = item.priority.toString() } - } - - class PriorityViewHolder( - val binding: PlayerPrioritizeItemBinding, - ) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: SourcePriority) { - /* val plusButton: ImageView = itemView.add_button - val subtractButton: ImageView = itemView.subtract_button - val priorityText: TextView = itemView.priority_text - val priorityNumber: TextView = itemView.priority_number*/ - binding.priorityText.text = item.name - - fun updatePriority() { - binding.priorityNumber.text = item.priority.toString() - } + updatePriority() + binding.addButton.setOnClickListener { + // If someone clicks til the integer limit then they deserve to crash. + item.priority++ updatePriority() - binding.addButton.setOnClickListener { - // If someone clicks til the integer limit then they deserve to crash. - item.priority++ - updatePriority() - } + } - binding.subtractButton.setOnClickListener { - 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 8153d7a10..85c2a85df 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt @@ -9,47 +9,26 @@ import android.widget.ImageView import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView +import androidx.palette.graphics.Palette import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding -import com.lagradost.cloudstream3.ui.result.UiImage -import com.lagradost.cloudstream3.utils.AppUtils -import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.drawableToBitmap +import com.lagradost.cloudstream3.utils.setText class ProfilesAdapter( - override val items: MutableList, - val usedProfile: Int, + val usedProfile: Int?, val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, ) : - AppUtils.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) - //LayoutInflater.from(parent.context) - // .inflate(R.layout.player_quality_profile_item, parent, false) - ) - } + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.id == b.id + })) { - 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( + companion object { + private val art = arrayOf( R.drawable.profile_bg_teal, R.drawable.profile_bg_blue, R.drawable.profile_bg_dark_blue, @@ -58,54 +37,101 @@ class ProfilesAdapter( R.drawable.profile_bg_red, R.drawable.profile_bg_orange, ) + } - fun bind(item: QualityDataHelper.QualityProfile, index: Int) { - val priorityText: TextView = binding.profileText - val profileBg: ImageView = binding.profileImageBackground - val wifiText: TextView = binding.textIsWifi - val dataText: TextView = binding.textIsMobileData - val outline: View = binding.outline - val cardView: View = binding.cardView + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + PlayerQualityProfileItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } - 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) - } - - outline.isVisible = currentItem?.second?.id == item.id - - profileBg.setImage(UiImage.Drawable(art[index % art.size]), null, false) { palette -> - val color = palette.getDarkVibrantColor( - ContextCompat.getColor( - itemView.context, - R.color.dubColorBg - ) - ) - wifiText.backgroundTintList = ColorStateList.valueOf(color) - dataText.backgroundTintList = ColorStateList.valueOf(color) - } - - val textStyle = - if (item.id == usedProfile) { - Typeface.BOLD - } else { - Typeface.NORMAL - } - - priorityText.setTypeface(null, textStyle) - - cardView.setOnClickListener { - setCurrentItem() + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is PlayerQualityProfileItemBinding -> { + clearImage(binding.profileImageBackground) } } } + + override fun onBindContent( + holder: ViewHolderState, + item: QualityDataHelper.QualityProfile, + position: Int + ) { + val binding = holder.view as? PlayerQualityProfileItemBinding ?: return + + val priorityText: TextView = binding.profileText + val profileBg: ImageView = binding.profileImageBackground + val wifiText: TextView = binding.textIsWifi + val dataText: TextView = binding.textIsMobileData + val downloadText: TextView = binding.textIsDownloadData + val outline: View = binding.outline + val cardView: View = binding.cardView + val itemView = holder.itemView + + priorityText.setText(item.name) + dataText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Data) + wifiText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.WiFi) + downloadText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Download) + + fun setCurrentItem() { + val prevIndex = currentItem + // Prevent UI bug when re-selecting the item quickly + if (prevIndex == position) { + return + } + currentItem = position + clickCallback.invoke(prevIndex, position) + } + + outline.isVisible = currentItem == position + val drawableResId = art[position % art.size] + profileBg.loadImage(drawableResId) + + val drawable = ContextCompat.getDrawable(itemView.context, drawableResId) + if (drawable != null) { + // Convert Drawable to Bitmap + val bitmap = drawableToBitmap(drawable) + if (bitmap != null) { + // Use Palette to extract colors from the bitmap + Palette.from(bitmap).generate { palette -> + val color = palette?.getDarkVibrantColor( + ContextCompat.getColor( + itemView.context, + R.color.dubColorBg + ) + ) + + if (color != null) { + wifiText.backgroundTintList = ColorStateList.valueOf(color) + dataText.backgroundTintList = ColorStateList.valueOf(color) + downloadText.backgroundTintList = ColorStateList.valueOf(color) + } + } + } + } + + val textStyle = + if (item.id == usedProfile) { + Typeface.BOLD + } else { + Typeface.NORMAL + } + + priorityText.setTypeface(null, textStyle) + + cardView.setOnClickListener { + setCurrentItem() + } + } + + private var currentItem: Int? = null + + fun getCurrentProfile(): QualityDataHelper.QualityProfile? { + return currentItem?.let { index -> immutableCurrentList.getOrNull(index) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt index 96249db40..02470484e 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,23 +1,32 @@ package com.lagradost.cloudstream3.ui.player.source_priority -import android.content.Context import androidx.annotation.StringRes -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.debugAssert -import com.lagradost.cloudstream3.ui.result.UiText -import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount +import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities +import kotlin.math.abs object QualityDataHelper { private const val VIDEO_SOURCE_PRIORITY = "video_source_priority" private const val VIDEO_PROFILE_NAME = "video_profile_name" private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority" + + // Old key only supporting one type per profile + @Deprecated("Changed to support multiple types per profile") private const val VIDEO_PROFILE_TYPE = "video_profile_type" + // New key supporting more than one type per profile + + private const val VIDEO_PROFILE_TYPES = "video_profile_types_2" private const val DEFAULT_SOURCE_PRIORITY = 1 + /** * Automatically skip loading links once this priority is reached **/ @@ -34,13 +43,14 @@ object QualityDataHelper { enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) { None(R.string.none, false), WiFi(R.string.wifi, true), - Data(R.string.mobile_data, true) + Data(R.string.mobile_data, true), + Download(R.string.download, true) } data class QualityProfile( val name: UiText, val id: Int, - val type: QualityProfileType + val types: Set ) fun getSourcePriority(profile: Int, name: String?): Int { @@ -52,8 +62,21 @@ object QualityDataHelper { ) ?: DEFAULT_SOURCE_PRIORITY } + fun getAllSourcePriorityNames(profile: Int): List { + val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" + return getKeys(folder)?.map { key -> + key.substringAfter("$folder/") + } ?: emptyList() + } + fun setSourcePriority(profile: Int, name: String, priority: Int) { - setKey("$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, priority) + val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" + // Prevent unnecessary keys + if (priority == DEFAULT_SOURCE_PRIORITY) { + removeKey(folder, name) + } else { + setKey(folder, name, priority) + } } fun setProfileName(profile: Int, name: String?) { @@ -86,16 +109,40 @@ object QualityDataHelper { ) } - fun getQualityProfileType(profile: Int): QualityProfileType { - return getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") ?: QualityProfileType.None + + @Suppress("DEPRECATION") + fun getQualityProfileTypes(profile: Int): Set { + val newKey = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" + // Use arrays for to make with work with setKey properly (weird crashes otherwise) + val newProfiles = getKey>(newKey)?.toSet() + + // Migrate to new profile key + if (newProfiles == null) { + val oldProfile = + getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") + val newSet = oldProfile?.let { arrayOf(it) } ?: arrayOf() + setKey(newKey, newSet) + return newSet.toSet() + } else { + return newProfiles + } } - fun setQualityProfileType(profile: Int, type: QualityProfileType?) { - val path = "$currentAccount/$VIDEO_PROFILE_TYPE/$profile" - if (type == QualityProfileType.None) { - removeKey(path) - } else { - setKey(path, type) + 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()) } } @@ -104,49 +151,51 @@ object QualityDataHelper { * Must under all circumstances at least return one profile **/ fun getProfiles(): List { - val availableTypes = QualityProfileType.values().toMutableList() + val availableTypes = QualityProfileType.entries.toMutableList() val profiles = (1..PROFILE_COUNT).map { profileNumber -> // Get the real type - val type = getQualityProfileType(profileNumber) + val types = getQualityProfileTypes(profileNumber) - // This makes it impossible to get more than one of each type - // Duplicates will be turned to None - val uniqueType = if (type.unique && !availableTypes.remove(type)) { - QualityProfileType.None - } else { - type - } + val uniqueTypes = types.mapNotNull { type -> + // This makes it impossible to get more than one of each type + if (type.unique && !availableTypes.remove(type)) { + null + } else { + type + } + }.toSet() QualityProfile( getProfileName(profileNumber), profileNumber, - uniqueType + uniqueTypes ) }.toMutableList() /** - * If no profile of this type exists: insert it on the earliest profile with None type + * If no profile of this type exists: insert it on the earliest profile **/ fun insertType( list: MutableList, type: QualityProfileType ) { - if (list.any { it.type == type }) return - val index = - list.indexOfFirst { it.type == QualityProfileType.None } - list.getOrNull(index)?.copy(type = type) - ?.let { fixed -> - list.set(index, fixed) - } + if (list.any { it.types.contains(type) }) return + + synchronized(list) { + val firstItem = list.firstOrNull() ?: return + val fixedTypes = firstItem.types + type + val fixedItem = firstItem.copy(types = fixedTypes) + list.set(0, fixedItem) + } } - QualityProfileType.values().forEach { + QualityProfileType.entries.forEach { if (it.unique) insertType(profiles, it) } debugAssert({ - !QualityProfileType.values().all { type -> - !type.unique || profiles.any { it.type == type } + !QualityProfileType.entries.all { type -> + !type.unique || profiles.any { it.types.contains(type) } } }, { "All unique quality types do not exist" }) @@ -156,4 +205,22 @@ object QualityDataHelper { return profiles } + + fun getLinkPriority( + qualityProfile: Int, + linkData: ExtractorLink? + ): Int { + val qualityPriority = getQualityPriority( + qualityProfile, + closestQuality(linkData?.quality) + ) + val sourcePriority = getSourcePriority(qualityProfile, linkData?.source) + + return qualityPriority + sourcePriority + } + + private fun closestQuality(target: Int?): Qualities { + if (target == null) return Qualities.Unknown + return Qualities.entries.minBy { abs(it.value - target) } + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt index e36291584..6a0f12e9a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt @@ -2,47 +2,78 @@ package com.lagradost.cloudstream3.ui.player.source_priority import android.app.Dialog import androidx.annotation.StyleRes +import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getAllSourcePriorityNames import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles -import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.Coroutines.ioWork +import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.setText -class QualityProfileDialog( +/** Simplified ExtractorLink for the quality profile dialog */ +data class LinkSource( + val source: String +) { + constructor(extractorLink: ExtractorLink) : this(extractorLink.source) +} + + +class QualityProfileDialog private constructor( val activity: FragmentActivity, @StyleRes val themeRes: Int, - private val links: List, - private val usedProfile: Int, - private val profileSelectionCallback: (QualityDataHelper.QualityProfile) -> Unit + private val links: List, + private val usedProfile: Int?, + private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?, + private val useProfileSelection: Boolean ) : Dialog(activity, themeRes) { - override fun show() { + constructor( + activity: FragmentActivity, + @StyleRes themeRes: Int, + links: List, + usedProfile: Int, + profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit), + ) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true) + constructor( + activity: FragmentActivity, + @StyleRes themeRes: Int, + links: List + ) : this(activity, themeRes, links, null, null, false) + + companion object { + // Run on IO as this may be a heavy operation + suspend fun getAllDefaultSources(): List = ioWork { + getProfiles().flatMap { + getAllSourcePriorityNames(it.id) + }.distinct().map { LinkSource(it) } + } + } + + override fun show() { val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false) - setContentView(binding.root)//R.layout.player_quality_profile_dialog) - /*val profilesRecyclerView: RecyclerView = profiles_recyclerview - val useBtt: View = use_btt - val editBtt: View = edit_btt - val cancelBtt: View = cancel_btt - val defaultBtt: View = set_default_btt - val currentProfileText: TextView = currently_selected_profile_text - val selectedItemActionsHolder: View = selected_item_holder*/ + setContentView(binding.root) + fixSystemBarsPadding(binding.root) binding.apply { fun getCurrentProfile(): QualityDataHelper.QualityProfile? { return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile() } fun refreshProfiles() { - currentlySelectedProfileText.text = getProfileName(usedProfile).asString(context) - (profilesRecyclerview.adapter as? ProfilesAdapter)?.updateList(getProfiles()) + if (usedProfile != null) { + currentlySelectedProfileText.setText(getProfileName(usedProfile)) + } + (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) } profilesRecyclerview.adapter = ProfilesAdapter( - mutableListOf(), usedProfile, ) { oldIndex: Int?, newIndex: Int -> profilesRecyclerview.adapter?.notifyItemChanged(newIndex) @@ -65,37 +96,52 @@ class QualityProfileDialog( setDefaultBtt.setOnClickListener { val currentProfile = getCurrentProfile() ?: return@setOnClickListener - val choices = QualityDataHelper.QualityProfileType.values() - .filter { it != QualityDataHelper.QualityProfileType.None } + val choices = + QualityDataHelper.QualityProfileType.entries.filter { it != QualityDataHelper.QualityProfileType.None } val choiceNames = choices.map { txt(it.stringRes).asString(context) } + val selectedIndices = choices.mapIndexed { index, type -> index to type } + .filter { currentProfile.types.contains(it.second) }.map { it.first } - activity.showBottomDialog( + activity.showMultiDialog( choiceNames, - choices.indexOf(currentProfile.type), + selectedIndices, txt(R.string.set_default).asString(context), - false, {}, { index -> - val pickedChoice = choices.getOrNull(index) ?: return@showBottomDialog - // Remove previous picks - if (pickedChoice.unique) { - getProfiles().filter { it.type == pickedChoice }.forEach { - QualityDataHelper.setQualityProfileType(it.id, null) + val pickedChoices = index.mapNotNull { choices.getOrNull(it) } + + pickedChoices.forEach { pickedChoice -> + // Remove previous picks + if (pickedChoice.unique) { + getProfiles().filter { it.types.contains(pickedChoice) }.forEach { + QualityDataHelper.removeQualityProfileType(it.id, pickedChoice) + } } + + QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice) } - QualityDataHelper.setQualityProfileType(currentProfile.id, pickedChoice) refreshProfiles() }) } - cancelBtt.setOnClickListener { - this@QualityProfileDialog.dismissSafe() - } + cancelBtt.isVisible = useProfileSelection + useBtt.isVisible = useProfileSelection + applyBtt.isVisible = !useProfileSelection - useBtt.setOnClickListener { - getCurrentProfile()?.let { - profileSelectionCallback.invoke(it) + if (useProfileSelection) { + cancelBtt.setOnClickListener { + this@QualityProfileDialog.dismissSafe() + } + + useBtt.setOnClickListener { + getCurrentProfile()?.let { + profileSelectionCallback?.invoke(it) + this@QualityProfileDialog.dismissSafe() + } + } + } else { + applyBtt.setOnClickListener { this@QualityProfileDialog.dismissSafe() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt index 1b59882e2..c8ac96ebb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt @@ -7,15 +7,15 @@ import androidx.annotation.StyleRes import androidx.appcompat.app.AlertDialog import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding -import com.lagradost.cloudstream3.ui.result.txt -import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SourcePriorityDialog( val ctx: Context, @StyleRes themeRes: Int, - val links: List, + val links: List, private val profile: QualityDataHelper.QualityProfile, /** * Notify that the profile overview should be updated, for example if the name has been updated @@ -24,8 +24,10 @@ class SourcePriorityDialog( private val updatedCallback: () -> Unit ) : Dialog(ctx, themeRes) { override fun show() { - val binding = PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) + val binding = + PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) setContentView(binding.root) + fixSystemBarsPadding(binding.root) val sourcesRecyclerView = binding.sortSources val qualitiesRecyclerView = binding.sortQualities val profileText = binding.profileTextEditable @@ -36,45 +38,46 @@ class SourcePriorityDialog( profileText.setText(QualityDataHelper.getProfileName(profile.id).asString(context)) profileText.hint = txt(R.string.profile_number, profile.id).asString(context) - sourcesRecyclerView.adapter = PriorityAdapter( - links.map { link -> + sourcesRecyclerView.adapter = PriorityAdapter( + ).apply { + submitList(links.map { link -> SourcePriority( null, link.source, QualityDataHelper.getSourcePriority(profile.id, link.source) ) - }.distinctBy { it.name }.sortedBy { -it.priority }.toMutableList() - ) + }.distinctBy { it.name }.sortedBy { -it.priority }) + } - qualitiesRecyclerView.adapter = PriorityAdapter( - Qualities.values().mapNotNull { + qualitiesRecyclerView.adapter = PriorityAdapter( + ).apply { + submitList(Qualities.entries.mapNotNull { SourcePriority( it, Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, QualityDataHelper.getQualityPriority(profile.id, it) ) - }.sortedBy { -it.priority }.toMutableList() - ) + }.sortedBy { -it.priority }) + } @Suppress("UNCHECKED_CAST") // We know the types saveBtt.setOnClickListener { val qualityAdapter = qualitiesRecyclerView.adapter as? PriorityAdapter - val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter + val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter - val qualities = qualityAdapter?.items ?: emptyList() - val sources = sourcesAdapter?.items ?: emptyList() + val qualities = qualityAdapter?.immutableCurrentList ?: emptyList() + val sources = sourcesAdapter?.immutableCurrentList ?: emptyList() qualities.forEach { - val data = it.data as? Qualities ?: return@forEach - QualityDataHelper.setQualityPriority(profile.id, data, it.priority) + QualityDataHelper.setQualityPriority(profile.id, it.data, it.priority) } sources.forEach { QualityDataHelper.setSourcePriority(profile.id, it.name, it.priority) } - qualityAdapter?.updateList(qualities.sortedBy { -it.priority }) - sourcesAdapter?.updateList(sources.sortedBy { -it.priority }) + qualityAdapter?.submitList(qualities.sortedBy { -it.priority }) + sourcesAdapter?.submitList(sources.sortedBy { -it.priority }) val savedProfileName = profileText.text.toString() if (savedProfileName.isBlank()) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt index 89a09ae20..cf9bc9975 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,6 @@ 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.view.LayoutInflater import android.view.View @@ -12,12 +11,10 @@ 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.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList @@ -26,22 +23,35 @@ import com.lagradost.cloudstream3.databinding.QuickSearchBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.loadHomepageList +import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchViewModel -import com.lagradost.cloudstream3.utils.AppUtils.ownShow -import com.lagradost.cloudstream3.utils.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +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.getSpanCount +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import java.util.concurrent.locks.ReentrantLock -class QuickSearchFragment : Fragment() { +class QuickSearchFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(QuickSearchBinding::inflate) +) { companion object { const val AUTOSEARCH_KEY = "autosearch" const val PROVIDER_KEY = "providers" @@ -80,30 +90,29 @@ class QuickSearchFragment : Fragment() { private var providers: Set? = null private lateinit var searchViewModel: SearchViewModel - var binding: QuickSearchBinding? = null - private var bottomSheetDialog: BottomSheetDialog? = null + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) + + // Fix grid + HomeFragment.currentSpan = view.context.getSpanCount() + binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan + HomeFragment.configEvent.invoke() + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { activity?.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] bottomSheetDialog?.ownShow() - val localBinding = QuickSearchBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.quick_search, container, false) - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroy() { @@ -125,25 +134,7 @@ class QuickSearchFragment : Fragment() { return false } - private fun fixGrid() { - activity?.getSpanCount()?.let { - HomeFragment.currentSpan = it - } - binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan - HomeFragment.currentSpan = HomeFragment.currentSpan - HomeFragment.configEvent.invoke(HomeFragment.currentSpan) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - fixGrid() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.quickSearchRoot) - fixGrid() - + override fun onBindingCreated(binding: QuickSearchBinding) { arguments?.getStringArray(PROVIDER_KEY)?.let { providers = it.toSet() } @@ -153,55 +144,101 @@ class QuickSearchFragment : Fragment() { getApiFromNameNull(providers?.first())?.hasQuickSearch ?: false } else false - if (isSingleProvider) { - binding?.quickSearchAutofitResults?.apply { + val firstProvider = providers?.firstOrNull() + if (isSingleProvider && firstProvider != null) { + binding.quickSearchAutofitResults.apply { + setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( - ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } } + binding.quickSearchAutofitResults.addOnScrollListener(object : + RecyclerView.OnScrollListener() { + var expandCount = 0 + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + + val adapter = recyclerView.adapter + if (adapter !is SearchAdapter) return + + val count = adapter.itemCount + val currentHasNext = adapter.hasNext + + if (!recyclerView.isRecyclerScrollable() && currentHasNext && expandCount != count) { + expandCount = count + ioSafe { + searchViewModel.expandAndReturn(firstProvider) + } + } + } + }) + try { - binding?.quickSearch?.queryHint = - getString(R.string.search_hint_site).format(providers?.first()) + binding.quickSearch.queryHint = + getString(R.string.search_hint_site).format(firstProvider) } catch (e: Exception) { logError(e) } } else { - binding?.quickSearchMasterRecycler?.adapter = - ParentItemAdapter(mutableListOf(), { callback -> - SearchHelper.handleSearchClickCallback(callback) - //when (callback.action) { - //SEARCH_ACTION_LOAD -> { - // clickCallback?.invoke(callback) - //} - // else -> SearchHelper.handleSearchClickCallback(activity, callback) - //} - }, { item -> - bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { - bottomSheetDialog = null + binding.quickSearchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) + binding.quickSearchMasterRecycler.adapter = + ParentItemAdapter( + id = "quickSearchMasterRecycler".hashCode(), + { callback -> + SearchHelper.handleSearchClickCallback(callback) + //when (callback.action) { + //SEARCH_ACTION_LOAD -> { + // clickCallback?.invoke(callback) + //} + // else -> SearchHelper.handleSearchClickCallback(activity, callback) + //} + }, + { item -> + bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { + bottomSheetDialog = null + }, expandCallback = { searchViewModel.expandAndReturn(it) }) + }, + expandCallback = { name -> + ioSafe { + searchViewModel.expandAndReturn(name) + } }) - }) - binding?.quickSearchMasterRecycler?.layoutManager = GridLayoutManager(context, 1) + binding.quickSearchMasterRecycler.layoutManager = GridLayoutManager(context, 1) } - binding?.quickSearchAutofitResults?.isVisible = isSingleProvider - binding?.quickSearchMasterRecycler?.isGone = isSingleProvider + binding.quickSearchAutofitResults.isVisible = isSingleProvider + binding.quickSearchMasterRecycler.isGone = isSingleProvider val listLock = ReentrantLock() observe(searchViewModel.currentSearch) { list -> try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (binding?.quickSearchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { - updateList(list.map { ongoing -> - val ongoingList = HomePageList( - ongoing.apiName, - if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() + (binding.quickSearchMasterRecycler.adapter as? ParentItemAdapter)?.apply { + val newItems = list.map { ongoing -> + val dataList = ongoing.value.list + val dataListFiltered = + context?.filterSearchResultByFilmQuality(dataList) ?: dataList + + val homePageList = HomePageList( + ongoing.key, + dataListFiltered ) - ongoingList - }) + + val expandableList = HomeViewModel.ExpandableHomepageList( + homePageList, + ongoing.value.currentPage, + ongoing.value.hasNext + ) + + expandableList + } + + submitList(newItems) + //notifyDataSetChanged() } } catch (e: Exception) { logError(e) @@ -211,18 +248,12 @@ class QuickSearchFragment : Fragment() { } val searchExitIcon = - binding?.quickSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) + binding.quickSearch.findViewById(androidx.appcompat.R.id.search_close_btn) - //val searchMagIcon = - // binding.quickSearch.findViewById(androidx.appcompat.R.id.search_mag_icon) - - //searchMagIcon?.scaleX = 0.65f - //searchMagIcon?.scaleY = 0.65f - - binding?.quickSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding.quickSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { if (search(context, query, false)) - UIHelper.hideKeyboard(binding?.quickSearch) + hideKeyboard(binding.quickSearch) return true } @@ -232,45 +263,50 @@ class QuickSearchFragment : Fragment() { return true } }) - binding?.quickSearchLoadingBar?.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - (binding?.quickSearchAutofitResults?.adapter as? SearchAdapter)?.updateList( - context?.filterSearchResultByFilmQuality(data) ?: data + val adapter = + (binding.quickSearchAutofitResults.adapter as? SearchAdapter) + adapter?.submitList( + context?.filterSearchResultByFilmQuality(data.list) ?: data.list ) + adapter?.hasNext = data.hasNext } searchExitIcon?.alpha = 1f - binding?.quickSearchLoadingBar?.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f } is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - binding?.quickSearchLoadingBar?.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f } is Resource.Loading -> { searchExitIcon?.alpha = 0f - binding?.quickSearchLoadingBar?.alpha = 1f + binding.quickSearchLoadingBar.alpha = 1f } } } + if (isLayout(PHONE or EMULATOR)) { + binding.quickSearchBack.apply { + isVisible = true + setOnClickListener { + activity?.popCurrentPage() + } + } + } - //quick_search.setOnQueryTextFocusChangeListener { _, b -> - // if (b) { - // // https://stackoverflow.com/questions/12022715/unable-to-show-keyboard-automatically-in-the-searchview - // UIHelper.showInputMethod(view.findFocus()) - // } - //} - binding?.quickSearchBack?.setOnClickListener { - activity?.popCurrentPage() + if (isLayout(TV)) { + 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 531cb5d22..056588d0b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt @@ -1,147 +1,140 @@ package com.lagradost.cloudstream3.ui.result +import android.app.SearchManager +import android.content.Intent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorRole import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.CastItemBinding -import com.lagradost.cloudstream3.utils.UIHelper.setImage +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 -class ActorAdaptor(private val focusCallback : (View?) -> Unit = {}) : RecyclerView.Adapter() { - data class ActorMetaData( - var isInverted: Boolean, - val actor: ActorData, - ) +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) } + } - private val actors: MutableList = mutableListOf() + // Easier to store it here than to store it in the ActorData + val inverted: HashMap = hashMapOf() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), focusCallback + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } - 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 onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is CastItemBinding -> { + clearImage(binding.actorImage) } } } - 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) - ) - - actors.clear() - actors.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - fun updateList(newList: List) { - if (actors.size >= newList.size) { - updateActorList(newList.mapIndexed { i, data -> actors[i].copy(actor = data) }) - } else { - updateActorList(newList.mapIndexed { i, data -> - if (i < actors.size) - actors[i].copy(actor = data) - else ActorMetaData(isInverted = false, actor = data) - }) - } - } - - private class CardViewHolder - constructor( - val binding: CastItemBinding, - private val focusCallback : (View?) -> Unit = {} - ) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(actor: ActorData, isInverted: Boolean, position: Int, callback: (Int) -> Unit) { - val (mainImg, vaImage) = if (!isInverted || actor.voiceActor?.image.isNullOrBlank()) { - Pair(actor.actor.image, actor.voiceActor?.image) - } else { - Pair(actor.voiceActor?.image, actor.actor.image) - } - - itemView.setOnFocusChangeListener { v, hasFocus -> - if(hasFocus) { - focusCallback(v) + val (mainImg, vaImage) = if (!isInverted || item.voiceActor?.image.isNullOrBlank()) { + Pair(item.actor.image, item.voiceActor?.image) + } else { + Pair(item.voiceActor?.image, item.actor.image) } - } - itemView.setOnClickListener { - callback(position) - } + // 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 + } - binding.apply { - actorImage.setImage(mainImg) + itemView.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focusCallback(v) + } + } - actorName.text = actor.actor.name - actor.role?.let { - actorExtra.context?.getString( - when (it) { - ActorRole.Main -> { - R.string.actor_main - } + itemView.setOnClickListener { + inverted[item] = !isInverted + this.onUpdateContent(holder, getItem(position), position) + } - ActorRole.Supporting -> { - R.string.actor_supporting - } - - ActorRole.Background -> { - R.string.actor_background + 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) + } } } - )?.let { text -> - actorExtra.isVisible = true - actorExtra.text = text } - } ?: actor.roleString?.let { - actorExtra.isVisible = true - actorExtra.text = it - } ?: run { - actorExtra.isVisible = false + true } - if (actor.voiceActor == null) { - voiceActorImageHolder.isVisible = false - voiceActorName.isVisible = false - } else { - voiceActorName.text = actor.voiceActor.name - voiceActorImageHolder.isVisible = voiceActorImage.setImage(vaImage) + binding.apply { + actorImage.loadImage(mainImg) + + actorName.text = item.actor.name + item.role?.let { + actorExtra.context?.getString( + when (it) { + ActorRole.Main -> { + R.string.actor_main + } + + ActorRole.Supporting -> { + R.string.actor_supporting + } + + ActorRole.Background -> { + R.string.actor_background + } + } + )?.let { text -> + actorExtra.isVisible = true + actorExtra.text = text + } + } ?: item.roleString?.let { + 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) + } } } } } -} - -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 6b63e623c..5e5504164 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,32 +1,49 @@ package com.lagradost.cloudstream3.ui.result -import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.setPadding import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import coil3.dispose +import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ResultEpisodeBinding import com.lagradost.cloudstream3.databinding.ResultEpisodeLargeBinding +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import java.util.* +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +/** + * Ids >= 1000 are reserved for VideoClickActions + * @see VideoClickActionHolder + */ const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 -const val ACTION_PLAY_EPISODE_IN_VLC_PLAYER = 2 -const val ACTION_PLAY_EPISODE_IN_BROWSER = 3 - const val ACTION_CHROME_CAST_EPISODE = 4 const val ACTION_CHROME_CAST_MIRROR = 5 @@ -34,7 +51,6 @@ const val ACTION_DOWNLOAD_EPISODE = 6 const val ACTION_DOWNLOAD_MIRROR = 7 const val ACTION_RELOAD_EPISODE = 8 -const val ACTION_COPY_LINK = 9 const val ACTION_SHOW_OPTIONS = 10 @@ -45,98 +61,77 @@ const val ACTION_SHOW_DESCRIPTION = 15 const val ACTION_DOWNLOAD_EPISODE_SUBTITLE = 13 const val ACTION_DOWNLOAD_EPISODE_SUBTITLE_MIRROR = 14 -const val ACTION_PLAY_EPISODE_IN_WEB_VIDEO = 16 -const val ACTION_PLAY_EPISODE_IN_MPV = 17 - const val ACTION_MARK_AS_WATCHED = 18 -const val TV_EP_SIZE_LARGE = 400 -const val TV_EP_SIZE_SMALL = 300 -data class EpisodeClickEvent(val action: Int, val data: ResultEpisode) + +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) +} class EpisodeAdapter( private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, private val downloadClickCallback: (DownloadClickEvent) -> Unit, -) : RecyclerView.Adapter() { +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.id == b.id +}, contentSame = { a, b -> + a == b +})) { companion object { - /** - * @return ACTION_PLAY_EPISODE_IN_PLAYER, ACTION_PLAY_EPISODE_IN_BROWSER or ACTION_PLAY_EPISODE_IN_VLC_PLAYER depending on player settings. - * See array.xml/player_pref_values - **/ + const val HAS_POSTER: Int = 0 + const val HAS_NO_POSTER: Int = 1 fun getPlayerAction(context: Context): Int { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - return when (settingsManager.getInt(context.getString(R.string.player_pref_key), 1)) { - 1 -> ACTION_PLAY_EPISODE_IN_PLAYER - 2 -> ACTION_PLAY_EPISODE_IN_VLC_PLAYER - 3 -> ACTION_PLAY_EPISODE_IN_BROWSER - 4 -> ACTION_PLAY_EPISODE_IN_WEB_VIDEO - 5 -> ACTION_PLAY_EPISODE_IN_MPV - else -> ACTION_PLAY_EPISODE_IN_PLAYER - } + 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) + } } - var cardList: MutableList = mutableListOf() - - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + override fun onClearView(holder: ViewHolderState) { if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } + + when (val binding = holder.view) { + is ResultEpisodeLargeBinding -> { + clearImage(binding.episodePoster) + } + } + super.onClearView(holder) } - 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()) 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 customContentViewType(item: ResultEpisode): Int = + if (item.poster.isNullOrBlank() && item.description.isNullOrBlank()) HAS_NO_POSTER else HAS_POSTER + override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { return when (viewType) { - 0 -> { - EpisodeCardViewHolderSmall( + HAS_NO_POSTER -> { + ViewHolderState( ResultEpisodeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ), - hasDownloadSupport, - clickCallback, - downloadClickCallback + ) ) } - 1 -> { - EpisodeCardViewHolderLarge( + HAS_POSTER -> { + ViewHolderState( ResultEpisodeLargeBinding.inflate( LayoutInflater.from(parent.context), parent, false - ), - hasDownloadSupport, - clickCallback, - downloadClickCallback + ) ) } @@ -144,244 +139,343 @@ class EpisodeAdapter( } } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is EpisodeCardViewHolderLarge -> { - holder.bind(getItem(position)) - } + override fun onBindContent(holder: ViewHolderState, item: ResultEpisode, position: Int) { + val itemView = holder.itemView + when (val binding = holder.view) { + is ResultEpisodeLargeBinding -> { + val setWidth = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - is EpisodeCardViewHolderSmall -> { - holder.bind(getItem(position)) - } - } - } + binding.apply { + episodeLinHolder.layoutParams.width = setWidth + episodeHolderLarge.layoutParams.width = setWidth + episodeHolder.layoutParams.width = setWidth - override fun getItemCount(): Int { - return cardList.size - } - - class EpisodeCardViewHolderLarge - constructor( - val binding: ResultEpisodeLargeBinding, - private val hasDownloadSupport: Boolean, - private val clickCallback: (EpisodeClickEvent) -> Unit, - private val downloadClickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(binding.root) { - var localCard: ResultEpisode? = null - - @SuppressLint("SetTextI18n") - fun bind(card: ResultEpisode) { - localCard = card - - val setWidth = - if (isTvSettings()) TV_EP_SIZE_LARGE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - - binding.episodeLinHolder.layoutParams.width = setWidth - binding.episodeHolderLarge.layoutParams.width = setWidth - binding.episodeHolder.layoutParams.width = setWidth - - val isTrueTv = isTrueTvSettings() - - binding.apply { - downloadButton.isVisible = hasDownloadSupport - downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( - card.name, - card.poster, - card.episode, - card.season, - card.id, - card.parentId, - card.rating, - card.description, - 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 - } - - episodePoster.isVisible = episodePoster.setImage(card.poster) == true - - if (card.rating != null) { - episodeRating.text = episodeRating.context?.getString(R.string.rated_format) - ?.format(card.rating.toFloat() / 10f) - } else { - episodeRating.text = "" - } - - episodeRating.isGone = episodeRating.text.isNullOrBlank() - - episodeDescript.apply { - text = card.description.html() - isGone = text.isNullOrBlank() - setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) - } - } - - if (!isTrueTv) { - episodePoster.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + if (isLayout(PHONE or EMULATOR) && CommonActivity.appliedTheme == R.style.AmoledMode) { + episodeHolderLarge.radius = 0.0f + episodeHolder.setPadding(0) } - episodePoster.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) - return@setOnLongClickListener true - } - } - } - itemView.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) - } + downloadButton.isVisible = hasDownloadSupport + downloadButton.setDefaultClickListener( + DownloadObjects.DownloadEpisodeCached( + name = item.name, + poster = item.poster, + episode = item.episode, + season = item.season, + id = item.id, + parentId = item.parentId, + score = item.score, + description = item.description, + cacheTime = System.currentTimeMillis(), + ), null + ) { + when (it.action) { + DOWNLOAD_ACTION_DOWNLOAD -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_EPISODE, + item + ) + ) + } - if (isTrueTv) { - itemView.isFocusable = true - itemView.isFocusableInTouchMode = true - //itemView.touchscreenBlocksFocus = false - } + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_MIRROR, + item + ) + ) + } - itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) - return@setOnLongClickListener true - } - - //binding.resultEpisodeDownload.isVisible = hasDownloadSupport - //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport - } - } - - class EpisodeCardViewHolderSmall - constructor( - 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) { - val isTrueTv = isTrueTvSettings() - - binding.episodeHolder.layoutParams.apply { - width = - if (isTvSettings()) TV_EP_SIZE_SMALL.toPx else ViewGroup.LayoutParams.MATCH_PARENT - } - - binding.apply { - downloadButton.isVisible = hasDownloadSupport - downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( - card.name, - card.poster, - card.episode, - card.season, - card.id, - card.parentId, - card.rating, - card.description, - 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) + 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 + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) - 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 + 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 + ) + ) + } + + episodePoster.setOnLongClickListener { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_SHOW_TOAST, + item + ) + ) + return@setOnLongClickListener true + } + } } itemView.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) } - if (isTrueTv) { + if (isLayout(TV)) { itemView.isFocusable = true itemView.isFocusableInTouchMode = true //itemView.touchscreenBlocksFocus = false } itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) + clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) return@setOnLongClickListener true } + } - //binding.resultEpisodeDownload.isVisible = hasDownloadSupport - //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport + 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 + } } } } -} - -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] -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt index ca2934ef3..54657ed57 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,23 +2,15 @@ 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.settings.SettingsFragment.Companion.isTrueTvSettings +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 -/* -class ImageAdapter(context: Context, val resource: Int) : ArrayAdapter(context, resource) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val newConvertView = convertView ?: run { - val mInflater = context - .getSystemService(Activity.LAYOUT_INFLATER_SERVICE) as LayoutInflater - mInflater.inflate(resource, null) - } - getItem(position)?.let { (newConvertView as? ImageView?)?.setImageResource(it) } - return newConvertView - } -}*/ const val IMAGE_CLICK = 0 const val IMAGE_LONG_CLICK = 1 @@ -27,90 +19,54 @@ class ImageAdapter( val nextFocusUp: Int? = null, val nextFocusDown: Int? = null, ) : - RecyclerView.Adapter() { - private val images: MutableList = mutableListOf() + NoStateAdapter( + diffCallback = BaseDiffCallback( + itemSame = Int::equals, + contentSame = Int::equals + ) + ) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } + } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return ImageViewHolder( - //result_mini_image + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) - // LayoutInflater.from(parent.context).inflate(layout, parent, false) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ImageViewHolder -> { - holder.bind(images[position], clickCallback, nextFocusUp, nextFocusDown) + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? ResultMiniImageBinding ?: return + clearImage(binding.root) + } + + override fun onBindContent(holder: ViewHolderState, item: Int, position: Int) { + val binding = holder.view as? ResultMiniImageBinding ?: return + + binding.root.apply { + loadImage(item) + if (nextFocusDown != null) { + this.nextFocusDownId = nextFocusDown } - } - } - - override fun getItemCount(): Int { - return images.size - } - - 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 - constructor(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 + if (nextFocusUp != null) { + this.nextFocusUpId = nextFocusUp + } + if (clickCallback != null) { + if (isLayout(TV)) { + isClickable = true + isLongClickable = true + isFocusable = true + isFocusableInTouchMode = true } - if (nextFocusUp != null) { - this.nextFocusUpId = nextFocusUp + setOnClickListener { + clickCallback.invoke(IMAGE_CLICK) } - if (clickCallback != null) { - if (isTrueTvSettings()) { - isClickable = true - isLongClickable = true - isFocusable = true - isFocusableInTouchMode = true - } - setOnClickListener { - clickCallback.invoke(IMAGE_CLICK) - } - setOnLongClickListener { - clickCallback.invoke(IMAGE_LONG_CLICK) - return@setOnLongClickListener true - } + setOnLongClickListener { + clickCallback.invoke(IMAGE_LONG_CLICK) + return@setOnLongClickListener true } } } } -} - -class DiffCallback(private val oldList: List, private val newList: List) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt index b4e3062b4..3a0edba2f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt @@ -8,6 +8,8 @@ import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.FocusDirection import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout const val FOCUS_SELF = View.NO_ID - 1 const val FOCUS_INHERIT = FOCUS_SELF - 1 @@ -21,18 +23,17 @@ fun RecyclerView?.setLinearListLayout( ) { if (this == null) return val ctx = this.context ?: return - this.layoutManager = - LinearListLayout(ctx).apply { - if (isHorizontal) setHorizontal() else setVertical() - nextFocusLeft = - if (nextLeft == FOCUS_INHERIT) this@setLinearListLayout.nextFocusLeftId else nextLeft - nextFocusRight = - if (nextRight == FOCUS_INHERIT) this@setLinearListLayout.nextFocusRightId else nextRight - nextFocusUp = - if (nextUp == FOCUS_INHERIT) this@setLinearListLayout.nextFocusUpId else nextUp - nextFocusDown = - if (nextDown == FOCUS_INHERIT) this@setLinearListLayout.nextFocusDownId else nextDown - } + this.layoutManager = (this.layoutManager as? LinearListLayout ?: LinearListLayout(ctx)).apply { + if (isHorizontal) setHorizontal() else setVertical() + nextFocusLeft = + if (nextLeft == FOCUS_INHERIT) this@setLinearListLayout.nextFocusLeftId else nextLeft + nextFocusRight = + if (nextRight == FOCUS_INHERIT) this@setLinearListLayout.nextFocusRightId else nextRight + nextFocusUp = + if (nextUp == FOCUS_INHERIT) this@setLinearListLayout.nextFocusUpId else nextUp + nextFocusDown = + if (nextDown == FOCUS_INHERIT) this@setLinearListLayout.nextFocusDownId else nextDown + } } open class LinearListLayout(context: Context?) : @@ -104,13 +105,33 @@ open class LinearListLayout(context: Context?) : } } + fun redirectRecycleToFirstItem(focused: View): View? { + return when (focused) { + is RecyclerView -> { + (focused.layoutManager as? LinearListLayout)?.let { focusedLayoutManager -> + val firstPosition = focusedLayoutManager.findFirstVisibleItemPosition() + val firstView = focusedLayoutManager.findViewByPosition(firstPosition) + firstView + } ?: focused + } + + else -> focused + } + } + override fun onInterceptFocusSearch(focused: View, direction: Int): View? { val dir = if (orientation == HORIZONTAL) { - if (direction == View.FOCUS_DOWN) getNextDirection(focused, FocusDirection.Down)?.let { newFocus -> - return newFocus + if (direction == View.FOCUS_DOWN) getNextDirection( + focused, + FocusDirection.Down + )?.let { newFocus -> + return redirectRecycleToFirstItem(newFocus) } - if (direction == View.FOCUS_UP) getNextDirection(focused, FocusDirection.Up)?.let { newFocus -> - return newFocus + if (direction == View.FOCUS_UP) getNextDirection( + focused, + FocusDirection.Up + )?.let { newFocus -> + return redirectRecycleToFirstItem(newFocus) } if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) { @@ -129,10 +150,16 @@ open class LinearListLayout(context: Context?) : } ret } else { - if (direction == View.FOCUS_RIGHT) getNextDirection(focused, FocusDirection.End)?.let { newFocus -> + if (direction == View.FOCUS_RIGHT) getNextDirection( + focused, + FocusDirection.End + )?.let { newFocus -> return newFocus } - if (direction == View.FOCUS_LEFT) getNextDirection(focused, FocusDirection.Start)?.let { newFocus -> + if (direction == View.FOCUS_LEFT) getNextDirection( + focused, + FocusDirection.Start + )?.let { newFocus -> return newFocus } @@ -151,9 +178,15 @@ open class LinearListLayout(context: Context?) : // if out of bounds then refocus as specified return if (lookFor >= itemCount) { - getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down) + getNextDirection( + focused, + if (orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down + ) } else if (lookFor < 0) { - getNextDirection(focused, if(orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up) + getNextDirection( + focused, + if (orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up + ) } else { getViewFromPos(lookFor) ?: run { scrollToPosition(lookFor) @@ -166,6 +199,38 @@ open class LinearListLayout(context: Context?) : } } + override fun requestChildRectangleOnScreen( + parent: RecyclerView, + child: View, + rect: android.graphics.Rect, + immediate: Boolean, + focusedChildVisible: Boolean + ): Boolean { + if (isLayout(TV) && orientation == HORIZONTAL) { + val dx = when { + isLayoutRTL -> getDecoratedRight(child) - (parent.width - parent.paddingRight) + else -> getDecoratedLeft(child) - parent.paddingLeft + } + return if (dx != 0) { + when { + immediate -> parent.scrollBy(dx, 0) + else -> parent.smoothScrollBy(dx, 0) + } + true + } else { + false + } + } else { + return super.requestChildRectangleOnScreen( + parent, + child, + rect, + immediate, + focusedChildVisible + ) + } + } + /*override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt index 7617bc111..cbf94fd97 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,18 +1,26 @@ 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 com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings +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 import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UiImage const val START_ACTION_RESUME_LATEST = 1 const val START_ACTION_LOAD_EP = 2 @@ -39,7 +47,7 @@ data class ResultEpisode( val index: Int, val position: Long, // time in MS val duration: Long, // duration in MS - val rating: Int?, + val score: Score?, val description: String?, val isFiller: Boolean?, val tvType: TvType, @@ -47,7 +55,12 @@ data class ResultEpisode( /** * Conveys if the episode itself is marked as watched **/ - val videoWatchState: VideoWatchState + val videoWatchState: VideoWatchState, + /** Sum of all previous season episode counts + episode */ + val totalEpisodeIndex: Int? = null, + val airDate: Long? = null, + val runTime: Int? = null, + val seasonData: SeasonData? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -77,33 +90,41 @@ fun buildResultEpisode( apiName: String, id: Int, index: Int, - rating: Int? = null, + rating: Score? = null, description: String? = null, isFiller: Boolean? = null, tvType: TvType, parentId: Int, + totalEpisodeIndex: Int? = null, + airDate: Long? = null, + runTime: Int? = null, + seasonData: SeasonData? = null, ): ResultEpisode { val posDur = getViewPos(id) val videoWatchState = getVideoWatchState(id) ?: VideoWatchState.None return ResultEpisode( - headerName, - name, - poster, - episode, - seasonIndex, - season, - data, - apiName, - id, - index, - posDur?.position ?: 0, - posDur?.duration ?: 0, - rating, - description, - isFiller, - tvType, - parentId, - videoWatchState + 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 ) } @@ -114,6 +135,7 @@ fun ResultEpisode.getWatchProgress(): Float { object ResultFragment { private const val URL_BUNDLE = "url" + private const val NAME_BUNDLE = "name" private const val API_NAME_BUNDLE = "apiName" private const val SEASON_BUNDLE = "season" private const val EPISODE_BUNDLE = "episode" @@ -127,6 +149,7 @@ object ResultFragment { return Bundle().apply { putString(URL_BUNDLE, card.url) putString(API_NAME_BUNDLE, card.apiName) + putString(NAME_BUNDLE, card.name) if (card is DataStoreHelper.ResumeWatchingResult) { if (card.season != null) putInt(SEASON_BUNDLE, card.season) @@ -145,12 +168,14 @@ object ResultFragment { fun newInstance( url: String, apiName: String, + name: String, startAction: Int = 0, startValue: Int = 0 ): Bundle { return Bundle().apply { putString(URL_BUNDLE, url) putString(API_NAME_BUNDLE, apiName) + putString(NAME_BUNDLE, name) putInt(START_ACTION_BUNDLE, startAction) putInt(START_VALUE_BUNDLE, startValue) putBoolean(RESTART_BUNDLE, true) @@ -158,9 +183,10 @@ object ResultFragment { } fun updateUI(id: Int? = null) { - // updateUIListener?.invoke() + // updateUIListener?.invoke() updateUIEvent.invoke(id) } + val updateUIEvent = Event() //private var updateUIListener: (() -> Unit)? = null @@ -188,10 +214,7 @@ object ResultFragment { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel super.onResume() - activity?.let { - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) - } + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) } override fun onDestroy() { @@ -208,18 +231,58 @@ object ResultFragment { data class StoredData( val url: String, val apiName: String, + val name: String, val showFillers: Boolean, val dubStatus: DubStatus, val start: AutoResume?, val playerAction: Int, - val restart : Boolean, + val restart: Boolean, ) + fun bindLogo( + url: String?, + headers: Map?, + logoView: ImageView, + titleView: TextView + ) { + // Cancel it, as we want to remove the listener onSuccess race condition + logoView.dispose() + + if (url.isNullOrBlank()) { + logoView.isVisible = false + titleView.isVisible = true + return + } + + logoView.isVisible = true + titleView.isVisible = false + + logoView.loadImage( + imageData = UiImage.Image(url, headers = headers), + builder = { + listener( + onSuccess = { _, _ -> + logoView.isVisible = true + titleView.isVisible = false + }, + onError = { _, _ -> + logoView.isVisible = false + titleView.isVisible = true + }, + onCancel = { + // If we manually cancel, then it should not do anything + } + ) + } + ) + } + fun Fragment.getStoredData(): StoredData? { val context = this.context ?: this.activity ?: return null val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val url = arguments?.getString(URL_BUNDLE) ?: return null val apiName = arguments?.getString(API_NAME_BUNDLE) ?: return null + val name = arguments?.getString(NAME_BUNDLE) ?: return null val showFillers = settingsManager.getBoolean(context.getString(R.string.show_fillers_key), false) val dubStatus = if (context.getApiDubstatusSettings() @@ -248,7 +311,7 @@ object ResultFragment { season = resumeSeason ) } - return StoredData(url, apiName, showFillers, dubStatus, start, playerAction, restart) + return StoredData(url, apiName, name, showFillers, dubStatus, start, playerAction, restart) } /*private fun reloadViewModel(forceReload: Boolean) { @@ -283,8 +346,6 @@ object ResultFragment { context?.updateHasTrailers() activity?.loadCache() - //activity?.fixPaddingStatusbar(result_barstatus) - /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams backParameter.setMargins( backParameter.leftMargin, @@ -294,8 +355,6 @@ object ResultFragment { ) result_back.layoutParams = backParameter*/ - // activity?.fixPaddingStatusbar(result_toolbar) - val storedData = (activity ?: context)?.let { getStoredData(it) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index a932a57c5..38b24b265 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 @@ -19,106 +19,214 @@ 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.APIHolder.updateHasTrailers -import com.lagradost.cloudstream3.CommonActivity +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.FullScreenPlayer +import com.lagradost.cloudstream3.ui.player.IPlayer +import com.lagradost.cloudstream3.ui.player.PlayerView +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment +import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable -import com.lagradost.cloudstream3.utils.AppUtils.loadCache -import com.lagradost.cloudstream3.utils.AppUtils.openBrowser +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.setImage -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems +import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.getImageFromDrawable +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.setTextHtml +import com.lagradost.cloudstream3.utils.txt +import java.net.URLEncoder +import java.util.concurrent.ConcurrentLinkedDeque +import kotlin.math.roundToInt +open class ResultFragmentPhone : BaseFragment( + BindingCreator.Inflate(FragmentResultSwipeBinding::inflate) +), PlayerView.Callbacks { + private val gestureRegionsListener = + object : PanelsChildGestureRegionObserver.GestureRegionsListener { + override fun onGestureRegionsUpdate(gestureRegions: List) { + binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions) + } + } -open class ResultFragmentPhone : FullScreenPlayer() { - private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { - override fun onGestureRegionsUpdate(gestureRegions: List) { - binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions) + /** Queue of pending actions that is deferred to after a custom path is set */ + private val pendingPathActions = ConcurrentLinkedDeque>() + + /** + * Appends all actions to a queue, and asks for a user to enter the download folder if not already set up. + * + * Then processes the queue in the given order, only after the user has selected a folder. + * This is to defer the download to after a file path is set, due to perms. + * */ + private fun requirePathForActions(list: Collection>) { + pendingPathActions.addAll(list) + val (_, path) = context?.getBasePath() ?: return + if (path == null) { + /** If we have not set any download path, then ask the user for it before we download it */ + try { + /** Give the user some info of what we are doing and why, even if it may be missed */ + showToast(R.string.download_path_pref) + pathPicker.launch(Uri.EMPTY) + } catch (t: Throwable) { + logError(t) + /** Something went wrong, TV Device? + * Use the fallback behavior of just downloading it even if no path is selected, + * and hope it works */ + processPendingActions() + } + } else { + /** + * Otherwise dispatch everything, as we already have a valid download path + * Even if this is "wrong", we do not care as the user has entered something + * */ + processPendingActions() + } + } + + /** Clear all the items in the queue and dispatch them to the viewmodel in order */ + private fun processPendingActions() = viewModel.viewModelScope.launchSafe { + while (!pendingPathActions.isEmpty()) { + try { + val (action, data) = pendingPathActions.pop() + viewModel.handleAction( + EpisodeClickEvent( + action, + data + ) + ) + } catch (_: NoSuchElementException) { + /** In case of a race */ + } + } + } + + private val pathPicker = getChooseFolderLauncher { uri, path -> + if (uri == null) { + /** No path selected, clear the list without acting on it, canceling */ + if (!pendingPathActions.isEmpty()) { + /** Only show on non-empty, just in case */ + showToast(R.string.download_canceled) + pendingPathActions.clear() + } + } else { + /** Select the folder, and dispatch everything */ + pickDownloadPath(uri, path) + processPendingActions() } } protected lateinit var viewModel: ResultViewModel2 protected lateinit var syncModel: SyncViewModel - protected var binding: FragmentResultSwipeBinding? = null protected var resultBinding: FragmentResultBinding? = null protected var recommendationBinding: ResultRecommendationsBinding? = null protected var syncBinding: ResultSyncBinding? = null - override var layout = R.layout.fragment_result_swipe + 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 fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - viewModel = - ViewModelProvider(this)[ResultViewModel2::class.java] - syncModel = - ViewModelProvider(this)[SyncViewModel::class.java] - updateUIEvent += ::updateUI + protected var playerHostView: PlayerView? = null - 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 - } + open fun updateUIVisibility() {} - return root + protected fun uiReset() { + isShowing = false + updateUIVisibility() } - var currentTrailers: List = emptyList() + open fun showMirrorsDialogue() {} + open fun showTracksDialogue() {} + open fun openOnlineSubPicker( + context: android.content.Context, + loadResponse: LoadResponse?, + dismissCallback: () -> Unit + ) {} + + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + PanelsChildGestureRegionObserver.Provider.get().apply { + resultBinding?.resultCastItems?.let { register(it) } + } + } + + var currentTrailers: List> = emptyList() var currentTrailerIndex = 0 override fun nextMirror() { @@ -130,34 +238,37 @@ open class ResultFragmentPhone : FullScreenPlayer() { return currentTrailerIndex + 1 < currentTrailers.size } - override fun playerError(exception: Exception) { - if (player.getIsPlaying()) { // because we dont want random toasts in player - super.playerError(exception) + override fun playerError(exception: Throwable) { + if (player.getIsPlaying()) { // because we don't want random toasts in player + playerHostView?.playerError(exception) } else { nextMirror() } } private fun loadTrailer(index: Int? = null) { + val isSuccess = - currentTrailers.getOrNull(index ?: currentTrailerIndex)?.let { trailer -> - context?.let { ctx -> - player.onPause() - player.loadPlayer( - ctx, - false, - trailer, - null, - startPosition = 0L, - subtitles = emptySet(), - subtitle = null, - autoPlay = false - ) - true + currentTrailers.getOrNull(index ?: currentTrailerIndex) + ?.let { (extractedTrailerLink, _) -> + context?.let { ctx -> + player.onPause() + player.loadPlayer( + ctx, + false, + extractedTrailerLink, + null, + startPosition = 0L, + subtitles = emptySet(), + subtitle = null, + autoPlay = false, + preview = false + ) + true + } ?: run { + false + } } ?: run { - false - } - } ?: run { false } //result_trailer_thumbnail?.setImageBitmap(result_poster_background?.drawable?.toBitmap()) @@ -166,6 +277,17 @@ open class ResultFragmentPhone : FullScreenPlayer() { // result_trailer_loading?.isVisible = isSuccess val turnVis = !isSuccess && !isFullScreenPlayer resultBinding?.apply { + // If we load a trailer, then cancel the big logo and only show the small title + if (isSuccess) { + // This is still a bit of a race condition, but it should work if we have the + // trailers observe after the page observe! + bindLogo( + url = null, + headers = null, + logoView = backgroundPosterWatermarkBadge, + titleView = resultTitle + ) + } resultSmallscreenHolder.isVisible = turnVis resultPosterBackgroundHolder.apply { val fadeIn: Animation = AlphaAnimation(alpha, if (turnVis) 1.0f else 0.0f).apply { @@ -185,8 +307,6 @@ open class ResultFragmentPhone : FullScreenPlayer() { } binding?.resultFullscreenHolder?.isVisible = !isSuccess && isFullScreenPlayer } - - //player_view?.apply { //alpha = 0.0f //ObjectAnimator.ofFloat(player_view, "alpha", 1f).apply { @@ -200,22 +320,17 @@ open class ResultFragmentPhone : FullScreenPlayer() { // fillAfter = true //} //startAnimation(fadeIn) - // } - - + //} } - private fun setTrailers(trailers: List?) { + private fun setTrailers(trailers: List>?) { context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return - currentTrailers = trailers?.sortedBy { -it.quality } ?: emptyList() + currentTrailers = trailers?.sortedBy { -it.first.quality } ?: emptyList() loadTrailer() } override fun onDestroyView() { - - //somehow this still leaks and I dont know why???? - // todo look at https://github.com/discord/OverlappingPanels/blob/70b4a7cf43c6771873b1e091029d332896d41a1a/sample_app/src/main/java/com/discord/sampleapp/MainActivity.kt PanelsChildGestureRegionObserver.Provider.get().let { obs -> resultBinding?.resultCastItems?.let { obs.unregister(it) @@ -225,10 +340,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { } updateUIEvent -= ::updateUI - binding = null + playerHostView?.release() + playerBinding = null + resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null syncBinding = null recommendationBinding = null + activity?.detachBackPressedCallback(this@ResultFragmentPhone.toString()) super.onDestroyView() } @@ -246,6 +364,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } var selectSeason: String? = null + var selectEpisodeRange: String? = null private fun setUrl(url: String?) { if (url == null) { @@ -287,9 +406,10 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel - activity?.let { - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + context?.let { ctx -> + playerHostView?.onResume(ctx) + playerHostView?.setupKeyEventListener() } super.onResume() PanelsChildGestureRegionObserver.Provider.get() @@ -298,25 +418,44 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel + playerHostView?.onStop() super.onStop() } + @Suppress("UNUSED_PARAMETER") private fun updateUI(id: Int?) { syncModel.updateUserData() viewModel.reloadEpisodes() } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + // Set up sub-binding references + viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] + syncModel = ViewModelProvider(this)[SyncViewModel::class.java] + updateUIEvent += ::updateUI + + resultBinding = binding.fragmentResult + recommendationBinding = binding.resultRecommendations + syncBinding = binding.resultSync + + // Set up trailer player + val ctx = context ?: return + playerHostView = PlayerView(ctx) + playerHostView?.player = player + playerHostView?.hasPipModeSupport = hasPipModeSupport + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerBinding = binding.root.findViewById(R.id.player_holder)?.let { + TrailerCustomLayoutBinding.bind(it) + } + playerHostView?.initialize() // ===== setup ===== - UIHelper.fixPaddingStatusbar(binding?.resultTopBar) val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() activity?.loadCache() context?.updateHasTrailers() - hideKeyboard() + hideKeyboard(binding.root) if (storedData.restart || !viewModel.hasLoaded()) viewModel.load( activity, @@ -331,17 +470,26 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncModel.addFromUrl(storedData.url) val api = APIHolder.getApiFromNameNull(storedData.apiName) - PanelsChildGestureRegionObserver.Provider.get().apply { - resultBinding?.resultCastItems?.let { - register(it) + // 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( + object : OverlappingPanelsLayout.PanelStateListener { + override fun onPanelStateChange(panelState: PanelState) { + PanelsChildGestureRegionObserver.Provider.get().apply { + resultBinding?.resultCastItems?.let { register(it) } + } + } } - addGestureRegionsUpdateListener(gestureRegionsListener) - } - - + ) // ===== ===== ===== + binding.resultSearch.isGone = storedData.name.isBlank() + binding.resultSearch.setOnClickListener { + QuickSearchFragment.pushSearch(activity, storedData.name) + } + resultBinding?.apply { resultReloadConnectionerror.setOnClickListener { viewModel.load( @@ -367,7 +515,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { focused: View? ): Boolean { // Make the cast always focus the first visible item when focused - // from somewhere else. Otherwise it jumps to the last item. + // from somewhere else. Otherwise, it jumps to the last item. return if (parent.focusedChild == null) { scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) true @@ -378,26 +526,57 @@ open class ResultFragmentPhone : FullScreenPlayer() { }.apply { this.orientation = RecyclerView.HORIZONTAL }*/ + resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) resultCastItems.adapter = ActorAdaptor() - + resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) resultEpisodes.adapter = EpisodeAdapter( api?.hasDownloadSupport == true, { episodeClick -> - viewModel.handleAction(episodeClick) + when (episodeClick.action) { + ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> { + requirePathForActions(listOf(episodeClick.action to episodeClick.data)) + } + + else -> viewModel.handleAction(episodeClick) + } }, { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) } + ) + 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 @@ -409,47 +588,84 @@ open class ResultFragmentPhone : FullScreenPlayer() { }) } - binding?.apply { + binding.apply { resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultBack.setOnClickListener { activity?.popCurrentPage() } + activity?.attachBackPressedCallback(this@ResultFragmentPhone.toString()) { + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + runDefault() + } else resultOverlappingPanels.closePanels() + } + resultMiniSync.setOnClickListener { + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + resultOverlappingPanels.openStartPanel() + } else resultOverlappingPanels.closePanels() + } + + /* + resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool) resultMiniSync.adapter = ImageAdapter( nextFocusDown = R.id.result_sync_set_score, clickCallback = { action -> if (action == IMAGE_CLICK || action == IMAGE_LONG_CLICK) { - if (binding?.resultOverlappingPanels?.getSelectedPanel()?.ordinal == 1) { - binding?.resultOverlappingPanels?.openStartPanel() - } else { - binding?.resultOverlappingPanels?.closePanels() - } + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + resultOverlappingPanels.openStartPanel() + } else resultOverlappingPanels.closePanels() } }) + */ resultSubscribe.setOnClickListener { - val isSubscribed = - viewModel.toggleSubscriptionStatus() ?: return@setOnClickListener + viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleSubscriptionStatus - val message = if (isSubscribed) { - // Kinda icky to have this here, but it works. - SubscriptionWorkManager.enqueuePeriodicWork(context) - R.string.subscription_new - } else { - R.string.subscription_deleted + val message = if (newStatus) { + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else { + R.string.subscription_deleted + } + + 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 + ) } + context?.let { openBatteryOptimizationSettings(it) } + } + resultFavorite.setOnClickListener { + viewModel.toggleFavoriteStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleFavoriteStatus - val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: txt(R.string.no_data).asStringNull(context) ?: "" - CommonActivity.showToast(txt(message, name), Toast.LENGTH_SHORT) + val message = if (newStatus) { + R.string.favorite_added + } else { + R.string.favorite_removed + } + + 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 + ) + } } mediaRouteButton.apply { val chromecastSupport = api?.hasChromecastSupport == true alpha = if (chromecastSupport) 1f else 0.3f if (!chromecastSupport) { setOnClickListener { - CommonActivity.showToast( + showToast( R.string.no_chromecast_support_toast, Toast.LENGTH_LONG ) @@ -459,8 +675,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { if (act.isCastApiAvailable()) { try { CastButtonFactory.setUpMediaRouteButton(act, this) - val castContext = CastContext.getSharedInstance(act.applicationContext) - isGone = castContext.castState == CastState.NO_DEVICES_AVAILABLE + CastContext.getSharedInstance(act.applicationContext) { + it.run() + }.addOnCompleteListener { + isGone = !it.isSuccessful + } // this shit leaks for some reason //castContext.addCastStateListener { state -> // media_route_button?.isGone = state == CastState.NO_DEVICES_AVAILABLE @@ -475,8 +694,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { playerBinding?.apply { playerOpenSource.setOnClickListener { - currentTrailers.getOrNull(currentTrailerIndex)?.let { - context?.openBrowser(it.url) + currentTrailers.getOrNull(currentTrailerIndex)?.let { (_, ogTrailerLink) -> + context?.openBrowser(ogTrailerLink) } } } @@ -484,9 +703,9 @@ open class ResultFragmentPhone : FullScreenPlayer() { recommendationBinding?.apply { resultRecommendationsList.apply { spanCount = 3 + setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( - ArrayList(), this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) @@ -510,10 +729,13 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultBinding?.apply { if (resume == null) { resultResumeParent.isVisible = false + resultPlayParent.isVisible = true + resultResumeProgressHolder.isVisible = false return@observeNullable } resultResumeParent.isVisible = true resume.progress?.let { progress -> + resultNextSeriesButton.isVisible = false resultResumeSeriesTitle.apply { isVisible = !resume.isMovie text = @@ -523,8 +745,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { resume.result.season ) } - - resultResumeSeriesProgressText.setText(progress.progressLeft) + if (resume.isMovie) { + resultPlayParent.isGone = true + resultResumeSeriesProgressText.isVisible = true + resultResumeSeriesProgressText.setText(progress.progressLeft) + } resultResumeSeriesProgress.apply { isVisible = true this.max = progress.maxProgress @@ -533,25 +758,30 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultResumeProgressHolder.isVisible = true } ?: run { resultResumeProgressHolder.isVisible = false + if (!resume.isMovie) { + resultNextSeriesButton.isVisible = true + resultNextSeriesButton.text = context?.getNameFull( + resume.result.name, + resume.result.episode, + resume.result.season + ) + } resultResumeSeriesProgress.isVisible = false resultResumeSeriesTitle.isVisible = false resultResumeSeriesProgressText.isVisible = false } - resultResumeSeriesButton.isVisible = !resume.isMovie resultResumeSeriesButton.setOnClickListener { - viewModel.handleAction( - EpisodeClickEvent( - storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, - resume.result - ) - ) + resumeAction(storedData, resume) + } + resultNextSeriesButton.setOnClickListener { + resumeAction(storedData, resume) } } } observeNullable(viewModel.subscribeStatus) { isSubscribed -> - binding?.resultSubscribe?.isVisible = isSubscribed != null + binding.resultSubscribe.isVisible = isSubscribed != null if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { @@ -560,11 +790,20 @@ open class ResultFragmentPhone : FullScreenPlayer() { R.drawable.baseline_notifications_none_24 } - binding?.resultSubscribe?.setImageResource(drawable) + binding.resultSubscribe.setImageResource(drawable) } - observe(viewModel.trailers) { trailers -> - setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! + observeNullable(viewModel.favoriteStatus) { isFavorite -> + binding.resultFavorite.isVisible = isFavorite != null + if (isFavorite == null) return@observeNullable + + val drawable = if (isFavorite) { + R.drawable.ic_baseline_favorite_24 + } else { + R.drawable.ic_baseline_favorite_border_24 + } + + binding.resultFavorite.setImageResource(drawable) } observeNullable(viewModel.episodes) { episodes -> @@ -572,8 +811,58 @@ open class ResultFragmentPhone : FullScreenPlayer() { // no failure? resultEpisodeLoading.isVisible = episodes is Resource.Loading resultEpisodes.isVisible = episodes is Resource.Success + resultBatchDownloadButton.isVisible = + episodes is Resource.Success && episodes.value.isNotEmpty() + if (episodes is Resource.Success) { - (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) + (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) + + // Show quality dialog with all sources + resultBatchDownloadButton.setOnLongClickListener { + ioSafe { + val defaultSources = QualityProfileDialog.getAllDefaultSources() + val activity = activity ?: return@ioSafe + activity.runOnUiThread { + QualityProfileDialog( + activity, + R.style.DialogFullscreenPlayer, + defaultSources, + ).show() + } + } + + true + } + + resultBatchDownloadButton.setOnClickListener { view -> + val episodeStart = + episodes.value.firstOrNull()?.episode ?: return@setOnClickListener + val episodeEnd = + episodes.value.lastOrNull()?.episode ?: return@setOnClickListener + + val episodeRange = if (episodeStart == episodeEnd) { + episodeStart.toString() + } else { + txt( + R.string.episodes_range, + episodeStart, + episodeEnd + ).asString(view.context) + } + + val rangeMessage = txt( + R.string.download_episode_range, + episodeRange + ).asString(view.context) + + AlertDialog.Builder(view.context, R.style.AlertDialogCustom) + .setTitle(R.string.download_all) + .setMessage(rangeMessage) + .setPositiveButton(R.string.yes) { _, _ -> + requirePathForActions(episodes.value.map { ACTION_DOWNLOAD_EPISODE to it }) + } + .setNegativeButton(R.string.cancel) { _, _ -> }.show() + } } } } @@ -597,34 +886,38 @@ open class ResultFragmentPhone : FullScreenPlayer() { ) return@setOnLongClickListener true } + resultResumeSeriesButton.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) + ) + return@setOnLongClickListener true + } + + val status = VideoDownloadManager.downloadStatus[ep.id] + downloadButton.setStatus(status) downloadButton.setDefaultClickListener( - VideoDownloadHelper.DownloadEpisodeCached( - ep.name, - ep.poster, - 0, - null, - ep.id, - ep.id, - null, - null, - System.currentTimeMillis(), + DownloadObjects.DownloadEpisodeCached( + name = ep.name, + poster = ep.poster, + episode = 0, + season = null, + id = ep.id, + parentId = ep.id, + score = ep.score, + description = ep.description, + cacheTime = System.currentTimeMillis(), ), null ) { click -> + context?.let { openBatteryOptimizationSettings(it) } + when (click.action) { DOWNLOAD_ACTION_DOWNLOAD -> { - viewModel.handleAction( - EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) - ) + requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep)) } DOWNLOAD_ACTION_LONG_CLICK -> { - viewModel.handleAction( - EpisodeClickEvent( - ACTION_DOWNLOAD_MIRROR, - ep - ) - ) + requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep)) } else -> DownloadButtonSetup.handleDownloadClick(click) @@ -637,6 +930,9 @@ open class ResultFragmentPhone : FullScreenPlayer() { observe(viewModel.page) { data -> if (data == null) return@observe resultBinding?.apply { + PanelsChildGestureRegionObserver.Provider.get().apply { + register(resultCastItems) + } (data as? Resource.Success)?.value?.let { d -> resultVpn.setText(d.vpnText) resultInfo.setText(d.metaText) @@ -647,20 +943,46 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultMetaYear.setText(d.yearText) resultMetaDuration.setText(d.durationText) resultMetaRating.setText(d.ratingText) + resultMetaStatus.setText(d.onGoingText) + resultMetaContentRating.setText(d.contentRatingText) resultCastText.setText(d.actorsText) resultNextAiring.setText(d.nextAiringEpisode) resultNextAiringTime.setText(d.nextAiringDate) - resultPoster.setImage(d.posterImage) - resultPosterBackground.setImage(d.posterBackgroundImage) - resultDescription.setTextHtml(d.plotText) - resultDescription.setOnClickListener { view -> - // todo bottom view? - view.context?.let { ctx -> - val builder: AlertDialog.Builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - builder.setMessage(d.plotText.asString(ctx).html()) - .setTitle(d.plotHeaderText.asString(ctx)) - .show() + resultPoster.loadImage(d.posterImage, headers = d.posterHeaders) { + error { + getImageFromDrawable( + context ?: return@error null, + R.drawable.default_cover + ) + } + } + resultPosterBackground.loadImage( + d.posterBackgroundImage, + headers = d.posterHeaders + ) { + error { + getImageFromDrawable( + context ?: return@error null, + R.drawable.default_cover + ) + } + } + + bindLogo( + url = d.logoUrl, + headers = d.posterHeaders, + titleView = resultTitle, + logoView = backgroundPosterWatermarkBadge + ) + + var isExpanded = false + resultDescription.apply { + setTextHtml(d.plotText) + setOnClickListener { + isExpanded = !isExpanded + maxLines = if (isExpanded) { + Integer.MAX_VALUE + } else 10 } } @@ -669,8 +991,20 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultComingSoon.isVisible = d.comingSoon resultDataHolder.isGone = d.comingSoon - resultCastItems.isGone = d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.updateList(d.actors ?: emptyList()) + val prefs = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) + val showCast = prefs.getBoolean( + root.context.getString(R.string.show_cast_in_details_key), + true + ) + + resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) + + if (d.contentRatingText == null) { + // If there is no rating to display, we don't want an empty gap + resultMetaContentRating.width = 0 + } if (syncModel.addSyncs(d.syncData)) { syncModel.updateMetaAndUser() @@ -679,7 +1013,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncModel.addFromUrl(d.url) } - binding?.apply { + binding.apply { + resultSearch.isGone = d.title.isBlank() resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) } @@ -687,15 +1022,23 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultShare.setOnClickListener { try { val i = Intent(Intent.ACTION_SEND) + val nameBase64 = + base64Encode(d.apiName.toString().toByteArray(Charsets.UTF_8)) + val urlBase64 = base64Encode(d.url.toByteArray(Charsets.UTF_8)) + val encodedUri = URLEncoder.encode( + "$APP_STRING_SHARE:$nameBase64?$urlBase64", + "UTF-8" + ) + val redirectUrl = + "https://recloudstream.github.io/csredirect?redirectto=$encodedUri" i.type = "text/plain" i.putExtra(Intent.EXTRA_SUBJECT, d.title) - i.putExtra(Intent.EXTRA_TEXT, d.url) + i.putExtra(Intent.EXTRA_TEXT, redirectUrl) startActivity(Intent.createChooser(i, d.title)) } catch (e: Exception) { logError(e) } } - setUrl(d.url) resultBookmarkFab.apply { isVisible = true @@ -705,10 +1048,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { } (data as? Resource.Failure)?.let { data -> + @SuppressLint("SetTextI18n") resultErrorText.text = storedData.url.plus("\n") + data.errorString } - binding?.resultBookmarkFab?.isVisible = data is Resource.Success + binding.resultBookmarkFab.isVisible = data is Resource.Success resultFinishLoading.isVisible = data is Resource.Success resultLoading.isVisible = data is Resource.Loading @@ -716,6 +1060,14 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultLoadingError.isVisible = data is Resource.Failure resultErrorText.isVisible = data is Resource.Failure resultReloadConnectionOpenInBrowser.isVisible = data is Resource.Failure + + resultTitle.setOnLongClickListener { + clipboardHelper( + com.lagradost.cloudstream3.utils.txt(R.string.title), + resultTitle.text + ) + true + } } } @@ -747,14 +1099,17 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + observe(viewModel.trailers) { trailers -> + setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet! + } + observe(syncModel.synced) { list -> syncBinding?.resultSyncNames?.text = list.filter { it.isSynced && it.hasAccount }.joinToString { it.name } val newList = list.filter { it.isSynced && it.hasAccount } - binding?.resultMiniSync?.isVisible = newList.isNotEmpty() - (binding?.resultMiniSync?.adapter as? ImageAdapter)?.updateList(newList.mapNotNull { it.icon }) + binding.resultMiniSync.isVisible = newList.isNotEmpty() } @@ -762,7 +1117,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { fun setSyncMaxEpisodes(totalEpisodes: Int?) { syncBinding?.resultSyncEpisodes?.max = (totalEpisodes ?: 0) * 1000 - normalSafeApiCall { + safe { val ctx = syncBinding?.resultSyncEpisodes?.context syncBinding?.resultSyncMaxEpisodes?.text = totalEpisodes?.let { episodes -> @@ -815,8 +1170,12 @@ open class ResultFragmentPhone : FullScreenPlayer() { resultSyncHolder.isVisible = true val d = status.value - resultSyncRating.value = d.score?.toFloat() ?: 0.0f - resultSyncCheck.setItemChecked(d.status + 1, true) + val desiredScore = d.score?.toFloat(1) ?: 0.0f + val totalSteps = (resultSyncRating.valueTo / resultSyncRating.stepSize) + val desiredStep = (totalSteps * desiredScore).roundToInt() + resultSyncRating.value = desiredStep * resultSyncRating.stepSize + + resultSyncCheck.setItemChecked(d.status.internalId + 1, true) val watchedEpisodes = d.watchedEpisodes ?: 0 currentSyncProgress = watchedEpisodes @@ -832,11 +1191,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { } resultSyncCurrentEpisodes.text = Editable.Factory.getInstance()?.newEditable(watchedEpisodes.toString()) - normalSafeApiCall { // format might fail - context?.getString(R.string.sync_score_format)?.format(d.score ?: 0) - ?.let { - resultSyncScoreText.text = it - } + safe { // format might fail + val text = d.score?.toFloat(10)?.roundToInt()?.let { + context?.getString(R.string.sync_score_format)?.format(it) + } ?: "?" + resultSyncScoreText.text = text } } @@ -845,24 +1204,11 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } } - binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) } - observe(viewModel.episodeSynopsis) { description -> - // TODO bottom dialog - view.context?.let { ctx -> - val builder: AlertDialog.Builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - builder.setMessage(description.html()) - .setTitle(R.string.synopsis) - .setOnDismissListener { - viewModel.releaseEpisodeSynopsis() - } - .show() - } - } context?.let { ctx -> val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) /* @@ -887,14 +1233,14 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncBinding?.apply { resultSyncCheck.choiceMode = AbsListView.CHOICE_MODE_SINGLE resultSyncCheck.adapter = arrayAdapter - UIHelper.setListViewHeightBasedOnItems(resultSyncCheck) + setListViewHeightBasedOnItems(resultSyncCheck) resultSyncCheck.setOnItemClickListener { _, _, which, _ -> syncModel.setStatus(which - 1) } - resultSyncRating.addOnChangeListener { _, value, _ -> - syncModel.setScore(value.toInt()) + resultSyncRating.addOnChangeListener { it, value, fromUser -> + if (fromUser) syncModel.setScore(Score.from(value, it.valueTo.roundToInt())) } resultSyncAddEpisode.setOnClickListener { @@ -919,7 +1265,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { } observe(viewModel.watchStatus) { watchType -> - binding?.resultBookmarkFab?.apply { + binding.resultBookmarkFab.apply { setText(watchType.stringRes) if (watchType == WatchType.NONE) { context?.colorFromAttribute(R.attr.white) @@ -933,12 +1279,12 @@ open class ResultFragmentPhone : FullScreenPlayer() { setOnClickListener { fab -> activity?.showBottomDialog( - WatchType.values().map { fab.context.getString(it.stringRes) }.toList(), + WatchType.entries.map { fab.context.getString(it.stringRes) }.toList(), watchType.ordinal, fab.context.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { - viewModel.updateWatchStatus(WatchType.values()[it]) + viewModel.updateWatchStatus(WatchType.entries[it], context) } } } @@ -956,20 +1302,28 @@ open class ResultFragmentPhone : FullScreenPlayer() { 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 -> @@ -997,6 +1351,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { observeNullable(viewModel.selectedRange) { range -> resultBinding?.apply { resultEpisodeSelect.setText(range) + + selectEpisodeRange = range?.asStringNull(resultEpisodeSelect.context) // If Season button is invisible then the bookmark button next focus is episode select if (resultEpisodeSelect.isVisible && !resultSeasonButton.isVisible && resultResumeParent.isVisible) { setFocusUpAndDown(resultResumeSeriesButton, resultEpisodeSelect) @@ -1009,14 +1365,15 @@ open class ResultFragmentPhone : FullScreenPlayer() { 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 - ) - }) { - viewModel.changeDubStatus(DubStatus.values()[itemId]) + view.popupMenuNoIconsAndNoStringRes( + range + .mapNotNull { (text, status) -> + Pair( + status.ordinal, + text?.asStringNull(ctx) ?: return@mapNotNull null + ) + }) { + viewModel.changeDubStatus(DubStatus.entries[itemId]) } } } @@ -1030,9 +1387,12 @@ open class ResultFragmentPhone : FullScreenPlayer() { r to (text?.asStringNull(ctx) ?: return@mapNotNull null) } - view.popupMenuNoIconsAndNoStringRes(names.mapIndexed { index, (_, name) -> - index to name - }) { + activity?.showDialog( + names.map { it.second }, + names.indexOfFirst { it.second == selectEpisodeRange }, + ctx.getString(R.string.episodes), + false, + {}) { itemId -> viewModel.changeRange(names[itemId].first) } } @@ -1051,7 +1411,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectSeason }, - "", + ctx.getString(R.string.season), false, {}) { itemId -> viewModel.changeSeason(names[itemId].first) @@ -1068,9 +1428,23 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } + private fun resumeAction( + storedData: ResultFragment.StoredData, + resume: ResumeWatchingStatus + ) { + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, + resume.result + ) + ) + } + override fun onPause() { + playerHostView?.releaseKeyEventListener() super.onPause() - PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(gestureRegionsListener) + PanelsChildGestureRegionObserver.Provider.get() + .addGestureRegionsUpdateListener(gestureRegionsListener) } private fun setRecommendations(rec: List?, validApiName: String?) { @@ -1081,7 +1455,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { root.isGone = isInvalid root.post { rec?.let { list -> - (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { it.apiName == matchAgainst }) } } } 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 be3de52b0..cfbacc5d1 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 @@ -8,15 +8,17 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup 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.lagradost.cloudstream3.APIHolder.updateHasTrailers +import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent @@ -26,35 +28,54 @@ import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding 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.utils.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.AppUtils.isRtl -import com.lagradost.cloudstream3.utils.AppUtils.loadCache +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.setImage +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 : Fragment() { - protected lateinit var viewModel: ResultViewModel2 - private var binding: FragmentResultTvBinding? = null +class ResultFragmentTv : BaseFragment( + BindingCreator.Inflate(FragmentResultTvBinding::inflate) +) { + + private lateinit var viewModel: ResultViewModel2 override fun onDestroyView() { - binding = null updateUIEvent -= ::updateUI + activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) super.onDestroyView() } @@ -62,15 +83,13 @@ class ResultFragmentTv : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] viewModel.EPISODE_RANGE_SIZE = 50 updateUIEvent += ::updateUI - val localBinding = FragmentResultTvBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root + return super.onCreateView(inflater, container, savedInstanceState) } private fun updateUI(id: Int?) { @@ -104,7 +123,7 @@ class ResultFragmentTv : Fragment() { } private fun RecyclerView?.update(data: List) { - (this?.adapter as? SelectAdaptor?)?.updateSelectionList(data) + (this?.adapter as? SelectAdaptor?)?.submitList(data) this?.isVisible = data.size > 1 } @@ -114,10 +133,20 @@ class ResultFragmentTv : Fragment() { } } - private fun hasNoFocus(): Boolean { - val focus = activity?.currentFocus - if (focus == null || !focus.isVisible) return true - return focus == binding?.resultRoot +// private fun hasNoFocus(): Boolean { +// val focus = activity?.currentFocus +// if (focus == null || !focus.isVisible) return true +// return focus == binding?.resultRoot +// } + + /** + * Force focus any play button. + * Note that this will steal any focus if the episode loading is too slow (unlikely). + */ + private fun focusPlayButton() { + binding?.resultPlayMovieButton?.requestFocus() + binding?.resultPlaySeriesButton?.requestFocus() + binding?.resultResumeSeriesButton?.requestFocus() } private fun setRecommendations(rec: List?, validApiName: String?) { @@ -127,13 +156,17 @@ class ResultFragmentTv : Fragment() { resultRecommendationsList.isGone = isInvalid resultRecommendationsHolder.isGone = isInvalid val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - (resultRecommendationsList.adapter as? SearchAdapter)?.updateList(rec?.filter { it.apiName == matchAgainst } + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(rec?.filter { it.apiName == matchAgainst } ?: emptyList()) rec?.map { it.apiName }?.distinct()?.let { apiNames -> // very dirty selection resultRecommendationsFilterSelection.isVisible = apiNames.size > 1 - resultRecommendationsFilterSelection.update(apiNames.map { txt(it) to it }) + resultRecommendationsFilterSelection.update(apiNames.map { + txt( + it + ) to it + }) resultRecommendationsFilterSelection.select(apiNames.indexOf(matchAgainst)) } ?: run { resultRecommendationsFilterSelection.isVisible = false @@ -159,10 +192,7 @@ class ResultFragmentTv : Fragment() { } override fun onResume() { - activity?.let { - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) - } + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) afterPluginsLoadedEvent += ::reloadViewModel super.onResume() } @@ -177,7 +207,7 @@ class ResultFragmentTv : Fragment() { isVisible = true } - this.animate().alpha(if (turnVisible) 1.0f else 0.0f).apply { + this.animate().alpha(if (turnVisible) 0.97f else 0.0f).apply { duration = 200 interpolator = DecelerateInterpolator() setListener(object : Animator.AnimatorListener { @@ -203,22 +233,29 @@ class ResultFragmentTv : Fragment() { 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()) { - episodesShadow.scaleX = -1.0f - episodesShadow.scaleY = -1.0f + episodesShadowBackground.scaleX = -1f } else { - episodesShadow.scaleX = 1.0f - episodesShadow.scaleY = 1.0f + episodesShadowBackground.scaleX = 1f } } } - @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) { + fixSystemBarsPadding(view, padTop = false) + } + @SuppressLint("SetTextI18n") + override fun onBindingCreated(binding: FragmentResultTvBinding) { // ===== setup ===== val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() @@ -234,48 +271,30 @@ class ResultFragmentTv : Fragment() { storedData.start ) // ===== ===== ===== + var comingSoon = false - binding?.apply { + binding.apply { //episodesShadow.rotationX = 180.0f//if(episodesShadow.isRtl()) 180.0f else 0.0f - val leftListener: View.OnFocusChangeListener = - View.OnFocusChangeListener { _, hasFocus -> - if (!hasFocus) return@OnFocusChangeListener - toggleEpisodes(false) - } - - val rightListener: View.OnFocusChangeListener = - View.OnFocusChangeListener { _, hasFocus -> - if (!hasFocus) return@OnFocusChangeListener - toggleEpisodes(true) - } - - resultPlayMovie.onFocusChangeListener = leftListener - resultPlaySeries.onFocusChangeListener = leftListener - resultResumeSeries.onFocusChangeListener = leftListener - resultPlayTrailer.onFocusChangeListener = leftListener - resultEpisodesShow.onFocusChangeListener = rightListener - resultDescription.onFocusChangeListener = leftListener - resultBookmarkButton.onFocusChangeListener = leftListener - resultEpisodesShow.setOnClickListener { - // toggle, to make it more touch accessable just in case someone thinks that a - // tv layout is better but is using a touch device - toggleEpisodes(!episodeHolderTv.isVisible) - } - - // resultEpisodes.onFocusChangeListener = leftListener + // parallax on background + resultFinishLoading.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { view, _, scrollY, _, oldScrollY -> + backgroundPosterHolder.translationY = -scrollY.toFloat() * 0.8f + }) redirectToPlay.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(false) - binding?.apply { + binding.apply { val views = listOf( - resultPlayMovie, - resultPlaySeries, - resultResumeSeries, - resultPlayTrailer, - resultBookmarkButton + resultPlayMovieButton, + resultPlaySeriesButton, + resultResumeSeriesButton, + resultPlayTrailerButton, + resultBookmarkButton, + resultFavoriteButton, + resultSubscribeButton, + resultSearchButton ) for (requestView in views) { if (!requestView.isVisible) continue @@ -284,21 +303,16 @@ class ResultFragmentTv : Fragment() { } } - // parallax on background - resultFinishLoading.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - backgroundPosterHolder.translationY = -scrollY.toFloat() * 0.8f - }) - redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(true) - binding?.apply { + binding.apply { val views = listOf( + resultDubSelection, resultSeasonSelection, resultRangeSelection, - resultDubSelection, resultEpisodes, - resultPlayTrailer, + resultPlayTrailerButton, ) for (requestView in views) { if (!requestView.isShown) continue @@ -307,6 +321,47 @@ class ResultFragmentTv : Fragment() { } } + mapOf( + resultPlayMovieButton to resultPlayMovieText, + resultPlaySeriesButton to resultPlaySeriesText, + resultResumeSeriesButton to resultResumeSeriesText, + resultPlayTrailerButton to resultPlayTrailerText, + resultBookmarkButton to resultBookmarkText, + resultFavoriteButton to resultFavoriteText, + resultSubscribeButton to resultSubscribeText, + resultSearchButton to resultSearchText, + resultEpisodesShowButton to resultEpisodesShowText + ).forEach { (button, text) -> + + button.setOnFocusChangeListener { view, hasFocus -> + if (!hasFocus) { + text.isSelected = false + if (view.id == R.id.result_episodes_show_button) toggleEpisodes(false) + return@setOnFocusChangeListener + } + + text.isSelected = true + 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) + } + } + } + } + + resultEpisodesShowButton.setOnClickListener { + // toggle, to make it more touch accessible just in case someone thinks that a + // tv layout is better but is using a touch device + toggleEpisodes(!episodeHolderTv.isVisible) + } + resultEpisodes.setLinearListLayout( isHorizontal = false, nextUp = FOCUS_SELF, @@ -348,10 +403,6 @@ class ResultFragmentTv : Fragment() { resultMetaSite.isFocusable = false - //resultReloadConnectionOpenInBrowser.setOnClickListener {view -> - // view.context?.openBrowser(storedData?.url ?: return@setOnClickListener, fallbackWebview = true) - //} - resultSeasonSelection.setAdapter() resultRangeSelection.setAdapter() resultDubSelection.setAdapter() @@ -359,24 +410,24 @@ class ResultFragmentTv : Fragment() { resultCastItems.setOnFocusChangeListener { _, hasFocus -> // Always escape focus - if (hasFocus) binding?.resultBookmarkButton?.requestFocus() + if (hasFocus) binding.resultBookmarkButton.requestFocus() } //resultBack.setOnClickListener { // activity?.popCurrentPage() //} resultRecommendationsList.spanCount = 8 + resultRecommendationsList.setRecycledViewPool(SearchAdapter.sharedPool) resultRecommendationsList.adapter = SearchAdapter( - ArrayList(), resultRecommendationsList, ) { callback -> - if (callback.action == SEARCH_ACTION_FOCUSED) + if (callback.action == SEARCH_ACTION_FOCUSED) { toggleEpisodes(false) - else - SearchHelper.handleSearchClickCallback(callback) + } else SearchHelper.handleSearchClickCallback(callback) } + resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) resultEpisodes.adapter = EpisodeAdapter( false, @@ -388,12 +439,7 @@ class ResultFragmentTv : Fragment() { } ) - resultCastItems.layoutManager = object : LinearListLayout(view.context) { - - override fun onInterceptFocusSearch(focused: View, direction: Int): View? { - return super.onInterceptFocusSearch(focused, direction) - } - + resultCastItems.layoutManager = object : LinearListLayout(root.context) { override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, @@ -409,19 +455,48 @@ class ResultFragmentTv : Fragment() { super.onRequestChildFocus(parent, state, child, focused) } } - }.apply { - setHorizontal() + }.apply { setHorizontal() } + + val aboveCast = listOf( + binding.resultEpisodesShow, + binding.resultBookmark, + binding.resultFavorite, + binding.resultSubscribe, + ).firstOrNull { it.isVisible } + + resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) + resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { + toggleEpisodes(false) } - resultCastItems.adapter = ActorAdaptor { - toggleEpisodes(false) + if (isLayout(EMULATOR)) { + episodesShadow.setOnClickListener { + toggleEpisodes(false) + } } } observeNullable(viewModel.resumeWatching) { resume -> - binding?.apply { + binding.apply { + if (resume == null) { + return@observeNullable + } + + resultResumeSeries.isVisible = true + resultPlayMovie.isVisible = false + resultPlaySeries.isVisible = false + // show progress no matter if series or movie - resume?.progress?.let { progress -> + 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 @@ -433,39 +508,25 @@ class ResultFragmentTv : Fragment() { resultResumeProgressHolder.isVisible = false } - // if movie then hide both as movie button is - // always visible on movies, this is done in movie observe + focusPlayButton() + // Stops last button right focus if it is a movie + if (resume.isMovie) + resultSearchButton.nextFocusRightId = R.id.result_search_Button - if (resume?.isMovie == true) { - resultPlaySeries.isVisible = false - resultResumeSeries.isVisible = false - return@observeNullable - } + resultResumeSeriesText.text = + 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}" - // if series then - // > resultPlaySeries is visible when null - // > resultResumeSeries is visible when not null - if (resume == null) { - resultPlaySeries.isVisible = true - resultResumeSeries.isVisible = false - return@observeNullable - } + else -> "${getString(R.string.episode)} ${resume.result.episode}" + } - resultPlaySeries.isVisible = false - resultResumeSeries.isVisible = true - - if (hasNoFocus()) { - resultResumeSeries.requestFocus() - } - - resultResumeSeries.text = - if (resume.isMovie) context?.getString(R.string.play_movie_button) else context?.getNameFull( - null, // resume.result.name, we don't want episode title - resume.result.episode, - resume.result.season - ) - - resultResumeSeries.setOnClickListener { + resultResumeSeriesButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent( storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, @@ -474,7 +535,7 @@ class ResultFragmentTv : Fragment() { ) } - resultResumeSeries.setOnLongClickListener { + resultResumeSeriesButton.setOnLongClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_SHOW_OPTIONS, resume.result) ) @@ -487,17 +548,18 @@ class ResultFragmentTv : Fragment() { observe(viewModel.trailers) { trailersLinks -> context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return@observe - val trailers = trailersLinks.flatMap { it.mirros } - binding?.resultPlayTrailer?.apply { - isGone = trailers.isEmpty() - setOnClickListener { - if (trailers.isEmpty()) return@setOnClickListener + val extractedTrailerLinks = trailersLinks.flatMap { it.mirros } + .map { (extractedTrailerLink, _) -> extractedTrailerLink } + binding.apply { + resultPlayTrailer.isGone = extractedTrailerLinks.isEmpty() + resultPlayTrailerButton.setOnClickListener { + if (extractedTrailerLinks.isEmpty()) return@setOnClickListener activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( ExtractorLinkGenerator( - trailers, + extractedTrailerLinks, emptyList() - ) + ), 0 ) ) } @@ -505,43 +567,135 @@ class ResultFragmentTv : Fragment() { } observe(viewModel.watchStatus) { watchType -> - binding?.resultBookmarkButton?.apply { - setText(watchType.stringRes) - setOnClickListener { view -> - activity?.showBottomDialog( - WatchType.values().map { view.context.getString(it.stringRes) }.toList(), - watchType.ordinal, - view.context.getString(R.string.action_add_to_bookmarks), - showApply = false, - {}) { - viewModel.updateWatchStatus(WatchType.values()[it]) + 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 + setIconResource(drawable) + + setOnClickListener { view -> + activity?.showBottomDialog( + WatchType.entries.map { view.context.getString(it.stringRes) }.toList(), + watchType.ordinal, + view.context.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + viewModel.updateWatchStatus(WatchType.entries[it], context) + } } } } } - observeNullable(viewModel.movie) { data -> - binding?.apply { - resultPlayMovie.isVisible = data is Resource.Success - seriesHolder.isVisible = data == null - resultEpisodesShow.isVisible = data == null + observeNullable(viewModel.favoriteStatus) { isFavorite -> + binding.resultFavorite.isVisible = isFavorite != null + binding.resultFavoriteButton.apply { + if (isFavorite == null) return@observeNullable - (data as? Resource.Success)?.value?.let { (text, ep) -> - resultPlayMovie.setText(text) - resultPlayMovie.setOnClickListener { + val drawable = if (isFavorite) { + R.drawable.ic_baseline_favorite_24 + } else R.drawable.ic_baseline_favorite_border_24 + setIconResource(drawable) + + setOnClickListener { + viewModel.toggleFavoriteStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleFavoriteStatus + + val message = if (newStatus) { + R.string.favorite_added + } else R.string.favorite_removed + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data) + .asStringNull(context) ?: "" + CommonActivity.showToast( + txt( + message, + name + ), Toast.LENGTH_SHORT + ) + } + } + } + + binding.resultFavoriteText.apply { + val text = if (isFavorite == true) { + R.string.unfavorite + } else R.string.favorite + setText(text) + } + } + + observeNullable(viewModel.subscribeStatus) { isSubscribed -> + 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 + setIconResource(drawable) + + setOnClickListener { + viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleSubscriptionStatus + + val message = if (newStatus) { + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else R.string.subscription_deleted + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data) + .asStringNull(context) ?: "" + CommonActivity.showToast( + txt( + message, + name + ), Toast.LENGTH_SHORT + ) + } + } + + binding.resultSubscribeText.apply { + val text = if (isSubscribed) { + R.string.action_unsubscribe + } else R.string.action_subscribe + setText(text) + } + } + } + + observeNullable(viewModel.movie) { data -> + if (data == null) { + return@observeNullable + } + + binding.apply { + (data as? Resource.Success)?.value?.let { (_, ep) -> + resultPlayMovieButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) ) } - resultPlayMovie.setOnLongClickListener { + resultPlayMovieButton.setOnLongClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) ) return@setOnLongClickListener true } - if (hasNoFocus()) { - resultPlayMovie.requestFocus() - } + + resultPlayMovie.isVisible = !comingSoon && resultResumeSeries.isGone + if (comingSoon) { + resultBookmarkButton.requestFocus() + } else resultPlayMovieButton.requestFocus() + + // Stops last button right focus + resultSearchButton.nextFocusRightId = R.id.result_search_Button } } } @@ -588,129 +742,124 @@ class ResultFragmentTv : Fragment() { 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) } - observe(viewModel.episodeSynopsis) { description -> - view.context?.let { ctx -> - val builder: AlertDialog.Builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - builder.setMessage(description.html()) - .setTitle(R.string.synopsis) - .setOnDismissListener { - viewModel.releaseEpisodeSynopsis() - } - .show() + + if (isLayout(TV)) { + observe(viewModel.episodeSynopsis) { description -> + context?.let { ctx -> + val builder: AlertDialog.Builder = + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + builder.setMessage(description.html()) + .setTitle(R.string.synopsis) + .setOnDismissListener { + viewModel.releaseEpisodeSynopsis() + } + .show() + } } } + + // Used to request focus the first time the episodes are loaded. + var hasLoadedEpisodesOnce = false observeNullable(viewModel.episodes) { episodes -> - binding?.apply { - resultEpisodes.isVisible = episodes is Resource.Success + if (episodes == null) return@observeNullable + binding.apply { + if (comingSoon) resultBookmarkButton.requestFocus() + // resultEpisodeLoading.isVisible = episodes is Resource.Loading if (episodes is Resource.Success) { - val first = episodes.value.firstOrNull() - if (first != null) { - resultPlaySeries.text = context?.getNameFull( - null, // resume.result.name, we don't want episode title - first.episode, - first.season - ) + val lastWatchedIndex = episodes.value.indexOfLast { ep -> + ep.getWatchProgress() >= NEXT_WATCH_EPISODE_PERCENTAGE.toFloat() / 100.0f || ep.videoWatchState == VideoWatchState.Watched + } - resultPlaySeries.setOnClickListener { + 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}" + + else -> "${getString(R.string.episode)} ${firstUnwatched.episode}" + } + resultPlaySeriesButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent( ACTION_CLICK_DEFAULT, - first + firstUnwatched ) ) } - resultPlaySeries.setOnLongClickListener { + resultPlaySeriesButton.setOnLongClickListener { viewModel.handleAction( - EpisodeClickEvent(ACTION_SHOW_OPTIONS, first) + EpisodeClickEvent(ACTION_SHOW_OPTIONS, firstUnwatched) ) return@setOnLongClickListener true } + if (!hasLoadedEpisodesOnce) { + hasLoadedEpisodesOnce = true + resultPlaySeries.isVisible = resultResumeSeries.isGone && !comingSoon + resultEpisodesShow.isVisible = true && !comingSoon + resultPlaySeriesButton.requestFocus() + } } - /* - * Okay so what is this fuckery? - * Basically Android TV will crash if you request a new focus while - * the adapter gets updated. - * - * This means that if you load thumbnails and request a next focus at the same time - * the app will crash without any way to catch it! - * - * How to bypass this? - * This code basically steals the focus for 500ms and puts it in an inescapable view - * then lets out the focus by requesting focus to result_episodes - */ - val hasEpisodes = - !(resultEpisodes.adapter as? EpisodeAdapter?)?.cardList.isNullOrEmpty() - /*val focus = activity?.currentFocus - - if (hasEpisodes) { - // Make it impossible to focus anywhere else! - temporaryNoFocus.isFocusable = true - temporaryNoFocus.requestFocus() - }*/ - - (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) - - /* if (hasEpisodes) main { - - delay(500) - // This might make some people sad as it changes the focus when leaving an episode :( - if(focus?.requestFocus() == true) { - temporaryNoFocus.isFocusable = false - return@main - } - temporaryNoFocus.isFocusable = false - temporaryNoFocus.requestFocus() - } - - if (hasNoFocus()) - binding?.resultEpisodes?.requestFocus()*/ + (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) } } } observeNullable(viewModel.page) { data -> if (data == null) return@observeNullable - binding?.apply { + binding.apply { when (data) { is Resource.Success -> { val d = data.value @@ -723,18 +872,31 @@ class ResultFragmentTv : Fragment() { resultMetaYear.setText(d.yearText) resultMetaDuration.setText(d.durationText) resultMetaRating.setText(d.ratingText) + resultMetaStatus.setText(d.onGoingText) + resultMetaContentRating.setText(d.contentRatingText) resultCastText.setText(d.actorsText) resultNextAiring.setText(d.nextAiringEpisode) resultNextAiringTime.setText(d.nextAiringDate) - resultPoster.setImage(d.posterImage) - resultDescription.setTextHtml(d.plotText) - resultDescription.setOnClickListener { view -> - view.context?.let { ctx -> - val builder: AlertDialog.Builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - builder.setMessage(d.plotText.asString(ctx).html()) - .setTitle(d.plotHeaderText.asString(ctx)) - .show() + resultPoster.loadImage(d.posterImage) + + var isExpanded = false + resultDescription.apply { + setTextHtml(d.plotText) + setOnClickListener { + if (isLayout(EMULATOR)) { + isExpanded = !isExpanded + maxLines = if (isExpanded) { + Integer.MAX_VALUE + } else 10 + } else { + context?.let { ctx -> + val builder: AlertDialog.Builder = + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + builder.setMessage(d.plotText.asString(ctx).html()) + .setTitle(d.plotHeaderText.asString(ctx)) + .show() + } + } } } @@ -747,24 +909,43 @@ class ResultFragmentTv : Fragment() { R.drawable.profile_bg_red, R.drawable.profile_bg_teal ).random() - backgroundPoster.setImage( - d.posterBackgroundImage ?: UiImage.Drawable(error), - radius = 0, - errorImageDrawable = error + + backgroundPoster.loadImage(d.posterBackgroundImage) { + error { getImageFromDrawable(context ?: return@error null, error) } + } + + bindLogo( + url = d.logoUrl, + headers = d.posterHeaders, + titleView = resultTitle, + logoView = backgroundPosterWatermarkBadgeHolder ) - resultComingSoon.isVisible = d.comingSoon - resultDataHolder.isGone = d.comingSoon - UIHelper.populateChips(resultTag, d.tags) - resultCastItems.isGone = d.actors.isNullOrEmpty() - (resultCastItems.adapter as? ActorAdaptor)?.updateList( - d.actors ?: emptyList() + 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 ) + + 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 + } + + resultSearchButton.setOnClickListener { + QuickSearchFragment.pushSearch(activity, d.title) + } } - is Resource.Loading -> { - - } + is Resource.Loading -> {} is Resource.Failure -> { resultErrorText.text = @@ -781,4 +962,4 @@ class ResultFragmentTv : Fragment() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index 91e97dfc1..3b1471e6a 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,37 +3,76 @@ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator import android.content.Context import android.content.res.Configuration -import android.graphics.Rect +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.IOnBackPressed +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding - -open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { +class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false override var hasPipModeSupport = false companion object { - const val TAG = "RESULT_TRAILER" + const val TAG = "ResultTrailerPlayer" } - var playerWidthHeight: Pair? = null + 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(posDur: Pair) {} - + override fun playerPositionChanged(position: Long, duration: Long) {} override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -43,18 +82,28 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { } 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 @@ -62,35 +111,30 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { resultBinding?.fragmentTrailer?.playerBackground?.apply { isVisible = true - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to + ) } playerBinding?.playerIntroPlay?.apply { - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - resultBinding?.resultTopHolder?.measuredHeight - ?: FrameLayout.LayoutParams.MATCH_PARENT - ) + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT + ) } if (playerBinding?.playerIntroPlay?.isGone == true) { resultBinding?.resultTopHolder?.apply { - val anim = ValueAnimator.ofInt( measuredHeight, if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to ) - anim.addUpdateListener { valueAnimator -> - val `val` = valueAnimator.animatedValue as Int - val layoutParams: ViewGroup.LayoutParams = - layoutParams - layoutParams.height = `val` - setLayoutParams(layoutParams) + anim.addUpdateListener { va -> + val v = va.animatedValue as Int + val lp: ViewGroup.LayoutParams = layoutParams + lp.height = v + layoutParams = lp } anim.duration = 200 anim.start() @@ -99,9 +143,14 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { } } - override fun playerDimensionsLoaded(widthHeight: Pair) { - playerWidthHeight = widthHeight + 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() {} @@ -109,35 +158,41 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { override fun openOnlineSubPicker( context: Context, - imdbId: Long?, + loadResponse: LoadResponse?, dismissCallback: () -> Unit - ) { - } + ) {} override fun subtitlesChanged() {} - override fun embeddedSubtitlesFetched(subtitles: List) {} override fun onTracksInfoChanged() {} - override fun exitedPipMode() {} + + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text + } + } + private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen + playerHostView?.isFullScreen = fullscreen - playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) + playerBinding?.playerFullscreen?.setImageResource( + if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24 + ) if (fullscreen) { - enterFullscreen() + playerHostView?.enterFullscreen() binding?.apply { resultTopBar.isVisible = false resultFullscreenHolder.isVisible = true resultMainHolder.isVisible = false } - resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) binding?.resultFullscreenHolder?.addView(view) } - } else { binding?.apply { resultTopBar.isVisible = true @@ -148,34 +203,55 @@ open class ResultTrailerPlayer : ResultFragmentPhone(), IOnBackPressed { resultBinding?.resultSmallscreenHolder?.addView(view) } } - exitFullscreen() + playerHostView?.exitFullscreen() } fixPlayerSize() uiReset() + + if (isFullScreenPlayer) { + activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) } + } else { + activity?.detachBackPressedCallback("ResultTrailerPlayer") + } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - playerBinding?.playerFullscreen?.setOnClickListener { - updateFullscreen(!isFullScreenPlayer) + 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) + } + + override fun playerStatusChanged() { + if (introVisible) { + playerBinding?.playerPausePlayHolderHolder?.isVisible = false + } + } + + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + + playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.isGone = true - player.handleEvent(CSPlayerEvent.Play) - updateUIVisibility() + introVisible = false + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) fixPlayerSize() + showControls() } } - - override fun onBackPressed(): Boolean { - return if (isFullScreenPlayer) { - updateFullscreen(false) - false - } else { - true - } - } -} \ 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 bdd27091e..c519e0de2 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,68 +1,152 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity -import android.content.* -import android.net.Uri -import android.os.Build -import android.os.Bundle +import android.content.Context +import android.content.DialogInterface import android.util.Log import android.widget.Toast -import androidx.core.content.FileProvider -import androidx.core.net.toUri +import androidx.annotation.MainThread +import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.getId +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.IDownloadableMinimum +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie +import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.ProviderType +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.SeasonData +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.SimklSyncServices +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TrackerType +import com.lagradost.cloudstream3.TrailerData +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.VPNStatus +import com.lagradost.cloudstream3.actions.AlwaysAskAction +import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.metaproviders.SyncRedirector -import com.lagradost.cloudstream3.mvvm.* +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugException +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.runAllAsync +import com.lagradost.cloudstream3.sortUrls import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.player.GeneratorPlayer -import com.lagradost.cloudstream3.ui.player.IGenerator +import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL +import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP +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.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppUtils.isAppInstalled -import com.lagradost.cloudstream3.utils.AppUtils.isConnectedToChromecast +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 +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions +import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub +import com.lagradost.cloudstream3.utils.DataStoreHelper.getFavoritesData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultEpisode import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultSeason import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState +import com.lagradost.cloudstream3.utils.DataStoreHelper.getSubscribedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.DataStoreHelper.removeFavoritesData +import com.lagradost.cloudstream3.utils.DataStoreHelper.removeSubscribedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.setBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setDub +import com.lagradost.cloudstream3.utils.DataStoreHelper.setFavoritesData import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultEpisode import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultSeason +import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState +import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState +import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData +import com.lagradost.cloudstream3.utils.Editor +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.FillerEpisodeCheck +import com.lagradost.cloudstream3.utils.INFER_TYPE +import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.navigate -import kotlinx.coroutines.* -import java.io.File +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit - /** This starts at 1 */ data class EpisodeRange( // used to index data @@ -91,11 +175,13 @@ data class ResultData( val title: String, var syncData: Map, - val posterImage: UiImage?, - val posterBackgroundImage: UiImage?, + val posterImage: String?, + val posterBackgroundImage: String?, + val logoUrl: String?, val plotText: UiText, val apiName: UiText, val ratingText: UiText?, + val contentRatingText: UiText?, val vpnText: UiText?, val metaText: UiText?, val durationText: UiText?, @@ -107,8 +193,30 @@ data class ResultData( val nextAiringDate: UiText?, val nextAiringEpisode: UiText?, val plotHeaderText: UiText, + val posterHeaders: Map? = null, ) +data class CheckDuplicateData( + val name: String, + val year: Int?, + val syncData: Map? +) + +enum class LibraryListType { + BOOKMARKS, + FAVORITES, + 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) { @@ -160,7 +268,11 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { else -> null }?.also { - nextAiringEpisode = txt(R.string.next_episode_format, airing.episode) + nextAiringEpisode = when (airing.season) { + + null -> txt(R.string.next_episode_format, airing.episode) + else -> txt(R.string.next_season_episode_format, airing.season, airing.episode) + } } } } @@ -175,12 +287,9 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { ), nextAiringDate = nextAiringDate, nextAiringEpisode = nextAiringEpisode, - posterImage = img( - posterUrl, posterHeaders - ) ?: img(R.drawable.default_cover), - posterBackgroundImage = img( - backgroundPosterUrl ?: posterUrl, posterHeaders - ) ?: img(R.drawable.default_cover), + posterImage = posterUrl ?: backgroundPosterUrl, + posterHeaders = posterHeaders, + posterBackgroundImage = backgroundPosterUrl ?: posterUrl, titleText = txt(name), url = url, tags = tags ?: emptyList(), @@ -190,10 +299,11 @@ 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) { @@ -209,12 +319,19 @@ 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.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 } ), yearText = txt(year?.toString()), apiName = txt(apiName), - ratingText = rating?.div(1000f) - ?.let { if (it <= 0.1f) null else txt(R.string.rating_format, it) }, + ratingText = score?.toStringNull(0.1, 10, 1, false, '.') + ?.let { txt(R.string.rating_format, it) }, + contentRatingText = txt(contentRating), vpnText = txt( when (repo.vpnStatus) { VPNStatus.None -> null @@ -223,10 +340,9 @@ 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( - R.string.duration_format, - dur + secondsToReadable(dur * 60, "0 mins") ), onGoingText = if (this is EpisodeResponse) { txt( @@ -238,12 +354,29 @@ 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 ) } +data class ExtractorSubtitleLink( + val name: String, + override val url: String, + override val referer: String, + override val headers: Map = mapOf() +) : IDownloadableMinimum + +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) +} + +private fun getLoadResponseIdFromUrl(url: String, apiName: String): Int { + return url.replace(getApiFromNameNull(apiName)?.mainUrl ?: "", "").replace("/", "") + .hashCode() +} data class LinkProgress( val linksLoaded: Int, @@ -265,6 +398,7 @@ data class ResumeWatchingStatus( data class LinkLoadingResult( val links: List, val subs: List, + val syncData: HashMap ) sealed class SelectPopup { @@ -315,7 +449,7 @@ fun SelectPopup.getOptions(context: Context): List { } data class ExtractedTrailerData( - var mirros: List, + var mirros: List>,//Pair of extracted trailer link and original trailer link var subtitles: List = emptyList(), ) @@ -340,12 +474,13 @@ 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: Map = emptyMap() - private var generator: IGenerator? = null + private var fillers: HashSet = hashSetOf() + private var generator: RepoLinkGenerator? = null private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null private var preferStartSeason: Int? = null @@ -393,6 +528,18 @@ 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 @@ -424,6 +571,9 @@ class ResultViewModel2 : ViewModel() { private val _subscribeStatus: MutableLiveData = MutableLiveData(null) val subscribeStatus: LiveData = _subscribeStatus + private val _favoriteStatus: MutableLiveData = MutableLiveData(null) + val favoriteStatus: LiveData = _favoriteStatus + companion object { const val TAG = "RVM2" //private const val EPISODE_RANGE_SIZE = 20 @@ -434,36 +584,32 @@ class ResultViewModel2 : ViewModel() { return this?.firstOrNull { it.season == season } } - fun updateWatchStatus(currentResponse: LoadResponse, status: WatchType) { - val currentId = currentResponse.getId() + fun seasonToTxt(seasonData: SeasonData?, season: Int?): UiText? { + if (season == 0) { + return txt(R.string.no_season) + } - val currentWatchType = getResultWatchState(currentId) - - DataStoreHelper.setResultWatchState(currentId, status.internalId) - val current = DataStoreHelper.getBookmarkedData(currentId) - val currentTime = System.currentTimeMillis() - DataStoreHelper.setBookmarkedData( - currentId, - DataStoreHelper.BookmarkedData( - currentId, - current?.bookmarkedTime ?: currentTime, - currentTime, - currentResponse.name, - currentResponse.url, - currentResponse.apiName, - currentResponse.type, - currentResponse.posterUrl, - currentResponse.year + // 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 ) - ) - if (currentWatchType != status) { - MainActivity.bookmarksUpdatedEvent(true) } } + private fun List?.getSeasonTxt(season: Int?): UiText? = + seasonToTxt(getSeason(season), season) + + private fun filterName(name: String?): String? { if (name == null) return null - Regex("[eE]pisode [0-9]*(.*)").find(name)?.groupValues?.get(1)?.let { + Regex("^[eE]pisode [0-9]*(.*)").find(name)?.groupValues?.get(1)?.let { if (it.isEmpty()) return null } @@ -517,7 +663,8 @@ class ResultViewModel2 : ViewModel() { val episodeNumber = episodes[currentIndex].episode if (episodeNumber < currentMin) { currentMin = episodeNumber - } else if (episodeNumber > currentMax) { + } + if (episodeNumber > currentMax) { currentMax = episodeNumber } ++currentIndex @@ -578,233 +725,6 @@ class ResultViewModel2 : ViewModel() { index to list }.toMap() } - - private fun downloadSubtitle( - context: Context?, - link: ExtractorSubtitleLink, - fileName: String, - folder: String - ) { - ioSafe { - VideoDownloadManager.downloadThing( - context ?: return@ioSafe, - link, - "$fileName ${link.name}", - folder, - if (link.url.contains(".srt")) ".srt" else "vtt", - false, - null, createNotificationCallback = {} - ) - } - } - - private fun getFolder(currentType: TvType, titleName: String): String { - val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName) - return when (currentType) { - TvType.Anime -> "Anime/$sanitizedFileName" - TvType.Movie -> "Movies" - TvType.AnimeMovie -> "Movies" - TvType.TvSeries -> "TVSeries/$sanitizedFileName" - TvType.OVA -> "OVA" - TvType.Cartoon -> "Cartoons/$sanitizedFileName" - TvType.Torrent -> "Torrent" - TvType.Documentary -> "Documentaries" - TvType.AsianDrama -> "AsianDrama" - TvType.Live -> "LiveStreams" - TvType.NSFW -> "NSFW" - TvType.Others -> "Others" - } - } - - 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, - url, - currentType, - currentHeaderName, - currentPoster, - parentId, - System.currentTimeMillis(), - ) - ) - - setKey( - DataStore.getFolderName( - DOWNLOAD_EPISODE_CACHE, - parentId.toString() - ), // 3 deep folder for faster acess - episode.id.toString(), - VideoDownloadHelper.DownloadEpisodeCached( - episode.name, - episode.poster, - episode.episode, - episode.season, - episode.id, - parentId, - episode.rating, - episode.description, - 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, "") } - .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, isCasting = false, 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) @@ -813,10 +733,81 @@ class ResultViewModel2 : ViewModel() { private val _selectPopup: MutableLiveData = MutableLiveData(null) val selectPopup: LiveData = _selectPopup + fun updateWatchStatus( + status: WatchType, + context: Context?, + loadResponse: LoadResponse? = null, + statusChangedCallback: ((statusChanged: Boolean) -> Unit)? = null + ) { + val (response, currentId) = loadResponse?.let { load -> + (load to load.getId()) + } ?: ((currentResponse ?: return) to (currentId ?: return)) - fun updateWatchStatus(status: WatchType) { - updateWatchStatus(currentResponse ?: return, status) - _watchStatus.postValue(status) + val currentStatus = getResultWatchState(currentId) + + // If the current status is "NONE" and the new status is not "NONE", + // fetch the bookmarked data to check for duplicates, otherwise set this + // to an empty list, so that we don't show the duplicate warning dialog, + // but we still want to update the current bookmark and refresh the data anyway. + val bookmarkedData = if (currentStatus == WatchType.NONE && status != WatchType.NONE) { + getAllBookmarkedData() + } else emptyList() + + checkAndWarnDuplicates( + context, + LibraryListType.BOOKMARKS, + CheckDuplicateData( + name = response.name, + year = response.year, + syncData = response.syncData, + ), + bookmarkedData + ) { shouldContinue: Boolean, duplicateIds: List -> + if (!shouldContinue) return@checkAndWarnDuplicates + + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + deleteBookmarkedData(duplicateId) + } + } + + setResultWatchState(currentId, status.internalId) + + // We don't need to store if WatchType.NONE. + // The key is removed in setResultWatchState, we don't want to + // re-add it again here if it was just removed. + if (status != WatchType.NONE) { + val current = getBookmarkedData(currentId) + + setBookmarkedData( + currentId, + DataStoreHelper.BookmarkedData( + current?.bookmarkedTime ?: unixTimeMS, + currentId, + unixTimeMS, + response.name, + response.url, + response.apiName, + response.type, + response.posterUrl, + response.year, + response.syncData, + plot = response.plot, + tags = response.tags, + score = response.score + ) + ) + } + + if (currentStatus != status) { + MainActivity.bookmarksUpdatedEvent(true) + MainActivity.reloadLibraryEvent(true) + } + + _watchStatus.postValue(status) + + statusChangedCallback?.invoke(true) + } } private fun startChromecast( @@ -825,45 +816,309 @@ class ResultViewModel2 : ViewModel() { isVisible: Boolean = true ) { if (activity == null) return - loadLinks(result, isVisible = isVisible, isCasting = true) { data -> + loadLinks( + result, + isVisible = isVisible, + sourceTypes = LOADTYPE_CHROMECAST, + isCasting = true + ) { data -> startChromecast(activity, result, data.links, data.subs, 0) } } /** - * @return true if the new status is Subscribed, false if not. Null if not possible to subscribe. - **/ - fun toggleSubscriptionStatus(): Boolean? { - val isSubscribed = _subscribeStatus.value ?: return null - val response = currentResponse ?: return null - if (response !is EpisodeResponse) return null + * Toggles the subscription status of an item. + * + * @param context The context to use for operations. + * @param statusChangedCallback A callback that is invoked when the subscription status changes. + * It provides the new subscription status (true if subscribed, false if unsubscribed, null if action was canceled). + */ + fun toggleSubscriptionStatus( + context: Context?, + statusChangedCallback: ((newStatus: Boolean?) -> Unit)? = null + ) { + val isSubscribed = _subscribeStatus.value ?: return + val response = currentResponse ?: return + val currentId = currentId ?: return - val currentId = response.getId() + // This might be a bit confusing, but even if the loadresponse is not a EpisodeResponse + // _subscribeStatus might be true. if (isSubscribed) { - DataStoreHelper.removeSubscribedData(currentId) + removeSubscribedData(currentId) + statusChangedCallback?.invoke(false) + _subscribeStatus.postValue(if (response is EpisodeResponse) false else null) + MainActivity.reloadLibraryEvent(true) } else { - val current = DataStoreHelper.getSubscribedData(currentId) + if (response !is EpisodeResponse) { + return + } + checkAndWarnDuplicates( + context, + LibraryListType.SUBSCRIPTIONS, + CheckDuplicateData( + name = response.name, + year = response.year, + syncData = response.syncData, + ), + getAllSubscriptions(), + ) { shouldContinue: Boolean, duplicateIds: List -> + if (!shouldContinue) { + statusChangedCallback?.invoke(null) + return@checkAndWarnDuplicates + } - DataStoreHelper.setSubscribedData( - currentId, - DataStoreHelper.SubscribedData( + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + removeSubscribedData(duplicateId) + } + } + + val current = getSubscribedData(currentId) + + setSubscribedData( currentId, - current?.bookmarkedTime ?: unixTimeMS, - unixTimeMS, - response.getLatestEpisodes(), - response.name, - response.url, - response.apiName, - response.type, - response.posterUrl, - response.year + DataStoreHelper.SubscribedData( + current?.subscribedTime ?: unixTimeMS, + response.getLatestEpisodes(), + currentId, + unixTimeMS, + response.name, + response.url, + response.apiName, + response.type, + response.posterUrl, + response.year, + response.syncData, + plot = response.plot, + score = response.score, + tags = response.tags + ) ) - ) + + _subscribeStatus.postValue(true) + statusChangedCallback?.invoke(true) + MainActivity.reloadLibraryEvent(true) + } + } + } + + 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. + * + * @param context The context to use. + * @param statusChangedCallback A callback that is invoked when the favorite status changes. + * It provides the new favorite status (true if added to favorites, false if removed, null if action was canceled). + */ + fun toggleFavoriteStatus( + context: Context?, + statusChangedCallback: ((newStatus: Boolean?) -> Unit)? = null + ) { + val isFavorite = _favoriteStatus.value ?: return + val response = currentResponse ?: return + + val currentId = currentId ?: return + + if (isFavorite) { + removeFavoritesData(currentId) + statusChangedCallback?.invoke(false) + _favoriteStatus.postValue(false) + MainActivity.reloadLibraryEvent(true) + } else { + checkAndWarnDuplicates( + context, + LibraryListType.FAVORITES, + CheckDuplicateData( + name = response.name, + year = response.year, + syncData = response.syncData, + ), + getAllFavorites(), + ) { shouldContinue: Boolean, duplicateIds: List -> + if (!shouldContinue) { + statusChangedCallback?.invoke(null) + return@checkAndWarnDuplicates + } + + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + removeFavoritesData(duplicateId) + } + } + + val current = getFavoritesData(currentId) + + setFavoritesData( + currentId, + DataStoreHelper.FavoritesData( + current?.favoritesTime ?: unixTimeMS, + currentId, + unixTimeMS, + response.name, + response.url, + response.apiName, + response.type, + response.posterUrl, + response.year, + response.syncData, + plot = response.plot, + score = response.score, + tags = response.tags + ) + ) + + _favoriteStatus.postValue(true) + statusChangedCallback?.invoke(true) + MainActivity.reloadLibraryEvent(true) + } + } + } + + @MainThread + private fun checkAndWarnDuplicates( + context: Context?, + listType: LibraryListType, + checkDuplicateData: CheckDuplicateData, + data: List, + checkDuplicatesCallback: (shouldContinue: Boolean, duplicateIds: List) -> Unit + ) { + val whitespaceRegex = "\\s+".toRegex() + fun normalizeString(input: String): String { + /** + * Trim the input string and replace consecutive spaces with a single space. + * This covers some edge-cases where the title does not match exactly across providers, + * and one provider has the title with an extra whitespace. This is minor enough that + * it should still match in this case. + */ + return input.trim().replace(whitespaceRegex, " ") } - _subscribeStatus.postValue(!isSubscribed) - return !isSubscribed + val syncData = checkDuplicateData.syncData + + val imdbId = getImdbIdFromSyncData(syncData) + val tmdbId = getTMDbIdFromSyncData(syncData) + val malId = syncData?.get(AccountManager.malApi.idPrefix) + val aniListId = syncData?.get(AccountManager.aniListApi.idPrefix) + val normalizedName = normalizeString(checkDuplicateData.name) + val year = checkDuplicateData.year + + val duplicateEntries = data.filter { it: DataStoreHelper.LibrarySearchResponse -> + val librarySyncData = it.syncData + val yearCheck = year == it.year || year == null || it.year == null + + val checks = listOf( + { imdbId != null && getImdbIdFromSyncData(librarySyncData) == imdbId }, + { tmdbId != null && getTMDbIdFromSyncData(librarySyncData) == tmdbId }, + { malId != null && librarySyncData?.get(AccountManager.malApi.idPrefix) == malId }, + { aniListId != null && librarySyncData?.get(AccountManager.aniListApi.idPrefix) == aniListId }, + { normalizedName == normalizeString(it.name) && yearCheck } + ) + + checks.any { it() } + } + + if (duplicateEntries.isEmpty() || context == null) { + checkDuplicatesCallback.invoke(true, emptyList()) + return + } + + val replaceMessage = if (duplicateEntries.size > 1) { + R.string.duplicate_replace_all + } else R.string.duplicate_replace + + val message = if (duplicateEntries.size == 1) { + val list = when (listType) { + LibraryListType.BOOKMARKS -> getResultWatchState( + duplicateEntries[0].id ?: 0 + ).stringRes + + LibraryListType.FAVORITES -> R.string.favorites_list_name + LibraryListType.SUBSCRIPTIONS -> R.string.subscription_list_name + } + + context.getString( + R.string.duplicate_message_single, + "${normalizeString(duplicateEntries[0].name)} (${context.getString(list)}) — ${duplicateEntries[0].apiName}" + ) + } else { + val bulletPoints = duplicateEntries.joinToString("\n") { + val list = when (listType) { + LibraryListType.BOOKMARKS -> getResultWatchState(it.id ?: 0).stringRes + LibraryListType.FAVORITES -> R.string.favorites_list_name + LibraryListType.SUBSCRIPTIONS -> R.string.subscription_list_name + } + + "• ${it.apiName}: ${normalizeString(it.name)} (${context.getString(list)})" + } + + context.getString(R.string.duplicate_message_multiple, bulletPoints) + } + + val builder: AlertDialog.Builder = AlertDialog.Builder(context) + + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + checkDuplicatesCallback.invoke(true, emptyList()) + } + + DialogInterface.BUTTON_NEGATIVE -> { + checkDuplicatesCallback.invoke(false, emptyList()) + } + + DialogInterface.BUTTON_NEUTRAL -> { + checkDuplicatesCallback.invoke(true, duplicateEntries.map { it.id }) + } + } + } + + builder.setTitle(R.string.duplicate_title) + .setMessage(message) + .setPositiveButton(R.string.duplicate_add, dialogClickListener) + .setNegativeButton(R.string.duplicate_cancel, dialogClickListener) + .setNeutralButton(replaceMessage, dialogClickListener) + .show().setDefaultFocus() + } + + private fun getImdbIdFromSyncData(syncData: Map?): String? { + return safe { + val imdbId = readIdFromString( + syncData?.get(AccountManager.simklApi.idPrefix) + )[SimklSyncServices.Imdb] + if (imdbId == "null") null else imdbId + } + } + + private fun getTMDbIdFromSyncData(syncData: Map?): String? { + return safe { + val tmdbId = readIdFromString( + syncData?.get(AccountManager.simklApi.idPrefix) + )[SimklSyncServices.Tmdb] + if (tmdbId == "null") null else tmdbId + } } private fun startChromecast( @@ -900,7 +1155,7 @@ class ResultViewModel2 : ViewModel() { _loadedLinks.postValue(null) } - private fun postPopup(text: UiText, options: List, callback: suspend (Int?) -> Unit) { + fun postPopup(text: UiText, options: List, callback: suspend (Int?) -> Unit) { _selectPopup.postValue( SelectPopup.SelectText( text, @@ -936,34 +1191,52 @@ class ResultViewModel2 : ViewModel() { private fun loadLinks( result: ResultEpisode, isVisible: Boolean, - isCasting: Boolean, + sourceTypes: Set = LOADTYPE_ALL, clearCache: Boolean = false, + isCasting: Boolean = false, work: suspend (CoroutineScope.(LinkLoadingResult) -> Unit) ) { currentLoadLinkJob?.cancel() currentLoadLinkJob = ioSafe { - val links = loadLinks( - result, - isVisible = isVisible, - isCasting = isCasting, - clearCache = clearCache - ) - if (!this.isActive) return@ioSafe - work(links) + 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) + } } } private var currentLoadLinkJob: Job? = null private fun acquireSingleLink( result: ResultEpisode, - isCasting: Boolean, + sourceTypes: Set, text: UiText, - callback: (Pair) -> Unit, + isCasting: Boolean = false, + callback: (Pair) -> Unit ) { - loadLinks(result, isVisible = true, isCasting = isCasting) { links -> + // 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 postPopup( text, - links.links.map { txt("${it.name} ${Qualities.getStringByInt(it.quality)}") }) { + 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") + }*/) { callback.invoke(links to (it ?: return@postPopup)) } } @@ -971,11 +1244,10 @@ class ResultViewModel2 : ViewModel() { private fun acquireSingleSubtitle( result: ResultEpisode, - isCasting: Boolean, text: UiText, callback: (Pair) -> Unit, ) { - loadLinks(result, isVisible = true, isCasting = isCasting) { links -> + loadLinks(result, isVisible = true) { links -> postPopup( text, links.subs.map { txt(it.name) }) @@ -985,11 +1257,17 @@ class ResultViewModel2 : ViewModel() { } } + fun skipLoading() { + currentLoadLinkJob?.cancelChildren() + currentLoadLinkJob = null + } + private suspend fun CoroutineScope.loadLinks( result: ResultEpisode, isVisible: Boolean, - isCasting: Boolean, + sourceTypes: Set = LOADTYPE_ALL, clearCache: Boolean = false, + isCasting: Boolean = false ): LinkLoadingResult { val tempGenerator = RepoLinkGenerator(listOf(result)) @@ -1002,187 +1280,85 @@ class ResultViewModel2 : ViewModel() { } try { updatePage() - tempGenerator.generateLinks(clearCache, isCasting, { (link, _) -> - if (link != null) { - links += link + tempGenerator.generateLinks( + clearCache, + sourceTypes = sourceTypes, + callback = { (link, _) -> + if (link != null) { + links += link + updatePage() + } + }, + subtitleCallback = { sub -> + subs += sub updatePage() - } - }, { sub -> - subs += sub - updatePage() - }) + }, + isCasting = isCasting, + offset = 0 + ) + } catch (_: CancellationException) { + // Do nothing } catch (e: Exception) { logError(e) } finally { _loadedLinks.postValue(null) } - return LinkLoadingResult(sortUrls(links), sortSubs(subs)) + return LinkLoadingResult( + sortUrls(links), + sortSubs(subs), + HashMap(currentResponse?.syncData ?: emptyMap()) + ) } - private fun launchActivity( - activity: Activity?, - resumeApp: ResultResume, - id: Int? = null, - work: suspend (Intent.(Activity) -> Unit) - ): Job? { - val act = activity ?: return null - return CoroutineScope(Dispatchers.IO).launch { - try { - resumeApp.launch(id) { - work(act) - } - } catch (t: Throwable) { - logError(t) - main { - if (t is ActivityNotFoundException) { - showToast(txt(R.string.app_not_found_error), Toast.LENGTH_LONG) - } else { - showToast(t.toString(), Toast.LENGTH_LONG) - } - } - } - } - } - - private fun playInWebVideo( - activity: Activity?, - link: ExtractorLink, - title: String?, - posterUrl: String?, - subtitles: List - ) = launchActivity(activity, WEB_VIDEO) { - setDataAndType(Uri.parse(link.url), "video/*") - - putExtra("subs", subtitles.map { it.url.toUri() }.toTypedArray()) - title?.let { putExtra("title", title) } - posterUrl?.let { putExtra("poster", posterUrl) } - val headers = Bundle().apply { - if (link.referer.isNotBlank()) - putString("Referer", link.referer) - putString("User-Agent", USER_AGENT) - for ((key, value) in link.headers) { - putString(key, value) - } - } - putExtra("android.media.intent.extra.HTTP_HEADERS", headers) - putExtra("secure_uri", true) - } - - private fun playWithMpv( - activity: Activity?, - id: Int, - link: ExtractorLink, - subtitles: List, - resume: Boolean = true, - ) = launchActivity(activity, MPV, id) { - putExtra("subs", subtitles.map { it.url.toUri() }.toTypedArray()) - putExtra("subs.name", subtitles.map { it.name }.toTypedArray()) - putExtra("subs.filename", subtitles.map { it.name }.toTypedArray()) - setDataAndType(Uri.parse(link.url), "video/*") - component = MPV_COMPONENT - putExtra("secure_uri", true) - putExtra("return_result", true) - val position = getViewPos(id)?.position - if (resume && position != null) - putExtra("position", position.toInt()) - } - - // https://wiki.videolan.org/Android_Player_Intents/ - private fun playWithVlc( - activity: Activity?, - data: LinkLoadingResult, - id: Int, - resume: Boolean = true, - // if it is only a single link then resume works correctly - singleFile: Boolean? = null - ) = launchActivity(activity, VLC, id) { act -> - addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) - addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - - val outputDir = act.cacheDir - - if (singleFile ?: (data.links.size == 1)) { - setDataAndType(data.links.first().url.toUri(), "video/*") - } else { - val outputFile = File.createTempFile("mirrorlist", ".m3u8", outputDir) - - var text = "#EXTM3U" - - // With subtitles it doesn't work for no reason :( -// for (sub in data.subs) { -// text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${sub.name}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.name}\",URI=\"${sub.url}\"" -// } - for (link in data.links) { - text += "\n#EXTINF:, ${link.name}\n${link.url}" - } - outputFile.writeText(text) - - setDataAndType( - FileProvider.getUriForFile( - act, - act.applicationContext.packageName + ".provider", - outputFile - ), "video/*" - ) - } - - val position = if (resume) { - getViewPos(id)?.position ?: 0L - } else { - 1L - } - - // Component no longer safe to use in A13 for VLC - // https://code.videolan.org/videolan/vlc-android/-/issues/2776 - // This will likely need to be updated once VLC fixes their documentation. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - component = VLC_COMPONENT - } - - putExtra("from_start", !resume) - putExtra("position", position) - } - - fun handleAction(click: EpisodeClickEvent) = viewModelScope.launchSafe { handleEpisodeClickEvent(click) } - data class ExternalApp( - val packageString: String, - val name: Int, - val action: Int, - ) - - private val apps = listOf( - ExternalApp( - VLC_PACKAGE, - R.string.player_settings_play_in_vlc, - ACTION_PLAY_EPISODE_IN_VLC_PLAYER - ), ExternalApp( - WEB_VIDEO_CAST_PACKAGE, - R.string.player_settings_play_in_web, - ACTION_PLAY_EPISODE_IN_WEB_VIDEO - ), - ExternalApp( - MPV_PACKAGE, - R.string.player_settings_play_in_mpv, - ACTION_PLAY_EPISODE_IN_MPV - ) - ) - fun releaseEpisodeSynopsis() { _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 -> { val options = mutableListOf>() + if (activity?.isConnectedToChromecast() == true) { options.addAll( listOf( @@ -1191,23 +1367,10 @@ class ResultViewModel2 : ViewModel() { ) ) } + options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) - - for (app in apps) { - if (activity?.isAppInstalled(app.packageString) == true) { - options.add( - txt( - R.string.episode_action_play_in_format, - txt(app.name) - ) to app.action - ) - } - } - options.addAll( listOf( - txt(R.string.episode_action_play_in_browser) to ACTION_PLAY_EPISODE_IN_BROWSER, - txt(R.string.episode_action_copy_link) to ACTION_COPY_LINK, txt(R.string.episode_action_auto_download) to ACTION_DOWNLOAD_EPISODE, txt(R.string.episode_action_download_mirror) to ACTION_DOWNLOAD_MIRROR, txt(R.string.episode_action_download_subtitle) to ACTION_DOWNLOAD_EPISODE_SUBTITLE_MIRROR, @@ -1215,17 +1378,26 @@ class ResultViewModel2 : ViewModel() { ) ) + options.addAll( + VideoClickActionHolder.makeOptionMap(activity, click.data) + ) + // Do not add mark as watched on movies if (!listOf(TvType.Movie, TvType.AnimeMovie).contains(click.data.tvType)) { val isWatched = - DataStoreHelper.getVideoWatchState(click.data.id) == VideoWatchState.Watched + getVideoWatchState(click.data.id) == VideoWatchState.Watched val watchedText = if (isWatched) R.string.action_remove_from_watched else R.string.action_mark_as_watched - options.add(txt(watchedText) to 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( @@ -1272,7 +1444,6 @@ class ResultViewModel2 : ViewModel() { acquireSingleSubtitle( click.data, - false, txt(R.string.episode_action_download_subtitle) ) { (links, index) -> downloadSubtitle( @@ -1300,16 +1471,17 @@ class ResultViewModel2 : ViewModel() { ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return - downloadEpisode( - activity, - click.data, - response.isMovie(), - response.name, - response.type, - response.posterUrl, - response.apiName, - response.getId(), - response.url + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( + click.data, + response.isMovie(), + response.name, + response.type, + response.posterUrl, + response.apiName, + response.getId(), + response.url, + ).toWrapper() ) } @@ -1317,12 +1489,11 @@ class ResultViewModel2 : ViewModel() { val response = currentResponse ?: return acquireSingleLink( click.data, - false, + LOADTYPE_INAPP_DOWNLOAD, txt(R.string.episode_action_download_mirror) ) { (result, index) -> - ioSafe { - startDownload( - activity, + DownloadQueueManager.addToQueue( + DownloadObjects.DownloadQueueItem( click.data, response.isMovie(), response.name, @@ -1333,8 +1504,8 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ) - } + ).toWrapper() + ) showToast( R.string.download_started, Toast.LENGTH_SHORT @@ -1347,139 +1518,148 @@ class ResultViewModel2 : ViewModel() { loadLinks( click.data, isVisible = false, - isCasting = false, + LOADTYPE_INAPP, clearCache = true ) } + showToast( + R.string.links_reloaded_toast, + Toast.LENGTH_SHORT + ) } ACTION_CHROME_CAST_MIRROR -> { acquireSingleLink( click.data, - isCasting = true, - txt(R.string.episode_action_chromecast_mirror) + LOADTYPE_CHROMECAST, + txt(R.string.episode_action_chromecast_mirror), + isCasting = true ) { (result, index) -> startChromecast(activity, click.data, result.links, result.subs, index) } } - ACTION_PLAY_EPISODE_IN_BROWSER -> acquireSingleLink( - click.data, - isCasting = true, - txt(R.string.episode_action_play_in_browser) - ) { (result, index) -> - try { - val i = Intent(Intent.ACTION_VIEW) - i.data = Uri.parse(result.links[index].url) - activity?.startActivity(i) - } catch (e: Exception) { - logError(e) - } - } - - ACTION_COPY_LINK -> { - acquireSingleLink( - click.data, - isCasting = true, - txt(R.string.episode_action_copy_link) - ) { (result, index) -> - val act = activity ?: return@acquireSingleLink - val serviceClipboard = - (act.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?) - ?: return@acquireSingleLink - val link = result.links[index] - val clip = ClipData.newPlainText(link.name, link.url) - serviceClipboard.setPrimaryClip(clip) - showToast(R.string.copy_link_toast, Toast.LENGTH_SHORT) - } - } - ACTION_CHROME_CAST_EPISODE -> { startChromecast(activity, click.data) } - ACTION_PLAY_EPISODE_IN_VLC_PLAYER -> { - loadLinks(click.data, isVisible = true, isCasting = true) { links -> - if (links.links.isEmpty()) { - showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) - return@loadLinks - } + ACTION_PLAY_EPISODE_IN_PLAYER -> { + val list = HashMap(currentResponse?.syncData ?: emptyMap()) + val generator = generator ?: return - playWithVlc( - activity, - links, - click.data.id + // I know kinda shit to iterate all, but it is 100% sure to work + val index = generator.videos.indexOfFirst { value -> value.id == click.data.id } + + if (currentResponse?.type == TvType.CustomMedia) { + generator.generateLinks( + offset = index, + clearCache = true, + isCasting = false, + sourceTypes = LOADTYPE_ALL, + callback = {}, + subtitleCallback = {}) + } else { + activity?.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + generator, index,list + ) ) } } - ACTION_PLAY_EPISODE_IN_WEB_VIDEO -> acquireSingleLink( - click.data, - isCasting = true, - txt( - R.string.episode_action_play_in_format, - txt(R.string.player_settings_play_in_web) - ) - ) { (result, index) -> - playInWebVideo( - activity, - result.links[index], - click.data.name ?: click.data.headerName, - click.data.poster, - result.subs - ) - } - - ACTION_PLAY_EPISODE_IN_MPV -> acquireSingleLink( - click.data, - isCasting = true, - txt( - R.string.episode_action_play_in_format, - txt(R.string.player_settings_play_in_mpv) - ) - ) { (result, index) -> - playWithMpv( - activity, - click.data.id, - result.links[index], - result.subs - ) - } - - ACTION_PLAY_EPISODE_IN_PLAYER -> { - val data = currentResponse?.syncData?.toList() ?: emptyList() - val list = - HashMap().apply { putAll(data) } - - activity?.navigate( - R.id.global_to_navigation_player, - GeneratorPlayer.newInstance( - 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) - } - - } ?: return, list - ) - ) - } - ACTION_MARK_AS_WATCHED -> { val isWatched = - DataStoreHelper.getVideoWatchState(click.data.id) == VideoWatchState.Watched - + getVideoWatchState(click.data.id) == VideoWatchState.Watched if (isWatched) { - DataStoreHelper.setVideoWatchState(click.data.id, VideoWatchState.None) + setVideoWatchState(click.data.id, VideoWatchState.None) } else { - DataStoreHelper.setVideoWatchState(click.data.id, VideoWatchState.Watched) + 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( + click.data, + action.sourceTypes, + action.name + ) { (result, index) -> + action.runActionSafe( + activity, + click.data, + result, + index + ) + } + } else { + loadLinks(click.data, isVisible = true, action.sourceTypes) { links -> + action.runActionSafe( + activity, + click.data, + links, + null + ) + } + } + } } } @@ -1495,7 +1675,7 @@ class ResultViewModel2 : ViewModel() { if (meta != null) { duration = duration ?: meta.duration - rating = rating ?: meta.publicScore + score = score ?: meta.publicScore tags = tags ?: meta.genres plot = if (plot.isNullOrBlank()) meta.synopsis else plot posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl @@ -1506,14 +1686,13 @@ class ResultViewModel2 : ViewModel() { } val realRecommendations = ArrayList() - val apiNames = synchronized(apis) { - apis.filter { - it.name.contains("gogoanime", true) || - it.name.contains("9anime", true) - }.map { - it.name - } + val apiNames = 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)) @@ -1528,11 +1707,11 @@ class ResultViewModel2 : ViewModel() { syncData[k] = v } - argamap( + runAllAsync( { - if (this !is AnimeLoadResponse) return@argamap + if (this !is AnimeLoadResponse) return@runAllAsync // already exist, no need to run getTracker - if (this.getAniListId() != null && this.getMalId() != null) return@argamap + if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync val res = APIHolder.getTracker( listOfNotNull( @@ -1540,14 +1719,22 @@ class ResultViewModel2 : ViewModel() { this.name, this.japName ).filter { it.length > 2 } - .distinct(), // the reason why we filter is due to not wanting smth like " " or "?" + .distinct().map { + // this actually would be nice if we improved a bit as 3rd season == season 3 == III ect + // right now it just removes the dubbed status + it.lowercase().replace(Regex("""\(?[ds]ub(bed)?\)?(\s|$)"""), "") + .trim() + }, TrackerType.getTypes(this.type), this.year ) + val kitsuId = AccountManager.kitsuApi.getAnimeIdByTitle(this.name) + val ids = arrayOf( AccountManager.malApi.idPrefix to res?.malId?.toString(), - AccountManager.aniListApi.idPrefix to res?.aniId + AccountManager.aniListApi.idPrefix to res?.aniId, + AccountManager.kitsuApi.idPrefix to kitsuId ) if (ids.any { (id, new) -> @@ -1556,7 +1743,7 @@ class ResultViewModel2 : ViewModel() { } ) { // getTracker fucked up as it conflicts with current implementation - return@argamap + return@runAllAsync } // set all the new data, prioritise old correct data @@ -1569,20 +1756,21 @@ 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@argamap + if (meta == null) return@runAllAsync addTrailer(meta.trailers) }, { - if (this !is AnimeLoadResponse) return@argamap + if (this !is AnimeLoadResponse) return@runAllAsync val map = Kitsu.getEpisodesDetails( getMalId(), getAniListId(), isResponseRequired = false ) - if (map.isNullOrEmpty()) return@argamap - updateEpisodes = DubStatus.values().map { dubStatus -> + if (map.isNullOrEmpty()) return@runAllAsync + updateEpisodes = DubStatus.entries.map { dubStatus -> val current = this.episodes[dubStatus]?.mapIndexed { index, episode -> episode.apply { @@ -1634,6 +1822,7 @@ class ResultViewModel2 : ViewModel() { postSuccessful( value ?: return@launchSafe, + currentId ?: return@launchSafe, currentRepo ?: return@launchSafe, updateEpisodes ?: return@launchSafe, false @@ -1642,23 +1831,36 @@ class ResultViewModel2 : ViewModel() { } - private suspend fun updateFillers(name: String) { - fillers = - ioWorkSafe { - FillerEpisodeCheck.getFillerEpisodes(name) - } ?: emptyMap() + private suspend fun updateFillers(data: LoadResponse) { + fillers = ioWorkSafe { + FillerEpisodeCheck.getFillerEpisodes(data) + } ?: hashSetOf() } fun changeDubStatus(status: DubStatus) { - postEpisodeRange(currentIndex?.copy(dubStatus = status), currentRange) + postEpisodeRange( + currentIndex?.copy(dubStatus = status), + currentRange, + currentSorting ?: DataStoreHelper.resultsSortingMode + ) } fun changeRange(range: EpisodeRange) { - postEpisodeRange(currentIndex, range) + postEpisodeRange(currentIndex, range, currentSorting ?: DataStoreHelper.resultsSortingMode) } fun changeSeason(season: Int) { - postEpisodeRange(currentIndex?.copy(season = season), currentRange) + 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) } private fun getMovie(): ResultEpisode? { @@ -1668,26 +1870,40 @@ class ResultViewModel2 : ViewModel() { } } - private fun getEpisodes(indexer: EpisodeIndexer, range: EpisodeRange): List { - val startIndex = range.startIndex - val length = range.length - - 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 = - DataStoreHelper.getVideoWatchState(it.id) ?: VideoWatchState.None - it.copy( - position = posDur?.position ?: 0, - duration = posDur?.duration ?: 0, - videoWatchState = watchState - ) - } + 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() + } ?: emptyList() + } + + 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 + } + + 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 } + } } private fun postMovie() { @@ -1702,6 +1918,7 @@ class ResultViewModel2 : ViewModel() { val text = txt( when (response.type) { TvType.Torrent -> R.string.play_torrent_button + TvType.TvSeries -> R.string.play_full_series_button else -> { if (response.type.isLiveStream()) R.string.play_livestream_button @@ -1726,9 +1943,11 @@ class ResultViewModel2 : ViewModel() { } else { _episodes.postValue( Resource.Success( - getEpisodes( - currentIndex ?: return, - currentRange ?: return + getSortedEpisodes( + getEpisodes( + currentIndex ?: return, + currentRange ?: return, + ), currentSorting ?: return ) ) ) @@ -1738,17 +1957,42 @@ class ResultViewModel2 : ViewModel() { } private fun postSubscription(loadResponse: LoadResponse) { + val id = loadResponse.getId() + val data = getSubscribedData(id) if (loadResponse.isEpisodeBased()) { - val id = loadResponse.getId() - val data = DataStoreHelper.getSubscribedData(id) - DataStoreHelper.updateSubscribedData(id, data, loadResponse as? EpisodeResponse) - val isSubscribed = data != null - _subscribeStatus.postValue(isSubscribed) + updateSubscribedData(id, data, loadResponse as? EpisodeResponse) + _subscribeStatus.postValue(data != null) + } + // lets say that we have subscribed, then we must be able to unsubscribe no matter what + else if (data != null) { + _subscribeStatus.postValue(true) + } else _subscribeStatus.postValue(null) + } + + private fun postFavorites(loadResponse: LoadResponse) { + val id = loadResponse.getId() + val isFavorite = getFavoritesData(id) != null + _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?) { - if (range == null || indexer == null) { + private fun postEpisodeRange( + indexer: EpisodeIndexer?, + range: EpisodeRange?, + sorting: EpisodeSortType? + ) { + if (range == null || indexer == null || sorting == null) { return } @@ -1756,10 +2000,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 happends when dub has less episodes then sub -> the range does not exist + // this usually happens 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) + postEpisodeRange(indexer, r, sorting) return } } @@ -1790,29 +2034,8 @@ class ResultViewModel2 : ViewModel() { ) _selectedSeason.postValue( - if (isMovie || currentSeasons.size <= 1) null else - when (indexer.season) { - 0 -> txt(R.string.no_season) - else -> { - val seasonNames = (currentResponse as? EpisodeResponse)?.seasonNames - val seasonData = seasonNames.getSeason(indexer.season) - - // If displaySeason is null then only show the name! - if (seasonData?.name != null && seasonData.displaySeason == null) { - txt(seasonData.name) - } else { - val suffix = seasonData?.name?.let { " $it" } ?: "" - txt( - R.string.season_format, - txt(R.string.season), - seasonData?.displaySeason ?: indexer.season, - suffix - ) - } - } - } - + (currentResponse as? EpisodeResponse)?.seasonNames.getSeasonTxt(indexer.season) ) _selectedRangeIndex.postValue( @@ -1862,42 +2085,94 @@ 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) + } } - }*/ - _episodes.postValue(Resource.Success(ret)) + + 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))) + } } } private suspend fun postSuccessful( loadResponse: LoadResponse, + mainId: Int, apiRepository: APIRepository, updateEpisodes: Boolean, updateFillers: Boolean, ) { + currentId = mainId currentResponse = loadResponse postPage(loadResponse, apiRepository) postSubscription(loadResponse) - if (updateEpisodes) - postEpisodes(loadResponse, updateFillers) - } - - private suspend fun postEpisodes(loadResponse: LoadResponse, updateFillers: Boolean) { - _episodes.postValue(Resource.Loading()) - - val mainId = loadResponse.getId() - currentId = mainId - + postFavorites(loadResponse) _watchStatus.postValue(getResultWatchState(mainId)) - if (updateFillers && loadResponse is AnimeLoadResponse) { - updateFillers(loadResponse.name) + if (updateEpisodes) + postEpisodes(loadResponse, mainId, updateFillers) + } + + private suspend fun postEpisodes( + loadResponse: LoadResponse, + mainId: Int, + updateFillers: Boolean + ) { + _episodes.postValue(Resource.Loading()) + + if (updateFillers) { + updateFillers(loadResponse) } val allEpisodes = when (loadResponse) { @@ -1912,6 +2187,15 @@ class ResultViewModel2 : ViewModel() { val id = mainId + episode + idIndex * 1_000_000 + (i.season?.times(10_000) ?: 0) + + val totalIndex = + i.season?.let { season -> + loadResponse.getTotalEpisodeIndex( + episode, + season + ) + } + if (!existingEpisodes.contains(id)) { existingEpisodes.add(id) val seasonData = loadResponse.seasonNames.getSeason(i.season) @@ -1921,17 +2205,21 @@ class ResultViewModel2 : ViewModel() { filterName(i.name), i.posterUrl, episode, - seasonData?.season ?: i.season, + i.season, if (seasonData != null) seasonData.displaySeason else i.season, i.data, loadResponse.apiName, id, index, - i.rating, + i.score, i.description, - fillers.getOrDefault(episode, false), + fillers.contains(episode), loadResponse.type, - mainId + mainId, + totalIndex, + airDate = i.date, + runTime = i.runTime, + seasonData = seasonData, ) val season = eps.seasonIndex ?: 0 @@ -1960,23 +2248,35 @@ class ResultViewModel2 : ViewModel() { val seasonData = loadResponse.seasonNames.getSeason(episode.season) + val totalIndex = + episode.season?.let { season -> + loadResponse.getTotalEpisodeIndex( + episodeIndex, + season + ) + } + val ep = buildResultEpisode( loadResponse.name, filterName(episode.name), episode.posterUrl, episodeIndex, - seasonData?.season ?: episode.season, + episode.season, if (seasonData != null) seasonData.displaySeason else episode.season, episode.data, loadResponse.apiName, id, index, - episode.rating, + episode.score, episode.description, null, loadResponse.type, - mainId + mainId, + totalIndex, + airDate = episode.date, + runTime = episode.runTime, + seasonData = seasonData, ) val season = ep.seasonIndex ?: 0 @@ -2007,7 +2307,8 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null, ) ) } @@ -2029,7 +2330,8 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null ) ) } @@ -2051,7 +2353,8 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null ) ) } @@ -2072,21 +2375,7 @@ class ResultViewModel2 : ViewModel() { _dubSubSelections.postValue(dubSelection.map { txt(it) to it }) if (loadResponse is EpisodeResponse) { _seasonSelections.postValue(seasonsSelection.map { seasonNumber -> - val seasonData = loadResponse.seasonNames.getSeason(seasonNumber) - val fixedSeasonNumber = seasonData?.displaySeason ?: seasonNumber - val suffix = seasonData?.name?.let { " $it" } ?: "" - // If displaySeason is null then only show the name! - val name = if (seasonData?.name != null && seasonData.displaySeason == null) { - txt(seasonData.name) - } else { - txt( - R.string.season_format, - txt(R.string.season), - fixedSeasonNumber, - suffix - ) - } - name to seasonNumber + loadResponse.seasonNames.getSeasonTxt(seasonNumber) to seasonNumber }) } @@ -2108,17 +2397,17 @@ class ResultViewModel2 : ViewModel() { it.startEpisode >= (preferStartEpisode ?: 0) } ?: ranger?.lastOrNull() - postEpisodeRange(min, range) + postEpisodeRange(min, range, DataStoreHelper.resultsSortingMode) postResume() } - fun postResume() { + private fun postResume() { _resumeWatching.postValue(resume()) } private fun resume(): ResumeWatchingStatus? { val correctId = currentId ?: return null - val resume = DataStoreHelper.getLastWatched(correctId) + val resume = getLastWatched(correctId) val resumeParentId = resume?.parentId if (resumeParentId != correctId) return null // is null or smth went wrong with getLastWatched val resumeId = resume.episodeId ?: return null// invalid episode id @@ -2133,7 +2422,13 @@ class ResultViewModel2 : ViewModel() { ResumeProgress( progress = (viewPos.position / 1000).toInt(), maxProgress = (viewPos.duration / 1000).toInt(), - txt(R.string.resume_time_left, (viewPos.duration - viewPos.position) / (60_000)) + txt( + R.string.resume_remaining, + secondsToReadable( + ((viewPos.duration - viewPos.position) / 1_000).toInt(), + "0 mins" + ) + ) ) } @@ -2158,22 +2453,33 @@ class ResultViewModel2 : ViewModel() { loadResponse.trailers.windowed(limit, limit, true).takeWhile { list -> list.amap { trailerData -> try { - val links = arrayListOf() + val links = arrayListOf>() val subs = arrayListOf() if (!loadExtractor( trailerData.extractorUrl, trailerData.referer, { subs.add(it) }, - { links.add(it) }) && trailerData.raw + { + links.add( + Pair( + it, + trailerData.extractorUrl + ) + ) + }) && trailerData.raw ) { arrayListOf( - ExtractorLink( - "", - "Trailer", - trailerData.extractorUrl, - trailerData.referer ?: "", - Qualities.Unknown.value, - trailerData.extractorUrl.contains(".m3u8") + Pair( + newExtractorLink( + "", + "Trailer", + trailerData.extractorUrl, + type = INFER_TYPE + ) { + this.referer = trailerData.referer ?: "" + this.quality = Qualities.Unknown.value + this.headers = trailerData.headers + }, trailerData.extractorUrl ) ) to arrayListOf() } else { @@ -2240,6 +2546,69 @@ class ResultViewModel2 : ViewModel() { } } + data class LoadResponseFromSearch( + override var name: String, + override var url: String, + override var apiName: String, + override var type: TvType, + override var posterUrl: String?, + override var year: Int? = null, + override var plot: String? = null, + override var score: Score? = null, + override var tags: List? = null, + override var duration: Int? = null, + override var trailers: MutableList = mutableListOf(), + override var recommendations: List? = null, + override var actors: List? = null, + override var comingSoon: Boolean = false, + 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 + + fun loadSmall(searchResponse: SearchResponse) = ioSafe { + val url = searchResponse.url + _page.postValue(Resource.Loading(url)) + _episodes.postValue(Resource.Loading()) + val api = + APIHolder.getApiFromNameNull(searchResponse.apiName) ?: APIHolder.getApiFromUrlNull( + searchResponse.url + ) ?: APIRepository.noneApi + val repo = APIRepository(api) + val response = LoadResponseFromSearch( + name = searchResponse.name, + url = searchResponse.url, + apiName = api.name, + type = searchResponse.type ?: TvType.Others, + posterUrl = searchResponse.posterUrl, + id = searchResponse.id + ).apply { + if (searchResponse is SyncAPI.LibraryItem) { + this.plot = searchResponse.plot + this.score = searchResponse.personalRating ?: searchResponse.score + this.tags = searchResponse.tags + } + if (searchResponse is DataStoreHelper.BookmarkedData) { + this.plot = searchResponse.plot + this.score = searchResponse.score + this.tags = searchResponse.tags + } + } + val mainId = response.getId() + + postSuccessful( + loadResponse = response, + mainId = mainId, + apiRepository = repo, + updateEpisodes = false, + updateFillers = false + ) + } + fun load( activity: Activity?, url: String, @@ -2262,8 +2631,6 @@ class ResultViewModel2 : ViewModel() { _page.postValue( Resource.Failure( false, - null, - null, "This provider does not exist" ) ) @@ -2311,20 +2678,21 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - VideoDownloadHelper.DownloadHeaderCached( - apiName, - validUrl, - loadResponse.type, - loadResponse.name, - loadResponse.posterUrl, - mainId, - System.currentTimeMillis(), + DownloadObjects.DownloadHeaderCached( + apiName = apiName, + url = validUrl, + type = loadResponse.type, + name = loadResponse.name, + poster = loadResponse.posterUrl, + id = mainId, + cacheTime = System.currentTimeMillis(), ) ) if (loadTrailers) loadTrailers(data.value) postSuccessful( data.value, + mainId, updateEpisodes = true, updateFillers = showFillers, apiRepository = repo 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 6fe457309..4231819dd 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,103 +2,68 @@ 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.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.setText typealias SelectData = Pair -class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter() { - private val selection: MutableList = mutableListOf() +class SelectAdaptor(val callback: (Any) -> Unit) : + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.second == b.second + }, contentSame = { a, b -> + a == b + })) { private var selectedIndex: Int = -1 - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return SelectViewHolder( - ResultSelectionBinding.inflate(LayoutInflater.from(parent.context), parent, false), - - //LayoutInflater.from(parent.context).inflate(R.layout.result_selection, parent, false), + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ResultSelectionBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is SelectViewHolder -> { - holder.bind(selection[position], position == selectedIndex, callback) + override fun onBindContent(holder: ViewHolderState, item: SelectData, position: Int) { + when (val binding = holder.view) { + is ResultSelectionBinding -> { + binding.root.apply { + if (isLayout(TV)) { + isFocusable = true + isFocusableInTouchMode = true + } + + isSelected = position == selectedIndex + setText(item.first) + setOnClickListener { + callback.invoke(item.second) + } + } } } } - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { - if(holder.itemView.hasFocus()) { + override fun onViewDetachedFromWindow(holder: ViewHolderState) { + if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } } - override fun getItemCount(): Int { - return selection.size - } - fun select(newIndex: Int, recyclerView: RecyclerView?) { - if(recyclerView == null) return - if(newIndex == selectedIndex) return + if (recyclerView == null) return + if (newIndex == selectedIndex) return val oldIndex = selectedIndex selectedIndex = newIndex notifyItemChanged(selectedIndex) notifyItemChanged(oldIndex) } - - fun updateSelectionList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SelectDataCallback(this.selection, newList) - ) - - selection.clear() - selection.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - - private class SelectViewHolder - constructor( - binding: ResultSelectionBinding, - ) : - RecyclerView.ViewHolder(binding.root) { - private val item: MaterialButton = binding.root - - fun bind( - data: SelectData, isSelected: Boolean, callback: (Any) -> Unit - ) { - val isTrueTv = isTrueTvSettings() - if (isTrueTv) { - 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 a3e2ed873..6c5c64ff8 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,13 +4,18 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +import com.lagradost.cloudstream3.mvvm.throwAbleToResource +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.SyncUtil import java.util.* @@ -29,17 +34,17 @@ class SyncViewModel : ViewModel() { const val TAG = "SYNCVM" } - private val repos = SyncApis + private val repos = AccountManager.syncApis - private val _metaResponse: MutableLiveData> = - MutableLiveData() + private val _metaResponse: MutableLiveData?> = + MutableLiveData(null) - val metadata: LiveData> get() = _metaResponse + val metadata: LiveData?> = _metaResponse private val _userDataResponse: MutableLiveData?> = MutableLiveData(null) - val userData: LiveData?> get() = _userDataResponse + val userData: LiveData?> = _userDataResponse // prefix, id private val syncs = mutableMapOf() @@ -55,7 +60,7 @@ class SyncViewModel : ViewModel() { MutableLiveData(getMissing()) // pair of name idPrefix isSynced - val synced: LiveData> get() = _currentSynced + val synced: LiveData> = _currentSynced private fun getMissing(): List { return repos.map { @@ -63,7 +68,7 @@ class SyncViewModel : ViewModel() { it.name, it.idPrefix, syncs.containsKey(it.idPrefix), - it.hasAccount(), + it.authUser() != null, it.icon, ) } @@ -155,7 +160,7 @@ class SyncViewModel : ViewModel() { } } - fun setScore(score: Int) { + fun setScore(score: Score?) { Log.i(TAG, "setScore = $score") val user = userData.value if (user is Resource.Success) { @@ -169,7 +174,7 @@ class SyncViewModel : ViewModel() { if (which < -1 || which > 5) return // validate input val user = userData.value if (user is Resource.Success) { - user.value.status = which + user.value.status = SyncWatchType.fromInternalId(which) _userDataResponse.postValue(Resource.Success(user.value)) } } @@ -179,7 +184,7 @@ class SyncViewModel : ViewModel() { val user = userData.value if (user is Resource.Success) { syncs.forEach { (prefix, id) -> - repos.firstOrNull { it.idPrefix == prefix }?.score(id, user.value) + repos.firstOrNull { it.idPrefix == prefix }?.updateStatus(id, user.value) } } updateUserData() @@ -201,17 +206,10 @@ class SyncViewModel : ViewModel() { ioSafe { syncs.amap { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> - if (repo.hasAccount()) { - val result = repo.getStatus(id) - if (result is Resource.Success) { - update(result.value)?.let { newData -> - Log.i(TAG, "modifyData ${repo.name} => $newData") - repo.score(id, newData) - } - } else if (result is Resource.Failure) { - Log.e(TAG, "modifyData getStatus error ${result.errorString}") - } - } + val result = + update(repo.status(id).getOrNull() ?: return@let null) ?: return@let null + Log.i(TAG, "modifyData ${repo.name} => $result") + repo.updateStatus(id, result) } } } @@ -219,29 +217,24 @@ class SyncViewModel : ViewModel() { fun updateUserData() = ioSafe { Log.i(TAG, "updateUserData") _userDataResponse.postValue(Resource.Loading()) - 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 - } - } - } + + val status = syncs.firstNotNullOfOrNull { (prefix, id) -> + repos.firstOrNull { it.idPrefix == prefix } + ?.status(id)?.getOrNull() + } + + if (status == null) { + _userDataResponse.postValue(Resource.Failure(false, "No data")) + } else { + _userDataResponse.postValue(Resource.Success(status)) } - _userDataResponse.postValue(lastError) } private fun updateMetadata() = ioSafe { Log.i(TAG, "updateMetadata") _metaResponse.postValue(Resource.Loading()) - var lastError: Resource = Resource.Failure(false, null, null, "No data") + var lastError: Resource = Resource.Failure(false, "No data") val current = ArrayList(syncs.toList()) // shitty way to sort anilist first, as it has trailers while mal does not @@ -259,19 +252,20 @@ class SyncViewModel : ViewModel() { current.forEach { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> - if (!repo.requiresLogin || repo.hasAccount()) { - Log.i(TAG, "updateMetadata loading ${repo.idPrefix}") - val result = repo.getResult(id) - if (result is Resource.Success) { - _metaResponse.postValue(result) - return@ioSafe - } else if (result is Resource.Failure) { - Log.e( - TAG, - "updateMetadata error $id at ${repo.idPrefix} ${result.errorString}" - ) - lastError = result - } + Log.i(TAG, "updateMetadata loading ${repo.idPrefix}") + val result = repo.load(id) + val resultValue = result.getOrNull() + val resultError = result.exceptionOrNull() + if (resultValue != null) { + _metaResponse.postValue(Resource.Success(resultValue)) + return@ioSafe + } else if (resultError != null) { + + /*Log.e( + TAG, + "updateMetadata error $id at ${repo.idPrefix} ${result.errorString}" + )*/ + lastError = throwAbleToResource(resultError) } } } @@ -279,7 +273,34 @@ class SyncViewModel : ViewModel() { setEpisodesDelta(0) } + fun syncName(syncName: String): String? { + // fix because of bad old data :pensive: + val realName = when (syncName) { + "MAL" -> malApi.idPrefix + "Kitsu" -> kitsuApi.idPrefix + "Simkl" -> simklApi.idPrefix + "AniList" -> aniListApi.idPrefix + else -> syncName + } + return repos.firstOrNull { it.idPrefix == realName }?.idPrefix + } + + fun setSync(syncName: String, syncId: String) { + syncs.clear() + syncs[syncName] = syncId + } + + fun clear() { + syncs.clear() + _metaResponse.postValue(null) + _currentSynced.postValue(getMissing()) + _userDataResponse.postValue(null) + } + fun updateMetaAndUser() { + _userDataResponse.postValue(Resource.Loading()) + _metaResponse.postValue(Resource.Loading()) + Log.i(TAG, "updateMetaAndUser") updateMetadata() updateUserData() 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 b516348da..7b63b6ede 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.utils.UIHelper.IsBottomLayout -import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout import kotlin.math.roundToInt /** Click */ @@ -31,17 +31,32 @@ class SearchClickCallback( ) class SearchAdapter( - private val cardList: MutableList, private val resView: AutofitRecyclerView, + private val isHorizontal:Boolean = false, private val clickCallback: (SearchClickCallback) -> Unit, -) : RecyclerView.Adapter() { +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + if (a.id != null || b.id != null) { + a.id == b.id + } else { + a.name == b.name + } +})) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } + } + var hasNext: Boolean = false - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + private val coverRatio = if(isHorizontal) 1.8 else 0.68 + + private val coverHeight: Int get() = (resView.itemWidth / coverRatio).roundToInt() + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val layout = - if (parent.context.IsBottomLayout()) SearchResultGridExpandedBinding.inflate( + if (parent.context.isBottomLayout()) SearchResultGridExpandedBinding.inflate( inflater, parent, false @@ -49,85 +64,36 @@ class SearchAdapter( inflater, parent, false - ) //R.layout.search_result_grid_expanded else R.layout.search_result_grid - - - - return CardViewHolder( - layout, - clickCallback, - resView - ) + ) + return ViewHolderState(layout) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position], position) + override fun onClearView(holder: ViewHolderState) { + clearImage( + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> binding.imageView + is SearchResultGridBinding -> binding.imageView + else -> null } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SearchResponseDiffCallback(this.cardList, newList) ) - - cardList.clear() - cardList.addAll(newList) - - diffResult.dispatchUpdatesTo(this) } - class CardViewHolder - constructor( - val binding: ViewBinding, - private val clickCallback: (SearchClickCallback) -> Unit, - resView: AutofitRecyclerView - ) : - RecyclerView.ViewHolder(binding.root) { - - private val compactView = false//itemView.context.getGridIsCompact() - private val coverHeight: Int = - if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() - - private val cardView = when(binding) { + override fun onBindContent(holder: ViewHolderState, item: SearchResponse, position: Int) { + val imageView = when (val binding = holder.view) { is SearchResultGridExpandedBinding -> binding.imageView is SearchResultGridBinding -> binding.imageView else -> null } - fun bind(card: SearchResponse, position: Int) { - if (!compactView) { - cardView?.apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - } + if (imageView != null) { + val params = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + coverHeight + ) + if (imageView.layoutParams.width != params.width || imageView.layoutParams.height != params.height) { + imageView.layoutParams = params } - - SearchResultBuilder.bind(clickCallback, card, position, itemView) } + SearchResultBuilder.bind(clickCallback, item, position, holder.view.root) } -} - -class SearchResponseDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].name == newList[newItemPosition].name - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt index bdf823772..5f5b064b5 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,7 +1,10 @@ package com.lagradost.cloudstream3.ui.search +import android.app.Activity +import android.content.Intent import android.content.DialogInterface -import android.content.res.Configuration +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -11,61 +14,79 @@ import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.ListView +import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.doOnLayout import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.APIHolder.getApiSettings -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -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.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 import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentSearchBinding import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding import com.lagradost.cloudstream3.mvvm.Resource 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.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.ownHide -import com.lagradost.cloudstream3.utils.AppUtils.ownShow -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +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 +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings +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.DataStore.getKey -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.SubtitleHelper +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import java.util.Locale import java.util.concurrent.locks.ReentrantLock -const val SEARCH_PREF_TAGS = "search_pref_tags" -const val SEARCH_PREF_PROVIDERS = "search_pref_providers" - -class SearchFragment : Fragment() { +class SearchFragment : BaseFragment( + BaseFragment.BindingCreator.Bind(FragmentSearchBinding::bind) +) { companion object { fun List.filterSearchResponse(): List { return this.filter { response -> @@ -84,14 +105,28 @@ class SearchFragment : Fragment() { fun newInstance(query: String): Bundle { return Bundle().apply { - if(query.isNotBlank()) putString(SEARCH_QUERY, query) + if (query.isNotBlank()) putString(SEARCH_QUERY, query) } } } private val searchViewModel: SearchViewModel by activityViewModels() private var bottomSheetDialog: BottomSheetDialog? = null - var binding: FragmentSearchBinding? = null + + private val speechRecognizerLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + 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 override fun onCreateView( inflater: LayoutInflater, @@ -102,34 +137,13 @@ class SearchFragment : Fragment() { WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) bottomSheetDialog?.ownShow() - - val layout = if (isTvSettings()) R.layout.fragment_search_tv else R.layout.fragment_search - - val root = inflater.inflate(layout, container, false) - // TODO TRYCATCH - binding = FragmentSearchBinding.bind(root) - - return root - } - - private fun fixGrid() { - activity?.getSpanCount()?.let { - currentSpan = it - } - binding?.searchAutofitResults?.spanCount = currentSpan - currentSpan = currentSpan - HomeFragment.configEvent.invoke(currentSpan) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - fixGrid() + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() - binding = null + activity?.detachBackPressedCallback("SearchFragment") super.onDestroyView() } @@ -153,7 +167,8 @@ class SearchFragment : Fragment() { **/ fun search(query: String?) { if (query == null) return - + // don't resume state from prev search + (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*, *>)?.clearState() context?.let { ctx -> val default = enumValues().sorted().filter { it != TvType.NSFW } .map { it.ordinal.toString() }.toSet() @@ -193,7 +208,7 @@ class SearchFragment : Fragment() { validAPIs.flatMap { api -> api.supportedTypes }.distinct() ) { list -> if (selectedSearchTypes.toSet() != list.toSet()) { - setKey(SEARCH_PREF_TAGS, selectedSearchTypes) + DataStoreHelper.searchPreferenceTags = list selectedSearchTypes.clear() selectedSearchTypes.addAll(list) search(binding?.mainSearch?.query?.toString()) @@ -202,45 +217,71 @@ class SearchFragment : Fragment() { } } + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + // Fix grid + currentSpan = view.context.getSpanCount() + binding?.searchAutofitResults?.spanCount = currentSpan + HomeFragment.configEvent.invoke() + } - fixPaddingStatusbar(binding?.searchRoot) - fixGrid() + override fun onBindingCreated( + binding: FragmentSearchBinding, + savedInstanceState: Bundle? + ) { reloadRepos() - - binding?.apply { - val adapter: RecyclerView.Adapter = + binding.apply { + val adapter = SearchAdapter( - ArrayList(), searchAutofitResults, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } - + searchRoot.findViewById(androidx.appcompat.R.id.search_src_text)?.tag = + "tv_no_focus_tag" + searchAutofitResults.setRecycledViewPool(SearchAdapter.sharedPool) searchAutofitResults.adapter = adapter searchLoadingBar.alpha = 0f } - - val searchExitIcon = - binding?.mainSearch?.findViewById(androidx.appcompat.R.id.search_close_btn) - // val searchMagIcon = - // main_search.findViewById(androidx.appcompat.R.id.search_mag_icon) - //searchMagIcon.scaleX = 0.65f - //searchMagIcon.scaleY = 0.65f - - context?.let { ctx -> - val validAPIs = ctx.filterProviderByPreferredMedia() - selectedApis = ctx.getKey( - SEARCH_PREF_PROVIDERS, - defVal = validAPIs.map { it.name } - )!!.toMutableSet() + 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) + } + } } - binding?.searchFilter?.setOnClickListener { searchView -> + val searchExitIcon = + binding.mainSearch.findViewById(androidx.appcompat.R.id.search_close_btn) + + selectedApis = DataStoreHelper.searchPreferenceProviders.toMutableSet() + + binding.searchFilter.setOnClickListener { searchView -> searchView?.context?.let { ctx -> val validAPIs = ctx.filterProviderByPreferredMedia(hasHomePageIsRequired = false) var currentValidApis = listOf() @@ -252,14 +293,18 @@ class SearchFragment : Fragment() { builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - val binding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( - builder.layoutInflater, - null, - false - ) - builder.setContentView(binding.root) + val selectMainpageBinding: HomeSelectMainpageBinding = + HomeSelectMainpageBinding.inflate( + builder.layoutInflater, + null, + false + ) + builder.setContentView(selectMainpageBinding.root) builder.show() builder.let { dialog -> + val previousSelectedApis = selectedApis.toSet() + val previousSelectedSearchTypes = selectedSearchTypes.toSet() + val isMultiLang = ctx.getApiProviderLangSettings().let { set -> set.size > 1 || set.contains(AllLanguagesName) } @@ -286,7 +331,7 @@ class SearchFragment : Fragment() { } fun updateList(types: List) { - setKey(SEARCH_PREF_TAGS, types.map { it.name }) + DataStoreHelper.searchPreferenceTags = types arrayAdapter.clear() currentValidApis = validAPIs.filter { api -> @@ -311,21 +356,26 @@ class SearchFragment : Fragment() { arrayAdapter.notifyDataSetChanged() } - val selectedSearchTypes = getKey>(SEARCH_PREF_TAGS) - ?.mapNotNull { listName -> - TvType.values().firstOrNull { it.name == listName } - } - ?.toMutableList() - ?: mutableListOf(TvType.Movie, TvType.TvSeries) - bindChips( - binding.tvtypesChipsScroll.tvtypesChips, + selectMainpageBinding.tvtypesChipsScroll.tvtypesChips, selectedSearchTypes, - TvType.values().toList() + validAPIs.flatMap { api -> api.supportedTypes }.distinct() ) { list -> updateList(list) + + // refresh selected chips in main chips + if (selectedSearchTypes.toSet() != list.toSet()) { + selectedSearchTypes.clear() + selectedSearchTypes.addAll(list) + updateChips( + binding.tvtypesChipsScroll.tvtypesChips, + selectedSearchTypes + ) + + } } + cancelBtt?.setOnClickListener { dialog.dismissSafe() } @@ -342,8 +392,13 @@ class SearchFragment : Fragment() { } dialog.setOnDismissListener { - context?.setKey(SEARCH_PREF_PROVIDERS, currentSelectedApis.toList()) + DataStoreHelper.searchPreferenceProviders = currentSelectedApis.toList() selectedApis = currentSelectedApis + + // run search when dialog is close + if (previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { + search(binding.mainSearch.query.toString()) + } } updateList(selectedSearchTypes.toList()) } @@ -352,22 +407,31 @@ class SearchFragment : Fragment() { val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) } val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true + val isSearchSuggestionsEnabled = settingsManager?.getBoolean("search_suggestions_enabled", true) ?: true - selectedSearchTypes = context?.getKey>(SEARCH_PREF_TAGS) - ?.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } } - ?.toMutableList() - ?: mutableListOf(TvType.Movie, TvType.TvSeries) + selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList() - if (isTrueTvSettings()) { - binding?.searchFilter?.isFocusable = true - binding?.searchFilter?.isFocusableInTouchMode = true + if (!isLayout(PHONE)) { + binding.searchFilter.isFocusable = true + binding.searchFilter.isFocusableInTouchMode = true } - binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + // Hide suggestions when search view loses focus (phone only) + if (isLayout(PHONE)) { + binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + searchViewModel.clearSuggestions() + } + } + } + + + binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { search(query) + searchViewModel.clearSuggestions() - binding?.mainSearch?.let { + binding.mainSearch.let { hideKeyboard(it) } @@ -380,76 +444,49 @@ class SearchFragment : Fragment() { if (showHistory) { searchViewModel.clearSearch() searchViewModel.updateHistory() + searchViewModel.clearSuggestions() + } else { + // Fetch suggestions when user is typing (if enabled) + if (isSearchSuggestionsEnabled) { + searchViewModel.fetchSuggestions(newText) + } } - binding?.apply { - searchHistoryHolder.isVisible = showHistory + binding.apply { + searchHistoryRecycler.isVisible = showHistory searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch + // Hide suggestions when showing history or showing search results + searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled } return true } }) - binding?.searchClearCallHistory?.setOnClickListener { - activity?.let { ctx -> - val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) - val dialogClickListener = - DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - removeKeys(SEARCH_HISTORY_KEY) - searchViewModel.updateHistory() - } - DialogInterface.BUTTON_NEGATIVE -> { - } - } - } - - try { - builder.setTitle(R.string.clear_history).setMessage( - ctx.getString(R.string.delete_message).format( - ctx.getString(R.string.history) - ) - ) - .setPositiveButton(R.string.sort_clear, dialogClickListener) - .setNegativeButton(R.string.cancel, dialogClickListener) - .show().setDefaultFocus() - } catch (e: Exception) { - logError(e) - // ye you somehow fucked up formatting did you? - } - } - - - } - - observe(searchViewModel.currentHistory) { list -> - binding?.searchClearCallHistory?.isVisible = list.isNotEmpty() - (binding?.searchHistoryRecycler?.adapter as? SearchHistoryAdaptor?)?.updateList(list) - } - - searchViewModel.updateHistory() - observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - if (data.isNotEmpty()) { - (binding?.searchAutofitResults?.adapter as? SearchAdapter)?.updateList(data) + val list = data.list + if (list.isNotEmpty()) { + (binding.searchAutofitResults.adapter as? SearchAdapter)?.submitList( + list + ) } } searchExitIcon?.alpha = 1f - binding?.searchLoadingBar?.alpha = 0f + binding.searchLoadingBar.alpha = 0f } + is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - binding?.searchLoadingBar?.alpha = 0f + binding.searchLoadingBar.alpha = 0f } + is Resource.Loading -> { searchExitIcon?.alpha = 0f - binding?.searchLoadingBar?.alpha = 1f + binding.searchLoadingBar.alpha = 1f } } } @@ -459,20 +496,33 @@ class SearchFragment : Fragment() { try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (binding?.searchMasterRecycler?.adapter as ParentItemAdapter?)?.apply { - val newItems = list.map { ongoing -> - val dataList = - if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() + + val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() + + val sortedList = list.toList().sortedWith(compareBy { (providerName, _) -> + val index = pinnedOrder.indexOf(providerName) + if (index == -1) Int.MAX_VALUE else index + }) + + (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { + val newItems = sortedList.map { (providerName, providerData) -> + val dataList = providerData.list val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList - val ongoingList = HomePageList( - ongoing.apiName, + + val homePageList = HomePageList( + providerName, dataListFiltered ) - ongoingList - } - updateList(newItems) + HomeViewModel.ExpandableHomepageList( + homePageList, + providerData.currentPage, + providerData.hasNext + ) + } + + submitList(newItems) //notifyDataSetChanged() } } catch (e: Exception) { @@ -491,53 +541,124 @@ class SearchFragment : Fragment() { }*/ //main_search.onActionViewExpanded()*/ - val masterAdapter: RecyclerView.Adapter = - ParentItemAdapter(mutableListOf(), { callback -> + val masterAdapter = + ParentItemAdapter(id = "masterAdapter".hashCode(), { callback -> SearchHelper.handleSearchClickCallback(callback) }, { item -> bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { bottomSheetDialog = null - }) + }, expandCallback = { name -> searchViewModel.expandAndReturn(name) }) + }, expandCallback = { name -> + ioSafe { + searchViewModel.expandAndReturn(name) + } }) - val historyAdapter = SearchHistoryAdaptor(mutableListOf()) { click -> + val historyAdapter = SearchHistoryAdaptor { click -> val searchItem = click.item when (click.clickAction) { SEARCH_HISTORY_OPEN -> { + if (searchItem == null) return@SearchHistoryAdaptor searchViewModel.clearSearch() if (searchItem.type.isNotEmpty()) - updateChips(binding?.tvtypesChipsScroll?.tvtypesChips, searchItem.type.toMutableList()) - binding?.mainSearch?.setQuery(searchItem.searchText, true) + updateChips( + binding.tvtypesChipsScroll.tvtypesChips, + searchItem.type.toMutableList() + ) + binding.mainSearch.setQuery(searchItem.searchText, true) } + SEARCH_HISTORY_REMOVE -> { - removeKey(SEARCH_HISTORY_KEY, searchItem.key) + if (searchItem == null) return@SearchHistoryAdaptor + removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) searchViewModel.updateHistory() } + + SEARCH_HISTORY_CLEAR -> { + // Show confirmation dialog (from footer button) + activity?.let { ctx -> + val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") + searchViewModel.updateHistory() + } + + DialogInterface.BUTTON_NEGATIVE -> { + } + } + } + + try { + builder.setTitle(R.string.clear_history).setMessage( + ctx.getString(R.string.delete_message).format( + ctx.getString(R.string.history) + ) + ) + .setPositiveButton(R.string.sort_clear, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (e: Exception) { + logError(e) + } + } + } + else -> { // wth are you doing??? } } } - binding?.apply { + val suggestionAdapter = SearchSuggestionAdapter { callback -> + when (callback.clickAction) { + SEARCH_SUGGESTION_CLICK -> { + // Search directly + binding.mainSearch.setQuery(callback.suggestion, true) + searchViewModel.clearSuggestions() + } + SEARCH_SUGGESTION_FILL -> { + // Fill the search box without searching + binding.mainSearch.setQuery(callback.suggestion, false) + } + SEARCH_SUGGESTION_CLEAR -> { + // Clear suggestions (from footer button) + searchViewModel.clearSuggestions() + } + } + } + + binding.apply { searchHistoryRecycler.adapter = historyAdapter searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) + // Setup suggestions RecyclerView + searchSuggestionsRecycler.adapter = suggestionAdapter + searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context) + + searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) searchMasterRecycler.adapter = masterAdapter //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) searchMasterRecycler.layoutManager = GridLayoutManager(context, 1) // Automatically search the specified query, this allows the app search to launch from intent - var sq = arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY) - if(sq.isNullOrBlank()) { + var sq = + arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY) + if (sq.isNullOrBlank()) { sq = MainActivity.nextSearchQuery } sq?.let { query -> if (query.isBlank()) return@let - mainSearch.setQuery(query, true) + + // Queries are dropped if you are submitted before layout finishes + mainSearch.doOnLayout { + mainSearch.setQuery(query, true) + } // Clear the query as to not make it request the same query every time the page is opened arguments?.remove(SEARCH_QUERY) savedInstanceState?.remove(SEARCH_QUERY) @@ -545,18 +666,37 @@ class SearchFragment : Fragment() { } } + observe(searchViewModel.currentHistory) { list -> + (binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list) + // Scroll to top to show newest items (list is sorted by newest first) + if (list.isNotEmpty()) { + binding.searchHistoryRecycler.scrollToPosition(0) + } + } - // SubtitlesFragment.push(activity) - //searchViewModel.search("iron man") - //(activity as AppCompatActivity).loadResult("https://shiro.is/overlord-dubbed", "overlord-dubbed", "Shiro") -/* - (activity as AppCompatActivity?)?.supportFragmentManager.beginTransaction() - .setCustomAnimations(R.anim.enter_anim, - R.anim.exit_anim, - R.anim.pop_enter, - R.anim.pop_exit) - .add(R.id.homeRoot, PlayerFragment.newInstance(PlayerData(0, null,0))) - .commit()*/ + // Observe search suggestions + observe(searchViewModel.searchSuggestions) { suggestions -> + val hasSuggestions = suggestions.isNotEmpty() + binding.searchSuggestionsRecycler.isVisible = hasSuggestions + (binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions) + + // On non-phone layouts, redirect focus and handle back button + if (!isLayout(PHONE)) { + if (hasSuggestions) { + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler + // Attach back button callback to clear suggestions + activity?.attachBackPressedCallback("SearchFragment") { + searchViewModel.clearSuggestions() + } + } else { + // Reset to default focus target (history) + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler + // Detach back button callback when no suggestions + activity?.detachBackPressedCallback("SearchFragment") + } + } + } + + searchViewModel.updateHistory() } - -} \ 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 3e33e01aa..449a04bf8 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 @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.ui.search -import android.app.Activity import android.widget.Toast import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast @@ -10,10 +9,9 @@ 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.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SearchHelper { fun handleSearchClickCallback(callback: SearchClickCallback) { @@ -22,26 +20,27 @@ object SearchHelper { SEARCH_ACTION_LOAD -> { loadSearchResult(card) } + SEARCH_ACTION_PLAY_FILE -> { if (card is DataStoreHelper.ResumeWatchingResult) { val id = card.id - if(id == null) { + if (id == null) { showToast(R.string.error_invalid_id, Toast.LENGTH_SHORT) } else { if (card.isFromDownload) { handleDownloadClick( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, - VideoDownloadHelper.DownloadEpisodeCached( - card.name, - card.posterUrl, - card.episode ?: 0, - card.season, - id, - card.parentId ?: return, - null, - null, - System.currentTimeMillis() + DownloadObjects.DownloadEpisodeCached( + name = card.name, + poster = card.posterUrl, + episode = card.episode ?: 0, + season = card.season, + id = id, + parentId = card.parentId ?: return, + score = null, + description = null, + cacheTime = System.currentTimeMillis(), ) ) ) @@ -55,14 +54,11 @@ object SearchHelper { ) } } + SEARCH_ACTION_SHOW_METADATA -> { - if(!isTvSettings()) { // 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 { + (activity as? MainActivity?)?.apply { + loadPopup(callback.card) + } ?: kotlin.run { 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 0a2ecb81e..4868abb3d 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 @@ -1,17 +1,18 @@ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView +import androidx.core.view.isGone import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.databinding.AccountSingleBinding +import com.lagradost.cloudstream3.databinding.SearchHistoryFooterBinding import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout data class SearchHistoryItem( @JsonProperty("searchedAt") val searchedAt: Long, @@ -21,85 +22,73 @@ data class SearchHistoryItem( ) data class SearchHistoryCallback( - val item: SearchHistoryItem, + val item: SearchHistoryItem?, val clickAction: Int, ) const val SEARCH_HISTORY_OPEN = 0 const val SEARCH_HISTORY_REMOVE = 1 +const val SEARCH_HISTORY_CLEAR = 2 class SearchHistoryAdaptor( - private val cardList: MutableList, private val clickCallback: (SearchHistoryCallback) -> Unit, -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a,b -> + a.searchedAt == b.searchedAt && a.searchText == b.searchText +})) { + + // Add footer for all layouts + override val footers = 1 + + override fun submitList(list: Collection?, commitCallback: Runnable?) { + super.submitList(list, commitCallback) + // Notify footer to rebind when list changes to update visibility + if (footers > 0) { + notifyItemChanged(itemCount - 1) + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), - clickCallback, ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position]) + override fun onBindContent( + holder: ViewHolderState, + item: SearchHistoryItem, + position: Int + ) { + val binding = holder.view as? SearchHistoryItemBinding ?: return + binding.apply { + homeHistoryTitle.text = item.searchText + + homeHistoryRemove.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_REMOVE)) + } + homeHistoryTab.setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_OPEN)) } } } - - override fun getItemCount(): Int { - return cardList.size - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SearchHistoryDiffCallback(this.cardList, newList) + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchHistoryFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) - - cardList.clear() - cardList.addAll(newList) - - diffResult.dispatchUpdatesTo(this) } - - class CardViewHolder - constructor( - val binding: SearchHistoryItemBinding, - private val clickCallback: (SearchHistoryCallback) -> Unit, - ) : - RecyclerView.ViewHolder(binding.root) { - // private val removeButton: ImageView = itemView.home_history_remove - // private val openButton: View = itemView.home_history_tab - // private val title: TextView = itemView.home_history_title - - fun bind(card: SearchHistoryItem) { - binding.apply { - homeHistoryTitle.text = card.searchText - - homeHistoryRemove.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_REMOVE)) - } - homeHistoryTab.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_OPEN)) - } + + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? SearchHistoryFooterBinding ?: return + // Hide footer when list is empty + binding.searchClearCallHistory.apply { + isGone = immutableCurrentList.isEmpty() + if (isLayout(TV or EMULATOR)) { + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(SearchHistoryCallback(null, SEARCH_HISTORY_CLEAR)) } } } } - -class SearchHistoryDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].searchText == newList[newItemPosition].searchText - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt index e1b72b309..fd99b8d4b 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 @@ -1,6 +1,8 @@ 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 @@ -17,12 +19,16 @@ import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.syncproviders.SyncAPI -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull +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.setImage +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.getImageFromDrawable object SearchResultBuilder { private val showCache: MutableMap = mutableMapOf() @@ -36,19 +42,15 @@ object SearchResultBuilder { } } - /** - * @param nextFocusBehavior True if first, False if last, Null if between. - * Used to prevent escaping the adapter horizontally (focus wise). - */ + @SuppressLint("StringFormatInvalid") fun bind( clickCallback: (SearchClickCallback) -> Unit, card: SearchResponse, position: Int, itemView: View, - nextFocusBehavior: Boolean? = null, 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) @@ -65,6 +67,7 @@ object SearchResultBuilder { val bar: ProgressBar? = itemView.findViewById(R.id.watchProgress) val playImg: ImageView? = itemView.findViewById(R.id.search_item_download_play) + val episodeText: TextView? = itemView.findViewById(R.id.episode_text) // Do logic @@ -74,20 +77,27 @@ object SearchResultBuilder { textIsSub?.isVisible = false textFlag?.isVisible = false rating?.isVisible = false + episodeText?.isVisible = false val showSub = showCache[textIsDub?.context?.getString(R.string.show_sub_key)] ?: false val showDub = showCache[textIsDub?.context?.getString(R.string.show_dub_key)] ?: false val showTitle = showCache[cardText?.context?.getString(R.string.show_title_key)] ?: false + val showEpisodeText = showCache[cardText?.context?.getString(R.string.show_episode_text_key)] ?: false val showHd = showCache[textQuality?.context?.getString(R.string.show_hd_key)] ?: false - - if(card is SyncAPI.LibraryItem) { - val showRating = (card.personalRating ?: 0) != 0 + val showRatingView = + showCache[textQuality?.context?.getString(R.string.show_rating_key)] ?: false + if (card is SyncAPI.LibraryItem) { + val ratingText = card.personalRating?.toStringNull(0.1, 10, 1) + val showRating = !ratingText.isNullOrBlank() + rating?.isVisible = showRating + if (showRating) { + rating?.text = ratingText + } + } else if (showRatingView) { + val ratingText = card.score?.toStringNull(0.1, 10, 1) + val showRating = !ratingText.isNullOrBlank() rating?.isVisible = showRating if (showRating) { - // We want to show 8.5 but not 8.0 hence the replace - val ratingText = ((card.personalRating ?: 0).toDouble() / 10).toString() - .replace(".0", "") - rating?.text = ratingText } } @@ -122,10 +132,11 @@ object SearchResultBuilder { cardText?.text = card.name cardText?.isVisible = showTitle cardView.isVisible = true - - if (!cardView.setImage(card.posterUrl, card.posterHeaders, colorCallback = colorCallback)) { - cardView.setImageResource(R.drawable.default_cover) - } + if (!card.posterUrl.isNullOrEmpty()) { + cardView.loadImage(card.posterUrl, card.posterHeaders) { + error { getImageFromDrawable(itemView.context, R.drawable.default_cover) } + } + } else cardView.loadImage(R.drawable.default_cover) fun click(view: View?) { clickCallback.invoke( @@ -164,7 +175,7 @@ object SearchResultBuilder { bg.isFocusable = false bg.isFocusableInTouchMode = false - if(!isTrueTvSettings()) { + if (!isLayout(TV)) { bg.setOnClickListener { click(it) } @@ -174,7 +185,7 @@ object SearchResultBuilder { } } // - // + // // itemView.setOnClickListener { @@ -207,10 +218,10 @@ object SearchResultBuilder { */ - if (isTrueTvSettings()) { - // bg.isFocusable = true - // bg.isFocusableInTouchMode = true - // bg.touchscreenBlocksFocus = false + if (isLayout(TV)) { + // bg.isFocusable = true + // bg.isFocusableInTouchMode = true + // bg.touchscreenBlocksFocus = false itemView.isFocusableInTouchMode = true itemView.isFocusable = true } @@ -239,6 +250,7 @@ object SearchResultBuilder { } } } + is DataStoreHelper.ResumeWatchingResult -> { val pos = card.watchPos?.fixVisual() if (pos != null) { @@ -246,14 +258,15 @@ object SearchResultBuilder { bar?.progress = (pos.position / 1000).toInt() bar?.visibility = View.VISIBLE } - playImg?.visibility = View.VISIBLE - - if (card.type?.isMovieType() == false) { - cardText?.text = - cardText?.context?.getNameFull(card.name, card.episode, card.season) + if (card.type?.isMovieType() == false && showEpisodeText) { + episodeText?.context?.getShortSeasonText(card.episode, card.season)?.let {text-> + episodeText.text = text + episodeText.isVisible = true + } } } + is AnimeSearchResponse -> { val dubStatus = card.dubStatus if (!dubStatus.isNullOrEmpty()) { @@ -289,5 +302,29 @@ object SearchResultBuilder { } } } + + // This is the logic for making the rounded corners more round on the top and bottom element + // a bit dirty to do memory allocation, but it makes it more extensible and is easier to reason about + // then a large if statement + + // Requires that the ordering here is the same as in the xml + val boxes = arrayListOf() + for (view in arrayOf(textIsDub, textIsSub, rating)) { + if (view?.isVisible == true) { + boxes.add(view) + } + } + if (boxes.size == 1) { + boxes[0].setBackgroundResource(R.drawable.bg_color_both) + } else if (boxes.size > 1) { + boxes[0].setBackgroundResource(R.drawable.bg_color_top) + for (i in 1 until boxes.size) { + boxes[i].setBackgroundResource(R.drawable.bg_color_center) + } + boxes[boxes.size - 1].setBackgroundResource(R.drawable.bg_color_bottom) + } + textIsDub?.apply { + backgroundTintList = ColorStateList.valueOf(context.colorFromAttribute(R.attr.textColor)) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt new file mode 100644 index 000000000..74d5e7b08 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt @@ -0,0 +1,85 @@ +package com.lagradost.cloudstream3.ui.search + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import com.lagradost.cloudstream3.databinding.SearchSuggestionFooterBinding +import com.lagradost.cloudstream3.databinding.SearchSuggestionItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout + +const val SEARCH_SUGGESTION_CLICK = 0 +const val SEARCH_SUGGESTION_FILL = 1 +const val SEARCH_SUGGESTION_CLEAR = 2 + +data class SearchSuggestionCallback( + val suggestion: String, + val clickAction: Int, +) + +class SearchSuggestionAdapter( + private val clickCallback: (SearchSuggestionCallback) -> Unit, +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a == b })) { + + // Add footer for all layouts + override val footers = 1 + + override fun submitList(list: Collection?, commitCallback: Runnable?) { + super.submitList(list, commitCallback) + // Notify footer to rebind when list changes to update visibility + if (footers > 0) { + notifyItemChanged(itemCount - 1) + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) + } + + override fun onBindContent( + holder: ViewHolderState, + item: String, + position: Int + ) { + val binding = holder.view as? SearchSuggestionItemBinding ?: return + binding.apply { + suggestionText.text = item + + // Click on the whole item to search + suggestionItem.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_CLICK)) + } + + // Click on the arrow to fill the search box without searching + suggestionFill.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_FILL)) + } + } + } + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + } + + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? SearchSuggestionFooterBinding ?: return + binding.clearSuggestionsButton.apply { + isGone = immutableCurrentList.isEmpty() + if (isLayout(TV or EMULATOR)) { + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback("", SEARCH_SUGGESTION_CLEAR)) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt new file mode 100644 index 000000000..8dbd78178 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt @@ -0,0 +1,74 @@ +package com.lagradost.cloudstream3.ui.search + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.nicehttp.NiceResponse + +/** + * API for fetching search suggestions from external sources. + * Uses TheMovieDB API to provide movie/show/anime related suggestions. + */ +object SearchSuggestionApi { + private const val TMDB_API_URL = "https://api.themoviedb.org/3/search/multi" + private const val TMDB_API_KEY = "e6333b32409e02a4a6eba6fb7ff866bb" + + data class TmdbSearchResult( + @JsonProperty("results") val results: List? + ) + + data class TmdbSearchItem( + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("original_title") val originalTitle: String?, + @JsonProperty("original_name") val originalName: String? + ) + + /** + * Fetches search suggestions from TheMovieDB multi search API. + * Returns suggestions for movies, TV series, and anime. + * + * @param query The search query to get suggestions for + * @return List of suggestion strings, empty list on failure + */ + suspend fun getSuggestions(query: String): List { + if (query.isBlank() || query.length < 2) return emptyList() + + return try { + val response = app.get( + TMDB_API_URL, + params = mapOf( + "api_key" to TMDB_API_KEY, + "query" to query, + "language" to "en-US" + ), + cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes) + ) + + parseSuggestions(response) + } catch (e: Exception) { + logError(e) + emptyList() + } + } + + /** + * Parses the TMDB search response and extracts movie/TV show titles. + * Filters to only include movies, TV shows, and anime. + */ + private fun parseSuggestions(response: NiceResponse): List { + return try { + val parsed = response.parsed() + parsed.results + ?.filter { it.mediaType == "movie" || it.mediaType == "tv" } + ?.mapNotNull { it.title ?: it.name } + ?.distinct() + ?.take(10) + ?: emptyList() + } catch (e: Exception) { + logError(e) + emptyList() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index 320687f85..f60588e35 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,50 +5,70 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugWarning import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -data class OnGoingSearch( - val apiName: String, - val data: Resource> + +data class ExpandableSearchList( + var list: List, var currentPage: Int, var hasNext: Boolean, ) const val SEARCH_HISTORY_KEY = "search_history" class SearchViewModel : ViewModel() { - private val _searchResponse: MutableLiveData>> = + private val _searchResponse: MutableLiveData> = MutableLiveData() - val searchResponse: LiveData>> get() = _searchResponse + val searchResponse: LiveData> get() = _searchResponse - private val _currentSearch: MutableLiveData> = MutableLiveData() - val currentSearch: LiveData> get() = _currentSearch + private val _currentSearch: MutableLiveData> = + MutableLiveData() + val currentSearch: LiveData> get() = _currentSearch private val _currentHistory: MutableLiveData> = MutableLiveData() val currentHistory: LiveData> get() = _currentHistory - private var repos = synchronized(apis) { apis.map { APIRepository(it) } } + private val _searchSuggestions: MutableLiveData> = MutableLiveData() + val searchSuggestions: LiveData> get() = _searchSuggestions + + private var suggestionJob: Job? = null + + private var repos = apis.withLock { apis.map { APIRepository(it) } } fun clearSearch() { - _searchResponse.postValue(Resource.Success(ArrayList())) - _currentSearch.postValue(emptyList()) + _searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false))) + _currentSearch.postValue(emptyMap()) + expandableSearches.clear() } + var lastQuery: String? = null + + /** Save which providers can searched again and which search result page they are on. + * Maps provider name to search list. + * @see [HomeViewModel.expandable] */ + private val expandableSearches: MutableMap = mutableMapOf() + private var currentSearchIndex = 0 private var onGoingSearch: Job? = null fun reloadRepos() { - repos = synchronized(apis) { apis.map { APIRepository(it) } } + repos = apis.withLock { apis.map { APIRepository(it) } } } fun searchAndCancel( @@ -62,13 +82,117 @@ class SearchViewModel : ViewModel() { onGoingSearch = search(query, providersActive, ignoreSettings, isQuickSearch) } - fun updateHistory() = viewModelScope.launch { - ioSafe { - val items = getKeys(SEARCH_HISTORY_KEY)?.mapNotNull { - getKey(it) - }?.sortedByDescending { it.searchedAt } ?: emptyList() - _currentHistory.postValue(items) + fun updateHistory() = ioSafe { + val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { + getKey(it) + }?.sortedByDescending { it.searchedAt } ?: emptyList() + _currentHistory.postValue(items) + } + + /** + * Fetches search suggestions with debouncing. + * Waits 300ms before making the API call to avoid too many requests. + * + * @param query The search query to get suggestions for + */ + fun fetchSuggestions(query: String) { + suggestionJob?.cancel() + + if (query.isBlank() || query.length < 2) { + _searchSuggestions.postValue(emptyList()) + return } + + suggestionJob = ioSafe { + delay(300) // Debounce + val suggestions = SearchSuggestionApi.getSuggestions(query) + _searchSuggestions.postValue(suggestions) + } + } + + /** + * Clears the current search suggestions. + */ + fun clearSuggestions() { + suggestionJob?.cancel() + _searchSuggestions.postValue(emptyList()) + } + + private val lock: MutableSet = mutableSetOf() + + // ExpandableHomepageList because the home adapter is reused in the search fragment + suspend fun expandAndReturn(name: String): HomeViewModel.ExpandableHomepageList? { + if (lock.contains(name)) return null + val query = lastQuery ?: return null + val repo = repos.find { it.name == name } ?: return null + + lock += name + + expandableSearches[name]?.let { current -> + debugAssert({ !current.hasNext }) { + "Expand called when not needed" + } + + val nextPage = current.currentPage + 1 + val next = repo.search(query, nextPage) + if (next is Resource.Success) { + val nextValue = next.value + expandableSearches[name]?.apply { + this.hasNext = nextValue.hasNext + this.currentPage = nextPage + + debugWarning({ nextValue.items.any { outer -> this.list.any { it.url == outer.url } } }) { + "Expanded search contained an item that was previously already in the list.\nQuery = $query, ${nextValue.items} = ${this.list}" + } + + // just to be sure we are not adding the same shit for some reason + // Avoids weird behavior in the recyclerview by recreating the list + this.list = (this.list + nextValue.items).distinctBy { it.url } + } ?: debugWarning { + "Expanded an item not in search load named $name, current list is ${expandableSearches.keys}" + } + } else { + current.hasNext = false + } + + _searchResponse.postValue(Resource.Success(bundleSearch(expandableSearches))) + _currentSearch.postValue(expandableSearches) + } + + lock -= name + + val item = expandableSearches[name] ?: return null + return HomeViewModel.ExpandableHomepageList( + HomePageList(name, item.list), + item.currentPage, + item.hasNext + ) + } + + private fun bundleSearch(lists: MutableMap): ExpandableSearchList { + if (lists.size == 1) { + return lists.values.first() + } + + val list = ArrayList() + val nestedList = + lists.map { it.value.list } + + // I do it this way to move the relevant search results to the top + var index = 0 + while (true) { + var added = 0 + for (sublist in nestedList) { + if (sublist.size > index) { + list.add(sublist[index]) + added++ + } + } + if (added == 0) break + index++ + } + + return ExpandableSearchList(list, 1, false) } private fun search( @@ -87,7 +211,7 @@ class SearchViewModel : ViewModel() { if (!isQuickSearch) { val key = query.hashCode().toString() setKey( - SEARCH_HISTORY_KEY, + "$currentAccount/$SEARCH_HISTORY_KEY", key, SearchHistoryItem( searchedAt = System.currentTimeMillis(), @@ -99,45 +223,32 @@ class SearchViewModel : ViewModel() { } _searchResponse.postValue(Resource.Loading()) + _currentSearch.postValue(emptyMap()) + expandableSearches.clear() - - _currentSearch.postValue(ArrayList()) + lastQuery = query withContext(Dispatchers.IO) { // This interrupts UI otherwise - val currentList = ArrayList() - repos.filter { a -> (ignoreSettings || (providersActive.isEmpty() || providersActive.contains(a.name))) && (!isQuickSearch || a.hasQuickSearch) }.amap { a -> // Parallel - val search = if (isQuickSearch) a.quickSearch(query) else a.search(query) + val search = if (isQuickSearch) a.quickSearch(query) else a.search(query, 1) if (currentSearchIndex != currentIndex) return@amap - currentList.add(OnGoingSearch(a.name, search)) - _currentSearch.postValue(currentList) + if (search is Resource.Success) { + val searchValue = search.value + expandableSearches[a.name] = + ExpandableSearchList(searchValue.items, 1, searchValue.hasNext) + } + + _currentSearch.postValue(expandableSearches) } if (currentSearchIndex != currentIndex) return@withContext // this should prevent rewrite of existing data bug - _currentSearch.postValue(currentList) - val list = ArrayList() - val nestedList = - currentList.map { it.data } - .filterIsInstance>>().map { it.value } - - // I do it this way to move the relevant search results to the top - var index = 0 - while (true) { - var added = 0 - for (sublist in nestedList) { - if (sublist.size > index) { - list.add(sublist[index]) - added++ - } - } - if (added == 0) break - index++ - } + _currentSearch.postValue(expandableSearches) + val list = bundleSearch(expandableSearches) _searchResponse.postValue(Resource.Success(list)) } } -} \ No newline at end of file +} 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 9e03079f6..938b870bb 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,13 +1,12 @@ 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 -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +//TODO Relevance of this class since it's not used class SyncSearchViewModel { - private val repos = SyncApis - data class SyncSearchResultSearchResponse( override val name: String, override val url: String, @@ -17,5 +16,6 @@ class SyncSearchViewModel { override var id: Int?, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, + override var score: Score? = null, ) : SearchResponse } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt index 1dc79dc05..be8b4180c 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 @@ -4,60 +4,53 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountSingleBinding -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -class AccountClickCallback(val action: Int, val view: View, val card: AuthAPI.LoginInfo) +class AccountClickCallback(val action: Int, val view: View, val card: AuthData) class AccountAdapter( - val cardList: List, private val clickCallback: (AccountClickCallback) -> Unit ) : - RecyclerView.Adapter() { + NoStateAdapter( + diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.user.id == b.user.id + }) + ) { - 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 onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + AccountSingleBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position]) - } - } + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? AccountSingleBinding ?: return + clearImage(binding.accountProfilePicture) } - override fun getItemCount(): Int { - return cardList.size - } - - override fun getItemId(position: Int): Long { - return cardList[position].accountIndex.toLong() - } - - class CardViewHolder - constructor(val binding: AccountSingleBinding, private val clickCallback: (AccountClickCallback) -> Unit) : - RecyclerView.ViewHolder(binding.root) { - // private val pfp: ImageView = itemView.findViewById(R.id.account_profile_picture)!! - // private val accountName: TextView = itemView.findViewById(R.id.account_name)!! - - fun bind(card: AuthAPI.LoginInfo) { - // just in case name is null account index will show, should never happened - binding.accountName.text = card.name ?: "%s %d".format( - binding.accountName.context.getString(R.string.account), - card.accountIndex + 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 ) - binding.accountProfilePicture.isVisible = binding.accountProfilePicture.setImage(card.profilePicture) - itemView.setOnClickListener { - clickCallback.invoke(AccountClickCallback(0, itemView, card)) + root.setOnClickListener { + clickCallback.invoke(AccountClickCallback(0, root, item)) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt new file mode 100644 index 000000000..93e469a4d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt @@ -0,0 +1,62 @@ +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 + +object Globals { + var beneneCount = 0 + + const val PHONE : Int = 0b001 + const val TV : Int = 0b010 + const val EMULATOR : Int = 0b100 + private const val INVALID = -1 + private var layoutId = INVALID + + private fun Context.getLayoutInt(): Int { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + return settingsManager.getInt(this.getString(R.string.app_layout_key), -1) + } + + private fun Context.isAutoTv(): Boolean { + val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? + // AFT = Fire TV + val model = Build.MODEL.lowercase() + return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION || Build.MODEL.contains( + "AFT" + ) || model.contains("firestick") || model.contains("fire tv") || model.contains("chromecast") + } + + private fun Context.layoutIntCorrected(): Int { + return when(getLayoutInt()) { + -1 -> if (isAutoTv()) TV else PHONE + 0 -> PHONE + 1 -> TV + 2 -> EMULATOR + else -> PHONE + } + } + + fun Context.updateTv() { + 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. + * + * Valid flags are: PHONE, TV, EMULATOR + * */ + fun isLayout(flags: Int) : Boolean { + return (layoutId and flags) != 0 + } +} 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 new file mode 100644 index 000000000..365990646 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.lagradost.cloudstream3.databinding.ItemLogcatBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState + +class LogcatAdapter() : NoStateAdapter( + diffCallback = BaseDiffCallback( + itemSame = String::equals, + contentSame = String::equals + ) +) { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ItemLogcatBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindContent(holder: ViewHolderState, item: String, position: Int) { + (holder.view as? ItemLogcatBinding)?.apply { + logText.text = item + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index b3225d5c8..8d96a6b14 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,48 +1,83 @@ package com.lagradost.cloudstream3.ui.settings +import android.annotation.SuppressLint +import android.graphics.Bitmap import android.os.Bundle +import android.os.CountDownTimer import android.view.View -import android.view.View.* +import android.view.View.FOCUS_DOWN 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.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountManagmentBinding import com.lagradost.cloudstream3.databinding.AccountSwitchBinding import com.lagradost.cloudstream3.databinding.AddAccountInputBinding +import com.lagradost.cloudstream3.databinding.DeviceAuthBinding import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI -import com.lagradost.cloudstream3.syncproviders.OAuth2API +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthRepo +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import com.lagradost.cloudstream3.syncproviders.SubtitleRepo +import com.lagradost.cloudstream3.syncproviders.SyncRepo +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +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.isTvSettings +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.authCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogText +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt +import qrcode.QRCode -class SettingsAccount : PreferenceFragmentCompat() { +class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { companion object { /** Used by nginx plugin too */ + @SuppressLint("StringFormatInvalid") fun showLoginInfo( activity: FragmentActivity?, - api: AccountManager, - info: AuthAPI.LoginInfo + api: AuthRepo, + info: AuthUser?, + index: Int, ) { if (activity == null) return val binding: AccountManagmentBinding = @@ -53,15 +88,24 @@ class SettingsAccount : PreferenceFragmentCompat() { val dialog = builder.show() binding.accountMainProfilePictureHolder.isVisible = - binding.accountMainProfilePicture.setImage(info.profilePicture) + !info?.profilePicture.isNullOrEmpty() + binding.accountMainProfilePicture.loadImage(info?.profilePicture) + binding.accountLogout.isVisible = info != null binding.accountLogout.setOnClickListener { - api.logOut() + if (info != null) { + ioSafe { api.logout(info) } + } dialog.dismissSafe(activity) } - (info.name ?: activity.getString(R.string.no_data)).let { - dialog.findViewById(R.id.account_name)?.text = it + dialog.findViewById(R.id.account_name)?.text = if (info != null) { + info.name ?: "%s %d".format( + activity.getString(R.string.account), + index + 1 + ) + } else { + activity.getString(R.string.no_account) } binding.accountSite.text = api.name @@ -70,13 +114,13 @@ class SettingsAccount : PreferenceFragmentCompat() { showAccountSwitch(activity, api) } - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { binding.accountSwitchAccount.requestFocus() } } - private fun showAccountSwitch(activity: FragmentActivity, api: AccountManager) { - val accounts = api.getAccounts() ?: return + private fun showAccountSwitch(activity: FragmentActivity, api: AuthRepo) { + val accounts = api.accounts val binding: AccountSwitchBinding = AccountSwitchBinding.inflate(activity.layoutInflater, null, false) @@ -90,157 +134,299 @@ class SettingsAccount : PreferenceFragmentCompat() { dialog?.dismissSafe(activity) } - val ogIndex = api.accountIndex - - val items = ArrayList() - - for (index in accounts) { - api.accountIndex = index - val accountInfo = api.loginInfo() - if (accountInfo != null) { - items.add(accountInfo) - } - } - api.accountIndex = ogIndex - val adapter = AccountAdapter(items) { + binding.accountNone.setOnClickListener { + api.accountId = -1 dialog?.dismissSafe(activity) - api.changeAccount(it.card.accountIndex) + } + + val adapter = AccountAdapter { + dialog?.dismissSafe(activity) + api.accountId = it.card.user.id + }.apply { + submitList(accounts.toList()) } val list = dialog.findViewById(R.id.account_list) list?.adapter = adapter } + @UiThread - fun addAccount(activity: FragmentActivity?, api: AccountManager) { - try { - when (api) { - is OAuth2API -> { - api.authenticate(activity) - } + fun showPin(activity: FragmentActivity, api: AuthRepo) { + val binding: DeviceAuthBinding = + DeviceAuthBinding.inflate(activity.layoutInflater, null, false) - 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 builder = + AlertDialog.Builder(activity) + .setView(binding.root) - val visibilityMap = listOf( - binding.loginEmailInput to api.requiresEmail, - binding.loginPasswordInput to api.requiresPassword, - binding.loginServerInput to api.requiresServer, - binding.loginUsernameInput to api.requiresUsername - ) - - if (isTvSettings()) { - 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 { - val isSuccessful = try { - api.login(loginData) - } catch (e: Exception) { - logError(e) - false - } - activity.runOnUiThread { - try { - showToast( - activity.getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail) - .format( - api.name - ) - ) - } catch (e: Exception) { - logError(e) // format might fail - } - } - } - 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") + builder.apply { + setNegativeButton(R.string.cancel) { _, _ -> } + if (api.hasOAuth2) { + setPositiveButton(R.string.auth_locally) { _, _ -> + api.openOAuth2PageWithToast() } } - } catch (e: Exception) { - logError(e) } + + val dialog = builder.create() + + 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) + ) + ) + + ioSafe { + if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.login( + 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() + } + } + } + + + fun showAppLogin(activity: FragmentActivity, api: AuthRepo) { + + val binding: AddAccountInputBinding = + AddAccountInputBinding.inflate(activity.layoutInflater, null, false) + val builder = + AlertDialog.Builder(activity, R.style.AlertDialogCustom) + .setView(binding.root) + val dialog = builder.show() + val req = + api.inAppLoginRequirement ?: throw ErrorLoadingException("Missing LoginRequirement") + val visibilityMap = listOf( + binding.loginEmailInput to req.email, + binding.loginPasswordInput to req.password, + binding.loginServerInput to req.server, + binding.loginUsernameInput to req.username + ) + + if (isLayout(TV or EMULATOR)) { + visibilityMap.forEach { (input, isVisible) -> + input.isVisible = isVisible + + // Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen + input.setOnEditorActionListener { textView, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_NEXT) { + val view = textView.focusSearch(FOCUS_DOWN) + return@setOnEditorActionListener view?.requestFocus( + FOCUS_DOWN + ) == true + } + return@setOnEditorActionListener true + } + } + } else { + visibilityMap.forEach { (input, isVisible) -> + input.isVisible = isVisible + } + } + + binding.createAccount.isGone = api.createAccountUrl.isNullOrBlank() + binding.createAccount.setOnClickListener { + openBrowser( + api.createAccountUrl ?: return@setOnClickListener, + activity + ) + dialog.dismissSafe() + } + + val displayedItems = listOf( + binding.loginUsernameInput, + binding.loginEmailInput, + binding.loginServerInput, + binding.loginPasswordInput + ).filter { it.isVisible } + + displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> + item.id.let { previous?.nextFocusDownId = it } + previous?.id?.let { item.nextFocusUpId = it } + item + } + + displayedItems.firstOrNull()?.let { + binding.createAccount.nextFocusDownId = it.id + it.nextFocusUpId = binding.createAccount.id + } + binding.applyBtt.id.let { + displayedItems.lastOrNull()?.nextFocusDownId = it + } + + binding.text1.text = api.name + + binding.applyBtt.setOnClickListener { + val loginData = AuthLoginResponse( + username = if (req.username) binding.loginUsernameInput.text?.toString() else null, + password = if (req.password) binding.loginPasswordInput.text?.toString() else null, + email = if (req.email) binding.loginEmailInput.text?.toString() else null, + server = if (req.server) binding.loginServerInput.text?.toString() else null, + ) + ioSafe { + try { + if (api.login(loginData)) { + showToast( + txt( + R.string.authenticated_user, + api.name + ) + ) + dialog.dismissSafe(activity) + } else { + showToast( + txt( + R.string.authenticated_user_fail, + api.name + ) + ) + } + } catch (t: Throwable) { + if (t is ErrorLoadingException && t.message != null) { + showToast(t.message) + return@ioSafe + } + showToast( + txt( + R.string.authenticated_user_fail, + api.name + ) + ) + } + } + } + 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) + } + } + } + + private fun updateAuthPreference(enabled: Boolean) { + val biometricKey = getString(R.string.biometric_key) + + PreferenceManager.getDefaultSharedPreferences(context ?: return).edit { + putBoolean(biometricKey, enabled) + } + findPreference(biometricKey)?.isChecked = enabled + } + + override fun onAuthenticationError() { + updateAuthPreference(!isAuthEnabled(context ?: return)) + } + + override fun onAuthenticationSuccess() { + if (isAuthEnabled(context ?: return)) { + updateAuthPreference(true) + BackupUtils.backup(activity) + activity?.showBottomDialogText( + getString(R.string.biometric_setting), + getString(R.string.biometric_warning).html() + ) { onDialogDismissedEvent } + } else { + updateAuthPreference(false) } } @@ -248,28 +434,54 @@ class SettingsAccount : PreferenceFragmentCompat() { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_account) setPaddingBottom() + setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_account, rootKey) + //Hides the security category on TV as it's only Biometric for now + getPref(R.string.pref_category_security_key)?.hideOn(TV or EMULATOR) + + getPref(R.string.biometric_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { + val ctx = context ?: return@setOnPreferenceClickListener false + + if (deviceHasPasswordPinLock(ctx)) { + startBiometricAuthentication( + activity ?: return@setOnPreferenceClickListener false, + R.string.biometric_authentication_title, + false + ) + promptInfo?.let { + authCallback = this + biometricPrompt?.authenticate(it) + } + } + + false + } + val syncApis = listOf( - R.string.mal_key to malApi, - R.string.anilist_key to aniListApi, - R.string.simkl_key to simklApi, - R.string.opensubtitles_key to openSubtitlesApi, + R.string.mal_key to SyncRepo(malApi), + R.string.kitsu_key to SyncRepo(kitsuApi), + R.string.anilist_key to SyncRepo(aniListApi), + R.string.simkl_key to SyncRepo(simklApi), + R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), + R.string.subdl_key to SubtitleRepo(subDlApi), + R.string.animeskip_key to PlainAuthRepo(animeSkipApi), ) for ((key, api) in syncApis) { getPref(key)?.apply { - title = - getString(R.string.login_format).format(api.name, getString(R.string.account)) + title = api.name setOnPreferenceClickListener { - val info = api.loginInfo() - if (info != null) { - showLoginInfo(activity, api, info) + val activity = activity ?: return@setOnPreferenceClickListener false + val info = api.authUser() + val index = api.accounts.indexOfFirst { account -> account.user.id == info?.id } + if (api.accounts.isNotEmpty()) { + showLoginInfo(activity, api, info, index) } else { addAccount(activity, api) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index e53fa91ab..e41109b59 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 @@ -1,43 +1,53 @@ package com.lagradost.cloudstream3.ui.settings -import android.app.UiModeManager -import android.content.Context -import android.content.res.Configuration -import android.os.Build import android.os.Bundle -import android.view.LayoutInflater +import android.util.Log import android.view.View -import android.view.ViewGroup import android.widget.ImageView import androidx.annotation.StringRes import androidx.core.view.children -import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat -import androidx.preference.PreferenceManager +import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.MaterialToolbar +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.syncproviders.AccountManager.Companion.accountManagers -import com.lagradost.cloudstream3.ui.home.HomeFragment -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.AuthRepo +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.errorProfilePic +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.getImageFromDrawable +import com.lagradost.cloudstream3.utils.txt import java.io.File +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone -class SettingsFragment : Fragment() { +class SettingsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(MainSettingsBinding::inflate) +) { companion object { - var beneneCount = 0 - - private var isTv: Boolean = false - private var isTrueTv: Boolean = false - fun PreferenceFragmentCompat?.getPref(id: Int): Preference? { if (this == null) return null - return try { findPreference(getString(id)) } catch (e: Exception) { @@ -46,27 +56,76 @@ class SettingsFragment : Fragment() { } } + /** + * Hide many Preferences on selected layouts. + **/ + fun PreferenceFragmentCompat?.hidePrefs(ids: List, layoutFlags: Int) { + if (this == null) return + + try { + ids.forEach { + getPref(it)?.isVisible = !isLayout(layoutFlags) + } + } catch (e: Exception) { + logError(e) + } + } + + /** + * Hide the [Preference] on selected layouts. + * @return [Preference] if visible otherwise null. + * + * [hideOn] is usually followed by some actions on the preference which are mostly + * unnecessary when the preference is disabled for the said layout thus returning null. + **/ + fun Preference?.hideOn(layoutFlags: Int): Preference? { + if (this == null) return null + this.isVisible = !isLayout(layoutFlags) + return if(this.isVisible) this else null + } + /** * On TV you cannot properly scroll to the bottom of settings, this fixes that. * */ fun PreferenceFragmentCompat.setPaddingBottom() { - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { listView?.setPadding(0, 0, 0, 100.toPx) } } + fun PreferenceFragmentCompat.setToolBarScrollFlags() { + if (isLayout(TV or EMULATOR)) { + val settingsAppbar = view?.findViewById(R.id.settings_toolbar) + + settingsAppbar?.updateLayoutParams { + scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL + } + } + } + + fun Fragment?.setToolBarScrollFlags() { + if (isLayout(TV or EMULATOR)) { + val settingsAppbar = this?.view?.findViewById(R.id.settings_toolbar) + + settingsAppbar?.updateLayoutParams { + scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL + } + } + } + fun Fragment?.setUpToolbar(title: String) { if (this == null) return val settingsToolbar = view?.findViewById(R.id.settings_toolbar) ?: return settingsToolbar.apply { setTitle(title) - setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - setNavigationOnClickListener { - activity?.onBackPressed() + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + activity?.onBackPressedDispatcher?.onBackPressed() + } } } - fixPaddingStatusbar(settingsToolbar) } fun Fragment?.setUpToolbar(@StringRes title: Int) { @@ -75,13 +134,24 @@ class SettingsFragment : Fragment() { settingsToolbar.apply { setTitle(title) - setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - children.firstOrNull { it is ImageView }?.tag = getString(R.string.tv_no_focus_tag) - setNavigationOnClickListener { - activity?.onBackPressed() + if (isLayout(PHONE or EMULATOR)) { + 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() } + } } } - fixPaddingStatusbar(settingsToolbar) + } + + fun Fragment.setSystemBarsPadding() { + view?.let { + fixSystemBarsPadding( + it, + padLeft = isLayout(TV or EMULATOR), + padBottom = isLandscape() + ) + } } fun getFolderSize(dir: File): Long { @@ -97,114 +167,99 @@ class SettingsFragment : Fragment() { return size } - - private fun Context.getLayoutInt(): Int { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - return settingsManager.getInt(this.getString(R.string.app_layout_key), -1) - } - - private fun Context.isTvSettings(): Boolean { - var value = getLayoutInt() - if (value == -1) { - value = if (isAutoTv()) 1 else 0 - } - return value == 1 || value == 2 - } - - private fun Context.isTrueTvSettings(): Boolean { - var value = getLayoutInt() - if (value == -1) { - value = if (isAutoTv()) 1 else 0 - } - return value == 1 - } - - fun Context.updateTv() { - isTrueTv = isTrueTvSettings() - isTv = isTvSettings() - } - - fun isTrueTvSettings(): Boolean { - return isTrueTv - } - - fun isTvSettings(): Boolean { - return isTv - } - - fun Context.isEmulatorSettings(): Boolean { - return getLayoutInt() == 2 - } - - private fun Context.isAutoTv(): Boolean { - val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? - // AFT = Fire TV - val model = Build.MODEL.lowercase() - return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION || Build.MODEL.contains( - "AFT" - ) || model.contains("firestick") || model.contains("fire tv") || model.contains("chromecast") - } } - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - var binding: MainSettingsBinding? = null - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val localBinding = MainSettingsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.main_settings, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: MainSettingsBinding) { fun navigate(id: Int) { activity?.navigate(id, Bundle()) } - // used to debug leaks showToast(activity,"${VideoDownloadManager.downloadStatusEvent.size} : ${VideoDownloadManager.downloadProgressEvent.size}") + /** used to debug leaks + showToast(activity,"${VideoDownloadManager.downloadStatusEvent.size} : + ${VideoDownloadManager.downloadProgressEvent.size}") **/ - val isTrueTv = isTrueTvSettings() + fun hasProfilePictureFromAccountManagers(accountManagers: Array): Boolean { + for (syncApi in accountManagers) { + val login = syncApi.authUser() + val pic = login?.profilePicture ?: continue - for (syncApi in accountManagers) { - val login = syncApi.loginInfo() - val pic = login?.profilePicture ?: continue - if (binding?.settingsProfilePic?.setImage( - pic, - errorImageDrawable = HomeFragment.errorProfilePic - ) == true - ) { - binding?.settingsProfileText?.text = login.name - binding?.settingsProfile?.isVisible = true - break + binding.settingsProfilePic.let { imageView -> + imageView.loadImage(pic) { + // Fallback to random error drawable + error { getImageFromDrawable(context ?: return@error null, errorProfilePic) } + } + } + binding.settingsProfileText.text = login.name + return true // sync profile exists } + return false // not syncing } - binding?.apply { + + // display local account information if not syncing + if (!hasProfilePictureFromAccountManagers(AccountManager.allApis)) { + val activity = activity ?: return + val currentAccount = try { + DataStoreHelper.accounts.firstOrNull { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } ?: activity.let { DataStoreHelper.getDefaultAccount(activity) } + + } catch (t: IllegalStateException) { + Log.e("AccountManager", "Activity not found", t) + null + } + + binding.settingsProfilePic.loadImage(currentAccount?.image) + binding.settingsProfileText.text = currentAccount?.name + } + + binding.apply { listOf( - settingsGeneral to R.id.action_navigation_settings_to_navigation_settings_general, - settingsPlayer to R.id.action_navigation_settings_to_navigation_settings_player, - settingsCredits to R.id.action_navigation_settings_to_navigation_settings_account, - settingsUi to R.id.action_navigation_settings_to_navigation_settings_ui, - settingsProviders to R.id.action_navigation_settings_to_navigation_settings_providers, - settingsUpdates to R.id.action_navigation_settings_to_navigation_settings_updates, - settingsExtensions to R.id.action_navigation_settings_to_navigation_settings_extensions, + settingsGeneral to R.id.action_navigation_global_to_navigation_settings_general, + settingsPlayer to R.id.action_navigation_global_to_navigation_settings_player, + settingsCredits to R.id.action_navigation_global_to_navigation_settings_account, + settingsUi to R.id.action_navigation_global_to_navigation_settings_ui, + settingsProviders to R.id.action_navigation_global_to_navigation_settings_providers, + settingsUpdates to R.id.action_navigation_global_to_navigation_settings_updates, + settingsExtensions to R.id.action_navigation_global_to_navigation_settings_extensions, ).forEach { (view, navigationId) -> view.apply { setOnClickListener { navigate(navigationId) } - if (isTrueTv) { + if (isLayout(TV)) { isFocusable = true isFocusableInTouchMode = true } } } + + // Default focus on TV + if (isLayout(TV)) { + settingsGeneral.requestFocus() + } + } + + val appVersion = BuildConfig.VERSION_NAME + val commitHash = activity?.currentCommitHash() ?: "" + val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, + Locale.getDefault() + ).apply { timeZone = TimeZone.getTimeZone("UTC") + }.format(Date(BuildConfig.BUILD_DATE)).replace("UTC", "") + + binding.appVersion.text = appVersion + binding.buildDate.text = buildTimestamp + binding.commitHash.text = commitHash + binding.appVersionInfo.setOnLongClickListener { + clipboardHelper(txt(R.string.extension_version), "$appVersion $commitHash $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 f46aac9b2..57f5aa870 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -1,20 +1,20 @@ package com.lagradost.cloudstream3.ui.settings import android.content.Context -import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.View import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit +import androidx.core.os.ConfigurationCompat +import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.allProviders -import com.lagradost.cloudstream3.AcraApplication -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity @@ -23,94 +23,126 @@ 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.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.network.initClient -import com.lagradost.cloudstream3.ui.EasterEggMonke +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.beneneCount import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar +import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher +import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.isAppRestricted +import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.showBatteryOptimizationDialog 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.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.VideoDownloadManager -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath -import com.lagradost.cloudstream3.utils.storage.SafeFile +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import java.util.Locale +// Change local language settings in the app. fun getCurrentLocale(context: Context): String { - val res = context.resources - // Change locale settings in the app. - // val dm = res.displayMetrics - val conf = res.configuration - return conf?.locale?.toString() ?: "en" + val conf = context.resources.configuration + return ConfigurationCompat.getLocales(conf).get(0)?.toLanguageTag() ?: "en" } -// idk, if you find a way of automating this it would be great -// https://www.iemoji.com/view/emoji/1794/flags/antarctica -// Emoji Character Encoding Data --> C/C++/Java Src -// https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes leave blank for auto +/** + * List of app supported languages. + * Language code shall be a IETF BCP 47 conformant tag + * + * See locales on: + * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json + * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry + * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml + * https://iso639-3.sil.org/code_tables/639/data/all +*/ val appLanguages = arrayListOf( /* begin language list */ - Triple("", "العربية", "ar"), - Triple("", "ars", "ars"), - 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("", "latviešu valoda", "lv"), - Triple("", "македонски", "mk"), - Triple("", "മലയാളം", "ml"), - Triple("", "bahasa Melayu", "ms"), - Triple("", "ဗမာစာ", "my"), - 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("", "Tagalog", "tl"), - Triple("", "Türkçe", "tr"), - Triple("", "українська", "uk"), - Triple("", "اردو", "ur"), - Triple("", "Tiếng Việt", "vi"), - Triple("", "中文", "zh"), - Triple("\uD83C\uDDF9\uD83C\uDDFC", "正體中文(臺灣)", "zh-rTW"), + Pair("Afrikaans", "af"), + Pair("Azərbaycan dili", "az"), + Pair("Bahasa Indonesia", "in"), + Pair("Bahasa Melayu", "ms"), + Pair("Deutsch", "de"), + Pair("English", "en"), + Pair("Español", "es"), + Pair("Esperanto", "eo"), + Pair("Français", "fr"), + Pair("Galego", "gl"), + Pair("hrvatski", "hr"), + Pair("Italiano", "it"), + Pair("Latviešu valoda", "lv"), + Pair("Lietuvių kalba", "lt"), + Pair("Magyar", "hu"), + Pair("Malti", "mt"), + Pair("mmmm... monke", "qt"), + Pair("Nederlands", "nl"), + Pair("Norsk bokmål", "no"), + Pair("Norsk nynorsk", "nn"), + Pair("Polski", "pl"), + Pair("Português", "pt"), + Pair("Português (Brasil)", "pt-BR"), + Pair("Română", "ro"), + Pair("Slovenčina", "sk"), + Pair("Soomaaliga", "so"), + Pair("Svenska", "sv"), + Pair("Tagalog", "tl"), + Pair("Tiếng Việt", "vi"), + Pair("Türkçe", "tr"), + Pair("Wikang Filipino", "fil"), + Pair("Čeština", "cs"), + Pair("Ελληνικά", "el"), + Pair("български", "bg"), + Pair("македонски", "mk"), + Pair("русский", "ru"), + Pair("українська", "uk"), + Pair("עברית", "iw"), + Pair("اردو", "ur"), + Pair("العربية", "ar"), + Pair("اللهجة النجدية", "ars"), + Pair("عربي شامي", "apc"), + Pair("فارسی", "fa"), + Pair("کوردیی ناوەندی", "ckb"), + Pair("नेपाली", "ne"), + Pair("हिन्दी", "hi"), + Pair("অসমীয়া", "as"), + Pair("বাংলা", "bn"), + Pair("ଓଡ଼ିଆ", "or"), + Pair("தமிழ்", "ta"), + Pair("ಕನ್ನಡ", "kn"), + Pair("മലയാളം", "ml"), + Pair("ဗမာစာ", "my"), + Pair("ትግርኛ", "ti"), + Pair("አማርኛ", "am"), + Pair("中文", "zh"), + Pair("日本語 (にほんご)", "ja"), + Pair("正體中文(臺灣)", "zh-TW"), + Pair("한국어", "ko"), /* end language list */ -).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top +).sortedBy { it.first.lowercase(Locale.ROOT) } // ye, we go alphabetical, so ppl don't put their lang on top -class SettingsGeneral : PreferenceFragmentCompat() { +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() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_general) setPaddingBottom() + setToolBarScrollFlags() } data class CustomSite( @@ -124,38 +156,26 @@ class SettingsGeneral : PreferenceFragmentCompat() { val lang: String, ) - // Open file picker - private val pathPicker = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> - // It lies, it can be null if file manager quits. - if (uri == null) return@registerForActivityResult - val context = context ?: AcraApplication.context ?: return@registerForActivityResult - // RW perms for the path - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + companion object { + fun Fragment.pickDownloadPath(uri: Uri?, path: String?) { + if (uri == null) return - context.contentResolver.takePersistableUriPermission(uri, flags) - - val file = SafeFile.fromUri(context, uri) - val filePath = file?.filePath() - println("Selected URI path: $uri - Full path: $filePath") - - // Stores the real URI using download_path_key - // Important that the URI is stored instead of filepath due to permissions. - PreferenceManager.getDefaultSharedPreferences(context) - .edit().putString(getString(R.string.download_path_key), uri.toString()).apply() - - // From URI -> File path - // File path here is purely for cosmetic purposes in settings - (filePath ?: uri.toString()).let { - PreferenceManager.getDefaultSharedPreferences(context) - .edit().putString(getString(R.string.download_path_pref), it).apply() + val context = context ?: CloudStreamApp.context ?: return + val visual = path ?: uri.toString() + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(getString(R.string.download_path_key), uri.toString()) + putString(context.getString(R.string.download_path_key_visual), visual) } } + } + + private val pathPicker = getChooseFolderLauncher { uri, path -> + pickDownloadPath(uri, path) + } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() - setPreferencesFromResource(R.xml.settins_general, rootKey) + setPreferencesFromResource(R.xml.settings_general, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) fun getCurrent(): MutableList { @@ -164,22 +184,20 @@ class SettingsGeneral : PreferenceFragmentCompat() { } getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> - val tempLangs = appLanguages.toMutableList() val current = getCurrentLocale(pref.context) - val languageCodes = tempLangs.map { (_, _, iso) -> iso } - val languageNames = tempLangs.map { (emoji, name, iso) -> - val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } - "$flag $name" - } - val index = languageCodes.indexOf(current) + val languageTagsIETF = appLanguages.map { it.second } + val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } + val currentIndex = languageTagsIETF.indexOf(current) activity?.showDialog( - languageNames, index, getString(R.string.app_language), true, { } - ) { languageIndex -> + languageNames, currentIndex, getString(R.string.app_language), true, { } + ) { selectedLangIndex -> try { - val code = languageCodes[languageIndex] - CommonActivity.setLocale(activity, code) - settingsManager.edit().putString(getString(R.string.locale_key), code).apply() + val langTagIETF = languageTagsIETF[selectedLangIndex] + CommonActivity.setLocale(activity, langTagIETF) + settingsManager.edit { + putString(getString(R.string.locale_key), langTagIETF) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -188,9 +206,20 @@ class SettingsGeneral : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + getPref(R.string.battery_optimisation_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { + val ctx = context ?: return@setOnPreferenceClickListener false + + if (isAppRestricted(ctx)) { + ctx.showBatteryOptimizationDialog() + } else { + showToast(R.string.app_unrestricted_toast) + } + + true + } fun showAdd() { - val providers = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } } + val providers = allProviders.distinctBy { it::class }.sortedBy { it.name } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, @@ -214,7 +243,7 @@ class SettingsGeneral : PreferenceFragmentCompat() { val url = binding.siteUrlInput.text?.toString() val lang = binding.siteLangInput.text?.toString() val realLang = if (lang.isNullOrBlank()) provider.lang else lang - if (url.isNullOrBlank() || name.isNullOrBlank() || realLang.length != 2) { + if (url.isNullOrBlank() || name.isNullOrBlank()) { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } @@ -299,16 +328,16 @@ class SettingsGeneral : PreferenceFragmentCompat() { getString(R.string.dns_pref), true, {}) { - settingsManager.edit().putInt(getString(R.string.dns_pref), prefValues[it]).apply() - (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } + settingsManager.edit { putInt(getString(R.string.dns_pref), prefValues[it]) } + (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } fun getDownloadDirs(): List { - return normalSafeApiCall { + return safe { context?.let { ctx -> - val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath() + val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.filePath() val first = listOf(defaultDir) (try { @@ -324,21 +353,27 @@ class SettingsGeneral : PreferenceFragmentCompat() { } ?: emptyList() } - settingsManager.edit().putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false).apply() + settingsManager.edit { putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false) } getPref(R.string.jsdelivr_proxy_key)?.setOnPreferenceChangeListener { _, newValue -> setKey(getString(R.string.jsdelivr_proxy_key), newValue) return@setOnPreferenceChangeListener true } + getPref(R.string.download_parallel_key)?.setOnPreferenceChangeListener { _, _ -> + // Notify that the queue logic has been changed + DownloadQueueManager.forceRefreshQueue() + return@setOnPreferenceChangeListener true + } + getPref(R.string.download_path_key)?.setOnPreferenceClickListener { val dirs = getDownloadDirs() val currentDir = - settingsManager.getString(getString(R.string.download_path_pref), null) - ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx).toString() } + settingsManager.getString(getString(R.string.download_path_key_visual), null) + ?: context?.let { ctx -> DownloadFileManagement.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( - dirs + listOf("Custom"), + dirs + listOf(getString(R.string.custom)), dirs.indexOf(currentDir), getString(R.string.download_path_pref), true, @@ -353,41 +388,40 @@ class SettingsGeneral : PreferenceFragmentCompat() { } else { // Sets both visual and actual paths. // key = used path - // pref = visual path - settingsManager.edit() - .putString(getString(R.string.download_path_key), dirs[it]).apply() - settingsManager.edit() - .putString(getString(R.string.download_path_pref), dirs[it]).apply() + // visual = visual path + settingsManager.edit { + putString(getString(R.string.download_path_key), dirs[it]) + putString(getString(R.string.download_path_key_visual), dirs[it]) + } } } return@setOnPreferenceClickListener true } try { - SettingsFragment.beneneCount = + beneneCount = settingsManager.getInt(getString(R.string.benene_count), 0) getPref(R.string.benene_count)?.let { pref -> pref.summary = - if (SettingsFragment.beneneCount <= 0) getString(R.string.benene_count_text_none) else getString( + if (beneneCount <= 0) getString(R.string.benene_count_text_none) else getString( R.string.benene_count_text ).format( - SettingsFragment.beneneCount + beneneCount ) pref.setOnPreferenceClickListener { try { - SettingsFragment.beneneCount++ - if (SettingsFragment.beneneCount%20 == 0) { - val intent = Intent(context, EasterEggMonke::class.java) - startActivity(intent) + beneneCount++ + if (beneneCount%20 == 0) { + activity?.navigate(R.id.action_navigation_settings_general_to_easterEggMonkeFragment) } - settingsManager.edit().putInt( - getString(R.string.benene_count), - SettingsFragment.beneneCount - ) - .apply() - it.summary = - getString(R.string.benene_count_text).format(SettingsFragment.beneneCount) + settingsManager.edit { + putInt( + getString(R.string.benene_count), + beneneCount + ) + } + it.summary = getString(R.string.benene_count_text).format(beneneCount) } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index e10a5a1ad..0a0fb33c8 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,32 +3,59 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.text.format.Formatter.formatShortFileSize import android.view.View -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize 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.hidePrefs import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -class SettingsPlayer : PreferenceFragmentCompat() { +class SettingsPlayer : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_player) setPaddingBottom() + setToolBarScrollFlags() } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + //Hide specific prefs on TV/EMULATOR + hidePrefs( + listOf( + R.string.pref_category_gestures_key, + R.string.rotate_video_key, + R.string.auto_rotate_video_key, + R.string.speedup_key + ), + TV or EMULATOR + ) + + getPref(R.string.preview_seekbar_key)?.hideOn(TV) + getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) + getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_length_names) val prefValues = resources.getIntArray(R.array.video_buffer_length_values) @@ -41,10 +68,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_length_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_length_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_length_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -59,38 +87,73 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.limit_title), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } - /*(getPref(R.string.double_tap_seek_time_key) as? SeekBarPreference?)?.let { - - }*/ - - getPref(R.string.prefer_limit_title_rez_key)?.setOnPreferenceClickListener { - val prefNames = resources.getStringArray(R.array.limit_title_rez_pref_names) - val prefValues = resources.getIntArray(R.array.limit_title_rez_pref_values) - val current = settingsManager.getInt(getString(R.string.prefer_limit_title_rez_key), 3) + getPref(R.string.software_decoding_key)?.setOnPreferenceClickListener { + val prefNames = resources.getStringArray(R.array.software_decoding_switch) + val prefValues = resources.getIntArray(R.array.software_decoding_switch_values) + val current = settingsManager.getInt(getString(R.string.software_decoding_key), -1) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), - getString(R.string.limit_title_rez), + getString(R.string.software_decoding), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.prefer_limit_title_rez_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.software_decoding_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } + getPref(R.string.prefer_limit_show_player_info)?.setOnPreferenceClickListener { + val ctx = context ?: return@setOnPreferenceClickListener false + + val prefNames = resources.getStringArray(R.array.title_info_pref_names) + val keys = resources.getStringArray(R.array.title_info_pref_values) + + // Player defaults + val playerDefaults = mapOf( + ctx.getString(R.string.show_name_key) to true, + ctx.getString(R.string.show_resolution_key) to true, + ctx.getString(R.string.show_media_info_key) to false + ) + + val selectedIndices = keys.map { key -> + settingsManager.getBoolean(key, playerDefaults[key] ?: false) + }.mapIndexedNotNull { index, enabled -> + if (enabled) index else null + } + + activity?.showMultiDialog( + prefNames.toList(), + selectedIndices, + getString(R.string.limit_title_rez), + {} + ) { selected -> + settingsManager.edit { + for ((index, key) in keys.withIndex()) { + putBoolean(key, selected.contains(index)) + } + } + } + + true + } + + getPref(R.string.hide_player_control_names_key)?.hideOn(TV) + getPref(R.string.quality_pref_key)?.setOnPreferenceClickListener { - val prefValues = Qualities.values().map { it.value }.reversed().toMutableList() + val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() prefValues.remove(Qualities.Unknown.value) val prefNames = prefValues.map { Qualities.getStringByInt(it) } @@ -98,7 +161,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { val currentQuality = settingsManager.getInt( getString(R.string.quality_pref_key), - Qualities.values().last().value + Qualities.entries.last().value ) activity?.showBottomDialog( @@ -106,15 +169,17 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref), true, - {}) { - settingsManager.edit().putInt(getString(R.string.quality_pref_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.quality_pref_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } getPref(R.string.quality_pref_mobile_data_key)?.setOnPreferenceClickListener { - val prefValues = Qualities.values().map { it.value }.reversed().toMutableList() + val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() prefValues.remove(Qualities.Unknown.value) val prefNames = prefValues.map { Qualities.getStringByInt(it) } @@ -122,7 +187,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { val currentQuality = settingsManager.getInt( getString(R.string.quality_pref_mobile_data_key), - Qualities.values().last().value + Qualities.entries.last().value ) activity?.showBottomDialog( @@ -130,25 +195,38 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref_data), true, - {}) { - settingsManager.edit().putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } - getPref(R.string.player_pref_key)?.setOnPreferenceClickListener { - val prefNames = resources.getStringArray(R.array.player_pref_names) - val prefValues = resources.getIntArray(R.array.player_pref_values) - val current = settingsManager.getInt(getString(R.string.player_pref_key), 1) + getPref(R.string.player_default_key)?.setOnPreferenceClickListener { + val players = VideoClickActionHolder.getPlayers(activity) + val prefNames = buildList { + add(getString(R.string.player_settings_play_in_app)) + addAll(players.map { it.name.asStringNull(activity) ?: it.javaClass.simpleName }) + } + val prefValues = buildList { + add("") + addAll(players.map { it.uniqueId() }) + } + val current = + settingsManager.getString(getString(R.string.player_default_key), "") ?: "" activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), getString(R.string.player_pref), true, - {}) { - settingsManager.edit().putInt(getString(R.string.player_pref_key), prefValues[it]).apply() + {} + ) { + settingsManager.edit { + putString(getString(R.string.player_default_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -163,6 +241,21 @@ class SettingsPlayer : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + getPref(R.string.player_source_priority_key)?.setOnPreferenceClickListener { + ioSafe { + val defaultSources = QualityProfileDialog.getAllDefaultSources() + val activity = activity ?: return@ioSafe + activity.runOnUiThread { + QualityProfileDialog( + activity, + R.style.DialogFullscreenPlayer, + defaultSources, + ).show() + } + } + return@setOnPreferenceClickListener true + } + getPref(R.string.video_buffer_disk_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_size_names) val prefValues = resources.getIntArray(R.array.video_buffer_size_values) @@ -175,10 +268,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_disk_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -194,10 +288,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_size_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_size_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_size_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -205,26 +300,25 @@ class SettingsPlayer : PreferenceFragmentCompat() { getPref(R.string.video_buffer_clear_key)?.let { pref -> val cacheDir = context?.cacheDir ?: return@let - fun updateSummery() { + fun updateSummary() { try { - pref.summary = formatShortFileSize(view?.context, getFolderSize(cacheDir)) + pref.summary = formatShortFileSize(pref.context, getFolderSize(cacheDir)) } catch (e: Exception) { logError(e) } } - updateSummery() + updateSummary() pref.setOnPreferenceClickListener { try { cacheDir.deleteRecursively() - updateSummery() + updateSummary() } catch (e: Exception) { logError(e) } return@setOnPreferenceClickListener true } } - } -} \ No newline at end of file +} 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 0bef5e9ad..c8478a840 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,29 +2,30 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View -import androidx.navigation.NavOptions +import androidx.core.content.edit import androidx.navigation.fragment.findNavController -import androidx.preference.PreferenceFragmentCompat +import androidx.navigation.NavOptions import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey 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 import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings +import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.UIHelper.navigate -class SettingsProviders : PreferenceFragmentCompat() { +class SettingsProviders : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_providers) setPaddingBottom() + setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -34,7 +35,7 @@ class SettingsProviders : PreferenceFragmentCompat() { getPref(R.string.display_sub_key)?.setOnPreferenceClickListener { activity?.getApiDubstatusSettings()?.let { current -> - val dublist = DubStatus.values() + val dublist = DubStatus.entries val names = dublist.map { it.name } val currentList = ArrayList() @@ -46,13 +47,15 @@ class SettingsProviders : PreferenceFragmentCompat() { names, currentList, getString(R.string.display_subbed_dubbed_settings), - {}) { selectedList -> + {} + ) { selectedList -> APIRepository.dubStatusActive = selectedList.map { dublist[it] }.toHashSet() - - settingsManager.edit().putStringSet( - this.getString(R.string.display_sub_key), - selectedList.map { names[it] }.toMutableSet() - ).apply() + settingsManager.edit { + putStringSet( + getString(R.string.display_sub_key), + selectedList.map { names[it] }.toMutableSet() + ) + } } } @@ -91,50 +94,46 @@ class SettingsProviders : PreferenceFragmentCompat() { names, currentList, getString(R.string.preferred_media_settings), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.prefer_media_type_key), - selectedList.map { it.toString() }.toMutableSet() - ).apply() - removeKey(USER_SELECTED_HOMEPAGE_API) - //(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.prefer_media_type_key), + selectedList.map { it.toString() }.toMutableSet() + ) + } + DataStoreHelper.currentHomePage = null + //(context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener { - activity?.getApiProviderLangSettings()?.let { current -> - val languages = synchronized(APIHolder.apis) { - APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName + activity?.getApiProviderLangSettings()?.let { currentLangTags -> + val languagesTagName = APIHolder.apis.withLock { + listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } } - val currentList = current.map { - languages.indexOf(it) - } - - val names = languages.map { - if (it == AllLanguagesName) { - Pair(it, getString(R.string.all_languages_preference)) - } else { - val emoji = SubtitleHelper.getFlagFromIso(it) - val name = SubtitleHelper.fromTwoLettersToLanguage(it) - val fullName = "$emoji $name" - Pair(it, fullName) - } + val currentIndexList = currentLangTags.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } } activity?.showMultiDialog( - names.map { it.second }, - currentList, + languagesTagName.map { it.second }, + currentIndexList, getString(R.string.provider_lang_settings), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.provider_lang_key), - selectedList.map { names[it].first }.toMutableSet() - ).apply() - //APIRepository.providersActive = it.context.getApiSettings() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.provider_lang_key), + selectedList.map { languagesTagName[it].first }.toSet() + ) + } + // APIRepository.providersActive = it.context.getApiSettings() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index e2fd24ca4..f4c522bf9 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,33 +3,68 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Build import android.os.Bundle import android.view.View -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager +import androidx.preference.SeekBarPreference +import com.lagradost.cloudstream3.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 -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv 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 : PreferenceFragmentCompat() { +class SettingsUI : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_ui) setPaddingBottom() + setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() - setPreferencesFromResource(R.xml.settins_ui, rootKey) + 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) @@ -45,12 +80,13 @@ class SettingsUI : PreferenceFragmentCompat() { prefNames.toList(), prefValues, getString(R.string.poster_ui_settings), - {}) { list -> - val edit = settingsManager.edit() - for ((i, key) in keys.withIndex()) { - edit.putBoolean(key, list.contains(i)) + {} + ) { list -> + settingsManager.edit { + for ((i, key) in keys.withIndex()) { + putBoolean(key, list.contains(i)) + } } - edit.apply() SearchResultBuilder.updateCache(it.context) } @@ -65,31 +101,32 @@ class SettingsUI : PreferenceFragmentCompat() { settingsManager.getInt(getString(R.string.app_layout_key), -1) activity?.showBottomDialog( - prefNames.toList(), - prefValues.indexOf(currentLayout), - getString(R.string.app_layout), - true, - {}) { - try { - settingsManager.edit() - .putInt(getString(R.string.app_layout_key), prefValues[it]) - .apply() - context?.updateTv() - activity?.recreate() - } catch (e: Exception) { - logError(e) + items = prefNames.toList(), + selectedIndex = prefValues.indexOf(currentLayout), + name = getString(R.string.app_layout), + showApply = true, + dismissCallback = {}, + callback = { + try { + settingsManager.edit { + putInt(getString(R.string.app_layout_key), prefValues[it]) + } + context?.updateTv() + activity?.recreate() + } catch (e: Exception) { + logError(e) + } } - } + ) return@setOnPreferenceClickListener true } getPref(R.string.app_theme_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_names).toMutableList() val prefValues = resources.getStringArray(R.array.themes_names_values).toMutableList() - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + val removeIncompatible = { text: String -> val toRemove = prefValues - .mapIndexed { idx, s -> if (s.startsWith("Monet")) idx else null } + .mapIndexed { idx, s -> if (s.startsWith(text)) idx else null } .filterNotNull() var offset = 0 toRemove.forEach { idx -> @@ -98,6 +135,12 @@ class SettingsUI : PreferenceFragmentCompat() { offset += 1 } } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + removeIncompatible("Monet") + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { // Remove system on android 9 and less + removeIncompatible("System") + } val currentLayout = settingsManager.getString(getString(R.string.app_theme_key), prefValues.first()) @@ -107,11 +150,12 @@ class SettingsUI : PreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.app_theme_settings), true, - {}) { + {} + ) { try { - settingsManager.edit() - .putString(getString(R.string.app_theme_key), prefValues[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.app_theme_key), prefValues[it]) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -121,7 +165,8 @@ class SettingsUI : PreferenceFragmentCompat() { } getPref(R.string.primary_color_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_overlay_names).toMutableList() - val prefValues = resources.getStringArray(R.array.themes_overlay_names_values).toMutableList() + val prefValues = + resources.getStringArray(R.array.themes_overlay_names_values).toMutableList() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less val toRemove = prefValues @@ -143,11 +188,12 @@ class SettingsUI : PreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.primary_color_settings), true, - {}) { + {} + ) { try { - settingsManager.edit() - .putString(getString(R.string.primary_color_key), prefValues[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.primary_color_key), prefValues[it]) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -169,15 +215,37 @@ class SettingsUI : PreferenceFragmentCompat() { names, currentList, getString(R.string.pref_filter_search_quality), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.pref_filter_search_quality_key), - selectedList.map { it.toString() }.toMutableSet() - ).apply() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.pref_filter_search_quality_key), + selectedList.map { it.toString() }.toMutableSet() + ) + } } return@setOnPreferenceClickListener true } + getPref(R.string.confirm_exit_key)?.setOnPreferenceClickListener { + val prefNames = resources.getStringArray(R.array.confirm_exit) + val prefValues = resources.getIntArray(R.array.confirm_exit_values) + val confirmExit = settingsManager.getInt(getString(R.string.confirm_exit_key), -1) + + activity?.showBottomDialog( + items = prefNames.toList(), + selectedIndex = prefValues.indexOf(confirmExit), + name = getString(R.string.confirm_before_exiting_title), + showApply = true, + dismissCallback = {}, + callback = { selectedOption -> + settingsManager.edit { + putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) + } + } + ) + return@setOnPreferenceClickListener true + } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index 62e46c084..c04215594 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -1,54 +1,104 @@ package com.lagradost.cloudstream3.ui.settings -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context +import android.net.Uri import android.os.Bundle -import android.os.TransactionTooLargeException 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 com.lagradost.cloudstream3.AcraApplication +import androidx.recyclerview.widget.LinearLayoutManager import com.lagradost.cloudstream3.AutoDownloadMode +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.databinding.LogcatBinding import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.network.initClient +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.services.BackupWorkManager +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.utils.BackupUtils.backup +import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher +import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate +import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded +import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import okhttp3.internal.closeQuietly +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.txt import java.io.BufferedReader import java.io.InputStreamReader import java.io.OutputStream +import java.lang.System.currentTimeMillis +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale -class SettingsUpdates : PreferenceFragmentCompat() { +class SettingsUpdates : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_updates) setPaddingBottom() + setToolBarScrollFlags() + } + + private val pathPicker = getChooseFolderLauncher { uri, path -> + if(uri == null) return@getChooseFolderLauncher + + val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher + (path ?: uri.toString()).let { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(getString(R.string.backup_path_key), uri.toString()) + putString(getString(R.string.backup_dir_key), it) + } + } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_updates, rootKey) - //val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) getPref(R.string.backup_key)?.setOnPreferenceClickListener { - activity?.backup() + BackupUtils.backup(activity) + return@setOnPreferenceClickListener true + } + + getPref(R.string.automatic_backup_key)?.setOnPreferenceClickListener { + val prefNames = resources.getStringArray(R.array.periodic_work_names) + val prefValues = resources.getIntArray(R.array.periodic_work_values) + val current = settingsManager.getInt(getString(R.string.automatic_backup_key), 0) + + activity?.showDialog( + prefNames.toList(), + prefValues.indexOf(current), + getString(R.string.backup_frequency), + true, + {} + ) { index -> + settingsManager.edit { + putInt(getString(R.string.automatic_backup_key), prefValues[index]) + } + BackupWorkManager.enqueuePeriodicWork( + context ?: CloudStreamApp.context, + prefValues[index].toLong() + ) + } return@setOnPreferenceClickListener true } @@ -61,96 +111,117 @@ class SettingsUpdates : PreferenceFragmentCompat() { activity?.restorePrompt() return@setOnPreferenceClickListener true } - getPref(R.string.show_logcat_key)?.setOnPreferenceClickListener { pref -> - val builder = - AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) + getPref(R.string.backup_path_key)?.hideOn(EMULATOR)?.setOnPreferenceClickListener { + val dirs = getBackupDirsForDisplay() + val currentDir = + settingsManager.getString(getString(R.string.backup_dir_key), null) + ?: context?.let { ctx -> BackupUtils.getDefaultBackupDir(ctx)?.filePath() } - val binding = LogcatBinding.inflate(layoutInflater,null,false ) - builder.setView(binding.root) - - val dialog = builder.create() - dialog.show() - val log = StringBuilder() - try { - //https://developer.android.com/studio/command-line/logcat - val process = Runtime.getRuntime().exec("logcat -d") - val bufferedReader = BufferedReader( - InputStreamReader(process.inputStream) - ) - - var line: String? - while (bufferedReader.readLine().also { line = it } != null) { - log.append("${line}\n") + activity?.showBottomDialog( + dirs + listOf(getString(R.string.custom)), + dirs.indexOf(currentDir), + getString(R.string.backup_path_title), + true, + {} + ) { + // Last = custom + if (it == dirs.size) { + try { + pathPicker.launch(Uri.EMPTY) + } catch (e: Exception) { + logError(e) + } + } else { + // Sets both visual and actual paths. + // path = used uri + // dir = dir path + settingsManager.edit { + putString(getString(R.string.backup_path_key), dirs[it]) + putString(getString(R.string.backup_dir_key), dirs[it]) + } } - } catch (e: Exception) { - logError(e) // kinda ironic - } - - val text = log.toString() - binding.text1.text = text - - binding.copyBtt.setOnClickListener { - // Can crash on too much text - try { - val serviceClipboard = - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?) - ?: return@setOnClickListener - val clip = ClipData.newPlainText("logcat", text) - serviceClipboard.setPrimaryClip(clip) - dialog.dismissSafe(activity) - } catch (e: TransactionTooLargeException) { - showToast(R.string.clipboard_too_large) - } - } - binding.clearBtt.setOnClickListener { - Runtime.getRuntime().exec("logcat -c") - dialog.dismissSafe(activity) - } - binding.saveBtt.setOnClickListener { - var fileStream: OutputStream? = null - try { - fileStream = - VideoDownloadManager.setupStream( - it.context, - "logcat", - null, - "txt", - false - ).openNew() - fileStream.writer().write(text) - dialog.dismissSafe(activity) - } catch (t: Throwable) { - logError(t) - showToast(t.message) - } finally { - fileStream?.closeQuietly() - } - } - binding.closeBtt.setOnClickListener { - dialog.dismissSafe(activity) } return@setOnPreferenceClickListener true } - getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) + getPref(R.string.show_logcat_key)?.setOnPreferenceClickListener { pref -> + val builder = AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) + val binding = LogcatBinding.inflate(layoutInflater, null, false) + builder.setView(binding.root) + + val dialog = builder.create() + dialog.show() + + val logList = mutableListOf() + try { + // https://developer.android.com/studio/command-line/logcat + val process = Runtime.getRuntime().exec("logcat -d") + val bufferedReader = BufferedReader(InputStreamReader(process.inputStream)) + bufferedReader.lineSequence().forEach { logList.add(it) } + } catch (e: Exception) { + logError(e) // kinda ironic + } + + val adapter = LogcatAdapter().apply { submitList(logList) } + binding.logcatRecyclerView.layoutManager = LinearLayoutManager(pref.context) + binding.logcatRecyclerView.adapter = adapter + + binding.copyBtt.setOnClickListener { + clipboardHelper(txt("Logcat"), logList.joinToString("\n")) + dialog.dismissSafe(activity) + } + + binding.clearBtt.setOnClickListener { + Runtime.getRuntime().exec("logcat -c") + dialog.dismissSafe(activity) + } + + binding.saveBtt.setOnClickListener { + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) + var fileStream: OutputStream? = null + try { + fileStream = VideoDownloadManager.setupStream( + it.context, + "logcat_${date}", + null, + "txt", + false + ).openNew() + fileStream.writer().use { writer -> writer.write(logList.joinToString("\n")) } + dialog.dismissSafe(activity) + } catch (t: Throwable) { + logError(t) + showToast(t.message) + } + } + + binding.closeBtt.setOnClickListener { + dialog.dismissSafe(activity) + } + + return@setOnPreferenceClickListener true + } + + getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) + // Use legacy installer as default until we make the new installer completely reliable val currentInstaller = - settingsManager.getInt(getString(R.string.apk_installer_key), 0) + settingsManager.getInt(getString(R.string.apk_installer_key), 1) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, - {}) { + {} + ) { num -> try { - settingsManager.edit() - .putInt(getString(R.string.apk_installer_key), prefValues[it]) - .apply() + settingsManager.edit { + putInt(getString(R.string.apk_installer_key), prefValues[num]) + } } catch (e: Exception) { logError(e) } @@ -158,25 +229,35 @@ class SettingsUpdates : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.manual_check_update_key)?.setOnPreferenceClickListener { - ioSafe { - if (activity?.runAutoUpdate(false) == false) { - activity?.runOnUiThread { - showToast( - R.string.no_update_found, - Toast.LENGTH_SHORT - ) + getPref(R.string.manual_check_update_key)?.let { pref -> + pref.summary = BuildConfig.VERSION_NAME + pref.setOnPreferenceClickListener { + ioSafe { + if (activity?.runAutoUpdate(false) == false) { + activity?.runOnUiThread { + showToast( + R.string.no_update_found, + Toast.LENGTH_SHORT + ) + } } } + return@setOnPreferenceClickListener true + } + } + + getPref(R.string.install_prerelease_key)?.let { pref -> + pref.isVisible = BuildConfig.FLAVOR == "stable" + pref.setOnPreferenceClickListener { + activity?.installPreReleaseIfNeeded() + return@setOnPreferenceClickListener true } - return@setOnPreferenceClickListener true } getPref(R.string.auto_download_plugins_key)?.setOnPreferenceClickListener { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) - val prefNames = resources.getStringArray(R.array.auto_download_plugin) - val prefValues = enumValues().sortedBy { x -> x.value }.map { x -> x.value } + val prefValues = + enumValues().sortedBy { x -> x.value }.map { x -> x.value } val current = settingsManager.getInt(getString(R.string.auto_download_plugins_key), 0) @@ -185,11 +266,35 @@ class SettingsUpdates : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.automatic_plugin_download_mode_title), true, - {}) { - settingsManager.edit().putInt(getString(R.string.auto_download_plugins_key), prefValues[it]).apply() - (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } + {} + ) { num -> + settingsManager.edit { + putInt(getString(R.string.auto_download_plugins_key), prefValues[num]) + } + (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } + + getPref(R.string.manual_update_plugins_key)?.setOnPreferenceClickListener { + ioSafe { + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity ?: return@ioSafe) + } + return@setOnPreferenceClickListener true // Return true for the listener + } + } + + private fun getBackupDirsForDisplay(): List { + return safe { + context?.let { ctx -> + val defaultDir = BackupUtils.getDefaultBackupDir(ctx)?.filePath() + val first = listOf(defaultDir) + (runCatching { + first + BackupUtils.getCurrentBackupDir(ctx).let { + it.first?.filePath() ?: it.second + } + }.getOrNull() ?: first).filterNotNull().distinct() + } + } ?: emptyList() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt index 553e7675e..af0d3dfe7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt @@ -4,10 +4,8 @@ import android.content.ClipboardManager import android.content.Context import android.content.DialogInterface import android.os.Build -import android.os.Bundle import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -15,7 +13,6 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginBottom import androidx.core.view.marginTop -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.CommonActivity.showToast @@ -26,33 +23,26 @@ import com.lagradost.cloudstream3.databinding.FragmentExtensionsBinding import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.ui.result.setText -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +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.AppUtils.downloadAllPluginsDialog -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.setText -class ExtensionsFragment : Fragment() { - var binding: FragmentExtensionsBinding? = null - override fun onDestroyView() { - binding = null - super.onDestroyView() - } +class ExtensionsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentExtensionsBinding::inflate) +) { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val localBinding = FragmentExtensionsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root//inflater.inflate(R.layout.fragment_extensions, container, false) - } + private val extensionViewModel: ExtensionsViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Int) { val param = LinearLayout.LayoutParams( @@ -63,8 +53,6 @@ class ExtensionsFragment : Fragment() { this.layoutParams = param } - private val extensionViewModel: ExtensionsViewModel by activityViewModels() - override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::reloadRepositories @@ -80,24 +68,25 @@ class ExtensionsFragment : Fragment() { extensionViewModel.loadRepositories() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - //context?.fixPaddingStatusbar(extensions_root) + override fun fixLayout(view: View) { + setSystemBarsPadding() + } + override fun onBindingCreated(binding: FragmentExtensionsBinding) { setUpToolbar(R.string.extensions) + setToolBarScrollFlags() - - binding?.repoRecyclerView?.apply { + binding.repoRecyclerView.apply { setLinearListLayout( isHorizontal = false, - nextUp = R.id.settings_toolbar, //FOCUS_SELF, // back has no id so we cant :pensive: + nextUp = R.id.settings_toolbar, // FOCUS_SELF, // back has no id so we cant :pensive: nextDown = R.id.plugin_storage_appbar, nextRight = FOCUS_SELF, nextLeft = R.id.nav_rail_view ) - if (!isTrueTvSettings()) - binding?.addRepoButton?.let { button -> + if (!isLayout(TV)) + binding.addRepoButton.let { button -> button.post { setPadding( paddingLeft, @@ -111,10 +100,10 @@ class ExtensionsFragment : Fragment() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY - if (dy > 0) { //check for scroll down - binding?.addRepoButton?.shrink() // hide + if (dy > 0) { // check for scroll down + binding.addRepoButton.shrink() // hide } else if (dy < -5) { - binding?.addRepoButton?.extend() // show + binding.addRepoButton.extend() // show } } } @@ -130,13 +119,14 @@ class ExtensionsFragment : Fragment() { }, { repo -> // Prompt user before deleting repo main { - val builder = AlertDialog.Builder(context ?: view.context) + val uiContext = context ?: binding.root.context + val builder = AlertDialog.Builder(uiContext) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { ioSafe { - RepositoryManager.removeRepository(view.context, repo) + RepositoryManager.removeRepository(uiContext.applicationContext, repo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() } @@ -147,9 +137,7 @@ class ExtensionsFragment : Fragment() { } builder.setTitle(R.string.delete_repository) - .setMessage( - context?.getString(R.string.delete_repository_plugins) - ) + .setMessage(uiContext.getString(R.string.delete_repository_plugins)) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() @@ -158,37 +146,15 @@ class ExtensionsFragment : Fragment() { } observe(extensionViewModel.repositories) { - binding?.repoRecyclerView?.isVisible = it.isNotEmpty() - binding?.blankRepoScreen?.isVisible = it.isEmpty() - (binding?.repoRecyclerView?.adapter as? RepoAdapter)?.updateList(it) + binding.repoRecyclerView.isVisible = it.isNotEmpty() + binding.blankRepoScreen.isVisible = it.isEmpty() + (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) } - /*binding?.repoRecyclerView?.apply { - context?.let { ctx -> - layoutManager = LinearRecycleViewLayoutManager(ctx, nextFocusUpId, nextFocusDownId) - } - }*/ - -// list_repositories?.setOnClickListener { -// // Open webview on tv if browser fails -// val isTv = isTvSettings() -// openBrowser(PUBLIC_REPOSITORIES_LIST, isTv, this) -// -// // Set clipboard on TV because the browser might not exist or work properly -// if (isTv) { -// val serviceClipboard = -// (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?) -// ?: return@setOnClickListener -// val clip = ClipData.newPlainText("Repository url", PUBLIC_REPOSITORIES_LIST) -// serviceClipboard.setPrimaryClip(clip) -// } -// } - observeNullable(extensionViewModel.pluginStats) { value -> - binding?.apply { + binding.apply { if (value == null) { pluginStorageAppbar.isVisible = false - return@observeNullable } @@ -208,7 +174,7 @@ class ExtensionsFragment : Fragment() { } } - binding?.pluginStorageAppbar?.setOnClickListener { + binding.pluginStorageAppbar.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( @@ -228,24 +194,24 @@ class ExtensionsFragment : Fragment() { val dialog = builder.create() dialog.show() - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( + (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( 0 - )?.text?.toString()?.let { copy -> - binding.repoUrlInput.setText(copy) + )?.text?.toString()?.let { copiedText -> + if (copiedText.contains(RepoAdapter.SHAREABLE_REPO_SEPARATOR)) { + // text is of format : + val (name, url) = copiedText.split(RepoAdapter.SHAREABLE_REPO_SEPARATOR, limit = 2) + binding.repoUrlInput.setText(url.trim()) + binding.repoNameInput.setText(name.trim()) + } else { + binding.repoUrlInput.setText(copiedText) + } } -// dialog.list_repositories?.setOnClickListener { -// // Open webview on tv if browser fails -// openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) -// dialog.dismissSafe() -// } - -// dialog.text2?.text = provider.name binding.applyBtt.setOnClickListener secondListener@{ val name = binding.repoNameInput.text?.toString() + val urlInput = binding.repoUrlInput.text?.toString() ioSafe { - val url = binding.repoUrlInput.text?.toString() - ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } + val url = urlInput?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } if (url.isNullOrBlank()) { main { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) @@ -261,8 +227,7 @@ class ExtensionsFragment : Fragment() { val fixedName = if (!name.isNullOrBlank()) name else repository.name - - val newRepo = RepositoryData(fixedName, url) + val newRepo = RepositoryData(repository.iconUrl,fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() @@ -271,9 +236,9 @@ class ExtensionsFragment : Fragment() { if (plugins.isNullOrEmpty()) { showToast(R.string.no_plugins_found_error, Toast.LENGTH_LONG) } else { - this@ExtensionsFragment.activity?.downloadAllPluginsDialog( + this@ExtensionsFragment.activity?.addRepositoryDialog( + fixedName, url, - fixedName ) } } @@ -285,8 +250,8 @@ class ExtensionsFragment : Fragment() { } } - val isTv = isTrueTvSettings() - binding?.apply { + val isTv = isLayout(TV) + binding.apply { addRepoButton.isGone = isTv addRepoButtonImageviewHolder.isVisible = isTv @@ -299,4 +264,4 @@ class ExtensionsFragment : Fragment() { } reloadRepositories() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt index 866d167c1..482251b78 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.debugAssert @@ -12,14 +12,17 @@ import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.getPluginsOnline import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES -import com.lagradost.cloudstream3.ui.result.UiText -import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe data class RepositoryData( + @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("url") val url: String -) +){ + constructor(name: String,url: String):this(null,name,url) +} const val REPOSITORIES_KEY = "REPOSITORIES_KEY" diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt index eb0082b80..d0f9ff565 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 @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.settings.extensions +import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.util.Log import android.view.LayoutInflater @@ -7,107 +8,217 @@ import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.plugins.VotingApi.getVotes -import com.lagradost.cloudstream3.ui.result.setText -import com.lagradost.cloudstream3.ui.result.txt -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.GlideApp -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso -import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx -import org.junit.Assert -import org.junit.Test +import com.lagradost.cloudstream3.utils.getImageFromDrawable +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt import java.text.DecimalFormat import kotlin.math.floor import kotlin.math.log10 - +import kotlin.math.pow data class PluginViewData( val plugin: Plugin, val isDownloaded: Boolean, ) +class RepositoryViewHolderState(view: ViewBinding) : ViewHolderState(view) { + // Store how many times this has called recycled, this is used to correctly sync text in jobs + var recycleCount = 0 +} + class PluginAdapter( val iconClickCallback: (Plugin) -> Unit -) : - RecyclerView.Adapter() { - private val plugins: MutableList = mutableListOf() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val layout = if(isTrueTvSettings()) R.layout.repository_item_tv else R.layout.repository_item +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.plugin.second.internalName == b.plugin.second.internalName && a.plugin.first == b.plugin.first +})) { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val layout = if (isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item val inflated = LayoutInflater.from(parent.context).inflate(layout, parent, false) - return PluginViewHolder( + return RepositoryViewHolderState( RepositoryItemBinding.bind(inflated) // may crash ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PluginViewHolder -> { - holder.bind(plugins[position]) + override fun onClearView(holder: ViewHolderState) { + if (holder is RepositoryViewHolderState) { + holder.recycleCount += 1 + } + when (val binding = holder.view) { + is RepositoryItemBinding -> { + clearImage(binding.entryIcon) } } } - override fun getItemCount(): Int { - return plugins.size - } + @SuppressLint("SetTextI18n") + override fun onBindContent(holder: ViewHolderState, item: PluginViewData, position: Int) { + val binding = holder.view as? RepositoryItemBinding ?: return + val itemView = holder.itemView - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - PluginDiffCallback(this.plugins, newList) + val metadata = item.plugin.second + val disabled = metadata.status == PROVIDER_STATUS_DOWN + val name = metadata.name.removeSuffix("Provider") + val alpha = if (disabled) 0.6f else 1f + val isLocal = !item.plugin.second.url.startsWith("http") + binding.mainText.alpha = alpha + binding.subText.alpha = alpha + + val drawableInt = if (item.isDownloaded) + R.drawable.ic_baseline_delete_outline_24 + else R.drawable.netflix_download + + binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false + binding.actionButton.setImageResource(drawableInt) + + binding.actionButton.setOnClickListener { + iconClickCallback.invoke(item.plugin) + } + itemView.setOnClickListener { + if (isLocal) return@setOnClickListener + + val sheet = PluginDetailsFragment(item) + val activity = itemView.context.getActivity() as AppCompatActivity + sheet.show(activity.supportFragmentManager, "PluginDetails") + } + //if (itemView.context?.isTrueTvSettings() == false) { + // val siteUrl = metadata.repositoryUrl + // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { + // itemView.setOnClickListener { + // openBrowser(siteUrl) + // } + // } + //} + + if (item.isDownloaded) { + // On local plugins page the filepath is provided instead of url. + val plugin = + (PluginManager.urlPlugins[metadata.url] + ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin + + if (plugin?.openSettings != null) { + binding.actionSettings.isVisible = true + binding.actionSettings.setOnClickListener { + try { + plugin.openSettings?.invoke(itemView.context) + } catch (e: Throwable) { + Log.e( + "PluginAdapter", + "Failed to open $name settings: ${ + Log.getStackTraceString(e) + }" + ) + } + } + } else { + binding.actionSettings.isVisible = false + } + } else { + binding.actionSettings.isVisible = false + } + + val url = metadata.iconUrl?.replace( + "%size%", + "$iconSize" + )?.replace( + "%exact_size%", + "$iconSizeExact" ) - plugins.clear() - plugins.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - /* - private var storedPlugins: Array = reloadStoredPlugins() - - private fun reloadStoredPlugins(): Array { - return PluginManager.getPluginsOnline().also { storedPlugins = it } - }*/ - - // Clear glide image because setImageResource doesn't override - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is PluginViewHolder) { - holder.binding.entryIcon.let { pluginIcon -> - GlideApp.with(pluginIcon).clear(pluginIcon) - } + 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)) } } - super.onViewRecycled(holder) + + 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() } 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 return findClosestBase2(target, current * 2, max) } - @Test + // 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 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 { @@ -121,149 +232,11 @@ class PluginAdapter( val base = value / 3 return if (value >= 3 && base < suffix.size) { DecimalFormat("#0.00").format( - numValue / Math.pow( - 10.0, - (base * 3).toDouble() - ) + numValue / 10.0.pow((base * 3).toDouble()) ) + suffix[base] } else { DecimalFormat().format(numValue) } } } - - inner class PluginViewHolder(val binding: RepositoryItemBinding) : - RecyclerView.ViewHolder(binding.root) { - - 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("NSFW") ?: false - binding.actionButton.setImageResource(drawableInt) - - binding.actionButton.setOnClickListener { - iconClickCallback.invoke(data.plugin) - } - itemView.setOnClickListener { - if (isLocal) return@setOnClickListener - - val sheet = PluginDetailsFragment(data) - val activity = itemView.context.getActivity() as AppCompatActivity - sheet.show(activity.supportFragmentManager, "PluginDetails") - } - //if (itemView.context?.isTrueTvSettings() == false) { - // val siteUrl = metadata.repositoryUrl - // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { - // itemView.setOnClickListener { - // openBrowser(siteUrl) - // } - // } - //} - - if (data.isDownloaded) { - // On local plugins page the filepath is provided instead of url. - val plugin = - PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url] - if (plugin?.openSettings != null) { - binding.actionSettings.isVisible = true - binding.actionSettings.setOnClickListener { - try { - plugin.openSettings!!.invoke(itemView.context) - } catch (e: Throwable) { - Log.e( - "PluginAdapter", - "Failed to open $name settings: ${ - Log.getStackTraceString(e) - }" - ) - } - } - } else { - binding.actionSettings.isVisible = false - } - } else { - binding.actionSettings.isVisible = false - } - - if (!binding.entryIcon.setImage(//itemView.entry_icon?.height ?: - metadata.iconUrl?.replace( - "%size%", - "$iconSize" - )?.replace( - "%exact_size%", - "$iconSizeExact" - ), - null, - errorImageDrawable = R.drawable.ic_baseline_extension_24 - ) - ) { - binding.entryIcon.setImageResource(R.drawable.ic_baseline_extension_24) - } - - binding.extVersion.isVisible = true - binding.extVersion.text = "v${metadata.version}" - - if (metadata.language.isNullOrBlank()) { - binding.langIcon.isVisible = false - } else { - binding.langIcon.isVisible = true - binding.langIcon.text = - "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" - } - - binding.extVotes.isVisible = false - if (!isLocal) { - ioSafe { - metadata.getVotes().main { - binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(it))) - binding.extVotes.isVisible = true - } - } - } - - - if (metadata.fileSize != null) { - binding.extFilesize.isVisible = true - binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) - } else { - binding.extFilesize.isVisible = false - } - binding.mainText.setText( - if (disabled) txt( - R.string.single_plugin_disabled, - name - ) else txt(name) - ) - binding.subText.isGone = metadata.description.isNullOrBlank() - binding.subText.text = metadata.description.html() - } - } -} - -class PluginDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].plugin.second.internalName == newList[newItemPosition].plugin.second.internalName && oldList[oldItemPosition].plugin.first == newList[newItemPosition].plugin.first - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt index 7d733be09..0dcbece6c 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,32 +1,36 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.content.res.ColorStateList -import android.os.Bundle import android.text.format.Formatter.formatFileSize import android.util.Log -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import androidx.core.view.isVisible -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.databinding.FragmentPluginDetailsBinding import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.VotingApi.canVote import com.lagradost.cloudstream3.plugins.VotingApi.getVotes import com.lagradost.cloudstream3.plugins.VotingApi.hasVoted import com.lagradost.cloudstream3.plugins.VotingApi.vote +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.BaseBottomSheetDialogFragment +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +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.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.toPx - -class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragment() { +class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFragment( + BaseFragment.BindingCreator.Inflate(FragmentPluginDetailsBinding::inflate) +) { companion object { private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { @@ -41,39 +45,20 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen } } - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - 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) + override fun onBindingCreated(binding: FragmentPluginDetailsBinding) { val metadata = data.plugin.second - binding?.apply { - if (!pluginIcon.setImage(//plugin_icon?.height ?: - metadata.iconUrl?.replace( - "%size%", - "$iconSize" - )?.replace( - "%exact_size%", - "$iconSizeExact" - ), - null, - errorImageDrawable = R.drawable.ic_baseline_extension_24 - ) - ) { - pluginIcon.setImageResource(R.drawable.ic_baseline_extension_24) + 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) } } pluginName.text = metadata.name.removeSuffix("Provider") pluginVersion.text = metadata.version.toString() @@ -94,9 +79,9 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen ", " ) pluginLang.text = if (metadata.language == null) - getString(R.string.no_data) - else - "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" + getString(R.string.no_data) + else + getNameNextToFlagEmoji(metadata.language) ?: metadata.language githubBtn.setOnClickListener { if (metadata.repositoryUrl != null) { @@ -111,7 +96,7 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen if (data.isDownloaded) { // On local plugins page the filepath is provided instead of url. val plugin = - PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url] + (PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url]) as? com.lagradost.cloudstream3.plugins.Plugin if (plugin?.openSettings != null && context != null) { actionSettings.isVisible = true actionSettings.setOnClickListener { @@ -159,7 +144,7 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen ) } else { upvote.imageTintList = ColorStateList.valueOf( - context?.colorFromAttribute(R.attr.colorOnSurface) ?: R.color.white + context?.colorFromAttribute(com.google.android.material.R.attr.colorOnSurface) ?: R.color.white ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt index 172ea659c..534ffa62a 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,80 +1,79 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AllLanguagesName -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.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.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.ui.settings.appLanguages +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx const val PLUGINS_BUNDLE_NAME = "name" const val PLUGINS_BUNDLE_URL = "url" const val PLUGINS_BUNDLE_LOCAL = "isLocal" -class PluginsFragment : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val localBinding = FragmentPluginsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root//inflater.inflate(R.layout.fragment_plugins, container, false) - } +class PluginsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) +) { + + private val pluginViewModel: PluginsViewModel by activityViewModels() override fun onDestroyView() { - binding = null + pluginViewModel.clear() // clear for the next observe super.onDestroyView() } - private val pluginViewModel: PluginsViewModel by activityViewModels() - var binding: FragmentPluginsBinding? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) { + setSystemBarsPadding() + } + override fun onBindingCreated(binding: FragmentPluginsBinding) { // Since the ViewModel is getting reused the tvTypes must be cleared between uses pluginViewModel.tvTypes.clear() - pluginViewModel.languages = listOf() - pluginViewModel.search(null) + pluginViewModel.selectedLanguages = listOf() + pluginViewModel.clear() // Filter by language set on preferred media activity?.let { val providerLangs = it.getApiProviderLangSettings().toList() if (!providerLangs.contains(AllLanguagesName)) { - pluginViewModel.languages = mutableListOf("none") + providerLangs - //Log.i("DevDebug", "providerLang => ${pluginViewModel.languages.toJson()}") + pluginViewModel.selectedLanguages = mutableListOf("none") + providerLangs } } 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) if (url == null || name == null) { - activity?.onBackPressed() + dispatchBackPressed() return } + setToolBarScrollFlags() setUpToolbar(name) - binding?.settingsToolbar?.apply { + binding.settingsToolbar.apply { setOnMenuItemClickListener { menuItem -> when (menuItem?.itemId) { R.id.download_all -> { @@ -82,24 +81,35 @@ class PluginsFragment : Fragment() { } R.id.lang_filter -> { - val tempLangs = appLanguages.toMutableList() - val languageCodes = - mutableListOf("none") + tempLangs.map { (_, _, iso) -> iso } - val languageNames = - mutableListOf(getString(R.string.no_data)) + tempLangs.map { (emoji, name, iso) -> - val flag = - emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } - "$flag $name" + val languagesTagName = pluginViewModel.pluginLanguages + .map { langTag -> + Pair( + langTag, + getNameNextToFlagEmoji(langTag) ?: langTag + ) } - val selectedList = - pluginViewModel.languages.map { languageCodes.indexOf(it) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + .toMutableList() + + // Move "none" to 1st position as it's special code to indicate unknown/missing language + if (languagesTagName.remove(Pair("none", "none"))) { + languagesTagName.add(0, Pair("none", getString(R.string.no_data))) + } + + val currentIndexList = pluginViewModel.selectedLanguages.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } + } activity?.showMultiDialog( - languageNames, - selectedList, + languagesTagName.map { it.second }, + currentIndexList, getString(R.string.provider_lang_settings), - {}) { newList -> - pluginViewModel.languages = newList.map { languageCodes[it] } + {} + ) { selectedList -> + pluginViewModel.selectedLanguages = + selectedList.map { languagesTagName[it].first } pluginViewModel.updateFilteredPlugins() } } @@ -117,7 +127,7 @@ class PluginsFragment : Fragment() { if (searchView?.isIconified == false) { searchView.isIconified = true } else { - activity?.onBackPressed() + dispatchBackPressed() } } searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> @@ -142,44 +152,48 @@ class PluginsFragment : Fragment() { // Because onActionViewCollapsed doesn't wanna work we need this workaround :( - binding?.pluginRecyclerView?.setLinearListLayout( - isHorizontal = false, - nextDown = FOCUS_SELF, - nextRight = FOCUS_SELF, - ) + binding.pluginRecyclerView.apply { + setLinearListLayout( + isHorizontal = false, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + setRecycledViewPool(PluginAdapter.sharedPool) + adapter = + PluginAdapter { + pluginViewModel.handlePluginAction(activity, url, it, isLocal) + } + } - binding?.pluginRecyclerView?.adapter = - PluginAdapter { - pluginViewModel.handlePluginAction(activity, url, it, isLocal) - } - - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { // Scrolling down does not reveal the whole RecyclerView on TV, add to bypass that. - binding?.pluginRecyclerView?.setPadding(0, 0, 0, 200.toPx) + binding.pluginRecyclerView.setPadding(0, 0, 0, 200.toPx) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> - (binding?.pluginRecyclerView?.adapter as? PluginAdapter)?.updateList(list) - - if (scrollToTop) - binding?.pluginRecyclerView?.scrollToPosition(0) + (binding.pluginRecyclerView.adapter as? PluginAdapter)?.submitList(list) + if (scrollToTop) { + binding.pluginRecyclerView.scrollToPosition(0) + } } if (isLocal) { // No download button and no categories on local - binding?.settingsToolbar?.menu?.findItem(R.id.download_all)?.isVisible = false - binding?.settingsToolbar?.menu?.findItem(R.id.lang_filter)?.isVisible = false + downloadAllButton?.isVisible = false + binding.settingsToolbar.menu?.findItem(R.id.lang_filter)?.isVisible = false pluginViewModel.updatePluginListLocal() - binding?.tvtypesChipsScroll?.root?.isVisible = false + binding.tvtypesChipsScroll.root.isVisible = false } else { pluginViewModel.updatePluginList(context, url) - binding?.tvtypesChipsScroll?.root?.isVisible = true + binding.tvtypesChipsScroll.root.isVisible = true + // not needed for users but may be useful for devs + downloadAllButton?.isVisible = BuildConfig.DEBUG bindChips( - binding?.tvtypesChipsScroll?.tvtypesChips, + binding.tvtypesChipsScroll.tvtypesChips, emptyList(), - TvType.values().toList(), + TvType.entries.toList(), callback = { list -> pluginViewModel.tvTypes.clear() pluginViewModel.tvTypes.addAll(list.map { it.name }) 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 471105be0..0cbef9cf2 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 @@ -8,22 +8,25 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType 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.RepositoryManager import com.lagradost.cloudstream3.plugins.SitePlugin -import com.lagradost.cloudstream3.ui.result.txt +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 me.xdrop.fuzzywuzzy.FuzzySearch +import com.lagradost.cloudstream3.utils.Levenshtein import java.io.File +// String => repository url typealias Plugin = Pair /** * The boolean signifies if the plugin list should be scrolled to the top, used for searching. @@ -34,13 +37,28 @@ class PluginsViewModel : ViewModel() { /** plugins is an unaltered list of plugins */ private var plugins: List = emptyList() + set(value) { + // Also set all the plugin languages for easier filtering + value.map { pluginViewData -> + val language = pluginViewData.plugin.second.language?.lowercase() + pluginLanguages.add( + when { + language.isNullOrBlank() -> "none" + else -> language.lowercase() + } + ) + // not sorting as most likely this is a language tag instead of name + } + field = value + } + var pluginLanguages = mutableSetOf() // set to avoid duplicates /** filteredPlugins is a subset of plugins following the current search query and tv type selection */ private var _filteredPlugins = MutableLiveData() var filteredPlugins: LiveData = _filteredPlugins val tvTypes = mutableListOf() - var languages = listOf() + var selectedLanguages = listOf() private var currentQuery: String? = null companion object { @@ -96,6 +114,7 @@ class PluginsViewModel : ViewModel() { R.string.batch_download_nothing_to_download_format, txt(R.string.plugin) ) + else -> txt( R.string.batch_download_start_format, list.size, @@ -109,6 +128,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -160,7 +180,8 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.name, + metadata.fileHash, + metadata.internalName, repo, isEnabled ) to message @@ -181,8 +202,15 @@ class PluginsViewModel : ViewModel() { } 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 = getPlugins(repositoryUrl) - val list = plugins.map { plugin -> + val list = plugins.filter { + // Show all non-nsfw plugins or all if nsfw is enabled + it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult + }.map { plugin -> PluginViewData(plugin, isDownloaded(context, plugin.second.internalName, plugin.first)) } @@ -197,18 +225,18 @@ class PluginsViewModel : ViewModel() { if (tvTypes.isEmpty()) return this return this.filter { (it.plugin.second.tvTypes?.any { type -> tvTypes.contains(type) } == true) || - (tvTypes.contains("Others") && (it.plugin.second.tvTypes + (tvTypes.contains(TvType.Others.name) && (it.plugin.second.tvTypes ?: emptyList()).isEmpty()) } } private fun List.filterLang(): List { - if (languages.isEmpty()) return this + if (selectedLanguages.isEmpty()) return this // do not filter return this.filter { if (it.plugin.second.language == null) { - return@filter languages.contains("none") + return@filter selectedLanguages.contains("none") } - languages.contains(it.plugin.second.language) + selectedLanguages.contains(it.plugin.second.language?.lowercase()) } } @@ -217,7 +245,12 @@ class PluginsViewModel : ViewModel() { // Return list to base state if no query this.sortedBy { it.plugin.second.name } } else { - this.sortedBy { -FuzzySearch.partialRatio(it.plugin.second.name.lowercase(), query.lowercase()) } + this.sortedBy { + -Levenshtein.partialRatio( + it.plugin.second.name.lowercase(), + query.lowercase() + ) + } } } @@ -227,6 +260,13 @@ class PluginsViewModel : ViewModel() { ) } + fun clear() { + currentQuery = null + _filteredPlugins.postValue( + false to emptyList() + ) + } + fun updatePluginList(context: Context?, repositoryUrl: String) = viewModelScope.launchSafe { if (context == null) return@launchSafe Log.i(TAG, "updatePluginList = $repositoryUrl") @@ -257,4 +297,4 @@ class PluginsViewModel : ViewModel() { false to downloadedPlugins.filterTvTypes().filterLang().sortByQuery(currentQuery) ) } -} \ No newline at end of file +} 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 602b45e44..0f9bf5f58 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,14 +2,19 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.view.LayoutInflater import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper +import com.lagradost.cloudstream3.utils.getImageFromDrawable +import com.lagradost.cloudstream3.utils.txt class RepoAdapter( val isSetup: Boolean, @@ -17,11 +22,12 @@ class RepoAdapter( val imageClickCallback: RepoAdapter.(RepositoryData) -> Unit, /** In setup mode the trash icons will be replaced with download icons */ ) : - RecyclerView.Adapter() { - private val repositories: MutableList = mutableListOf() + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.url == b.url + })) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val layout = if (isTrueTvSettings()) RepositoryItemTvBinding.inflate( + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val layout = if (isLayout(TV)) RepositoryItemTvBinding.inflate( LayoutInflater.from(parent.context), parent, false @@ -29,110 +35,97 @@ class RepoAdapter( LayoutInflater.from(parent.context), parent, false - ) //R.layout.repository_item_tv else R.layout.repository_item - return RepoViewHolder( - layout ) + return ViewHolderState(layout) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is RepoViewHolder -> { - holder.bind(repositories[position]) - } + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is RepositoryItemBinding -> clearImage(binding.entryIcon) + is RepositoryItemTvBinding -> clearImage(binding.entryIcon) } } - // Clear glide image because setImageResource doesn't override -// override fun onViewRecycled(holder: RecyclerView.ViewHolder) { -// holder.itemView.entry_icon?.let { repoIcon -> -// GlideApp.with(repoIcon).clear(repoIcon) -// } -// super.onViewRecycled(holder) -// } + 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) - ) - - 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) + 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 + ) + ) } - - actionButton.setOnClickListener { - imageClickCallback(repositoryData) - } - - repositoryItemRoot.setOnClickListener { - clickCallback(repositoryData) - } - mainText.text = repositoryData.name - subText.text = repositoryData.url + } else { + entryIcon.loadImage(R.drawable.ic_github_logo) } } + } - is RepositoryItemBinding -> { - binding.apply { - // Only shows icon if on setup or if it isn't a prebuilt repo. - // No delete buttons on prebuilt repos. - if (!isPrebuilt || isSetup) { - actionButton.setImageResource(drawable) - } + is RepositoryItemBinding -> { + binding.apply { + // Only shows icon if on setup or if it isn't a prebuilt repo. + // No delete buttons on prebuilt repos. + if (!isPrebuilt || isSetup) { + actionButton.setImageResource(drawable) + } - actionButton.setOnClickListener { - imageClickCallback(repositoryData) - } + actionButton.setOnClickListener { + imageClickCallback(item) + } - repositoryItemRoot.setOnClickListener { - clickCallback(repositoryData) + repositoryItemRoot.setOnClickListener { + clickCallback(item) + } + + repositoryItemRoot.setOnLongClickListener { + val shareableRepoData = + "${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 + ) + ) } - mainText.text = repositoryData.name - subText.text = repositoryData.url + } else { + entryIcon.loadImage(R.drawable.ic_github_logo) } } } } } -} -class RepoDiffCallback( - private val oldList: List, - private val newList: Array -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].url == newList[newItemPosition].url - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] + companion object { + const val SHAREABLE_REPO_SEPARATOR = " : " + } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt index 59b1b856c..4ec005a09 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,38 +1,35 @@ package com.lagradost.cloudstream3.ui.settings.testing -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentTestingBinding -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar - -class TestFragment : Fragment() { +class TestFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentTestingBinding::inflate) +) { private val testViewModel: TestViewModel by activityViewModels() - var binding: FragmentTestingBinding? = null - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + setSystemBarsPadding() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: FragmentTestingBinding) { setUpToolbar(R.string.category_provider_test) - super.onViewCreated(view, savedInstanceState) + setToolBarScrollFlags() - binding?.apply { - providerTestRecyclerView.adapter = TestResultAdapter( - mutableListOf() - ) + binding.apply { + providerTestRecyclerView.adapter = TestResultAdapter() testViewModel.init() if (testViewModel.isRunningTest) { @@ -43,10 +40,10 @@ class TestFragment : Fragment() { providerTest.setProgress(passed, failed, total) } - observeNullable(testViewModel.providerResults) { - normalSafeApiCall { + observe(testViewModel.providerResults) { + safe { val newItems = it.sortedBy { api -> api.first.name } - (providerTestRecyclerView.adapter as? TestResultAdapter)?.updateList( + (providerTestRecyclerView.adapter as? TestResultAdapter)?.submitList( newItems ) } @@ -60,7 +57,7 @@ class TestFragment : Fragment() { } } - if (isTrueTvSettings()) { + if (isLayout(TV)) { providerTest.playPauseButton?.isFocusableInTouchMode = true providerTest.playPauseButton?.requestFocus() } @@ -73,7 +70,7 @@ class TestFragment : Fragment() { fun focusRecyclerView() { // Hack to make it possible to focus the recyclerview. - if (isTrueTvSettings()) { + if (isLayout(TV)) { providerTestRecyclerView.requestFocus() providerTestAppbar.setExpanded(false, true) } @@ -93,13 +90,4 @@ class TestFragment : Fragment() { } } } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val localBinding = FragmentTestingBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root//inflater.inflate(R.layout.fragment_testing, container, false) - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt index 83480542a..c53ff1fcf 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 @@ -2,80 +2,129 @@ package com.lagradost.cloudstream3.ui.settings.testing import android.app.AlertDialog import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup 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 import com.lagradost.cloudstream3.databinding.ProviderTestItemBinding import com.lagradost.cloudstream3.mvvm.getAllMessages import com.lagradost.cloudstream3.mvvm.getStackTracePretty -import com.lagradost.cloudstream3.utils.AppUtils +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.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.TestingUtils +import java.io.File -class TestResultAdapter(override val items: MutableList>) : - AppUtils.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 onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ProviderTestViewHolder -> { - val item = items[position] - holder.bind(item.first, item.second) - } - } - } - - 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 - +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() } } - - fun bind(api: MainAPI, result: TestingUtils.TestResultProvider) { - languageText.text = getFlagFromIso(api.lang) - providerTitle.text = api.name - - val (resultText, resultColor) = if (result.success) { - R.string.test_passed to R.color.colorTestPass - } else { - R.string.test_failed to R.color.colorTestFail - } - - statusText.setText(resultText) - statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) - - val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } - val messages = result.exception?.getAllMessages()?.ifBlank { null } - val fullLog = - result.log + (messages?.let { "\n\n$it" } ?: "") + (stackTrace?.let { "\n\n$it" } ?: "") - - failDescription.text = messages?.lastLine() ?: result.log.lastLine() - - logButton.setOnClickListener { - val builder: AlertDialog.Builder = - AlertDialog.Builder(it.context, R.style.AlertDialogCustom) - builder.setMessage(fullLog) - .setTitle(R.string.test_log) - .show() - } - } } + 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 + ) + ) + } + + 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 + } + } else { + R.string.test_failed to R.color.colorTestFail + } + + statusText.setText(resultText) + statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) + + val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } + val messages = result.exception?.getAllMessages()?.ifBlank { null } + val resultLog = result.log.joinToString("\n") + val fullLog = + resultLog + + (messages?.let { "\n\nError: $it" } ?: "") + + (stackTrace?.let { "\n\n$it" } ?: "") + + failDescription.text = messages?.lastLine() ?: resultLog.lastLine() + + logButton.setOnClickListener { + val builder: AlertDialog.Builder = + AlertDialog.Builder(it.context, R.style.AlertDialogCustom) + builder.setMessage(fullLog) + .setTitle(R.string.test_log) + // Ok button just closes the dialog + .setPositiveButton(R.string.ok) { _, _ -> } + + api.sourcePlugin?.let { path -> + val pluginFile = File(path) + // Cannot delete a deleted plugin + if (!pluginFile.exists()) return@let + + builder.setNegativeButton(R.string.delete_plugin) { _, _ -> + ioSafe { + val success = PluginManager.deletePlugin(pluginFile) + + runOnMainThread { + if (success) { + showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT) + } else { + showToast(R.string.error, Toast.LENGTH_SHORT) + } + } + } + } + } + + 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 26513f4a1..65ed47a54 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,11 +9,12 @@ 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 import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.AppUtils.animateProgressTo +import com.lagradost.cloudstream3.utils.AppContextUtils.animateProgressTo class TestView @JvmOverloads constructor( context: Context, @@ -59,10 +60,9 @@ class TestView @JvmOverloads constructor( playPauseButton = findViewById(R.id.tests_play_pause) attrs?.let { - val typedArray = context.obtainStyledAttributes(it, R.styleable.TestView) - val headerText = typedArray.getString(R.styleable.TestView_header_text) - mainSectionHeader?.text = headerText - typedArray.recycle() + context.withStyledAttributes(it, R.styleable.TestView) { + mainSectionHeader?.text = getString(R.styleable.TestView_header_text) + } } playPauseButton?.setOnClickListener { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt index 4fd24afed..22500d931 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,12 +5,11 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel -import okhttp3.internal.toImmutableList class TestViewModel : ViewModel() { data class TestProgress( @@ -41,7 +40,7 @@ class TestViewModel : ViewModel() { get() = scope != null private var filter = ProviderFilter.All - private val providers = threadSafeListOf>() + private val providers = atomicListOf>() private var passed = 0 private var failed = 0 private var total = 0 @@ -52,9 +51,9 @@ class TestViewModel : ViewModel() { } private fun postProviders() { - synchronized(providers) { + providers.withLock { val filtered = when (filter) { - ProviderFilter.All -> providers + ProviderFilter.All -> providers.toList() ProviderFilter.Passed -> providers.filter { it.second.success } ProviderFilter.Failed -> providers.filter { !it.second.success } } @@ -69,7 +68,7 @@ class TestViewModel : ViewModel() { } private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) { - synchronized(providers) { + providers.withLock { val index = providers.indexOfFirst { it.first == api } if (index == -1) { providers.add(api to results) @@ -82,21 +81,21 @@ class TestViewModel : ViewModel() { } fun init() { - total = synchronized(APIHolder.allProviders) { APIHolder.allProviders.size } + total = APIHolder.allProviders.withLock { APIHolder.allProviders.size } updateProgress() } fun startTest() { scope = CoroutineScope(Dispatchers.Default) - val apis = synchronized(APIHolder.allProviders) { APIHolder.allProviders.toTypedArray() } + val apis = APIHolder.allProviders.withLock { APIHolder.allProviders.toTypedArray() } total = apis.size failed = 0 passed = 0 providers.clear() updateProgress() - TestingUtils.getDeferredProviderTests(scope ?: return, apis, ::println) { api, result -> + TestingUtils.getDeferredProviderTests(scope ?: return, apis) { api, result -> addProvider(api, result) } } 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 new file mode 100644 index 000000000..dfc931174 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.ui.settings.utils + +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.safefile.SafeFile + +fun Fragment.getChooseFolderLauncher(dirSelected: (uri: Uri?, path: String?) -> Unit) = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + // It lies, it can be null if file manager quits. + if(uri == null) { + dirSelected(null, null) + return@registerForActivityResult + } + val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult + // RW perms for the path + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, flags) + + val filePath = SafeFile.fromUri(context, uri)?.filePath() + println("Selected URI path: $uri - Full path: $filePath") + + // store the actual URI instead of the path due to permissions. + // filePath should only be used for cosmetic purposes. + dirSelected(uri, filePath) + } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt index 4369b22f9..8c2e8e344 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,26 +1,25 @@ 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.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel import com.lagradost.cloudstream3.ui.settings.extensions.RepoAdapter import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding - -class SetupFragmentExtensions : Fragment() { +class SetupFragmentExtensions : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupExtensionsBinding::inflate) +) { companion object { const val SETUP_EXTENSION_BUNDLE_IS_SETUP = "isSetup" @@ -34,24 +33,6 @@ class SetupFragmentExtensions : Fragment() { } } - var binding: FragmentSetupExtensionsBinding? = null - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val localBinding = FragmentSetupExtensionsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_setup_extensions, container, false) - } - - override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::setRepositories @@ -62,18 +43,21 @@ class SetupFragmentExtensions : Fragment() { afterRepositoryLoadedEvent -= ::setRepositories } + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) + } + private fun setRepositories(success: Boolean = true) { main { val repositories = RepositoryManager.getRepositories() + PREBUILT_REPOSITORIES val hasRepos = repositories.isNotEmpty() binding?.repoRecyclerView?.isVisible = hasRepos binding?.blankRepoScreen?.isVisible = !hasRepos -// view_public_repositories_button?.isVisible = hasRepos if (hasRepos) { binding?.repoRecyclerView?.adapter = RepoAdapter(true, {}, { PluginsViewModel.downloadAll(activity, it.url, null) - }).apply { updateList(repositories) } + }).apply { submitList(repositories.toList()) } } // else { // list_repositories?.setOnClickListener { @@ -84,19 +68,12 @@ class SetupFragmentExtensions : Fragment() { } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) + override fun onBindingCreated(binding: FragmentSetupExtensionsBinding) { val isSetup = arguments?.getBoolean(SETUP_EXTENSION_BUNDLE_IS_SETUP) ?: false -// view_public_repositories_button?.setOnClickListener { -// openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) -// } - - normalSafeApiCall { - // val ctx = context ?: return@normalSafeApiCall + safe { setRepositories() - binding?.apply { + binding.apply { if (!isSetup) { nextBtt.setText(R.string.setup_done) } @@ -107,7 +84,7 @@ class SetupFragmentExtensions : Fragment() { if (isSetup) if ( // If any available languages - synchronized(apis) { apis.distinctBy { it.lang }.size > 1 } + apis.distinctBy { it.lang }.size > 1 ) { findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) } else { @@ -123,6 +100,4 @@ class SetupFragmentExtensions : Fragment() { } } } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt index 5c473b731..e96a662c3 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,90 +1,71 @@ package com.lagradost.cloudstream3.ui.setup -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment +import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity -import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.getCurrentLocale -import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.ui.settings.nameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding const val HAS_DONE_SETUP_KEY = "HAS_DONE_SETUP" -class SetupFragmentLanguage : Fragment() { - var binding: FragmentSetupLanguageBinding? = null +class SetupFragmentLanguage : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupLanguageBinding::inflate) +) { - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val localBinding = FragmentSetupLanguageBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_setup_language, container, false) - } - - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - + override fun onBindingCreated(binding: FragmentSetupLanguageBinding) { // We don't want a crash for all users - normalSafeApiCall { - fixPaddingStatusbar(binding?.setupRoot) - - val ctx = context ?: return@normalSafeApiCall + safe { + val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - binding?.apply { + binding.apply { // Icons may crash on some weird android versions? - normalSafeApiCall { + safe { val drawable = when { BuildConfig.DEBUG -> R.drawable.cloud_2_gradient_debug - BuildConfig.BUILD_TYPE == "prerelease" -> R.drawable.cloud_2_gradient_beta + BuildConfig.FLAVOR == "prerelease" -> R.drawable.cloud_2_gradient_beta else -> R.drawable.cloud_2_gradient } appIconImage.setImageDrawable(ContextCompat.getDrawable(ctx, drawable)) } val current = getCurrentLocale(ctx) - val languageCodes = appLanguages.map { it.third } - val languageNames = appLanguages.map { (emoji, name, iso) -> - val flag = emoji.ifBlank { SubtitleHelper.getFlagFromIso(iso) ?: "ERROR" } - "$flag $name" - } - val index = languageCodes.indexOf(current) + val languageTagsIETF = appLanguages.map { it.second } + val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } + val currentIndex = languageTagsIETF.indexOf(current) arrayAdapter.addAll(languageNames) listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1.setItemChecked(index, true) + listview1.setItemChecked(currentIndex, true) - listview1.setOnItemClickListener { _, _, position, _ -> - val code = languageCodes[position] - CommonActivity.setLocale(activity, code) - settingsManager.edit().putString(getString(R.string.locale_key), code) - .apply() - activity?.recreate() + listview1.setOnItemClickListener { _, _, selectedLangIndex, _ -> + val langTagIETF = languageTagsIETF[selectedLangIndex] + CommonActivity.setLocale(activity, langTagIETF) + settingsManager.edit { + putString(getString(R.string.locale_key), langTagIETF) + } } nextBtt.setOnClickListener { @@ -103,12 +84,10 @@ class SetupFragmentLanguage : Fragment() { } skipBtt.setOnClickListener { + setKey(HAS_DONE_SETUP_KEY, true) findNavController().navigate(R.id.navigation_home) } } - } } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt index 988038180..4a8e784a1 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,46 +1,29 @@ package com.lagradost.cloudstream3.ui.setup -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter -import androidx.fragment.app.Fragment +import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import org.acra.ACRA +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +class SetupFragmentLayout : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupLayoutBinding::inflate) +) { -class SetupFragmentLayout : Fragment() { - - var binding: FragmentSetupLayoutBinding? = null - - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val localBinding = FragmentSetupLayoutBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_setup_layout, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) - - normalSafeApiCall { - val ctx = context ?: return@normalSafeApiCall + override fun onBindingCreated(binding: FragmentSetupLayoutBinding) { + safe { + val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) @@ -54,7 +37,7 @@ class SetupFragmentLayout : Fragment() { ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(prefNames.toList()) - binding?.apply { + binding.apply { listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE listview1.setItemChecked( @@ -62,30 +45,14 @@ class SetupFragmentLayout : Fragment() { ) listview1.setOnItemClickListener { _, _, position, _ -> - settingsManager.edit() - .putInt(getString(R.string.app_layout_key), prefValues[position]) - .apply() + settingsManager.edit { + putInt(getString(R.string.app_layout_key), prefValues[position]) + } activity?.recreate() } - acraSwitch.setOnCheckedChangeListener { _, enableCrashReporting -> - // Use same pref as in settings - settingsManager.edit().putBoolean(ACRA.PREF_DISABLE_ACRA, !enableCrashReporting) - .apply() - val text = - if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on - crashReportingText.text = getText(text) - } - - val enableCrashReporting = !settingsManager.getBoolean(ACRA.PREF_DISABLE_ACRA, true) - - acraSwitch.isChecked = enableCrashReporting - crashReportingText.text = - getText( - if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on - ) - nextBtt.setOnClickListener { + setKey(HAS_DONE_SETUP_KEY, true) findNavController().navigate(R.id.navigation_home) } @@ -95,4 +62,4 @@ class SetupFragmentLayout : Fragment() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 6916cafe2..8da121daa 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,49 +1,31 @@ package com.lagradost.cloudstream3.ui.setup -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter +import androidx.core.content.edit import androidx.core.util.forEach -import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +class SetupFragmentMedia : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupMediaBinding::inflate) +) { -class SetupFragmentMedia : Fragment() { - var binding: FragmentSetupMediaBinding? = null - - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val localBinding = 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 + override fun onBindingCreated(binding: FragmentSetupMediaBinding) { + safe { + val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = @@ -53,7 +35,7 @@ class SetupFragmentMedia : Fragment() { val selected = mutableListOf() arrayAdapter.addAll(names) - binding?.apply { + binding.apply { listview1.let { it.adapter = arrayAdapter it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE @@ -72,12 +54,12 @@ class SetupFragmentMedia : Fragment() { val itemVal = TvType.valueOf(item) itemVal.ordinal.toString() }.toSet() - settingsManager.edit() - .putStringSet(getString(R.string.prefer_media_type_key), prefValues) - .apply() + settingsManager.edit { + putStringSet(getString(R.string.prefer_media_type_key), prefValues) + } // Regenerate set homepage - removeKey(USER_SELECTED_HOMEPAGE_API) + DataStoreHelper.currentHomePage = null } } @@ -91,4 +73,4 @@ class SetupFragmentMedia : Fragment() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt index 59dcc4023..c18be8a2f 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,100 +1,80 @@ package com.lagradost.cloudstream3.ui.setup -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter +import androidx.core.content.edit import androidx.core.util.forEach -import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.AllLanguagesName -import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBinding -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding -class SetupFragmentProviderLanguage : Fragment() { - var binding: FragmentSetupProviderLanguagesBinding? = null +class SetupFragmentProviderLanguage : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupProviderLanguagesBinding::inflate) +) { - override fun onDestroyView() { - binding = null - super.onDestroyView() + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val localBinding = FragmentSetupProviderLanguagesBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root - //return inflater.inflate(R.layout.fragment_setup_provider_languages, container, false) - } - - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixPaddingStatusbar(binding?.setupRoot) - - normalSafeApiCall { - val ctx = context ?: return@normalSafeApiCall + override fun onBindingCreated(binding: FragmentSetupProviderLanguagesBinding) { + safe { + val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - val current = ctx.getApiProviderLangSettings() - val langs = synchronized(APIHolder.apis) { APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName} + val currentLangTags = ctx.getApiProviderLangSettings() - val currentList = - current.map { langs.indexOf(it) }.filter { it != -1 } // TODO LOOK INTO + 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 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" + 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() } } - - 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 ffb593a08..f9b1cb1fe 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,28 +7,34 @@ import android.graphics.Color import android.os.Bundle import android.util.DisplayMetrics import android.util.TypedValue -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.TextView import android.widget.Toast -import androidx.fragment.app.Fragment -import com.fasterxml.jackson.annotation.JsonProperty +import androidx.annotation.OptIn import androidx.media3.common.text.Cue -import com.google.android.gms.cast.TextTrackStyle -import com.google.android.gms.cast.TextTrackStyle.* +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 +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_NONE +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_OUTLINE +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_RAISED import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.ChromecastSubtitleSettingsBinding -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage @@ -40,13 +46,15 @@ data class SaveChromeCaptionStyle( @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 = TextTrackStyle.EDGE_TYPE_OUTLINE, + @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 : Fragment() { +class ChromecastSubtitlesFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(ChromecastSubtitleSettingsBinding::inflate) +) { companion object { val applyStyleEvent = Event() @@ -97,7 +105,7 @@ class ChromecastSubtitlesFragment : Fragment() { } private fun onColorSelected(stuff: Pair) { - context?.setColor(stuff.first, stuff.second) + setColor(stuff.first, stuff.second) if (hide) activity?.hideSystemUI() } @@ -120,7 +128,7 @@ class ChromecastSubtitlesFragment : Fragment() { return if (color == Color.TRANSPARENT) Color.BLACK else color } - private fun Context.setColor(id: Int, color: Int?) { + private fun setColor(id: Int, color: Int?) { val realColor = color ?: getDefColor(id) when (id) { 0 -> state.foregroundColor = realColor @@ -133,27 +141,10 @@ class ChromecastSubtitlesFragment : Fragment() { updateState() } - private fun Context.updateState() { + private fun updateState() { //subtitle_text?.setStyle(fromSaveToStyle(state)) } - var binding : ChromecastSubtitleSettingsBinding? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - val localBinding = ChromecastSubtitleSettingsBinding.inflate(inflater, container, false) - binding = localBinding - return localBinding.root//inflater.inflate(R.layout.chromecast_subtitle_settings, container, false) - } - - override fun onDestroyView() { - binding = null - super.onDestroyView() - } - private lateinit var state: SaveChromeCaptionStyle private var hide: Boolean = true @@ -162,26 +153,29 @@ class ChromecastSubtitlesFragment : Fragment() { onColorSelectedEvent -= ::onColorSelected } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } + + override fun onBindingCreated(binding: ChromecastSubtitleSettingsBinding) { hide = arguments?.getBoolean("hide") ?: true onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed - fixPaddingStatusbar(binding?.subsRoot) - state = getCurrentSavedStyle() - context?.updateState() - - val isTvSettings = isTvSettings() + updateState() + val isTvSettings = isLayout(TV or EMULATOR) fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvSettings } fun View.setup(id: Int) { setFocusableInTv() - this.setOnClickListener { activity?.let { ColorPickerDialog.newBuilder() @@ -193,26 +187,25 @@ class ChromecastSubtitlesFragment : Fragment() { } this.setOnLongClickListener { - it.context.setColor(id, null) + setColor(id, null) showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } } - binding?.apply { + binding.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) } - val dismissCallback = { if (hide) activity?.hideSystemUI() } - binding?.subsEdgeType?.setFocusableInTv() - binding?.subsEdgeType?.setOnClickListener { textView -> + binding.subsEdgeType.setFocusableInTv() + binding.subsEdgeType.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, @@ -245,19 +238,19 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.edgeType = edgeTypes.map { it.first }[index] - textView.context.updateState() + updateState() } } - binding?.subsEdgeType?.setOnLongClickListener { + binding.subsEdgeType.setOnLongClickListener { state.edgeType = defaultState.edgeType - it.context.updateState() + updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding?.subsFontSize?.setFocusableInTv() - binding?.subsFontSize?.setOnClickListener { textView -> + binding.subsFontSize.setFocusableInTv() + binding.subsFontSize.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), @@ -290,17 +283,15 @@ class ChromecastSubtitlesFragment : Fragment() { } } - binding?.subsFontSize?.setOnLongClickListener { _ -> + binding.subsFontSize.setOnLongClickListener { _ -> state.fontScale = defaultState.fontScale //textView.context.updateState() // font size not changed showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - - - binding?.subsFont?.setFocusableInTv() - binding?.subsFont?.setOnClickListener { textView -> + binding.subsFont.setFocusableInTv() + binding.subsFont.setOnClickListener { textView -> val fontTypes = listOf( null to textView.context.getString(R.string.normal), "Droid Sans" to "Droid Sans", @@ -321,27 +312,33 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.fontFamily = fontTypes.map { it.first }[index] - textView.context.updateState() + updateState() } } - binding?.subsFont?.setOnLongClickListener { textView -> + binding.subsFont.setOnLongClickListener { _ -> state.fontFamily = defaultState.fontFamily - textView.context.updateState() + updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - binding?.cancelBtt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { activity?.popCurrentPage() } - binding?.applyBtt?.setOnClickListener { + binding.applyBtt.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) //it.context.fromSaveToStyle(state) activity?.popCurrentPage() } - binding?.subtitleText?.apply { + + setSubtitleCues(binding) + } + + @OptIn(UnstableApi::class) + private fun setSubtitleCues(binding: ChromecastSubtitleSettingsBinding) { + binding.subtitleText.apply { setCues( listOf( Cue.Builder() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index f053023d7..5f716cca3 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,38 +6,54 @@ import android.content.res.Resources import android.graphics.Color import android.graphics.Typeface import android.os.Bundle +import android.text.Layout +import android.text.Spannable +import android.text.SpannableString +import android.text.style.StyleSpan import android.util.DisplayMetrics import android.util.TypedValue -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.TextView import android.widget.Toast import androidx.annotation.FontRes +import androidx.annotation.OptIn +import androidx.annotation.Px +import androidx.core.content.edit import androidx.core.content.res.ResourcesCompat -import androidx.fragment.app.Fragment +import androidx.media3.common.text.Cue +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import androidx.media3.common.text.Cue -import androidx.media3.ui.CaptionStyleCompat import com.jaredrummler.android.colorpicker.ColorPickerDialog -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.ui.BaseDialogFragment +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.player.CustomDecoder +import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.setSubtitleAlignment +import com.lagradost.cloudstream3.ui.player.OutlineSpan +import com.lagradost.cloudstream3.ui.player.RoundedBackgroundColorSpan +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.SubtitleHelper.languages +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.toPx import java.io.File const val SUBTITLE_KEY = "subtitle_settings" @@ -48,6 +64,7 @@ data class SaveCaptionStyle( @JsonProperty("foregroundColor") var foregroundColor: Int, @JsonProperty("backgroundColor") var backgroundColor: Int, @JsonProperty("windowColor") var windowColor: Int, + @OptIn(UnstableApi::class) @JsonProperty("edgeType") var edgeType: @CaptionStyleCompat.EdgeType Int, @JsonProperty("edgeColor") var edgeColor: Int, @FontRes @@ -57,22 +74,149 @@ data class SaveCaptionStyle( @JsonProperty("elevation") var elevation: Int, /**in sp**/ @JsonProperty("fixedTextSize") var fixedTextSize: Float?, + @Px + @JsonProperty("edgeSize") var edgeSize: Float? = null, @JsonProperty("removeCaptions") var removeCaptions: Boolean = false, @JsonProperty("removeBloat") var removeBloat: Boolean = true, /** Apply caps lock to the text **/ @JsonProperty("upperCase") var upperCase: Boolean = false, + /** Apply bold to the text **/ + @JsonProperty("bold") var bold: Boolean = false, + /** Apply italic to the text **/ + @JsonProperty("italic") var italic: Boolean = false, + /** in px, background radius, aka how round the background (backgroundColor) on each row is **/ + @JsonProperty("backgroundRadius") var backgroundRadius: Float? = null, + /** The SSA_ALIGNMENT */ + @JsonProperty("alignment") var alignment: Int? = null, ) const val DEF_SUBS_ELEVATION = 20 -class SubtitlesFragment : Fragment() { +@OptIn(UnstableApi::class) +class SubtitlesFragment : BaseDialogFragment( + BaseFragment.BindingCreator.Inflate(SubtitleSettingsBinding::inflate) +) { companion object { val applyStyleEvent = Event() + private val captionRegex = Regex("""(-\s?|)[\[({][\S\s]*?[])}]\s*""") - fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { + fun setSubtitleViewStyle( + view: SubtitleView?, + data: SaveCaptionStyle, + applyElevation: Boolean + ) { + if (view == null) return + val ctx = view.context ?: return + val style = ctx.fromSaveToStyle(data) + view.setStyle(style) + + if (applyElevation) { + view.setPadding( + view.paddingLeft, data.elevation.toPx, view.paddingRight, view.paddingBottom + ) + } + + // we default to 25sp, this is needed as RoundedBackgroundColorSpan breaks on override sizes + val size = data.fixedTextSize ?: 25.0f + view.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) + view.setBottomPaddingFraction(0.0f) + /*if (size != null) { + view.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) + } else { + view.setUserDefaultTextSize() + }*/ + } + + fun Cue.Builder.applyStyle(style: SaveCaptionStyle): Cue.Builder { + val edgeSize = style.edgeSize + + /* + This is old code for only applying on non null + + val fixedFontSize = style.fixedTextSize + val absoluteFontSize = + fixedFontSize?.let { getPixels(TypedValue.COMPLEX_UNIT_SP, it).toFloat() } + + // 1. apply override size + if (absoluteFontSize != null) { + setTextSize(absoluteFontSize, Cue.TEXT_SIZE_TYPE_ABSOLUTE) + }*/ + + // 1. remove any subtitle size set by the subtitle file (like ass) + // instead we use the inherit size of the subtitle view + setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET) + + // 2. apply edge + text?.let { text -> + val customSpan = SpannableString.valueOf(text) + if (edgeSize != null) { + customSpan.setSpan( + OutlineSpan(edgeSize), 0, customSpan.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + setText(customSpan) + } + + // 3. apply bold + italic + text?.let { text -> + val customSpan = SpannableString.valueOf(text) + + val typeface = when (style.bold to style.italic) { + (true to true) -> Typeface.BOLD_ITALIC + (true to false) -> Typeface.BOLD + (false to true) -> Typeface.ITALIC + (false to false) -> Typeface.NORMAL + else -> { + Typeface.NORMAL + } + } + if (typeface != Typeface.NORMAL) { + val styleSpan = StyleSpan(typeface) + customSpan.setSpan( + styleSpan, 0, customSpan.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + setText(customSpan) + } + + // 4. apply radius + text?.let { text -> + val customSpan = SpannableString.valueOf(text) + val radius = style.backgroundRadius + + if (radius != null && style.backgroundColor != Color.TRANSPARENT) { + val styleSpan = RoundedBackgroundColorSpan( + style.backgroundColor, + this.textAlignment ?: Layout.Alignment.ALIGN_CENTER, + 2.0F + radius * 0.5f, + radius + ) + customSpan.setSpan( + styleSpan, 0, customSpan.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + setText(customSpan) + } + + // 5. remove captions + text?.let { text -> + if (style.removeCaptions) { + setText(text.replace(captionRegex, "")) + } + } + + // 6. set alignment + return this.setSubtitleAlignment(style.alignment) + } + + private fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { return CaptionStyleCompat( data.foregroundColor, - data.backgroundColor, + // we actually override with a custom span when backgroundRadius != null + if (data.backgroundRadius == null) data.backgroundColor else Color.TRANSPARENT, data.windowColor, data.edgeType, data.edgeColor, @@ -96,6 +240,7 @@ class SubtitlesFragment : Fragment() { fun push(activity: Activity?, hide: Boolean = true) { activity.navigate(R.id.global_to_navigation_subtitles, Bundle().apply { putBoolean("hide", hide) + putBoolean("popFragment", true) }) } @@ -109,22 +254,25 @@ class SubtitlesFragment : Fragment() { } } + private var cachedSubtitleStyle: SaveCaptionStyle? = null + fun Context.saveStyle(style: SaveCaptionStyle) { + cachedSubtitleStyle = style this.setKey(SUBTITLE_KEY, style) } fun getCurrentSavedStyle(): SaveCaptionStyle { - return getKey(SUBTITLE_KEY) ?: SaveCaptionStyle( - getDefColor(0), - getDefColor(2), - getDefColor(3), - CaptionStyleCompat.EDGE_TYPE_OUTLINE, - getDefColor(1), - null, - null, - DEF_SUBS_ELEVATION, - null, - ) + return cachedSubtitleStyle ?: (getKey(SUBTITLE_KEY) ?: SaveCaptionStyle( + foregroundColor = getDefColor(0), + backgroundColor = getDefColor(2), + windowColor = getDefColor(3), + edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE, + edgeColor = getDefColor(1), + typeface = null, + typefaceFilePath = null, + elevation = DEF_SUBS_ELEVATION, + fixedTextSize = null, + )).also { cachedSubtitleStyle = it } } private fun Context.getSavedFonts(): List { @@ -140,20 +288,16 @@ class SubtitlesFragment : Fragment() { } ?: 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 getDownloadSubsLanguageISO639_1(): List { + fun getDownloadSubsLanguageTagIETF(): List { return getKey(SUBTITLE_DOWNLOAD_KEY) ?: listOf("en") } - fun getAutoSelectLanguageISO639_1(): String { + fun getAutoSelectLanguageTagIETF(): String { return getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" } } @@ -164,7 +308,7 @@ class SubtitlesFragment : Fragment() { activity?.hideSystemUI() } - private fun onDialogDismissed(id: Int) { + private fun onDialogDismissed(@Suppress("UNUSED_PARAMETER") id: Int) { if (hide) activity?.hideSystemUI() } @@ -183,17 +327,15 @@ class SubtitlesFragment : Fragment() { } private fun Context.updateState() { - binding?.subtitleText?.setStyle(fromSaveToStyle(state)) val text = getString(R.string.subtitles_example_text) - val fixedText = if (state.upperCase) text.uppercase() else text + val fixedText = SpannableString.valueOf(if (state.upperCase) text.uppercase() else text) + setSubtitleViewStyle(binding?.subtitleText, state, false) + 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() ) ) @@ -212,23 +354,6 @@ class SubtitlesFragment : Fragment() { 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 @@ -237,22 +362,37 @@ class SubtitlesFragment : Fragment() { onColorSelectedEvent -= ::onColorSelected } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onStart() { + super.onStart() + dialog?.window?.setWindowAnimations(R.style.DialogFullscreenPlayer) + } + + override fun getTheme(): Int { + return R.style.DialogFullscreenPlayer + } + + var systemBarsAddPadding = isLayout(TV or EMULATOR) + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = systemBarsAddPadding || isLandscape(), + padLeft = systemBarsAddPadding + ) + } + + override fun onBindingCreated(binding: SubtitleSettingsBinding) { 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 = isTrueTvSettings() - + val isTvTrueSettings = isLayout(TV) fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvTrueSettings } @@ -276,7 +416,7 @@ class SubtitlesFragment : Fragment() { return@setOnLongClickListener true } } - binding?.apply { + binding.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) @@ -289,20 +429,13 @@ class SubtitlesFragment : Fragment() { subsSubtitleElevation.setFocusableInTv() subsSubtitleElevation.setOnClickListener { textView -> - val suffix = "dp" + // tbh this should not be a dialog if it has so many values val elevationTypes = listOf( - Pair(0, textView.context.getString(R.string.none)), - Pair(10, "10$suffix"), - Pair(20, "20$suffix"), - Pair(30, "30$suffix"), - Pair(40, "40$suffix"), - Pair(50, "50$suffix"), - Pair(60, "60$suffix"), - Pair(70, "70$suffix"), - Pair(80, "80$suffix"), - Pair(90, "90$suffix"), - Pair(100, "100$suffix"), - ) + 0 to textView.context.getString(R.string.none) + ) + (1..40).map { x -> + val i = x * 10 + i to "${i}dp" + } //showBottomDialog activity?.showDialog( @@ -326,29 +459,75 @@ class SubtitlesFragment : Fragment() { 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( - Pair( - CaptionStyleCompat.EDGE_TYPE_NONE, - textView.context.getString(R.string.subtitles_none) - ), - Pair( - CaptionStyleCompat.EDGE_TYPE_OUTLINE, - textView.context.getString(R.string.subtitles_outline) - ), - Pair( - CaptionStyleCompat.EDGE_TYPE_DEPRESSED, - textView.context.getString(R.string.subtitles_depressed) - ), - Pair( - CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW, - textView.context.getString(R.string.subtitles_shadow) - ), - Pair( - CaptionStyleCompat.EDGE_TYPE_RAISED, - textView.context.getString(R.string.subtitles_raised) - ), + CaptionStyleCompat.EDGE_TYPE_NONE to + textView.context.getString(R.string.subtitles_none), + CaptionStyleCompat.EDGE_TYPE_OUTLINE to + textView.context.getString(R.string.subtitles_outline), + CaptionStyleCompat.EDGE_TYPE_DEPRESSED to + textView.context.getString(R.string.subtitles_depressed), + CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW to + textView.context.getString(R.string.subtitles_shadow), + CaptionStyleCompat.EDGE_TYPE_RAISED to + textView.context.getString(R.string.subtitles_raised), ) //showBottomDialog @@ -373,42 +552,9 @@ class SubtitlesFragment : Fragment() { subsFontSize.setFocusableInTv() subsFontSize.setOnClickListener { textView -> - val suffix = "sp" val fontSizes = listOf( - Pair(null, textView.context.getString(R.string.normal)), - Pair(6f, "6$suffix"), - Pair(7f, "7$suffix"), - Pair(8f, "8$suffix"), - Pair(9f, "9$suffix"), - Pair(10f, "10$suffix"), - Pair(11f, "11$suffix"), - Pair(12f, "12$suffix"), - Pair(13f, "13$suffix"), - Pair(14f, "14$suffix"), - Pair(15f, "15$suffix"), - Pair(16f, "16$suffix"), - Pair(17f, "17$suffix"), - Pair(18f, "18$suffix"), - Pair(19f, "19$suffix"), - Pair(20f, "20$suffix"), - Pair(21f, "21$suffix"), - Pair(22f, "22$suffix"), - Pair(23f, "23$suffix"), - Pair(24f, "24$suffix"), - Pair(25f, "25$suffix"), - Pair(26f, "26$suffix"), - Pair(28f, "28$suffix"), - Pair(30f, "30$suffix"), - Pair(32f, "32$suffix"), - Pair(34f, "34$suffix"), - Pair(36f, "36$suffix"), - Pair(38f, "38$suffix"), - Pair(40f, "40$suffix"), - Pair(42f, "42$suffix"), - Pair(44f, "44$suffix"), - Pair(48f, "48$suffix"), - Pair(60f, "60$suffix"), - ) + null to textView.context.getString(R.string.normal), + ) + (6..60).map { i -> i.toFloat() to "${i}sp" } //showBottomDialog activity?.showDialog( @@ -419,7 +565,26 @@ class SubtitlesFragment : Fragment() { dismissCallback ) { index -> state.fixedTextSize = fontSizes.map { it.first }[index] - //textView.context.updateState() // font size not changed + textView.context.updateState() + } + } + + subsEdgeSize.setFocusableInTv() + subsEdgeSize.setOnClickListener { textView -> + val fontSizes = listOf( + null to textView.context.getString(R.string.normal), + ) + (1..60).map { i -> i.toFloat() to "${i}px" } + + //showBottomDialog + activity?.showDialog( + fontSizes.map { it.second }, + fontSizes.map { it.first }.indexOf(state.edgeSize), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.edgeSize = fontSizes.map { it.first }[index] + textView.context.updateState() } } @@ -438,9 +603,28 @@ class SubtitlesFragment : Fragment() { 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 - //textView.context.updateState() // font size not changed + context?.updateState() + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + subsEdgeSize.setOnLongClickListener { _ -> + state.edgeSize = null + context?.updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } @@ -454,31 +638,30 @@ class SubtitlesFragment : Fragment() { subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx) - .edit() - .putBoolean(getString(R.string.filter_sub_lang_key), b) - .apply() + PreferenceManager.getDefaultSharedPreferences(ctx).edit { + putBoolean(getString(R.string.filter_sub_lang_key), b) + } } } subsFont.setFocusableInTv() subsFont.setOnClickListener { textView -> val fontTypes = listOf( - Pair(null, textView.context.getString(R.string.normal)), - Pair(R.font.trebuchet_ms, "Trebuchet MS"), - Pair(R.font.netflix_sans, "Netflix Sans"), - Pair(R.font.google_sans, "Google Sans"), - Pair(R.font.open_sans, "Open Sans"), - Pair(R.font.futura, "Futura"), - Pair(R.font.consola, "Consola"), - Pair(R.font.gotham, "Gotham"), - Pair(R.font.lucida_grande, "Lucida Grande"), - Pair(R.font.stix_general, "STIX General"), - Pair(R.font.times_new_roman, "Times New Roman"), - Pair(R.font.verdana, "Verdana"), - Pair(R.font.ubuntu_regular, "Ubuntu"), - Pair(R.font.comic_sans, "Comic Sans"), - Pair(R.font.poppins_regular, "Poppins"), + null to textView.context.getString(R.string.normal), + R.font.trebuchet_ms to "Trebuchet MS", + R.font.netflix_sans to "Netflix Sans", + R.font.google_sans to "Google Sans", + R.font.open_sans to "Open Sans", + R.font.futura to "Futura", + R.font.consola to "Consola", + R.font.gotham to "Gotham", + R.font.lucida_grande to "Lucida Grande", + R.font.stix_general to "STIX General", + R.font.times_new_roman to "Times New Roman", + R.font.verdana to "Verdana", + R.font.ubuntu_regular to "Ubuntu", + R.font.comic_sans to "Comic Sans", + R.font.poppins_regular to "Poppins", ) val savedFontTypes = textView.context.getSavedFonts() @@ -519,28 +702,29 @@ class SubtitlesFragment : Fragment() { subsAutoSelectLanguage.setFocusableInTv() subsAutoSelectLanguage.setOnClickListener { textView -> - val langMap = arrayListOf( - SubtitleHelper.Language639( - textView.context.getString(R.string.none), - textView.context.getString(R.string.none), - "", - "", - "", - "", - "" - ), - ) - langMap.addAll(SubtitleHelper.languages) + val languagesTagName = + listOf( + Pair( + textView.context.getString(R.string.none), + textView.context.getString(R.string.none) + ) + ) + + languages + .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + + val (langTagsIETF, langNames) = languagesTagName.unzip() - val lang639_1 = langMap.map { it.ISO_639_1 } activity?.showDialog( - langMap.map { it.languageName }, - lang639_1.indexOf(getAutoSelectLanguageISO639_1()), + langNames, + langTagsIETF.indexOf(getAutoSelectLanguageTagIETF()), (textView as TextView).text.toString(), true, dismissCallback ) { index -> - setKey(SUBTITLE_AUTO_SELECT_KEY, lang639_1[index]) + setKey(SUBTITLE_AUTO_SELECT_KEY, langTagsIETF[index]) } } @@ -552,18 +736,26 @@ class SubtitlesFragment : Fragment() { subsDownloadLanguages.setFocusableInTv() subsDownloadLanguages.setOnClickListener { textView -> - val langMap = SubtitleHelper.languages - val lang639_1 = langMap.map { it.ISO_639_1 } - val keys = getDownloadSubsLanguageISO639_1() - val keyMap = keys.map { lang639_1.indexOf(it) }.filter { it >= 0 } + val languagesTagName = + languages + .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + + val (langTagsIETF, langNames) = languagesTagName.unzip() + + val selectedLanguages = getDownloadSubsLanguageTagIETF() + .map { langTagsIETF.indexOf(it) } + .filter { it >= 0 } activity?.showMultiDialog( - langMap.map { it.languageName }, - keyMap, + langNames, + selectedLanguages, (textView as TextView).text.toString(), dismissCallback ) { indexList -> - setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { lang639_1[it] }.toList()) + setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { langTagsIETF[it] }.toList()) } } @@ -575,14 +767,21 @@ class SubtitlesFragment : Fragment() { } cancelBtt.setOnClickListener { - activity?.popCurrentPage() + if (popFragment) { + activity?.popCurrentPage() + } else { + dismiss() + } } applyBtt.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) - it.context.fromSaveToStyle(state) - activity?.popCurrentPage() + if (popFragment) { + activity?.popCurrentPage() + } else { + dismiss() + } } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt deleted file mode 100644 index e9b69c5b0..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.util.Log -import androidx.annotation.StringRes -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.ui.result.txt -import java.lang.Long.min - -object EpisodeSkip { - private const val TAG = "EpisodeSkip" - - enum class SkipType(@StringRes name: Int) { - Opening(R.string.skip_type_op), - Ending(R.string.skip_type_ed), - Recap(R.string.skip_type_recap), - MixedOpening(R.string.skip_type_mixed_op), - MixedEnding(R.string.skip_type_mixed_ed), - Credits(R.string.skip_type_creddits), - Intro(R.string.skip_type_creddits), - } - - data class SkipStamp( - val type: SkipType, - val skipToNextEpisode: Boolean, - val startMs: Long, - val endMs: Long, - ) { - val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt( - R.string.skip_type_format, - txt(type.name) - ) - } - - private val cachedStamps = HashMap>() - - private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean { - return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh - } - - suspend fun getStamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long, - hasNextEpisode: Boolean, - ): List { - cachedStamps[episode.id]?.let { list -> - return list - } - - val out = mutableListOf() - Log.i(TAG, "Requesting SkipStamp from ${data.syncData}") - - if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) { - data.getMalId()?.toIntOrNull()?.let { malId -> - val (resultLength, stamps) = AniSkip.getResult( - malId, - episode.episode, - episodeDurationMs - ) ?: return@let null - // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work - val dur = min(episodeDurationMs, resultLength) - stamps.mapNotNull { stamp -> - val skipType = when (stamp.skipType) { - "op" -> SkipType.Opening - "ed" -> SkipType.Ending - "recap" -> SkipType.Recap - "mixed-ed" -> SkipType.MixedEnding - "mixed-op" -> SkipType.MixedOpening - else -> null - } ?: return@mapNotNull null - val end = (stamp.interval.endTime * 1000.0).toLong() - val start = (stamp.interval.startTime * 1000.0).toLong() - SkipStamp( - type = skipType, - skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode( - end, - dur - ), - startMs = start, - endMs = end - ) - }?.let { list -> - out.addAll(list) - } - } - } - if (out.isNotEmpty()) - cachedStamps[episode.id] = out - return out - } -} - -// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt -// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md -object AniSkip { - private const val TAG = "AniSkip" - suspend fun getResult( - malId: Int, - episodeNumber: Int, - episodeLength: Long - ): Pair>? { - return try { - val url = - "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}" - Log.i(TAG, "Requesting $url") - - val a = app.get(url) - val res = a.parsed() - Log.i(TAG, "Found ${res.found} with ${res.results?.size} results") - if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null - } catch (t: Throwable) { - Log.i(TAG, "error = ${t.message}") - logError(t) - null - } - } - - data class AniSkipResponse( - @JsonSerialize val found: Boolean, - @JsonSerialize val results: List?, - @JsonSerialize val message: String?, - @JsonSerialize val statusCode: Int - ) - - data class Stamp( - @JsonSerialize val interval: AniSkipInterval, - @JsonSerialize val skipType: String, - @JsonSerialize val skipId: String, - @JsonSerialize val episodeLength: Double - ) - - data class AniSkipInterval( - @JsonSerialize val startTime: Double, - @JsonSerialize val endTime: Double - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt similarity index 65% rename from app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index 48917889e..7278fcdd7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -6,7 +6,9 @@ import android.app.Activity import android.app.Activity.RESULT_CANCELED import android.app.NotificationChannel import android.app.NotificationManager -import android.content.* +import android.content.Context +import android.content.DialogInterface +import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor import android.media.AudioAttributes @@ -14,10 +16,11 @@ import android.media.AudioFocusRequest import android.media.AudioManager import android.media.tv.TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID import android.net.ConnectivityManager +import android.net.Network import android.net.NetworkCapabilities -import android.net.Uri -import android.os.* -import android.provider.MediaStore +import android.os.Build +import android.os.Handler +import android.os.Looper import android.text.Spanned import android.util.Log import android.view.View @@ -29,7 +32,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat +import androidx.core.net.toUri import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned import androidx.core.widget.ContentLoadingProgressBar @@ -37,33 +40,43 @@ 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.* +import androidx.tvprovider.media.tv.PreviewChannelHelper +import androidx.tvprovider.media.tv.TvContractCompat +import androidx.tvprovider.media.tv.WatchNextProgram import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor import androidx.viewpager2.widget.ViewPager2 -import com.fasterxml.jackson.module.kotlin.readValue import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.wrappers.Wrappers import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.apis +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.RepositoryManager -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.WebviewFragment +import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultFragment -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel.Companion.downloadAll +import com.lagradost.cloudstream3.ui.settings.Globals +import com.lagradost.cloudstream3.ui.settings.extensions.PluginsFragment import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main @@ -72,20 +85,18 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache -import java.io.* +import java.io.File import java.net.URL import java.net.URLDecoder -import kotlin.system.measureTimeMillis +import java.util.concurrent.Executor +import java.util.concurrent.Executors -object AppUtils { - fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) { - for (i in 0..maxViewTypeId) - recycledViewPool.setMaxRecycledViews(i, maxPoolSize) - } +object AppContextUtils { fun RecyclerView.isRecyclerScrollable(): Boolean { val layoutManager = this.layoutManager as? LinearLayoutManager? @@ -105,15 +116,15 @@ object AppUtils { this?.window?.setWindowAnimations(-1) this?.show() Handler(Looper.getMainLooper()).postDelayed({ - this?.window?.setWindowAnimations(R.style.Animation_Design_BottomSheetDialog) + this?.window?.setWindowAnimations(com.google.android.material.R.style.Animation_Design_BottomSheetDialog) }, 200) } //fun Context.deleteFavorite(data: SearchResponse) { // if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - // normalSafeApiCall { + // safe { // val existingId = - // getWatchNextProgramByVideoId(data.url, this).second ?: return@normalSafeApiCall + // getWatchNextProgramByVideoId(data.url, this).second ?: return@safe // contentResolver.delete( // // TvContractCompat.buildWatchNextProgramUri(existingId), @@ -136,12 +147,12 @@ object AppUtils { text.toSpanned() } } - + /** Get channel ID by name */ @SuppressLint("RestrictedApi") private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult, - resumeWatching: VideoDownloadHelper.ResumeWatching? + resumeWatching: DownloadObjects.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { @@ -159,10 +170,10 @@ object AppUtils { ) .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) .setTitle(title) - .setPosterArtUri(Uri.parse(card.posterUrl)) - .setIntentUri(Uri.parse(card.id?.let { - "$appStringResumeWatching://$it" - } ?: card.url)) + .setPosterArtUri(card.posterUrl?.toUri()) + .setIntentUri((card.id?.let { + "$APP_STRING_RESUME_WATCHING://$it" + } ?: card.url).toUri()) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( resumeWatching?.updateTime ?: System.currentTimeMillis() @@ -308,7 +319,7 @@ object AppUtils { 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 @@ -353,30 +364,162 @@ object AppUtils { } } - @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 + fun sortSubs(subs: Set): List { + return subs.sortedBy { it.name } + } + + fun Context.getApiSettings(): HashSet { + val hashSet = HashSet() + val activeLangs = getApiProviderLangSettings() + val hasUniversal = activeLangs.contains(AllLanguagesName) + hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) }.map { it.name }) + return hashSet + } + + fun Context.getApiDubstatusSettings(): HashSet { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = HashSet() + hashSet.addAll(DubStatus.values()) + val list = settingsManager.getStringSet( + this.getString(R.string.display_sub_key), + hashSet.map { it.name }.toMutableSet() + ) ?: return hashSet + + val names = DubStatus.values().map { it.name }.toHashSet() + //if(realSet.isEmpty()) return hashSet + + return list.filter { names.contains(it) }.map { DubStatus.valueOf(it) }.toHashSet() + } + + fun Context.getApiProviderLangSettings(): HashSet { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = hashSetOf(AllLanguagesName) // def is all languages +// hashSet.add("en") // def is only en + val list = settingsManager.getStringSet( + this.getString(R.string.provider_lang_key), + hashSet ) - 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) + + if (list.isNullOrEmpty()) return hashSet + return list.toHashSet() + } + + fun Context.getApiTypeSettings(): HashSet { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = HashSet() + hashSet.addAll(TvType.values()) + val list = settingsManager.getStringSet( + this.getString(R.string.search_types_list_key), + hashSet.map { it.name }.toMutableSet() + ) + + if (list.isNullOrEmpty()) return hashSet + + val names = TvType.values().map { it.name }.toHashSet() + val realSet = list.filter { names.contains(it) }.map { TvType.valueOf(it) }.toHashSet() + if (realSet.isEmpty()) return hashSet + + return realSet + } + + fun Context.updateHasTrailers() { + LoadResponse.isTrailersEnabled = getHasTrailers() + } + + private fun Context.getHasTrailers(): Boolean { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + 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 + // Trying fixing using classloader fuckery + val oldLoader = Thread.currentThread().contextClassLoader + Thread.currentThread().contextClassLoader = TvType::class.java.classLoader + + val default = TvType.values() + .sorted() + .filter { it != TvType.NSFW } + .map { it.ordinal } + + Thread.currentThread().contextClassLoader = oldLoader + + val defaultSet = default.map { it.toString() }.toSet() + val currentPrefMedia = try { + PreferenceManager.getDefaultSharedPreferences(this) + .getStringSet(this.getString(R.string.prefer_media_type_key), defaultSet) + ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } + } catch (e: Throwable) { + null + } ?: default + val langs = this.getApiProviderLangSettings() + val hasUniversal = langs.contains(AllLanguagesName) + val allApis = apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } + return if (currentPrefMedia.isEmpty()) { + allApis } else { - val values = ContentValues() - values.put(MediaStore.Video.Media.DATA, videoFilePath) - context.contentResolver.insert( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values - ) + // Filter API depending on preferred media type + allApis.filter { api -> api.supportedTypes.any { currentPrefMedia.contains(it.ordinal) } } } } + fun Context.filterSearchResultByFilmQuality(data: List): List { + // Filter results omitting entries with certain quality + if (data.isNotEmpty()) { + val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) + ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) + ?.mapNotNull { entry -> + entry.toIntOrNull() ?: return@mapNotNull null + } ?: listOf() + if (filteredSearchQuality.isNotEmpty()) { + return data.filter { item -> + val searchQualVal = item.quality?.ordinal ?: -1 + //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") + !filteredSearchQuality.contains(searchQualVal) + } + } + } + return data + } + + fun Context.filterHomePageListByFilmQuality(data: HomePageList): HomePageList { + // Filter results omitting entries with certain quality + if (data.list.isNotEmpty()) { + val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) + ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) + ?.mapNotNull { entry -> + entry.toIntOrNull() ?: return@mapNotNull null + } ?: listOf() + if (filteredSearchQuality.isNotEmpty()) { + return HomePageList( + name = data.name, + isHorizontalImages = data.isHorizontalImages, + list = data.list.filter { item -> + val searchQualVal = item.quality?.ordinal ?: -1 + //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") + !filteredSearchQuality.contains(searchQualVal) + } + ) + } + } + return data + } + fun Activity.loadRepository(url: String) { ioSafe { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe RepositoryManager.addRepository( RepositoryData( + repo.iconUrl ?: "", repo.name, url ) @@ -388,68 +531,40 @@ object AppUtils { ) } afterRepositoryLoadedEvent.invoke(true) - downloadAllPluginsDialog(url, repo.name) + 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 Activity.addRepositoryDialog( + repositoryName: String, + repositoryURL: String, + ) { + val repos = RepositoryManager.getRepositories() - 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.downloadAllPluginsDialog(repositoryUrl: String, repositoryName: String) { - runOnUiThread { - val context = this - val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setTitle( - repositoryName - ) - builder.setMessage( - R.string.download_all_plugins_from_repo - ) - builder.apply { - setPositiveButton(R.string.download) { _, _ -> - downloadAll(context, repositoryUrl, null) - } - - setNegativeButton(R.string.no) { _, _ -> } + // navigate to newly added repository on pressing Open Repository + fun openAddedRepo() { + if (repos.isNotEmpty()) { + navigate( + R.id.global_to_navigation_settings_plugins, + PluginsFragment.newInstance( + repositoryName, + repositoryURL, + false, + ) + ) + } + } + + runOnUiThread { + AlertDialog.Builder(this).apply { + setTitle(repositoryName) + setMessage(R.string.download_all_plugins_from_repo) + setPositiveButton(R.string.open_downloaded_repo) { _, _ -> + openAddedRepo() + } + setNegativeButton(R.string.dismiss, null) + show().setDefaultFocus() } - builder.show().setDefaultFocus() } } @@ -459,7 +574,7 @@ object AppUtils { fun openWebView(fragment: Fragment?, url: String) { if (fragment?.context?.hasWebView() == true) - normalSafeApiCall { + safe { fragment .findNavController() .navigate(R.id.navigation_webview, WebviewFragment.newInstance(url)) @@ -473,10 +588,10 @@ object AppUtils { url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null, - ) { + ) = (this.getActivity() ?: activity)?.runOnUiThread { try { val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(url) + intent.data = url.toUri() intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) // activityResultRegistry is used to fall back to webview if a browser is missing @@ -492,10 +607,7 @@ object AppUtils { openWebView(fragment, url) } }.launch(intent) - } else { - ContextCompat.startActivity(this, intent, null) - } - + } else this.startActivity(intent) } catch (e: Exception) { logError(e) if (fallbackWebview) { @@ -505,9 +617,17 @@ object AppUtils { } fun Context.isNetworkAvailable(): Boolean { - val manager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetworkInfo = manager.activeNetworkInfo - return activeNetworkInfo != null && activeNetworkInfo.isConnected || manager.allNetworkInfo?.any { it.isConnected } ?: false + val connectivityManager = + getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return false + val networkCapabilities = + connectivityManager.getNetworkCapabilities(network) ?: return false + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } else { + @Suppress("DEPRECATION") + connectivityManager.activeNetworkInfo?.isConnected == true + } } fun splitQuery(url: URL): Map { @@ -522,24 +642,6 @@ object AppUtils { return queryPairs } - /** Any object as json string */ - fun Any.toJson(): String { - if (this is String) return this - return mapper.writeValueAsString(this) - } - - inline fun parseJson(value: String): T { - return mapper.readValue(value) - } - - inline fun tryParseJson(value: String?): T? { - return try { - parseJson(value ?: return null) - } catch (_: Exception) { - null - } - } - /**| S1:E2 Hello World * | Episode 2. Hello world * | Hello World @@ -573,6 +675,18 @@ object AppUtils { 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()) @@ -583,7 +697,7 @@ object AppUtils { //private val viewModel: ResultViewModel by activityViewModels() private fun getResultsId(): Int { - return if (isTrueTvSettings()) { + return if (Globals.isLayout(Globals.TV or Globals.EMULATOR)) { R.id.global_to_navigation_results_tv } else { R.id.global_to_navigation_results_phone @@ -593,15 +707,17 @@ object AppUtils { fun loadResult( url: String, apiName: String, + name : String, startAction: Int = 0, startValue: Int = 0 ) { - (activity as FragmentActivity?)?.loadResult(url, apiName, startAction, startValue) + (activity as FragmentActivity?)?.loadResult(url, apiName, name, startAction, startValue) } fun FragmentActivity.loadResult( url: String, apiName: String, + name : String, startAction: Int = 0, startValue: Int = 0 ) { @@ -609,7 +725,7 @@ object AppUtils { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) Kitsu.isEnabled = settingsManager.getBoolean(this.getString(R.string.show_kitsu_posters_key), true) - }catch (t : Throwable) { + } catch (t: Throwable) { logError(t) } @@ -617,7 +733,7 @@ object AppUtils { // viewModelStore.clear() this.navigate( getResultsId(), - ResultFragment.newInstance(url, apiName, startAction, startValue) + ResultFragment.newInstance(url, apiName, name, startAction, startValue) ) } } @@ -646,12 +762,18 @@ object AppUtils { } fun Activity.requestLocalAudioFocus(focusRequest: AudioFocusRequest?) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (focusRequest == null) { + Log.e("TAG", "focusRequest was null") + return + } + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.requestAudioFocus(focusRequest) } else { val audioManager: AudioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + @Suppress("DEPRECATION") audioManager.requestAudioFocus( null, AudioManager.STREAM_MUSIC, @@ -683,20 +805,26 @@ object AppUtils { val isCastApiAvailable = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(applicationContext) == ConnectionResult.SUCCESS + try { - applicationContext?.let { CastContext.getSharedInstance(it) } + applicationContext?.let { + val task = CastContext.getSharedInstance(it) { it.run() } + task.result + } } catch (e: Exception) { println(e) - // track non-fatal + // Track non-fatal return false } + return isCastApiAvailable } fun Context.isConnectedToChromecast(): Boolean { if (isCastApiAvailable()) { - val castContext = CastContext.getSharedInstance(this) - if (castContext.castState == CastState.CONNECTED) { + val executor: Executor = Executors.newSingleThreadExecutor() + val castContext = CastContext.getSharedInstance(this, executor) + if (castContext.result.castState == CastState.CONNECTED) { return true } } @@ -707,118 +835,24 @@ object AppUtils { * Sets the focus to the negative button when in TV and Emulator layout. **/ fun AlertDialog.setDefaultFocus(buttonFocus: Int = DialogInterface.BUTTON_NEGATIVE) { - if (!isTvSettings()) return + if (!Globals.isLayout(Globals.TV or Globals.EMULATOR)) return this.getButton(buttonFocus).run { isFocusableInTouchMode = true requestFocus() } } - // Copied from https://github.com/videolan/vlc-android/blob/master/application/vlc-android/src/org/videolan/vlc/util/FileUtils.kt - @SuppressLint("Range") - fun Context.getUri(data: Uri?): Uri? { - var uri = data - val ctx = this - if (data != null && data.scheme == "content") { - // Mail-based apps - download the stream to a temporary file and play it - if ("com.fsck.k9.attachmentprovider" == data.host || "gmail-ls" == data.host) { - var inputStream: InputStream? = null - var os: OutputStream? = null - var cursor: Cursor? = null - try { - cursor = ctx.contentResolver.query( - data, - arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, null - ) - if (cursor != null && cursor.moveToFirst()) { - val filename = - cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)) - .replace("/", "") - inputStream = ctx.contentResolver.openInputStream(data) - if (inputStream == null) return data - os = - FileOutputStream(Environment.getExternalStorageDirectory().path + "/Download/" + filename) - val buffer = ByteArray(1024) - var bytesRead = inputStream.read(buffer) - while (bytesRead >= 0) { - os.write(buffer, 0, bytesRead) - bytesRead = inputStream.read(buffer) - } - uri = - Uri.fromFile(File(Environment.getExternalStorageDirectory().path + "/Download/" + filename)) - } - } catch (e: Exception) { - return null - } finally { - inputStream?.close() - os?.close() - cursor?.close() - } - } else if (data.authority == "media") { - uri = this.contentResolver.query( - data, - arrayOf(MediaStore.Video.Media.DATA), null, null, null - )?.use { - val columnIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA) - if (it.moveToFirst()) Uri.fromFile(File(it.getString(columnIndex))) - ?: data else data - } - //uri = MediaUtils.getContentMediaUri(data) - /*} else if (data.authority == ctx.getString(R.string.tv_provider_authority)) { - println("TV AUTHORITY") - //val medialibrary = Medialibrary.getInstance() - //val media = medialibrary.getMedia(data.lastPathSegment!!.toLong()) - uri = null//media.uri*/ - } else { - val inputPFD: ParcelFileDescriptor? - try { - inputPFD = ctx.contentResolver.openFileDescriptor(data, "r") - if (inputPFD == null) return data - uri = Uri.parse("fd://" + inputPFD.fd) - // Cursor returnCursor = - // getContentResolver().query(data, null, null, null, null); - // if (returnCursor != null) { - // if (returnCursor.getCount() > 0) { - // int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - // if (nameIndex > -1) { - // returnCursor.moveToFirst(); - // title = returnCursor.getString(nameIndex); - // } - // } - // returnCursor.close(); - // } - } catch (e: FileNotFoundException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: IllegalArgumentException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: IllegalStateException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: NullPointerException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: SecurityException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } - }// Media or MMS URI - } - return uri - } - fun Context.isUsingMobileData(): Boolean { - val conManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val networkInfo = conManager.allNetworks - return networkInfo.any { - conManager.getNetworkCapabilities(it) - ?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true - } && - !networkInfo.any { - conManager.getNetworkCapabilities(it) - ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true - } + val connectionManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val activeNetwork: Network? = connectionManager.activeNetwork + val networkCapabilities = connectionManager.getNetworkCapabilities(activeNetwork) + networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true && + !networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } else { + @Suppress("DEPRECATION") + connectionManager.activeNetworkInfo?.type == ConnectivityManager.TYPE_MOBILE + } } @@ -859,9 +893,7 @@ object AppUtils { } build() } - } else { - null - } + } else null return currentAudioFocusRequest } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt new file mode 100644 index 000000000..10736e13e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -0,0 +1,67 @@ +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>() + + class CallbackHelper( + private val activityRef: WeakReference, + private val callback: OnBackPressedCallback + ) { + fun runDefault() { + val activity = activityRef.get() ?: return + val wasEnabled = callback.isEnabled + callback.isEnabled = false + try { + activity.onBackPressedDispatcher.onBackPressed() + } finally { + callback.isEnabled = wasEnabled + } + } + } + + fun ComponentActivity.attachBackPressedCallback( + id: String, + callback: CallbackHelper.() -> Unit + ) { + val callbackMap = backPressedCallbacks.getOrPut(this) { mutableMapOf() } + if (callbackMap.containsKey(id)) return + + // We use WeakReference to protect against potential leaks. + val activityRef = WeakReference(this) + val newCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + CallbackHelper(activityRef, this).callback() + } + } + + 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() + callbackMap.remove(id) + } + + if (callbackMap.isEmpty()) { + backPressedCallbacks.remove(this) + } + } +} 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 2da54678c..62426197e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,45 +1,49 @@ package com.lagradost.cloudstream3.utils -import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.WorkerThread +import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.readValue +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PLUGINS_KEY import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_TOKEN_KEY -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_USER_KEY import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_REFRESH_TOKEN_KEY -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_TOKEN_KEY -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_UNIXTIME_KEY -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_USER_KEY -import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi.Companion.OPEN_SUBTITLES_USER_KEY +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs -import com.lagradost.cloudstream3.utils.DataStore.mapper -import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.QUEUE_KEY +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.safefile.MediaFileContentType +import com.lagradost.safefile.SafeFile import okhttp3.internal.closeQuietly +import java.io.IOException import java.io.OutputStream import java.io.PrintWriter import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale object BackupUtils { @@ -47,40 +51,80 @@ object BackupUtils { * No sensitive or breaking data in the backup * */ private val nonTransferableKeys = listOf( - // When sharing backup we do not want to transfer what is essentially the password - ANILIST_TOKEN_KEY, ANILIST_CACHED_LIST, - ANILIST_UNIXTIME_KEY, - ANILIST_USER_KEY, - MAL_TOKEN_KEY, - MAL_REFRESH_TOKEN_KEY, MAL_CACHED_LIST, - MAL_UNIXTIME_KEY, - MAL_USER_KEY, + KITSU_CACHED_LIST, // The plugins themselves are not backed up PLUGINS_KEY, PLUGINS_KEY_LOCAL, - OPEN_SUBTITLES_USER_KEY, + AccountManager.ACCOUNT_TOKEN, + AccountManager.ACCOUNT_IDS, + + // TODO proper getter for string res keys to ensure that they are updated + "biometric_key", // can lock down users if backup is shared on a incompatible device "nginx_user", // Nginx user key + + // No access rights after restore data from backup + "download_path_key", + "download_path_key_visual", + "backup_path_key", + "backup_dir_path_key", + + // When sharing backup we do not want to transfer what is essentially the password + // Note that this is deprecated, and can be removed after all tokens have expired + "anilist_token", + "anilist_user", + "mal_user", + "mal_token", + "mal_refresh_token", + "mal_unixtime", + "open_subtitles_user", + "subdl_user", + "simkl_token", + + + // Downloads can not be restored from backups. + // The download path URI can not be transferred. + // In the future we may potentially write metadata to files in the download directory + // and make it possible to restore download folders using that metadata. + DOWNLOAD_EPISODE_CACHE_BACKUP, + DOWNLOAD_EPISODE_CACHE, + + // Download headers are unintuitively used in the resume watching system. + // We can therefore not prune download headers in backups. + //DOWNLOAD_HEADER_CACHE_BACKUP, + //DOWNLOAD_HEADER_CACHE, + + + // This may overwrite valid local data with invalid data + KEY_DOWNLOAD_INFO, + + // Prevent backups from automatically starting downloads + KEY_RESUME_IN_QUEUE, + KEY_RESUME_PACKAGES, + QUEUE_KEY, + + // Prevent automatic plugin download after restoring backup + "auto_download_plugins_key2" ) - /** false if blacklisted key */ + /** false if key should not be contained in backup */ private fun String.isTransferable(): Boolean { - return !nonTransferableKeys.contains(this) + return !nonTransferableKeys.any { this.contains(it) } } private var restoreFileSelector: ActivityResultLauncher>? = null // Kinda hack, but I couldn't think of a better way data class BackupVars( - @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?>?, + @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?>?, ) data class BackupFile( @@ -89,9 +133,9 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - fun Context.getBackup(): BackupFile { - val allData = getSharedPrefs().all.filter { it.key.isTransferable() } - val allSettings = getDefaultSharedPrefs().all.filter { it.key.isTransferable() } + private fun getBackup(context: Context): BackupFile { + val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } + val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } val allDataSorted = BackupVars( allData.filter { it.value is Boolean } as? Map, @@ -118,89 +162,57 @@ object BackupUtils { } @WorkerThread - fun Context.restore( + fun restore( + context: Context?, backupFile: BackupFile, restoreSettings: Boolean, restoreDataStore: Boolean ) { + if (context == null) return if (restoreSettings) { - restoreMap(backupFile.settings._Bool, true) - restoreMap(backupFile.settings._Int, true) - restoreMap(backupFile.settings._String, true) - restoreMap(backupFile.settings._Float, true) - restoreMap(backupFile.settings._Long, true) - restoreMap(backupFile.settings._StringSet, true) + context.restoreMap(backupFile.settings.bool, true) + context.restoreMap(backupFile.settings.int, true) + context.restoreMap(backupFile.settings.string, true) + context.restoreMap(backupFile.settings.float, true) + context.restoreMap(backupFile.settings.long, true) + context.restoreMap(backupFile.settings.stringSet, true) } if (restoreDataStore) { - restoreMap(backupFile.datastore._Bool) - restoreMap(backupFile.datastore._Int) - restoreMap(backupFile.datastore._String) - restoreMap(backupFile.datastore._Float) - restoreMap(backupFile.datastore._Long) - restoreMap(backupFile.datastore._StringSet) + context.restoreMap(backupFile.datastore.bool) + context.restoreMap(backupFile.datastore.int) + context.restoreMap(backupFile.datastore.string) + context.restoreMap(backupFile.datastore.float) + 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 FragmentActivity.backup() { + fun backup(context: Context?) = ioSafe { + if (context == null) return@ioSafe + var fileStream: OutputStream? = null var printStream: PrintWriter? = null try { - if (!checkWrite()) { - showToast(getString(R.string.backup_failed), Toast.LENGTH_LONG) - requestRW() - return + if (!context.checkWrite()) { + showToast(R.string.backup_failed, Toast.LENGTH_LONG) + context.getActivity()?.requestRW() + return@ioSafe } - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) - val ext = "json" + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) val displayName = "CS3_Backup_${date}" - val backupFile = getBackup() - val stream = setupStream(this, displayName, null, ext, false) + val backupFile = getBackup(context) + val stream = setupBackupStream(context, displayName) + fileStream = stream.openNew() printStream = PrintWriter(fileStream) - printStream.print(mapper.writeValueAsString(backupFile)) - - /*val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && subDir?.isDownloadDir() == true - ) { - val cr = this.contentResolver - val contentUri = - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI - //val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - - val newFile = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.TITLE, displayName) - // While it a json file we store as txt because not - // all file managers support mimetype json - put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") - //put(MediaStore.MediaColumns.RELATIVE_PATH, folder) - } - - val newFileUri = cr.insert( - contentUri, - newFile - ) ?: throw IOException("Error creating file uri") - cr.openOutputStream(newFileUri, "w") - ?: throw IOException("Error opening stream") - } else { - val fileName = "$displayName.$ext" - val rFile = subDir?.findFile(fileName) - if (rFile?.exists() == true) { - rFile.delete() - } - val file = - subDir?.createFile(fileName) - ?: throw IOException("Error creating file") - if (!file.exists()) throw IOException("File does not exist") - file.openOutputStream() - } - - val printStream = PrintWriter(steam) - printStream.print(mapper.writeValueAsString(backupFile)) - printStream.close()*/ + printStream.print(backupFile.toJson()) showToast( R.string.backup_success, @@ -210,7 +222,7 @@ object BackupUtils { logError(e) try { showToast( - getString(R.string.backup_failed_error_format).format(e.toString()), + txt(R.string.backup_failed_error_format, e.toString()), Toast.LENGTH_LONG ) } catch (e: Exception) { @@ -222,6 +234,18 @@ object BackupUtils { } } + @Throws(IOException::class) + private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): DownloadObjects.StreamData { + return setupStream( + baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) + ?: throw IOException("Bad config"), + name, + folder = null, + extension = ext, + tryResume = false + ) + } + fun FragmentActivity.setUpBackup() { try { restoreFileSelector = @@ -233,10 +257,11 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - val restoredValue = - mapper.readValue(input) + val text = input.bufferedReader().readText() + val restoredValue = parseJson(text) - activity.restore( + restore( + activity, restoredValue, restoreSettings = true, restoreDataStore = true @@ -282,8 +307,36 @@ object BackupUtils { map: Map?, isEditingAppSettings: Boolean = false ) { - map?.filter { it.key.isTransferable() }?.forEach { - setKeyRaw(it.key, it.value, isEditingAppSettings) + val editor = DataStore.editor(this, isEditingAppSettings) + map?.forEach { + if (it.key.isTransferable()) { + editor.setKeyRaw(it.key, it.value) + } + } + editor.apply() + } + + /** + * Copy of [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.basePathToFile], [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDefaultDir] and [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getBasePath] + * modded for backup specific paths + * */ + + fun getDefaultBackupDir(context: Context): SafeFile? { + return SafeFile.fromMedia(context, MediaFileContentType.Downloads) + } + + fun getCurrentBackupDir(context: Context): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) + val basePathSetting = + settingsManager.getString(context.getString(R.string.backup_path_key), null) + return baseBackupPathToFile(context, basePathSetting) to basePathSetting + } + + private fun baseBackupPathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultBackupDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt new file mode 100644 index 000000000..bce8f09dc --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt @@ -0,0 +1,193 @@ +package com.lagradost.cloudstream3.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getString +import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R + +object BiometricAuthenticator { + + const val TAG = "cs3Auth" + private const val MAX_FAILED_ATTEMPTS = 3 + private var failedAttempts = 0 + private var biometricManager: BiometricManager? = null + var biometricPrompt: BiometricPrompt? = null + var promptInfo: BiometricPrompt.PromptInfo? = null + var authCallback: BiometricCallback? = null // listen to authentication success + + private fun initializeBiometrics(activity: FragmentActivity) { + val executor = ContextCompat.getMainExecutor(activity) + + biometricManager = BiometricManager.from(activity) + + biometricPrompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + showToast("$errString") + Log.e(TAG, "$errorCode") + authCallback?.onAuthenticationError() + //activity.finish() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + failedAttempts = 0 + authCallback?.onAuthenticationSuccess() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + failedAttempts++ + if (failedAttempts >= MAX_FAILED_ATTEMPTS) { + failedAttempts = 0 + activity.finish() + } + } + }) + } + + @Suppress("DEPRECATION") + // authentication dialog prompt builder + private fun authenticationDialog( + activity: Activity, + title: Int, + setDeviceCred: Boolean, + ) { + val description = activity.getString(R.string.biometric_prompt_description) + + if (setDeviceCred) { + // For API level > 30, Newer API setAllowedAuthenticators is used + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + + val authFlag = DEVICE_CREDENTIAL or BIOMETRIC_WEAK or BIOMETRIC_STRONG + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(title)) + .setDescription(description) + .setAllowedAuthenticators(authFlag) + .build() + } else { + // for apis < 30 + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(title)) + .setDescription(description) + .setDeviceCredentialAllowed(true) + .build() + } + } else { + // fallback for A12+ when both fingerprint & Face unlock is absent but PIN is set + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(title)) + .setDescription(description) + .setDeviceCredentialAllowed(true) + .build() + } + } + + private fun isBiometricHardWareAvailable(): Boolean { + // 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 + } + } + + 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 + } + } + } + + return result + } + + // checks if device is secured i.e has at least some type of lock + fun deviceHasPasswordPinLock(context: Context?): Boolean { + val keyMgr = + context?.getSystemService(AppCompatActivity.KEYGUARD_SERVICE) as? KeyguardManager + return keyMgr?.isKeyguardSecure ?: false + } + + // function to start authentication in any fragment or activity + fun startBiometricAuthentication(activity: FragmentActivity, title: Int, setDeviceCred: Boolean) { + initializeBiometrics(activity) + authCallback = activity as? BiometricCallback + if (isBiometricHardWareAvailable()) { + authCallback = activity as? BiometricCallback + authenticationDialog(activity, title, setDeviceCred) + promptInfo?.let { biometricPrompt?.authenticate(it) } + } else { + if (deviceHasPasswordPinLock(activity)) { + authCallback = activity as? BiometricCallback + authenticationDialog(activity, R.string.password_pin_authentication_title, true) + promptInfo?.let { biometricPrompt?.authenticate(it) } + + } else { + showToast(R.string.biometric_unsupported) + } + } + } + + fun isAuthEnabled(ctx: Context):Boolean { + return ctx.let { + PreferenceManager.getDefaultSharedPreferences(ctx) + .getBoolean(getString(ctx, R.string.biometric_key), false) + } + } + + interface BiometricCallback { + fun onAuthenticationSuccess() + fun onAuthenticationError() + } +} 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 6b5e9ec2a..b48c8d40a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt @@ -1,6 +1,6 @@ package com.lagradost.cloudstream3.utils -import android.net.Uri +import androidx.core.net.toUri import androidx.media3.common.MimeTypes import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.CastSession @@ -41,7 +41,7 @@ object CastHelper { val srcPoster = epData.poster ?: holder.poster if (srcPoster != null) { - movieMetadata.addImage(WebImage(Uri.parse(srcPoster))) + movieMetadata.addImage(WebImage(srcPoster.toUri())) } var subIndex = 0 @@ -55,7 +55,11 @@ object CastHelper { val builder = MediaInfo.Builder(link.url) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) - .setContentType(if (link.isM3u8) MimeTypes.APPLICATION_M3U8 else MimeTypes.VIDEO_MP4) + .setContentType(when(link.type) { + ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 + ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD + else -> MimeTypes.VIDEO_MP4 + }) .setMetadata(movieMetadata) .setMediaTracks(tracks) data?.let { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt new file mode 100644 index 000000000..def41d7a0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt @@ -0,0 +1,47 @@ +package com.lagradost.cloudstream3.utils + +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import com.lagradost.cloudstream3.mvvm.Resource + +/** + * This is an atomic LiveData where you can do .value instantly after doing .postValue. + * + * The default behavior is a footgun that will cause race conditions, + * as we do not really care if it is posted as we only want the latest data (even in the binding). + * + * Fuck all that is LiveData, because we want this value to be accessible everywhere instantly. + * */ +open class ConsistentLiveData(initValue : T? = null) : LiveData(initValue) { + @Volatile private var internalValue : T? = initValue + + override fun getValue(): T? { + return internalValue + } + + /** If someone want the old behavior then good for them */ + val postedValue : T? get() = super.getValue() + + public override fun postValue(value : T?) { + super.postValue(value) + internalValue = value + } + + @MainThread + public override fun setValue(value: T?) { + super.setValue(value) + internalValue = value + } +} + +/** Atomic resource livedata, to make it easier to work with resources without local copies */ +class ResourceLiveData(initValue : Resource? = null) : ConsistentLiveData>(initValue) { + var success + get() = when(val output = this.value) { + is Resource.Success -> { + output.value + } + else -> null + } + set(value) = this.postValue(value?.let { Resource.Success(it) } ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Coroutines.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Coroutines.kt deleted file mode 100644 index c3b244c26..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Coroutines.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.os.Handler -import android.os.Looper -import com.lagradost.cloudstream3.mvvm.launchSafe -import com.lagradost.cloudstream3.mvvm.logError -import kotlinx.coroutines.* -import java.util.Collections.synchronizedList - -object Coroutines { - fun T.main(work: suspend ((T) -> Unit)): Job { - val value = this - return CoroutineScope(Dispatchers.Main).launchSafe { - work(value) - } - } - - fun T.ioSafe(work: suspend (CoroutineScope.(T) -> Unit)): Job { - val value = this - - return CoroutineScope(Dispatchers.IO).launchSafe { - work(value) - } - } - - suspend fun V.ioWorkSafe(work: suspend (CoroutineScope.(V) -> T)): T? { - val value = this - return withContext(Dispatchers.IO) { - try { - work(value) - } catch (e: Exception) { - logError(e) - null - } - } - } - - suspend fun V.ioWork(work: suspend (CoroutineScope.(V) -> T)): T { - val value = this - return withContext(Dispatchers.IO) { - work(value) - } - } - - suspend fun V.mainWork(work: suspend (CoroutineScope.(V) -> T)): T { - val value = this - return withContext(Dispatchers.Main) { - work(value) - } - } - - fun runOnMainThread(work: (() -> Unit)) { - val mainHandler = Handler(Looper.getMainLooper()) - mainHandler.post { - work() - } - } - - /** - * Safe to add and remove how you want - * If you want to iterate over the list then you need to do: - * synchronized(allProviders) { code here } - **/ - fun threadSafeListOf(vararg items: T): MutableList { - return synchronizedList(items.toMutableList()) - } -} \ No newline at end of file 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 abcef753a..02ee69791 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,25 +2,27 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit import androidx.preference.PreferenceManager -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.KotlinModule -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeyClass -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError +import 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 @@ -29,6 +31,7 @@ class PreferenceDelegate( val key: String, val default: T //, private val klass: KClass ) { private val klass: KClass = default::class + // simple cache to make it not get the key every time it is accessed, however this requires // that ONLY this changes the key private var cache: T? = null @@ -50,9 +53,52 @@ 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 +) { + /** Always remember to call apply after */ + fun setKeyRaw(path: String, value: T) { + @Suppress("UNCHECKED_CAST") + if (isStringSet(value)) { + editor.putStringSet(path, value as Set) + } else { + when (value) { + is Boolean -> editor.putBoolean(path, value) + is Int -> editor.putInt(path, value) + is String -> editor.putString(path, value) + is Float -> editor.putFloat(path, value) + is Long -> editor.putLong(path, value) + } + } + } + + private fun isStringSet(value: Any?): Boolean { + if (value is Set<*>) { + return value.filterIsInstance().size == value.size + } + return false + } + + fun apply() { + editor.apply() + System.gc() + } +} + object DataStore { - val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + // 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 private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) @@ -66,22 +112,11 @@ object DataStore { return "${folder}/${path}" } - fun Context.setKeyRaw(path: String, value: T, isEditingAppSettings: Boolean = false) { - try { - val editor: SharedPreferences.Editor = - if (isEditingAppSettings) getDefaultSharedPrefs().edit() else getSharedPrefs().edit() - when (value) { - is Boolean -> editor.putBoolean(path, value) - is Int -> editor.putInt(path, value) - is String -> editor.putString(path, value) - is Float -> editor.putFloat(path, value) - is Long -> editor.putLong(path, value) - (value as? Set != null) -> editor.putStringSet(path, value as Set) - } - editor.apply() - } catch (e: Exception) { - logError(e) - } + fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { + val editor: SharedPreferences.Editor = + if (isEditingAppSettings) context.getDefaultSharedPrefs() + .edit() else context.getSharedPrefs().edit() + return Editor(editor) } fun Context.getDefaultSharedPrefs(): SharedPreferences { @@ -89,7 +124,9 @@ object DataStore { } fun Context.getKeys(folder: String): List { - return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } + // Ensure that the folder ends with "/" to prevent matching with other folders + val fixedFolder = folder.trimEnd('/') + "/" + return this.getSharedPrefs().all.keys.filter { it.startsWith(fixedFolder) } } fun Context.removeKey(folder: String, path: String) { @@ -109,9 +146,9 @@ object DataStore { try { val prefs = getSharedPrefs() if (prefs.contains(path)) { - val editor: SharedPreferences.Editor = prefs.edit() - editor.remove(path) - editor.apply() + prefs.edit { + remove(path) + } } } catch (e: Exception) { logError(e) @@ -120,26 +157,33 @@ object DataStore { fun Context.removeKeys(folder: String): Int { val keys = getKeys("$folder/") - keys.forEach { value -> - removeKey(value) + try { + getSharedPrefs().edit { + keys.forEach { value -> + remove(value) + } + } + return keys.size + } catch (e: Exception) { + logError(e) + return 0 } - return keys.size } fun Context.setKey(path: String, value: T) { try { - val editor: SharedPreferences.Editor = getSharedPrefs().edit() - editor.putString(path, mapper.writeValueAsString(value)) - editor.apply() + getSharedPrefs().edit { + putString(path, value?.toJsonLiteral()) + } } catch (e: Exception) { logError(e) } } - fun Context.getKey(path: String, valueType: Class): T? { + fun Context.getKey(path: String, valueType: Class): T? { try { val json: String = getSharedPrefs().getString(path, null) ?: return null - return json.toKotlinObject(valueType) + return parseJson(json, valueType.kotlin) } catch (e: Exception) { return null } @@ -150,11 +194,11 @@ object DataStore { } inline fun String.toKotlinObject(): T { - return mapper.readValue(this, T::class.java) + return parseJson(this) } - fun String.toKotlinObject(valueType: Class): T { - return mapper.readValue(this, valueType) + fun String.toKotlinObject(valueType: Class): T { + return parseJson(this, valueType.kotlin) } // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR @@ -178,4 +222,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 991651dc7..19caead21 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,52 +1,81 @@ package com.lagradost.cloudstream3.utils import android.content.Context -import android.content.DialogInterface -import android.text.Editable -import android.view.LayoutInflater -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isGone -import androidx.core.widget.doOnTextChanged import com.fasterxml.jackson.annotation.JsonProperty -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.unixTimeMS -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.databinding.WhoIsWatchingAccountEditBinding -import com.lagradost.cloudstream3.databinding.WhoIsWatchingBinding -import com.lagradost.cloudstream3.mvvm.logError +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 import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.WhoIsWatchingAdapter -import com.lagradost.cloudstream3.ui.result.FOCUS_SELF -import com.lagradost.cloudstream3.ui.result.UiImage +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.ui.result.setImage -import com.lagradost.cloudstream3.ui.result.setLinearListLayout -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus -import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import kotlin.reflect.KClass +import kotlin.reflect.KProperty const val VIDEO_POS_DUR = "video_pos_dur" const val VIDEO_WATCH_STATE = "video_watch_state" 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_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 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 + + operator fun setValue( + self: Any?, + property: KProperty<*>, + t: T? + ) { + if (t == null) { + removeKey(realKey) + } else { + setKeyClass(realKey, t) + } + } +} object DataStoreHelper { // be aware, don't change the index of these as Account uses the index for the art - private val profileImages = arrayOf( + val profileImages = arrayOf( R.drawable.profile_bg_dark_blue, R.drawable.profile_bg_blue, R.drawable.profile_bg_orange, @@ -56,6 +85,69 @@ object DataStoreHelper { R.drawable.profile_bg_teal ) + 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 deserializeTv(data: List): List { + return data.mapNotNull { listName -> + TvType.values().firstOrNull { it.name == listName } + } + } + + var searchPreferenceProviders: List + get() { + val ret = searchPreferenceProvidersStrings + return ret.ifEmpty { + context?.filterProviderByPreferredMedia()?.map { it.name } ?: emptyList() + } + } + set(value) { + searchPreferenceProvidersStrings = value + } + + 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 + get() = deserializeTv(homePreferenceStrings) + set(value) { + homePreferenceStrings = serializeTv(value) + } + + var homeBookmarkedList: IntArray by UserPreferenceDelegate( + "home_bookmarked_last_list", + IntArray(0) + ) + var playBackSpeed: Float by UserPreferenceDelegate("playback_speed", 1.0f) + var resizeMode: Int by UserPreferenceDelegate("resize_mode", 0) + var librarySortingMode: Int by UserPreferenceDelegate( + "library_sorting_mode", + ListSorting.AlphabeticalA.ordinal + ) + private var _resultsSortingMode: Int by UserPreferenceDelegate( + "results_sorting_mode", + EpisodeSortType.NUMBER_ASC.ordinal + ) + var resultsSortingMode: EpisodeSortType + get() = EpisodeSortType.entries.getOrNull(_resultsSortingMode) ?: EpisodeSortType.NUMBER_ASC + set(value) { + _resultsSortingMode = value.ordinal + } + data class Account( @JsonProperty("keyIndex") val keyIndex: Int, @@ -65,111 +157,51 @@ object DataStoreHelper { val customImage: String? = null, @JsonProperty("defaultImageIndex") val defaultImageIndex: Int, + @JsonProperty("lockPin") + val lockPin: String? = null, ) { - val image: UiImage - get() = customImage?.let { UiImage.Image(it) } ?: UiImage.Drawable( - profileImages.getOrNull(defaultImageIndex) ?: 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" - private var accounts by PreferenceDelegate("$TAG/account", arrayOf()) + var accounts by PreferenceDelegate("$TAG/account", arrayOf()) var selectedKeyIndex by PreferenceDelegate("$TAG/account_key_index", 0) val currentAccount: String get() = selectedKeyIndex.toString() - private fun setAccount(account: Account) { - selectedKeyIndex = account.keyIndex - showToast(account.name) - MainActivity.bookmarksUpdatedEvent(true) - } - - private fun editAccount(context: Context, account: Account, isNewAccount: Boolean) { - val binding = - WhoIsWatchingAccountEditBinding.inflate(LayoutInflater.from(context), null, false) - val builder = - AlertDialog.Builder(context, R.style.AlertDialogCustom) - .setView(binding.root) - - var currentEditAccount = account - val dialog = builder.show() - binding.accountName.text = Editable.Factory.getInstance()?.newEditable(account.name) - binding.accountName.doOnTextChanged { text, _, _, _ -> - currentEditAccount = currentEditAccount.copy(name = text?.toString() ?: "") - } - - binding.deleteBtt.isGone = isNewAccount - binding.deleteBtt.setOnClickListener { - val dialogClickListener = - DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - // remove all keys as well as the account, note that default wont get - // deleted from currentAccounts, as it is not part of "accounts", - // but the watch keys will - removeKeys(account.keyIndex.toString()) - val currentAccounts = accounts.toMutableList() - currentAccounts.removeIf { it.keyIndex == account.keyIndex } - accounts = currentAccounts.toTypedArray() - - // update UI - setAccount(getDefaultAccount(context)) - MainActivity.bookmarksUpdatedEvent(true) - dialog?.dismissSafe() - } - - DialogInterface.BUTTON_NEGATIVE -> {} - } - } - - try { - AlertDialog.Builder(context).setTitle(R.string.delete).setMessage( - context.getString(R.string.delete_message).format( - currentEditAccount.name - ) - ) - .setPositiveButton(R.string.delete, dialogClickListener) - .setNegativeButton(R.string.cancel, dialogClickListener) - .show().setDefaultFocus() - } catch (t: Throwable) { - logError(t) - // ye you somehow fucked up formatting did you? - } - } - - binding.cancelBtt.setOnClickListener { - dialog?.dismissSafe() - } - - binding.profilePic.setImage(account.image) - binding.profilePic.setOnClickListener { - // rolls the image forwards once - currentEditAccount = - currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % profileImages.size) - binding.profilePic.setImage(currentEditAccount.image) - } - - binding.applyBtt.setOnClickListener { - val currentAccounts = accounts.toMutableList() - - val overrideIndex = - currentAccounts.indexOfFirst { it.keyIndex == currentEditAccount.keyIndex } - - // if an account is found that has the same keyIndex then override that one, if not then append it - if (overrideIndex != -1) { - currentAccounts[overrideIndex] = currentEditAccount + /** + * Get or set the current account homepage. + * Setting this does not automatically reload the homepage. + */ + var currentHomePage: String? + get() = getKey("$currentAccount/$USER_SELECTED_HOMEPAGE_API") + set(value) { + val key = "$currentAccount/$USER_SELECTED_HOMEPAGE_API" + if (value == null) { + removeKey(key) } else { - currentAccounts.add(currentEditAccount) + setKey(key, value) } + } - // set the new default account as well as add the key for the new account - setAccount(currentEditAccount) - accounts = currentAccounts.toTypedArray() + fun setAccount(account: Account) { + val homepage = currentHomePage - dialog.dismissSafe() + selectedKeyIndex = account.keyIndex + AccountManager.updateAccountIds() + showToast(context?.getString(R.string.logged_account, account.name) ?: account.name) + MainActivity.bookmarksUpdatedEvent(true) + MainActivity.reloadLibraryEvent(true) + val oldAccount = accounts.find { it.keyIndex == account.keyIndex } + if (oldAccount != null && currentHomePage != homepage) { + // This is not a new account, and the homepage has changed, reload it + MainActivity.reloadHomeEvent(true) } } - private fun getDefaultAccount(context: Context): Account { + fun getDefaultAccount(context: Context): Account { return accounts.let { currentAccounts -> currentAccounts.getOrNull(currentAccounts.indexOfFirst { it.keyIndex == 0 }) ?: Account( keyIndex = 0, @@ -179,68 +211,26 @@ object DataStoreHelper { } } - fun showWhoIsWatching(context: Context) { - val binding: WhoIsWatchingBinding = WhoIsWatchingBinding.inflate( - LayoutInflater.from(context) - ) - - val showAccount = accounts.toMutableList().apply { + fun getAccounts(context: Context): List { + return accounts.toMutableList().apply { val item = getDefaultAccount(context) remove(item) add(0, item) } - - val builder = - BottomSheetDialog(context) - builder.setContentView(binding.root) - val accountName = context.getString(R.string.account) - - binding.profilesRecyclerview.setLinearListLayout( - isHorizontal = true, - nextUp = FOCUS_SELF, - nextDown = FOCUS_SELF, - nextLeft = FOCUS_SELF, - nextRight = FOCUS_SELF - ) - binding.profilesRecyclerview.adapter = WhoIsWatchingAdapter( - selectCallBack = { account -> - setAccount(account) - builder.dismissSafe() - }, - addAccountCallback = { - val currentAccounts = accounts - val remainingImages = - profileImages.toSet() - currentAccounts.filter { it.customImage == null } - .mapNotNull { profileImages.getOrNull(it.defaultImageIndex) }.toSet() - val image = - profileImages.indexOf(remainingImages.randomOrNull() ?: profileImages.random()) - val keyIndex = (currentAccounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 - - // create a new dummy account - editAccount( - context, - Account( - keyIndex = keyIndex, - name = "$accountName $keyIndex", - customImage = null, - defaultImageIndex = image - ), isNewAccount = true - ) - builder.dismissSafe() - }, - editCallBack = { account -> - editAccount( - context, account, isNewAccount = false - ) - builder.dismissSafe() - } - ).apply { - submitList(showAccount) - } - - builder.show() } + /** Gets the current selected account (or default), may return null if context is null and the user is using the default account */ + fun getCurrentAccount(): Account? { + return (context?.let { + getAccounts(it) + } ?: accounts.toList()).firstNotNullOfOrNull { account -> + if (account.keyIndex == selectedKeyIndex) { + account + } else { + null + } + } + } data class PosDur( @JsonProperty("position") val position: Long, @@ -256,23 +246,76 @@ object DataStoreHelper { return this } + fun Int.toYear(): Date = + GregorianCalendar.getInstance().also { it.set(Calendar.YEAR, this) }.time + /** * Used to display notifications on new episodes and posters in library. **/ - data class SubscribedData( + abstract class LibrarySearchResponse( @JsonProperty("id") override var id: Int?, - @JsonProperty("subscribedTime") val bookmarkedTime: Long, - @JsonProperty("latestUpdatedTime") val latestUpdatedTime: Long, - @JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map, + @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? = null, + @JsonProperty("type") override var type: TvType?, @JsonProperty("posterUrl") override var posterUrl: String?, - @JsonProperty("year") val year: Int?, - @JsonProperty("quality") override var quality: SearchQuality? = null, - @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, + @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("score") override var score: Score? = null, + @JsonProperty("tags") open val tags: List? = null, ) : SearchResponse { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } + + data class SubscribedData( + @JsonProperty("subscribedTime") val subscribedTime: Long, + @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 var score: Score? = null, + override val tags: List? = null, + ) : LibrarySearchResponse( + id, + latestUpdatedTime, + name, + url, + apiName, + type, + posterUrl, + year, + syncData, + quality, + posterHeaders, + plot, + score, + tags + ) { fun toLibraryItem(): SyncAPI.LibraryItem? { return SyncAPI.LibraryItem( name, @@ -282,24 +325,50 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, type, posterUrl, posterHeaders, quality, this.id + apiName, + type, + posterUrl, + posterHeaders, + quality, + year?.toYear(), + this.id, + plot = this.plot, + score = this.score, + tags = this.tags ) } } data class BookmarkedData( - @JsonProperty("id") override var id: Int?, @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, - @JsonProperty("latestUpdatedTime") 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? = null, - @JsonProperty("posterUrl") override var posterUrl: String?, - @JsonProperty("year") val year: Int?, - @JsonProperty("quality") override var quality: SearchQuality? = null, - @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, - ) : SearchResponse { + 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 var score: Score? = 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, @@ -309,7 +378,69 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, type, posterUrl, posterHeaders, quality, this.id + apiName, + type, + posterUrl, + posterHeaders, + quality, + year?.toYear(), + this.id, + plot = this.plot, + score = this.score, + tags = this.tags + ) + } + } + + data class FavoritesData( + @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 var score: Score? = 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, + url, + id?.toString() ?: return null, + null, + null, + null, + latestUpdatedTime, + apiName, + type, + posterUrl, + posterHeaders, + quality, + year?.toYear(), + this.id, + plot = this.plot, + score = this.score, + tags = this.tags ) } } @@ -328,6 +459,7 @@ object DataStoreHelper { @JsonProperty("isFromDownload") val isFromDownload: Boolean, @JsonProperty("quality") override var quality: SearchQuality? = null, @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, + @JsonProperty("score") override var score: Score? = null, ) : SearchResponse /** @@ -346,11 +478,11 @@ object DataStoreHelper { removeKeys(folder) } - fun deleteAllBookmarkedData() { - val folder1 = "$currentAccount/$RESULT_WATCH_STATE" - val folder2 = "$currentAccount/$RESULT_WATCH_STATE_DATA" - removeKeys(folder1) - removeKeys(folder2) + fun deleteBookmarkedData(id: Int?) { + if (id == null) return + AccountManager.localListApi.requireLibraryRefresh = true + removeKey("$currentAccount/$RESULT_WATCH_STATE", id.toString()) + removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) } fun getAllResumeStateIds(): List? { @@ -398,7 +530,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - VideoDownloadHelper.ResumeWatching( + DownloadObjects.ResumeWatching( parentId, episodeId, episode, @@ -419,7 +551,7 @@ object DataStoreHelper { removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) } - fun getLastWatched(id: Int?): VideoDownloadHelper.ResumeWatching? { + fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING", @@ -427,7 +559,7 @@ object DataStoreHelper { ) } - private fun getLastWatchedOld(id: Int?): VideoDownloadHelper.ResumeWatching? { + private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING_OLD", @@ -446,6 +578,12 @@ object DataStoreHelper { return getKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) } + fun getAllBookmarkedData(): List { + return getKeys("$currentAccount/$RESULT_WATCH_STATE_DATA")?.mapNotNull { + getKey(it) + } ?: emptyList() + } + fun getAllSubscriptions(): List { return getKeys("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA")?.mapNotNull { getKey(it) @@ -481,12 +619,91 @@ object DataStoreHelper { return getKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) } + fun getAllFavorites(): List { + return getKeys("$currentAccount/$RESULT_FAVORITES_STATE_DATA")?.mapNotNull { + getKey(it) + } ?: emptyList() + } + + fun removeFavoritesData(id: Int?) { + if (id == null) return + AccountManager.localListApi.requireLibraryRefresh = true + removeKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString()) + } + + fun setFavoritesData(id: Int?, data: FavoritesData) { + if (id == null) return + setKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString(), data) + AccountManager.localListApi.requireLibraryRefresh = true + } + + fun getFavoritesData(id: Int?): FavoritesData? { + if (id == null) return null + return getKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString()) + } + fun setViewPos(id: Int?, pos: Long, dur: Long) { if (id == null) return if (dur < 30_000) return // too short 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) @@ -509,7 +726,7 @@ object DataStoreHelper { } fun getDub(id: Int): DubStatus? { - return DubStatus.values() + return DubStatus.entries .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) } @@ -519,12 +736,10 @@ object DataStoreHelper { fun setResultWatchState(id: Int?, status: Int) { if (id == null) return - val folder = "$currentAccount/$RESULT_WATCH_STATE" if (status == WatchType.NONE.internalId) { - removeKey(folder, id.toString()) - removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) + deleteBookmarkedData(id) } else { - setKey(folder, id.toString(), status) + setKey("$currentAccount/$RESULT_WATCH_STATE", id.toString(), status) } } @@ -563,4 +778,9 @@ object DataStoreHelper { getKey("${idPrefix}_sync", id.toString()) } } -} \ No newline at end of file + + var pinnedProviders: Array + get() = getKey(USER_PINNED_PROVIDERS) ?: emptyArray() + set(value) = setKey(USER_PINNED_PROVIDERS, value) + +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt deleted file mode 100644 index 421e44204..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.app.Notification -import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.ForegroundInfo -import androidx.work.WorkerParameters -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO -import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_PACKAGE -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadCheck -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadEpisode -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadFromResume -import com.lagradost.cloudstream3.utils.VideoDownloadManager.downloadStatusEvent -import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadResumePackage -import kotlinx.coroutines.delay - -const val DOWNLOAD_CHECK = "DownloadCheck" - -class DownloadFileWorkManager(val context: Context, private val workerParams: WorkerParameters) : - CoroutineWorker(context, workerParams) { - - override suspend fun doWork(): Result { - val key = workerParams.inputData.getString("key") - try { - if (key == DOWNLOAD_CHECK) { - downloadCheck(applicationContext, ::handleNotification) - } else if (key != null) { - val info = - applicationContext.getKey(WORK_KEY_INFO, key) - val pkg = - applicationContext.getKey( - WORK_KEY_PACKAGE, - key - ) - - if (info != null) { - getDownloadResumePackage(applicationContext, info.ep.id)?.let { dpkg -> - downloadFromResume(applicationContext, dpkg, ::handleNotification) - } ?: run { - downloadEpisode( - applicationContext, - info.source, - info.folder, - info.ep, - info.links, - ::handleNotification - ) - } - } else if (pkg != null) { - downloadFromResume(applicationContext, pkg, ::handleNotification) - } - removeKeys(key) - } - return Result.success() - } catch (e: Exception) { - logError(e) - if (key != null) { - removeKeys(key) - } - return Result.failure() - } - } - - private fun removeKeys(key: String) { - removeKey(WORK_KEY_INFO, key) - removeKey(WORK_KEY_PACKAGE, key) - } - - private suspend fun awaitDownload(id: Int) { - var isDone = false - val listener = { (localId, localType): Pair -> - if (id == localId) { - when (localType) { - VideoDownloadManager.DownloadType.IsDone, VideoDownloadManager.DownloadType.IsFailed, VideoDownloadManager.DownloadType.IsStopped -> { - isDone = true - } - - else -> Unit - } - } - } - downloadStatusEvent += listener - while (!isDone) { - println("AWAITING $id") - delay(1000) - } - downloadStatusEvent -= listener - } - - private fun handleNotification(id: Int, notification: Notification) { - main { - setForegroundAsync(ForegroundInfo(id, notification)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt index 57a5f8e42..f66da4e5f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -3,18 +3,49 @@ package com.lagradost.cloudstream3.utils class Event { private val observers = mutableSetOf<(T) -> Unit>() - val size : Int get() = observers.size + val size: Int get() = observers.size operator fun plusAssign(observer: (T) -> Unit) { - observers.add(observer) + synchronized(observers) { + observers.add(observer) + } } operator fun minusAssign(observer: (T) -> Unit) { - observers.remove(observer) + synchronized(observers) { + observers.remove(observer) + } } operator fun invoke(value: T) { - for (observer in observers) - observer(value) + synchronized(observers) { + for (observer in observers) + observer(value) + } } -} \ No newline at end of file +} + +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/ExtractorApi.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt deleted file mode 100644 index 83c615425..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ /dev/null @@ -1,556 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.net.Uri -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.extractors.* -import kotlinx.coroutines.delay -import org.jsoup.Jsoup -import kotlin.collections.MutableList - -/** - * For use in the ConcatenatingMediaSource. - * If features are missing (headers), please report and we can add it. - * @param durationUs use Long.toUs() for easier input - * */ -data class PlayListItem( - val url: String, - val durationUs: Long, -) - -/** - * Converts Seconds to MicroSeconds, multiplication by 1_000_000 - * */ -fun Long.toUs(): Long { - return this * 1_000_000 -} - -/** - * If your site has an unorthodox m3u8-like system where there are multiple smaller videos concatenated - * use this. - * */ -data class ExtractorLinkPlayList( - override val source: String, - override val name: String, - val playlist: List, - override val referer: String, - override val quality: Int, - override val isM3u8: Boolean = false, - override val headers: Map = mapOf(), - /** Used for getExtractorVerifierJob() */ - override val extractorData: String? = null, -) : ExtractorLink( - source, - name, - // Blank as un-used - "", - referer, - quality, - isM3u8, - headers, - extractorData -) - - -open class ExtractorLink constructor( - open val source: String, - open val name: String, - override val url: String, - override val referer: String, - open val quality: Int, - open val isM3u8: Boolean = false, - override val headers: Map = mapOf(), - /** Used for getExtractorVerifierJob() */ - open val extractorData: String? = null, - open val isDash: Boolean = false, -) : VideoDownloadManager.IDownloadableMinimum { - /** - * Old constructor without isDash, allows for backwards compatibility with extensions. - * Should be removed after all extensions have updated their cloudstream.jar - **/ - constructor( - source: String, - name: String, - url: String, - referer: String, - quality: Int, - isM3u8: Boolean = false, - headers: Map = mapOf(), - /** Used for getExtractorVerifierJob() */ - extractorData: String? = null - ) : this(source, name, url, referer, quality, isM3u8, headers, extractorData, false) - - override fun toString(): String { - return "ExtractorLink(name=$name, url=$url, referer=$referer, isM3u8=$isM3u8)" - } -} - -data class ExtractorUri( - val uri: Uri, - val name: String, - - val basePath: String? = null, - val relativePath: String? = null, - val displayName: String? = null, - - val id: Int? = null, - val parentId: Int? = null, - val episode: Int? = null, - val season: Int? = null, - val headerName: String? = null, - val tvType: TvType? = null, -) - -data class ExtractorSubtitleLink( - val name: String, - override val url: String, - override val referer: String, - override val headers: Map = mapOf() -) : VideoDownloadManager.IDownloadableMinimum - -/** - * Removes https:// and www. - * To match urls regardless of schema, perhaps Uri() can be used? - */ -val schemaStripRegex = Regex("""^(https:|)//(www\.|)""") - -enum class Qualities(var value: Int, val defaultPriority: Int) { - Unknown(400, 4), - P144(144, 0), // 144p - P240(240, 2), // 240p - P360(360, 3), // 360p - P480(480, 4), // 480p - P720(720, 5), // 720p - P1080(1080, 6), // 1080p - P1440(1440, 7), // 1440p - P2160(2160, 8); // 4k or 2160p - - companion object { - fun getStringByInt(qual: Int?): String { - return when (qual) { - 0 -> "Auto" - Unknown.value -> "" - P2160.value -> "4K" - null -> "" - else -> "${qual}p" - } - } - fun getStringByIntFull(quality: Int): String { - return when (quality) { - 0 -> "Auto" - Unknown.value -> "Unknown" - P2160.value -> "4K" - else -> "${quality}p" - } - } - } -} - -fun getQualityFromName(qualityName: String?): Int { - if (qualityName == null) - return Qualities.Unknown.value - - val match = qualityName.lowercase().replace("p", "").trim() - return when (match) { - "4k" -> Qualities.P2160 - else -> null - }?.value ?: match.toIntOrNull() ?: Qualities.Unknown.value -} - -private val packedRegex = Regex("""eval\(function\(p,a,c,k,e,.*\)\)""") -fun getPacked(string: String): String? { - return packedRegex.find(string)?.value -} - -fun getAndUnpack(string: String): String { - val packedText = getPacked(string) - return JsUnpacker(packedText).unpack() ?: string -} - -suspend fun unshortenLinkSafe(url: String): String { - return try { - if (ShortLink.isShortLink(url)) - ShortLink.unshorten(url) - else url - } catch (e: Exception) { - logError(e) - url - } -} - -suspend fun loadExtractor( - url: String, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit -): Boolean { - return loadExtractor( - url = url, - referer = null, - subtitleCallback = subtitleCallback, - callback = callback - ) -} - -/** - * Tries to load the appropriate extractor based on link, returns true if any extractor is loaded. - * */ -suspend fun loadExtractor( - url: String, - referer: String? = null, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit -): Boolean { - val currentUrl = unshortenLinkSafe(url) - val compareUrl = currentUrl.lowercase().replace(schemaStripRegex, "") - for (extractor in extractorApis) { - if (compareUrl.startsWith(extractor.mainUrl.replace(schemaStripRegex, ""))) { - extractor.getSafeUrl(currentUrl, referer, subtitleCallback, callback) - return true - } - } - - return false -} - -val extractorApis: MutableList = arrayListOf( - //AllProvider(), - WcoStream(), - Vidstreamz(), - Vizcloud(), - Vizcloud2(), - VizcloudOnline(), - VizcloudXyz(), - VizcloudLive(), - VizcloudInfo(), - MwvnVizcloudInfo(), - VizcloudDigital(), - VizcloudCloud(), - VizcloudSite(), - VideoVard(), - VideovardSX(), - Mp4Upload(), - StreamTape(), - StreamTapeNet(), - ShaveTape(), - - //mixdrop extractors - MixDropBz(), - MixDropCh(), - MixDropTo(), - - MixDrop(), - - Mcloud(), - XStreamCdn(), - - StreamSB(), - Sblona(), - Vidgomunimesb(), - StreamSB1(), - StreamSB2(), - StreamSB3(), - StreamSB4(), - StreamSB5(), - StreamSB6(), - StreamSB7(), - StreamSB8(), - StreamSB9(), - StreamSB10(), - StreamSB11(), - SBfull(), - // Streamhub(), cause Streamhub2() works - Streamhub2(), - Ssbstream(), - Sbthe(), - Vidgomunime(), - Sbflix(), - Streamsss(), - Sbspeed(), - Sbsonic(), - Sbface(), - Sbrapid(), - Lvturbo(), - - Fastream(), - - FEmbed(), - FeHD(), - Fplayer(), - DBfilm(), - Luxubu(), - LayarKaca(), - Rasacintaku(), - FEnet(), - Kotakajair(), - Cdnplayer(), - // WatchSB(), 'cause StreamSB.kt works - Uqload(), - Uqload1(), - Uqload2(), - Evoload(), - Evoload1(), - UpstreamExtractor(), - - Tomatomatela(), - TomatomatelalClub(), - Cinestart(), - OkRu(), - OkRuHttps(), - Okrulink(), - Sendvid(), - - // dood extractors - DoodCxExtractor(), - DoodPmExtractor(), - DoodToExtractor(), - DoodSoExtractor(), - DoodLaExtractor(), - Dooood(), - DoodWsExtractor(), - DoodShExtractor(), - DoodWatchExtractor(), - DoodWfExtractor(), - DoodYtExtractor(), - - AsianLoad(), - - // GenericM3U8(), - Jawcloud(), - Zplayer(), - ZplayerV2(), - Upstream(), - - Maxstream(), - Tantifilm(), - Userload(), - Supervideo(), - GuardareStream(), - CineGrabber(), - Vanfem(), - - // StreamSB.kt works - // SBPlay(), - // SBPlay1(), - // SBPlay2(), - - PlayerVoxzer(), - - BullStream(), - GMPlayer(), - - Blogger(), - Solidfiles(), - YourUpload(), - - Hxfile(), - KotakAnimeid(), - Neonime8n(), - Neonime7n(), - Yufiles(), - Aico(), - - JWPlayer(), - Meownime(), - DesuArcg(), - DesuOdchan(), - DesuOdvip(), - DesuDrive(), - - Chillx(), - Moviesapi(), - Watchx(), - Bestx(), - Keephealth(), - Sbnet(), - Sbasian(), - Sblongvu(), - Fembed9hd(), - StreamM4u(), - Krakenfiles(), - Gofile(), - Vicloud(), - Uservideo(), - Userscloud(), - - Movhide(), - StreamhideCom(), - StreamhideTo(), - Pixeldrain(), - Wibufile(), - FileMoonIn(), - Moviesm4u(), - Filesim(), - Ahvsh(), - Guccihide(), - FileMoon(), - FileMoonSx(), - Vido(), - Linkbox(), - Acefile(), - SpeedoStream(), - SpeedoStream1(), - Zorofile(), - Embedgram(), - Mvidoo(), - Streamplay(), - Vidmoly(), - Vidmolyme(), - Voe(), - Tubeless(), - Moviehab(), - MoviehabNet(), - Jeniusplay(), - StreamoUpload(), - - Gdriveplayerapi(), - Gdriveplayerapp(), - Gdriveplayerfun(), - Gdriveplayerio(), - Gdriveplayerme(), - Gdriveplayerbiz(), - Gdriveplayerorg(), - Gdriveplayerus(), - Gdriveplayerco(), - Gdriveplayer(), - DatabaseGdrive(), - DatabaseGdrive2(), - - YoutubeExtractor(), - YoutubeShortLinkExtractor(), - YoutubeMobileExtractor(), - YoutubeNoCookieExtractor(), - Streamlare(), - VidSrcExtractor(), - VidSrcExtractor2(), - PlayLtXyz(), - AStreamHub(), - - Cda(), - Dailymotion(), - ByteShare(), - Ztreamhub(), - Rabbitstream(), - Dokicloud(), - Megacloud(), -) - - -fun getExtractorApiFromName(name: String): ExtractorApi { - for (api in extractorApis) { - if (api.name == name) return api - } - return extractorApis[0] -} - -fun requireReferer(name: String): Boolean { - return getExtractorApiFromName(name).requiresReferer -} - -fun httpsify(url: String): String { - return if (url.startsWith("//")) "https:$url" else url -} - -suspend fun getPostForm(requestUrl: String, html: String): String? { - val document = Jsoup.parse(html) - val inputs = document.select("Form > input") - if (inputs.size < 4) return null - var op: String? = null - var id: String? = null - var mode: String? = null - var hash: String? = null - - for (input in inputs) { - val value = input.attr("value") ?: continue - when (input.attr("name")) { - "op" -> op = value - "id" -> id = value - "mode" -> mode = value - "hash" -> hash = value - else -> Unit - } - } - if (op == null || id == null || mode == null || hash == null) { - return null - } - delay(5000) // ye this is needed, wont work with 0 delay - - return app.post( - requestUrl, - headers = mapOf( - "content-type" to "application/x-www-form-urlencoded", - "referer" to requestUrl, - "user-agent" to USER_AGENT, - "accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" - ), - data = mapOf("op" to op, "id" to id, "mode" to mode, "hash" to hash) - ).text -} - -fun ExtractorApi.fixUrl(url: String): String { - if (url.startsWith("http") || - // Do not fix JSON objects when passed as urls. - url.startsWith("{\"") - ) { - return url - } - if (url.isEmpty()) { - return "" - } - - val startsWithNoHttp = url.startsWith("//") - if (startsWithNoHttp) { - return "https:$url" - } else { - if (url.startsWith('/')) { - return mainUrl + url - } - return "$mainUrl/$url" - } -} - -abstract class ExtractorApi { - abstract val name: String - abstract val mainUrl: String - abstract val requiresReferer: Boolean - - /** Determines which plugin a given extractor is from */ - var sourcePlugin: String? = null - - //suspend fun getSafeUrl(url: String, referer: String? = null): List? { - // return suspendSafeApiCall { getUrl(url, referer) } - //} - - // this is the new extractorapi, override to add subtitles and stuff - open suspend fun getUrl( - url: String, - referer: String? = null, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - getUrl(url, referer)?.forEach(callback) - } - - suspend fun getSafeUrl( - url: String, - referer: String? = null, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - try { - getUrl(url, referer, subtitleCallback, callback) - } catch (e: Exception) { - logError(e) - } - } - - /** - * Will throw errors, use getSafeUrl if you don't want to handle the exception yourself - */ - open suspend fun getUrl(url: String, referer: String? = null): List? { - return emptyList() - } - - open fun getExtractorUrl(id: String): String { - return id - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt index 14d1b0556..8456094d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt @@ -1,112 +1,166 @@ package com.lagradost.cloudstream3.utils -import com.lagradost.cloudstream3.app +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.utils.Coroutines.main -import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import java.io.InputStream +import kotlin.let object FillerEpisodeCheck { - private const val MAIN_URL = "https://www.animefillerlist.com" - - 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 } - suspend fun getFillerEpisodes(query: String): HashMap? { - try { - cache[query]?.let { - return it - } - if (!getFillerList()) return null - val localList = list ?: return null + data class Show( + @JsonProperty("slug") + val slug: String, + @JsonProperty("title") + val title: String, + @JsonProperty("filler") + val filler: ArrayList, + @JsonProperty("mixedCanon") + val mixedCanon: ArrayList, + @JsonProperty("mangaCanon") + val mangaCanon: ArrayList, + @JsonProperty("animeCanon") + val animeCanon: ArrayList, + ) - // Strips these from the name - val blackList = listOf( - "TV Dubbed", - "(Dub)", - "Subbed", - "(TV)", - "(Uncensored)", - "(Censored)", - "(\\d+)" // year - ) - val blackListRegex = - Regex( - """ (${ - blackList.joinToString(separator = "|").replace("(", "\\(") - .replace(")", "\\)") - })""" - ) + data class MappingRoot( + @JsonProperty("type") + val type: String?, + @JsonProperty("anidb_id") + val anidbId: Long?, + @JsonProperty("anilist_id") + val anilistId: Long?, + @JsonProperty("animecountdown_id") + val animecountdownId: Long?, + @JsonProperty("animenewsnetwork_id") + val animenewsnetworkId: Long?, + @JsonProperty("anime-planet_id") + val animePlanetId: String?, + @JsonProperty("anisearch_id") + val anisearchId: Long?, + @JsonProperty("imdb_id") + val imdbId: String?, + @JsonProperty("kitsu_id") + val kitsuId: Long?, + @JsonProperty("livechart_id") + val livechartId: Long?, + @JsonProperty("mal_id") + val malId: Long?, + @JsonProperty("simkl_id") + val simklId: Long?, + @JsonProperty("themoviedb_id") + val themoviedbId: Long?, + @JsonProperty("tvdb_id") + val tvdbId: Long?, + @JsonProperty("season") + val season: Season?, + ) - 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() + data class Season( + @JsonProperty("tvdb") + val tvdb: Long?, + @JsonProperty("tmdb") + val tmdb: Long?, + ) + + data class CombinedMedia( + @JsonProperty("mapping") + val mapping: MappingRoot?, + @JsonProperty("show") + val show: Show + ) + + data class Database( + val mal: HashMap = hashMapOf(), + val anilist: HashMap = hashMapOf(), + val kitsu: HashMap = hashMapOf(), + val tmdb: HashMap = hashMapOf(), + val imdb: HashMap = hashMapOf(), + val name: HashMap = hashMapOf(), + ) + + private var database: Database? = null + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String): String = + name.replace(strip, "").lowercase() + + + @Synchronized + @Throws + @WorkerThread + fun loadJson(): Database { + database?.let { + return it + } + + /** The entire "database" is stored as a json file we can parse */ + val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!! + val text = stream.reader().readText() + + val allMedia = parseJson>(text) + val pending = Database() + for (media in allMedia) { + val lowercase = stripName(media.show.title) + pending.name[lowercase] = media + val map = media.mapping ?: continue + + map.imdbId?.let { id -> pending.imdb[id] = media } + map.malId?.let { id -> pending.mal[id] = media } + map.anilistId?.let { id -> pending.anilist[id] = media } + map.kitsuId?.let { id -> pending.kitsu[id] = media } + map.season?.tmdb?.let { id -> pending.tmdb[id] = media } + } + database = pending + return pending + } + + val loadCache: HashMap?> = hashMapOf() + + @Synchronized + @Throws + @WorkerThread + fun getFillerEpisodes(data: LoadResponse): HashSet? { + /** Only for anime */ + if (data.type != TvType.Anime) { return null } + /** Try to hit the cache for this entry, to avoid recreating the hashset */ + loadCache[data.getId()]?.let { cachedResponse -> + return cachedResponse + } + val db = loadJson() + + val media = + db.mal[data.getMalId()?.toLongOrNull()] + ?: db.anilist[data.getAniListId()?.toLongOrNull()] + ?: db.kitsu[data.getKitsuId()?.toLongOrNull()] + ?: db.imdb[data.getImdbId()] + ?: db.tmdb[data.getTMDbId()?.toLongOrNull()] + ?: db.name[stripName(data.name)] + + return media?.show?.filler?.toHashSet().also { response -> + loadCache[data.getId()] = response + } } private fun Int.calc(): Int { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt new file mode 100644 index 000000000..58ff44bb2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt @@ -0,0 +1,20 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context + +/** + * Simple helper to get the short commit hash from assets. + * The hash is generated at build and stored as an asset + * that can be accessed at runtime for Gradle + * configuration cache support. + */ +object GitInfo { + fun Context.currentCommitHash(): String = try { + assets.open("git-hash.txt") + .bufferedReader() + .readText() + .trim() + } catch (_: Exception) { + "" + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt deleted file mode 100644 index 4b0ee8903..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.annotation.SuppressLint -import android.content.Context -import com.bumptech.glide.Glide -import com.bumptech.glide.GlideBuilder -import com.bumptech.glide.Registry -import com.bumptech.glide.annotation.GlideModule -import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.load.model.GlideUrl -import com.bumptech.glide.module.AppGlideModule -import com.bumptech.glide.request.RequestOptions -import com.bumptech.glide.signature.ObjectKey -import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.network.DdosGuardKiller -import com.lagradost.cloudstream3.network.initClient -import com.lagradost.nicehttp.Requests -import java.io.InputStream - -@GlideModule -class GlideModule : AppGlideModule() { - @SuppressLint("CheckResult") - override fun applyOptions(context: Context, builder: GlideBuilder) { - super.applyOptions(context, builder) - builder.apply { - RequestOptions() - .diskCacheStrategy(DiskCacheStrategy.ALL) - .signature(ObjectKey(System.currentTimeMillis().toShort())) - } - } - - // Needed for DOH - // https://stackoverflow.com/a/61634041 - override fun registerComponents(context: Context, glide: Glide, registry: Registry) { - val client = - Requests().apply { - defaultHeaders = mapOf("user-agent" to USER_AGENT) - }.initClient(context) - .newBuilder() - .addInterceptor(DdosGuardKiller(false)) - .build() - - registry.replace( - GlideUrl::class.java, - InputStream::class.java, - OkHttpUrlLoader.Factory(client) - ) - super.registerComponents(context, glide, registry) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt deleted file mode 100644 index b4922945b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.lagradost.cloudstream3.utils - -interface IOnBackPressed { - fun onBackPressed(): Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt new file mode 100644 index 000000000..96193fe45 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt @@ -0,0 +1,187 @@ +package com.lagradost.cloudstream3.utils + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.util.Log +import android.widget.ImageView +import androidx.annotation.DrawableRes +import coil3.EventListener +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.decode.BitmapFactoryDecoder +import coil3.disk.DiskCache +import coil3.dispose +import coil3.load +import coil3.memory.MemoryCache +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.CachePolicy +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.request.bitmapConfig +import coil3.request.crossfade +import coil3.util.DebugLogger +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.network.buildDefaultClient +import okio.Path.Companion.toOkioPath +import java.io.File +import java.nio.ByteBuffer + +object ImageLoader { + private const val TAG = "CoilImgLoader" + internal fun buildImageLoader(context: PlatformContext): ImageLoader { + val isBrokenHardware = hasPotentialBrokenHardware() + return ImageLoader.Builder(context) + .crossfade(200) + .allowHardware(SDK_INT >= 28 && !isBrokenHardware) + .diskCachePolicy(CachePolicy.ENABLED) + .networkCachePolicy(CachePolicy.ENABLED) + .memoryCache { + MemoryCache.Builder().maxSizePercent(context, 0.1)//10 % of heap for mem-cache + .strongReferencesEnabled(false) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) // 512 MB + .maxSizePercent(0.04) // max 4% of storage for disk caching + .build() + } + /** Pass interceptors with care, unnecessary passing tokens to servers + or image hosting services causes unauthorized exceptions **/ + .components { + add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) + if (isBrokenHardware) { + add(BitmapFactoryDecoder.Factory()) + } // sw decoder + } + .apply { + if (isBrokenHardware) { // coil will auto choose optimal config on modern device + bitmapConfig(Bitmap.Config.ARGB_8888) + } + setupCoilLogger() + } + .build() + } + + /** DebugLogger on debug builds which won't slow down release builds & use EventListener for + Errors on release builds. **/ + internal fun ImageLoader.Builder.setupCoilLogger() { + if (BuildConfig.DEBUG) { + logger(DebugLogger()) + } else { + eventListener(object : EventListener() { + override fun onError(request: ImageRequest, result: ErrorResult) { + super.onError(request, result) + Log.e(TAG, "Image load error: ${result.throwable.message ?: result.throwable}") + Log.e(TAG, " URL: ${request.data}") + Log.e(TAG, " allowHardware: ${request.allowHardware}") + Log.e(TAG, " hardware: ${Build.HARDWARE}, board: ${Build.BOARD}") + } + }) + } + } + + /** coil's built in loader attached w/ global synchronized instance **/ + private fun ImageView.loadImageInternal( + imageData: Any?, + headers: Map? = null, + builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations + ) { + // clear image to avoid loading & flickering issue at fast scrolling (~recycler view/lazy column) + this.dispose() + if (imageData == null) return + // setImageResource is better than coil3 on resources due to attr + if (imageData is Int) { + this.setImageResource(imageData); return + } + // headers can be overridden by extensions. + this.load(imageData, SingletonImageLoader.get(context)) { + this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder -> + headerBuilder["User-Agent"] = USER_AGENT + headers?.forEach { (key, value) -> + headerBuilder[key] = value + } + }.build()) + builder() // if passed + } + } + + private fun hasPotentialBrokenHardware(): Boolean { + val hardware = Build.HARDWARE?.lowercase() ?: "" + val board = Build.BOARD?.lowercase() ?: "" + val model = Build.MODEL?.lowercase() ?: "" + val manufacturer = Build.MANUFACTURER?.lowercase() ?: "" + val allwinnerPatterns = listOf("sun50iw9", "h713", "allwinner", "sunxi") + val problematicModels = + listOf("hy320", "hy300", "a10plus", "magcubic", "sinoy", "android tv box") + return allwinnerPatterns.any { it in hardware || it in board || it in manufacturer } || + problematicModels.any { it in model } + } + + /** TYPE_SAFE_LOADERS **/ + fun ImageView.loadImage( + imageData: UiImage?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = when (imageData) { + is UiImage.Image -> loadImageInternal( + imageData = imageData.url, + headers = imageData.headers, + builder = builder + ) + + is UiImage.Bitmap -> loadImageInternal(imageData = imageData.bitmap, builder = builder) + is UiImage.Drawable -> loadImageInternal(imageData = imageData.resId, builder = builder) + null -> loadImageInternal(null, builder = builder) + } + + fun ImageView.loadImage( + imageData: String?, + headers: Map? = null, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) + + fun ImageView.loadImage( + imageData: Uri?, + headers: Map? = null, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) + + fun ImageView.loadImage( + imageData: File?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + @DrawableRes imageData: Int?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + imageData: Drawable?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + imageData: Bitmap?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + imageData: ByteArray?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + imageData: ByteBuffer?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt new file mode 100644 index 000000000..6ed4d4afa --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt @@ -0,0 +1,40 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import coil3.Image +import coil3.asImage + +/// Type safe any image, because THIS IS NOT PYTHON +sealed class UiImage { + data class Image( + val url: String, + val headers: Map? = null + ) : UiImage() + + data class Drawable(@DrawableRes val resId: Int) : UiImage() + data class Bitmap(val bitmap: android.graphics.Bitmap) : UiImage() +} + +fun getImageFromDrawable(context: Context, drawableRes: Int): Image? { + return ContextCompat.getDrawable(context, drawableRes)?.asImage() +} + +fun drawableToBitmap(drawable: Drawable): Bitmap? { + return when (drawable) { + is BitmapDrawable -> drawable.bitmap + else -> { + val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index b2c4aa5cc..b01f6e07e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -3,383 +3,363 @@ package com.lagradost.cloudstream3.utils import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.PackageManager.NameNotFoundException import android.net.Uri import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.content.edit import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.services.PackageInstallerService +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink import okio.buffer import okio.sink -import java.io.File -import android.text.TextUtils -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import java.io.BufferedReader +import java.io.File import java.io.IOException import java.io.InputStreamReader +object InAppUpdater { + private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_REPO = "cloudstream" -class InAppUpdater { - companion object { - const val GITHUB_USER_NAME = "recloudstream" - const val GITHUB_REPO = "cloudstream" + private const val PRERELEASE_PACKAGE_NAME = "com.lagradost.cloudstream3.prerelease" + private const val LOG_TAG = "InAppUpdater" - const val LOG_TAG = "InAppUpdater" + private data class GithubAsset( + @JsonProperty("name") val name: String, + @JsonProperty("size") val size: Int, // Size in bytes + @JsonProperty("browser_download_url") val browserDownloadUrl: String, + @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive + ) - // === IN APP UPDATER === - data class GithubAsset( - @JsonProperty("name") val name: String, - @JsonProperty("size") val size: Int, // Size bytes - @JsonProperty("browser_download_url") val browser_download_url: String, // download link - @JsonProperty("content_type") val content_type: String, // application/vnd.android.package-archive - ) + private data class GithubRelease( + @JsonProperty("tag_name") val tagName: String, // Version code + @JsonProperty("body") val body: String, // Description + @JsonProperty("assets") val assets: List, + @JsonProperty("target_commitish") val targetCommitish: String, // Branch + @JsonProperty("prerelease") val prerelease: Boolean, + @JsonProperty("node_id") val nodeId: String, + ) - data class GithubRelease( - @JsonProperty("tag_name") val tag_name: String, // Version code - @JsonProperty("body") val body: String, // Desc - @JsonProperty("assets") val assets: List, - @JsonProperty("target_commitish") val target_commitish: String, // branch - @JsonProperty("prerelease") val prerelease: Boolean, - @JsonProperty("node_id") val node_id: String //Node Id - ) + private data class GithubObject( + @JsonProperty("sha") val sha: String, // SHA-256 hash + @JsonProperty("type") val type: String, + @JsonProperty("url") val url: String, + ) - data class GithubObject( - @JsonProperty("sha") val sha: String, // sha 256 hash - @JsonProperty("type") val type: String, // object type - @JsonProperty("url") val url: String, - ) + private data class GithubTag( + @JsonProperty("object") val githubObject: GithubObject, + ) - data class GithubTag( - @JsonProperty("object") val github_object: GithubObject, - ) + private data class Update( + @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, + @JsonProperty("updateURL") val updateURL: String?, + @JsonProperty("updateVersion") val updateVersion: String?, + @JsonProperty("changelog") val changelog: String?, + @JsonProperty("updateNodeId") val updateNodeId: String?, + ) - 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.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 - ) + 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 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 found = - response.filter { rel -> - !rel.prerelease - }.sortedWith(compareBy { release -> - release.assets.filter { it.content_type == "application/vnd.android.package-archive" } - .getOrNull(0)?.name?.let { it1 -> - versionRegex.find( - it1 - )?.groupValues?.get(2) - } - }).toList().lastOrNull() - - val foundAsset = found?.assets?.getOrNull(0) - val currentVersion = packageName?.let { - packageManager.getPackageInfo( - it, - 0 - ) - } - - foundAsset?.name?.let { assetName -> - val foundVersion = versionRegex.find(assetName) - val shouldUpdate = - if (foundAsset.browser_download_url != "" && 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.browser_download_url, - foundVersion.groupValues[2], - found.body, - found.node_id - ) - } else { - Update(false, null, null, null, null) + 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) } - 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.tag_name == "pre-release" - } - val foundAsset = found?.assets?.filter { it -> - it.content_type == "application/vnd.android.package-archive" - }?.getOrNull(0) - - val tagResponse = - parseJson(app.get(tagUrl, headers = headers).text) - - Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.github_object.sha.take(7)}") - - val shouldUpdate = - (getString(R.string.commit_hash) - .trim { c -> c.isWhitespace() } - .take(7) - != - tagResponse.github_object.sha - .trim { c -> c.isWhitespace() } - .take(7)) - - return if (foundAsset != null) { - Update( - shouldUpdate, - foundAsset.browser_download_url, - tagResponse.github_object.sha, - found.body, - found.node_id - ) - } else { - Update(false, null, null, null, null) - } + val currentVersion = packageName?.let { + packageManager.getPackageInfo(it, 0) } - - 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 { - it.deleteOnExit() + 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() } - - 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 - } + }?.compareTo( + foundVersion.groupValues.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + })!! < 0 } - 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) - } + 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" } - /** - * @param checkAutoUpdate if the update check was launched automatically - **/ - suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val foundAsset = found?.assets?.filter { it -> + it.contentType == "application/vnd.android.package-archive" + }?.getOrNull(0) - if (!checkAutoUpdate || settingsManager.getBoolean( - getString(R.string.auto_update_key), - true - ) - ) { - val update = getAppUpdate() - if ( - update.shouldUpdate && - update.updateURL != null) { + if (foundAsset == null) { + return Update(false, null, null, null, null) + } - // Check if update should be skipped - val updateNodeId = - settingsManager.getString(getString(R.string.skip_update_key), "") + 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") - // 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 - } + return Update( + currentCommitHash() != updateCommitHash, + foundAsset.browserDownloadUrl, + updateCommitHash, + found.body, + found.nodeId + ) + } - runOnUiThread { - try { - val currentVersion = packageName?.let { - packageManager.getPackageInfo( - it, - 0 - ) - } + private val updateLock = Mutex() - val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setTitle( - getString(R.string.new_update_format).format( - currentVersion?.versionName, - update.updateVersion - ) - ) - builder.setMessage("${update.changelog}") + private suspend fun Activity.downloadUpdate(url: String): Boolean { + try { + Log.d(LOG_TAG, "Downloading update: $url") + val appUpdateName = "CloudStream" + val appUpdateSuffix = "apk" - val context = this - builder.apply { - setPositiveButton(R.string.update) { _, _ -> - // Forcefully start any delayed installations - if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton + // Delete all old updates + this.cacheDir.listFiles()?.filter { + it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix + }?.forEach { deleteFileOnExit(it) } - showToast(R.string.download_started, Toast.LENGTH_LONG) + val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") + val sink: BufferedSink = downloadedFile.sink().buffer() - // Check if the setting hasn't been changed - if (settingsManager.getInt( - getString(R.string.apk_installer_key), - -1 - ) == -1 - ) { - if (isMiUi()) // Set to legacy if using miui - settingsManager.edit() - .putInt(getString(R.string.apk_installer_key), 1) - .apply() - } - - val currentInstaller = - settingsManager.getInt( - getString(R.string.apk_installer_key), - 0 - ) - - when (currentInstaller) { - // New method - 0 -> { - val intent = PackageInstallerService.getIntent( - context, - update.updateURL - ) - ContextCompat.startForegroundService(context, intent) - } - // Legacy - 1 -> { - ioSafe { - if (!downloadUpdate(update.updateURL)) - runOnUiThread { - showToast( - R.string.download_failed, - Toast.LENGTH_LONG - ) - } - } - } - } - } - - setNegativeButton(R.string.cancel) { _, _ -> } - - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit().putString( - getString(R.string.skip_update_key), - update.updateNodeId ?: "" - ).apply() - } - } - } - builder.show().setDefaultFocus() - } catch (e: Exception) { - logError(e) - } - } - return true - } - return false + 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 } - private fun isMiUi(): Boolean { - return !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.name")) + val update = getAppUpdate(installPrerelease) + if (!update.shouldUpdate || update.updateURL == null) { + return false } - private fun getSystemProperty(propName: String): String? { - return try { - val p = Runtime.getRuntime().exec("getprop $propName") - BufferedReader(InputStreamReader(p.inputStream), 1024).use { - it.readLine() + // 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 { + safe { + val currentVersion = packageName?.let { + packageManager.getPackageInfo(it, 0) } - } catch (ex: IOException) { - null + + val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) + 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) + 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 currentInstaller = settingsManager.getInt( + getString(R.string.apk_installer_key), 1 + ) + + when (currentInstaller) { + // New method + 0 -> { + val intent = PackageInstallerService.Companion.getIntent( + this@runAutoUpdate, update.updateURL + ) + ContextCompat.startForegroundService( + this@runAutoUpdate, intent + ) + } + // Legacy + 1 -> { + ioSafe { + if (!downloadUpdate(update.updateURL)) { + runOnUiThread { + showToast( + R.string.download_failed, Toast.LENGTH_LONG + ) + } + } + } + } + } + } + + setNegativeButton(R.string.cancel) { _, _ -> } + + if (checkAutoUpdate) { + setNeutralButton(R.string.skip_update) { _, _ -> + settingsManager.edit { + putString( + getString(R.string.skip_update_key), update.updateNodeId ?: "" + ) + } + } + } + } + builder.show().setDefaultFocus() } } + return true + } + + private fun isMiUi(): Boolean = !getSystemProperty("ro.miui.ui.version.name").isNullOrEmpty() + + private fun getSystemProperty(propName: String): String? = try { + val p = Runtime.getRuntime().exec("getprop $propName") + BufferedReader(InputStreamReader(p.inputStream), 1024).use { + it.readLine() + } + } catch (_: IOException) { + null } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt new file mode 100644 index 000000000..d37d8aad4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt @@ -0,0 +1,24 @@ +package com.lagradost.cloudstream3.utils + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build + +inline fun Intent.getSafeParcelableExtra(key: String): T? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) + @Suppress("DEPRECATION") + getParcelableExtra(key) else getParcelableExtra(key, T::class.java) + +@SuppressLint("UnspecifiedRegisterReceiverFlag") +fun Context.registerBroadcastReceiver(receiver: BroadcastReceiver, actionFilter: IntentFilter) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Register receiver with the context with flag to indicate internal usage + registerReceiver(receiver, actionFilter, Context.RECEIVER_NOT_EXPORTED) + } else { + // For older versions, no special export flag is needed + registerReceiver(receiver, actionFilter) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt deleted file mode 100644 index 11dfa4416..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ /dev/null @@ -1,306 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.app -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.delay -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec -import kotlin.math.pow - -/** backwards api surface */ -class M3u8Helper { - companion object { - suspend fun generateM3u8( - source: String, - streamUrl: String, - referer: String, - quality: Int? = null, - headers: Map = mapOf(), - name: String = source - ): List { - return M3u8Helper2.generateM3u8(source, streamUrl, referer, quality, headers, name) - } - } - - - data class M3u8Stream( - val streamUrl: String, - val quality: Int? = null, - val headers: Map = mapOf() - ) - - suspend fun m3u8Generation(m3u8: M3u8Stream, returnThis: Boolean? = true): List { - return M3u8Helper2.m3u8Generation(m3u8, returnThis) - } -} - -object M3u8Helper2 { - suspend fun generateM3u8( - source: String, - streamUrl: String, - referer: String, - quality: Int? = null, - headers: Map = mapOf(), - name: String = source - ): List { - return m3u8Generation( - M3u8Helper.M3u8Stream( - streamUrl = streamUrl, - quality = quality, - headers = headers, - ), null - ) - .map { stream -> - ExtractorLink( - source, - name = name, - stream.streamUrl, - referer, - stream.quality ?: Qualities.Unknown.value, - true, - stream.headers, - ) - } - } - - private val ENCRYPTION_DETECTION_REGEX = Regex("#EXT-X-KEY:METHOD=([^,]+),") - private val ENCRYPTION_URL_IV_REGEX = - Regex("#EXT-X-KEY:METHOD=([^,]+),URI=\"([^\"]+)\"(?:,IV=(.*))?") - private val QUALITY_REGEX = - Regex("""#EXT-X-STREAM-INF:(?:(?:.*?(?:RESOLUTION=\d+x(\d+)).*?\s+(.*))|(?:.*?\s+(.*)))""") - private val TS_EXTENSION_REGEX = - Regex("""#EXTINF:.*\n(.+?\n)""") // fuck it we ball, who cares about the type anyways - //Regex("""(.*\.(ts|jpg|html).*)""") //.jpg here 'case vizcloud uses .jpg instead of .ts - - private fun absoluteExtensionDetermination(url: String): String? { - val split = url.split("/") - val gg: String = split[split.size - 1].split("?")[0] - return if (gg.contains(".")) { - gg.split(".").ifEmpty { null }?.last() - } else null - } - - private fun toBytes16Big(n: Int): ByteArray { - return ByteArray(16) { - val fixed = n / 256.0.pow((15 - it)) - (maxOf(0, fixed.toInt()) % 256).toByte() - } - } - - private fun defaultIv(index: Int) : ByteArray { - return toBytes16Big(index+1) - } - - fun getDecrypted( - secretKey: ByteArray, - data: ByteArray, - iv: ByteArray = byteArrayOf(), - index : Int, - ): ByteArray { - val ivKey = if (iv.isEmpty()) defaultIv(index) else iv - val c = Cipher.getInstance("AES/CBC/PKCS5Padding") - val skSpec = SecretKeySpec(secretKey, "AES") - val ivSpec = IvParameterSpec(ivKey) - c.init(Cipher.DECRYPT_MODE, skSpec, ivSpec) - return c.doFinal(data) - } - - private fun isEncrypted(m3u8Data: String): Boolean { - val st = ENCRYPTION_DETECTION_REGEX.find(m3u8Data) - return st != null && (st.value.isNotEmpty() || st.destructured.component1() != "NONE") - } - - - private fun selectBest(qualities: List): M3u8Helper.M3u8Stream? { - val result = qualities.sortedBy { - if (it.quality != null && it.quality <= 1080) it.quality else 0 - }.filter { - listOf("m3u", "m3u8").contains(absoluteExtensionDetermination(it.streamUrl)) - } - return result.lastOrNull() - } - - private fun getParentLink(uri: String): String { - val split = uri.split("/").toMutableList() - split.removeLast() - return split.joinToString("/") - } - - private fun isNotCompleteUrl(url: String): Boolean { - return !url.startsWith("https://") && !url.startsWith("http://") - } - - suspend fun m3u8Generation(m3u8: M3u8Helper.M3u8Stream, returnThis: Boolean? = true): List { - val list = mutableListOf() - - val m3u8Parent = getParentLink(m3u8.streamUrl) - val response = app.get(m3u8.streamUrl, headers = m3u8.headers, verify = false).text - - for (match in QUALITY_REGEX.findAll(response)) { - var (quality, m3u8Link, m3u8Link2) = match.destructured - if (m3u8Link.isEmpty()) m3u8Link = m3u8Link2 - if (absoluteExtensionDetermination(m3u8Link) == "m3u8") { - if (isNotCompleteUrl(m3u8Link)) { - m3u8Link = "$m3u8Parent/$m3u8Link" - } - if (quality.isEmpty()) { - println(m3u8.streamUrl) - } - list += m3u8Generation( - M3u8Helper.M3u8Stream( - m3u8Link, - quality.toIntOrNull(), - m3u8.headers - ), false - ) - } - list += M3u8Helper.M3u8Stream( - m3u8Link, - quality.toIntOrNull(), - m3u8.headers - ) - } - if (returnThis != false) { - list += M3u8Helper.M3u8Stream( - m3u8.streamUrl, - Qualities.Unknown.value, - m3u8.headers - ) - } - - return list - } - - data class LazyHlsDownloadData( - private val encryptionData: ByteArray, - private val encryptionIv: ByteArray, - private val isEncrypted: Boolean, - private val allTsLinks: List, - private val relativeUrl: String, - private val headers: Map, - ) { - val size get() = allTsLinks.size - - suspend fun resolveLinkWhileSafe( - index: Int, - tries: Int = 3, - failDelay: Long = 3000, - condition : (() -> Boolean) - ): ByteArray? { - for (i in 0 until tries) { - if(!condition()) return null - - try { - val out = resolveLink(index) - return if(condition()) out else null - } catch (e: IllegalArgumentException) { - return null - } catch (e : CancellationException) { - return null - } catch (t: Throwable) { - delay(failDelay) - } - } - return null - } - - suspend fun resolveLinkSafe( - index: Int, - tries: Int = 3, - failDelay: Long = 3000 - ): ByteArray? { - for (i in 0 until tries) { - try { - return resolveLink(index) - } catch (e: IllegalArgumentException) { - return null - } catch (e : CancellationException) { - return null - } catch (t: Throwable) { - delay(failDelay) - } - } - return null - } - - @Throws - suspend fun resolveLink(index: Int): ByteArray { - if (index < 0 || index >= size) throw IllegalArgumentException("index must be in the bounds of the ts") - val url = allTsLinks[index] - - val tsResponse = app.get(url, headers = headers, verify = false) - val tsData = tsResponse.body.bytes() - if (tsData.isEmpty()) throw ErrorLoadingException("no data") - - return if (isEncrypted) { - getDecrypted(encryptionData, tsData, encryptionIv, index) - } else { - tsData - } - } - } - - @Throws - suspend fun hslLazy( - qualities: List - ): LazyHlsDownloadData { - if (qualities.isEmpty()) throw IllegalArgumentException("qualities must be non empty") - val selected = selectBest(qualities) ?: qualities.first() - val headers = selected.headers - val streams = qualities.map { m3u8Generation(it, false) }.flatten() - // this selects the best quality of the qualities offered, - // due to the recursive nature of m3u8, we only go 2 depth - val secondSelection = selectBest(streams.ifEmpty { listOf(selected) }) - ?: throw IllegalArgumentException("qualities has no streams") - - val m3u8Response = - app.get( - secondSelection.streamUrl, - headers = headers, - verify = false - ).text - - // encryption, this is because crunchy uses it - var encryptionIv = byteArrayOf() - var encryptionData = byteArrayOf() - - val encryptionState = isEncrypted(m3u8Response) - - if (encryptionState) { - // its safe to assume that its not going to be null - val match = - ENCRYPTION_URL_IV_REGEX.find(m3u8Response)!!.groupValues - - var encryptionUri = match[2] - - if (isNotCompleteUrl(encryptionUri)) { - encryptionUri = "${getParentLink(secondSelection.streamUrl)}/$encryptionUri" - } - - encryptionIv = match[3].toByteArray() - val encryptionKeyResponse = app.get(encryptionUri, headers = headers, verify = false) - encryptionData = encryptionKeyResponse.body.bytes() - } - val relativeUrl = getParentLink(secondSelection.streamUrl) - val allTsList = TS_EXTENSION_REGEX.findAll(m3u8Response + "\n").map { ts -> - val value = ts.groupValues[1] - if (isNotCompleteUrl(value)) { - "$relativeUrl/${value}" - } else { - value - } - }.toList() - if (allTsList.isEmpty()) throw IllegalArgumentException("ts must be non empty") - - return LazyHlsDownloadData( - encryptionData = encryptionData, - encryptionIv = encryptionIv, - isEncrypted = encryptionState, - allTsLinks = allTsList, - relativeUrl = relativeUrl, - headers = headers - ) - } -} 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 bc81a5b9f..67851f629 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context @@ -8,16 +9,17 @@ import android.content.IntentFilter import android.content.IntentSender import android.content.pm.PackageInstaller import android.os.Build +import android.util.Log import android.widget.Toast +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.services.PackageInstallerService import com.lagradost.cloudstream3.utils.Coroutines.main import java.io.InputStream const val INSTALL_ACTION = "ApkInstaller.INSTALL_ACTION" - class ApkInstaller(private val service: PackageInstallerService) { companion object { @@ -25,6 +27,8 @@ class ApkInstaller(private val service: PackageInstallerService) { * Used for postponed installations **/ var delayedInstaller: DelayedInstaller? = null + private var isReceiverRegistered = false + private const val TAG = "ApkInstaller" } inner class DelayedInstaller( @@ -36,6 +40,7 @@ class ApkInstaller(private val service: PackageInstallerService) { session.commit(intent) true } catch (e: Exception) { + logError(e) false }.also { delayedInstaller = null } } @@ -51,13 +56,14 @@ class ApkInstaller(private val service: PackageInstallerService) { } private val installActionReceiver = object : BroadcastReceiver() { + @SuppressLint("UnsafeIntentLaunch") override fun onReceive(context: Context, intent: Intent) { when (intent.getIntExtra( PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE )) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val userAction = intent.getParcelableExtra(Intent.EXTRA_INTENT) + val userAction = intent.getSafeParcelableExtra(Intent.EXTRA_INTENT) userAction?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(userAction) } @@ -104,12 +110,20 @@ class ApkInstaller(private val service: PackageInstallerService) { inputStream.close() } + // We must create an explicit intent or it will fail on Android 15+ + val installIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Intent(service, PackageInstallerService::class.java) + .setAction(INSTALL_ACTION) + } else Intent(INSTALL_ACTION) + + val installFlags = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> PendingIntent.FLAG_MUTABLE + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> PendingIntent.FLAG_IMMUTABLE + else -> 0 + } val intentSender = PendingIntent.getBroadcast( - service, - activeSession, - Intent(INSTALL_ACTION), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0, + service, activeSession, installIntent, installFlags ).intentSender // Use delayed installations on android 13 and only if "allow from unknown sources" is enabled @@ -141,8 +155,30 @@ class ApkInstaller(private val service: PackageInstallerService) { } init { - service.registerReceiver(installActionReceiver, IntentFilter(INSTALL_ACTION)) - service.receivers.add(installActionReceiver) + // Might be dangerous + registerInstallActionReceiver() + } + + private fun registerInstallActionReceiver() { + if (!isReceiverRegistered) { + val intentFilter = IntentFilter().apply { + addAction(INSTALL_ACTION) + } + Log.d(TAG, "Registering install action event receiver") + context?.registerBroadcastReceiver(installActionReceiver, intentFilter) + isReceiverRegistered = true + } + } + + fun unregisterInstallActionReceiver() { + if (isReceiverRegistered) { + Log.d(TAG, "Unregistering install action event receiver") + try { + context?.unregisterReceiver(installActionReceiver) + } catch (e: Exception) { + logError(e) + } + isReceiverRegistered = false + } } } - diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt new file mode 100644 index 000000000..6580182bb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -0,0 +1,168 @@ +package com.lagradost.cloudstream3.utils +//Reference: https://stackoverflow.com/a/29055283 +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?, + defStyle: Int + ) : super(context!!, attrs, defStyle) { + initAttrs(context, attrs) + } + + var cropYCenterOffsetPct: Float + get() = mCropYCenterOffsetPct!! + set(cropYCenterOffsetPct) { + require(cropYCenterOffsetPct <= 1.0) { "Value too large: Must be <= 1.0" } + mCropYCenterOffsetPct = cropYCenterOffsetPct + } + var cropXCenterOffsetPct: Float + get() = mCropXCenterOffsetPct!! + set(cropXCenterOffsetPct) { + require(cropXCenterOffsetPct <= 1.0) { "Value too large: Must be <= 1.0" } + mCropXCenterOffsetPct = cropXCenterOffsetPct + } + + private fun myConfigureBounds() { + if (this.scaleType == ScaleType.MATRIX) { + + val d = this.drawable + if (d != null) { + val dWidth = d.intrinsicWidth + val dHeight = d.intrinsicHeight + val m = Matrix() + val vWidth = width - this.paddingLeft - this.paddingRight + val vHeight = height - this.paddingTop - this.paddingBottom + val scale: Float + var dx = 0f + var dy = 0f + if (dWidth * vHeight > vWidth * dHeight) { + val cropXCenterOffsetPct = + if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!! else 0.5f + scale = vHeight.toFloat() / dHeight.toFloat() + dx = (vWidth - dWidth * scale) * cropXCenterOffsetPct + } else { + val cropYCenterOffsetPct = + if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!! else 0f + scale = vWidth.toFloat() / dWidth.toFloat() + dy = (vHeight - dHeight * scale) * cropYCenterOffsetPct + } + m.setScale(scale, scale) + m.postTranslate((dx + 0.5f).toInt().toFloat(), (dy + 0.5f).toInt().toFloat()) + this.imageMatrix = m + } + } + } + + // These 3 methods call configureBounds in ImageView.java class, which + // adjusts the matrix in a call to center_crop (android's built-in + // scaling and centering crop method). We also want to trigger + // in the same place, but using our own matrix, which is then set + // directly at line 588 of ImageView.java and then copied over + // as the draw matrix at line 942 of ImageView.java + override fun setFrame(l: Int, t: Int, r: Int, b: Int): Boolean { + val changed = super.setFrame(l, t, r, b) + myConfigureBounds() + return changed + } + + override fun setImageDrawable(d: Drawable?) { + super.setImageDrawable(d) + myConfigureBounds() + } + + override fun setImageResource(resId: Int) { + 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(); + fun redraw() { + val d = this.drawable + if (d != null) { + // Force toggle to recalculate our bounds + setImageDrawable(null) + 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 new file mode 100644 index 000000000..e3c7d68df --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -0,0 +1,82 @@ +package com.lagradost.cloudstream3.utils + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.Build.VERSION.SDK_INT +import android.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 +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout + +private const val PACKAGE_NAME = BuildConfig.APPLICATION_ID +private const val TAG = "PowerManagerAPI" + +object BatteryOptimizationChecker { + + fun isAppRestricted(context: Context?): Boolean { + if (SDK_INT >= 23 && context != null) { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return !powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + return false // below Marshmallow, it's always unrestricted when app is in background + } + + fun openBatteryOptimizationSettings(context: Context) { + if (shouldShowBatteryOptimizationDialog(context)) { + context.showBatteryOptimizationDialog() + } + } + + fun Context.showBatteryOptimizationDialog() { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + try { + AlertDialog.Builder(this) + .setTitle(R.string.battery_dialog_title) + .setIcon(R.drawable.ic_battery) + .setMessage(R.string.battery_dialog_message) + .setPositiveButton(R.string.ok) { _, _ -> showRequestIgnoreBatteryOptDialog() } + .setNegativeButton(R.string.cancel) { _, _ -> + settingsManager.edit { + putBoolean(getString(R.string.battery_optimisation_key), false) + } + } + .show() + } catch (t: Throwable) { + Log.e(TAG, "Error showing battery optimization dialog", t) + } + } + + private fun shouldShowBatteryOptimizationDialog(context: Context): Boolean { + val isRestricted = isAppRestricted(context) + val isOptimizedNotShown = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.battery_optimisation_key), true) + return isRestricted && isOptimizedNotShown && isLayout(PHONE) + } + + private fun Context.showRequestIgnoreBatteryOptDialog() { + try { + val intent = Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = "package:$PACKAGE_NAME".toUri() + } + startActivity(intent) + } catch (t: Throwable) { + Log.e(TAG, "Unable to invoke APP_DETAILS intent", t) + if (t is ActivityNotFoundException) { + showToast("Exception: Activity Not Found") + } else { + showToast(R.string.app_info_intent_error) + } + } + } +} 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 8285b8ab0..26c710103 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -2,14 +2,12 @@ package com.lagradost.cloudstream3.utils import android.app.Activity import android.app.Dialog +import android.text.Spanned import android.view.LayoutInflater import android.view.View import android.widget.AbsListView import android.widget.ArrayAdapter -import android.widget.EditText -import android.widget.ImageView import android.widget.LinearLayout -import android.widget.ListView import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone @@ -19,11 +17,17 @@ import androidx.core.view.marginRight import androidx.core.view.marginTop import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding import com.lagradost.cloudstream3.databinding.BottomSelectionDialogBinding -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.databinding.BottomTextDialogBinding +import com.lagradost.cloudstream3.databinding.OptionsPopupTvBinding +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.UIHelper.setImage object SingleSelectionHelper { fun Activity?.showOptionSelectStringRes( @@ -53,15 +57,16 @@ object SingleSelectionHelper { ) { if (this == null) return - if (isTvSettings()) { - val builder = - AlertDialog.Builder(this, R.style.AlertDialogCustom) - .setView(R.layout.options_popup_tv) + // This was temporarily removed until better UI is made + /*if (isLayout(TV or EMULATOR)) { + val binding = OptionsPopupTvBinding.inflate(layoutInflater) + val dialog = AlertDialog.Builder(this, R.style.AlertDialogCustom) + .setView(binding.root) + .create() - val dialog = builder.create() dialog.show() - dialog.findViewById(R.id.listview1)?.let { listView -> + binding.listview1.let { listView -> listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE listView.adapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice_color).apply { @@ -74,20 +79,20 @@ object SingleSelectionHelper { } } - dialog.findViewById(R.id.imageView)?.apply { + binding.imageView.apply { isGone = poster.isNullOrEmpty() - setImage(poster) - } - } else { - view?.popupMenuNoIconsAndNoStringRes(options.mapIndexed { index, s -> - Pair( - index, - s - ) - }) { - callback(Pair(false, this.itemId)) + loadImage(poster) } + } else {*/ + view?.popupMenuNoIconsAndNoStringRes(options.mapIndexed { index, s -> + Pair( + index, + s + ) + }) { + callback(Pair(false, this.itemId)) } + //} } fun Activity?.showDialog( @@ -105,12 +110,16 @@ object SingleSelectionHelper { if (this == null) return val realShowApply = showApply || isMultiSelect - val listView = binding.listview1//.findViewById(R.id.listview1)!! - val textView = binding.text1//.findViewById(R.id.text1)!! - val applyButton = binding.applyBtt//.findViewById(R.id.apply_btt) - val cancelButton = binding.cancelBtt//findViewById(R.id.cancel_btt) - val applyHolder = - binding.applyBttHolder//.findViewById(R.id.apply_btt_holder) + val listView = binding.listview1 + 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 + } applyHolder.isVisible = realShowApply if (!realShowApply) { @@ -173,8 +182,8 @@ object SingleSelectionHelper { } } - private fun Activity?.showInputDialog( + binding: BottomInputDialogBinding, dialog: Dialog, value: String, name: String, @@ -184,11 +193,11 @@ object SingleSelectionHelper { ) { if (this == null) return - val inputView = dialog.findViewById(R.id.nginx_text_input)!! - val textView = dialog.findViewById(R.id.text1)!! - val applyButton = dialog.findViewById(R.id.apply_btt)!! - val cancelButton = dialog.findViewById(R.id.cancel_btt)!! - val applyHolder = dialog.findViewById(R.id.apply_btt_holder)!! + val inputView = binding.nginxTextInput + val textView = binding.text1 + val applyButton = binding.applyBtt + val cancelButton = binding.cancelBtt + val applyHolder = binding.applyBttHolder applyHolder.isVisible = true textView.text = name @@ -350,11 +359,17 @@ object SingleSelectionHelper { dismissCallback: () -> Unit, callback: (String) -> Unit, ) { - val builder = BottomSheetDialog(this) // probably the stuff at the bottom - builder.setContentView(R.layout.bottom_input_dialog) // input layout + val builder = BottomSheetDialog(this) + + val binding: BottomInputDialogBinding = BottomInputDialogBinding.inflate( + LayoutInflater.from(this) + ) + + builder.setContentView(binding.root) builder.show() showInputDialog( + binding, builder, value, name, @@ -363,4 +378,24 @@ object SingleSelectionHelper { dismissCallback ) } + + fun Activity.showBottomDialogText( + title: String, + text: Spanned, + dismissCallback: () -> Unit + ) { + val binding = BottomTextDialogBinding.inflate(layoutInflater) + val dialog = BottomSheetDialog(this) + + dialog.setContentView(binding.root) + + binding.dialogTitle.text = title + binding.dialogText.text = text + + dialog.setOnDismissListener { + dismissCallback.invoke() + } + + dialog.show() + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt new file mode 100644 index 000000000..b43b51c74 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt @@ -0,0 +1,83 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Activity +import android.view.View +import androidx.annotation.MainThread +import androidx.annotation.StringRes +import com.google.android.material.snackbar.Snackbar +import com.lagradost.api.Log +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute + +object SnackbarHelper { + + private const val TAG = "COMPACT" + private var currentSnackbar: Snackbar? = null + + @MainThread + fun showSnackbar( + act: Activity?, + message: UiText, + duration: Int = Snackbar.LENGTH_SHORT, + actionText: UiText? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null) return + showSnackbar(act, message.asString(act), duration, + actionText?.asString(act), actionCallback) + } + + @MainThread + fun showSnackbar( + act: Activity?, + @StringRes message: Int, + duration: Int = Snackbar.LENGTH_SHORT, + @StringRes actionText: Int? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null) return + showSnackbar(act, act.getString(message), duration, + actionText?.let { act.getString(it) }, actionCallback) + } + + @MainThread + fun showSnackbar( + act: Activity?, + message: String?, + duration: Int = Snackbar.LENGTH_SHORT, + actionText: String? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null || message == null) { + Log.w(TAG, "Invalid showSnackbar: act = $act, message = $message") + return + } + Log.i(TAG, "showSnackbar: $message") + + try { + currentSnackbar?.dismiss() + } catch (e: Exception) { + logError(e) + } + + try { + val parentView = act.findViewById(android.R.id.content) + val snackbar = Snackbar.make(parentView, message, duration) + + actionCallback?.let { + snackbar.setAction(actionText) { actionCallback.invoke() } + } + + snackbar.show() + currentSnackbar = snackbar + + snackbar.setBackgroundTint(act.colorFromAttribute(R.attr.primaryBlackBackground)) + snackbar.setTextColor(act.colorFromAttribute(R.attr.textColor)) + snackbar.setActionTextColor(act.colorFromAttribute(R.attr.colorPrimary)) + + } catch (e: Exception) { + logError(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt deleted file mode 100644 index 33f1b6ffd..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt +++ /dev/null @@ -1,518 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.lagradost.cloudstream3.mvvm.logError -import java.util.* - - -object SubtitleHelper { - data class Language639( - val languageName: String, - val nativeName: String, - val ISO_639_1: String, - val ISO_639_2_T: String, - val ISO_639_2_B: String, - val ISO_639_3: String, - val ISO_639_6: String, - ) - - /*fun createISO() { - val url = "https://infogalactic.com/info/List_of_ISO_639-1_codes" - val response = get(url).text - val document = Jsoup.parse(response) - val headers = document.select("table.wikitable > tbody > tr") - - var text = "listOf(\n" - for (head in headers) { - val tds = head.select("td") - if (tds.size < 8) continue - val name = tds[2].selectFirst("> a").text() - val native = tds[3].text() - val ISO_639_1 = tds[4].ownText().replace("+", "").replace(" ", "") - val ISO_639_2_T = tds[5].ownText().replace("+", "").replace(" ", "") - val ISO_639_2_B = tds[6].ownText().replace("+", "").replace(" ", "") - val ISO_639_3 = tds[7].ownText().replace("+", "").replace(" ", "") - val ISO_639_6 = tds[8].ownText().replace("+", "").replace(" ", "") - - val txtAdd = - "Language(\"$name\", \"$native\", \"$ISO_639_1\", \"$ISO_639_2_T\", \"$ISO_639_2_B\", \"$ISO_639_3\", \"$ISO_639_6\"),\n" - text += txtAdd - } - text += ")" - println("ISO CREATED:\n$text") - }*/ - - /** lang -> ISO_639_1 - * @param looseCheck will use .contains in addition to .equals - * */ - fun fromLanguageToTwoLetters(input: String, looseCheck: Boolean): String? { - languages.forEach { - if (it.languageName.equals(input, ignoreCase = true) - || it.nativeName.equals(input, ignoreCase = true) - ) return it.ISO_639_1 - } - - // Runs as a separate loop as to prioritize fully matching languages. - if (looseCheck) - languages.forEach { - if (input.contains(it.languageName, ignoreCase = true) - || input.contains(it.nativeName, ignoreCase = true) - ) return it.ISO_639_1 - } - - return null - } - - private var ISO_639_1Map: HashMap = hashMapOf() - private fun initISO6391Map() { - for (lang in languages) { - ISO_639_1Map[lang.ISO_639_1] = lang.languageName - } - } - - /** ISO_639_1 -> lang*/ - fun fromTwoLettersToLanguage(input: String): String? { - // pr-BR - if (input.substringBefore("-").length != 2) return null - if (ISO_639_1Map.isEmpty()) { - initISO6391Map() - } - val comparison = input.lowercase(Locale.ROOT) - - return ISO_639_1Map[comparison] - } - - /**ISO_639_2_B or ISO_639_2_T or ISO_639_3-> lang*/ - fun fromThreeLettersToLanguage(input: String): String? { - if (input.length != 3) return null - val comparison = input.lowercase(Locale.ROOT) - for (lang in languages) { - if (lang.ISO_639_2_B == comparison) { - return lang.languageName - } - } - for (lang in languages) { - if (lang.ISO_639_2_T == comparison) { - return lang.languageName - } - } - for (lang in languages) { - if (lang.ISO_639_3 == comparison) { - return lang.languageName - } - } - return null - } - - /** lang -> ISO_639_2_T*/ - fun fromLanguageToThreeLetters(input: String): String? { - for (lang in languages) { - if (lang.languageName == input || lang.nativeName == input) { - return lang.ISO_639_2_T - } - } - return null - } - - private const val flagOffset = 0x1F1E6 - private const val asciiOffset = 0x41 - private const val offset = flagOffset - asciiOffset - - private val flagRegex = Regex("[\uD83C\uDDE6-\uD83C\uDDFF]{2}") - - fun getFlagFromIso(inp: String?): String? { - if (inp.isNullOrBlank() || inp.length < 2) return null - - try { - val ret = getFlagFromIsoShort(flags[inp]) - ?: getFlagFromIsoShort(inp.uppercase()) ?: return null - - return if (flagRegex.matches(ret)) { - ret - } else { - null - } - } catch (e: Exception) { - logError(e) - return null - } - } - - private fun getFlagFromIsoShort(flagAscii: String?): String? { - if (flagAscii.isNullOrBlank() || flagAscii.length < 2) return null - return try { - val firstChar: Int = Character.codePointAt(flagAscii, 0) + offset - val secondChar: Int = Character.codePointAt(flagAscii, 1) + offset - - (String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))) - } catch (e: Exception) { - logError(e) - null - } - } - - private val flags = mapOf( - "af" to "ZA", - "agq" to "CM", - "ak" to "GH", - "am" to "ET", - "ar" to "AE", - "as" to "IN", - "asa" to "TZ", - "az" to "AZ", - "bas" to "CM", - "be" to "BY", - "bem" to "ZM", - "bez" to "IT", - "bg" to "BG", - "bm" to "ML", - "bn" to "BD", - "bo" to "CN", - "br" to "FR", - "brx" to "IN", - "bs" to "BA", - "ca" to "ES", - "cgg" to "UG", - "chr" to "US", - "cs" to "CZ", - "cy" to "GB", - "da" to "DK", - "dav" to "KE", - "de" to "DE", - "dje" to "NE", - "dua" to "CM", - "dyo" to "SN", - "ebu" to "KE", - "ee" to "GH", - "en" to "GB", - "el" to "GR", - "es" to "ES", - "et" to "EE", - "eu" to "ES", - "ewo" to "CM", - "fa" to "IR", - "fil" to "PH", - "fr" to "FR", - "ga" to "IE", - "gl" to "ES", - "gsw" to "CH", - "gu" to "IN", - "guz" to "KE", - "gv" to "GB", - "ha" to "NG", - "haw" to "US", - "he" to "IL", - "hi" to "IN", - "ff" to "CN", - "fi" to "FI", - "fo" to "FO", - "hr" to "HR", - "hu" to "HU", - "hy" to "AM", - "id" to "ID", - "ig" to "NG", - "ii" to "CN", - "is" to "IS", - "it" to "IT", - "ita" to "IT", - "ja" to "JP", - "jmc" to "TZ", - "ka" to "GE", - "kab" to "DZ", - "ki" to "KE", - "kam" to "KE", - "mer" to "KE", - "kde" to "TZ", - "kea" to "CV", - "khq" to "ML", - "kk" to "KZ", - "kl" to "GL", - "kln" to "KE", - "km" to "KH", - "kn" to "IN", - "ko" to "KR", - "kok" to "IN", - "ksb" to "TZ", - "ksf" to "CM", - "kw" to "GB", - "lag" to "TZ", - "lg" to "UG", - "ln" to "CG", - "lt" to "LT", - "lu" to "CD", - "lv" to "LV", - "lat" to "LV", - "luo" to "KE", - "luy" to "KE", - "mas" to "TZ", - "mfe" to "MU", - "mg" to "MG", - "mgh" to "MZ", - "ml" to "IN", - "mk" to "MK", - "mr" to "IN", - "ms" to "MY", - "mt" to "MT", - "mua" to "CM", - "my" to "MM", - "naq" to "NA", - "nb" to "NO", - "no" to "NO", - "nn" to "NO", - "nd" to "ZW", - "ne" to "NP", - "nl" to "NL", - "nmg" to "CM", - "nus" to "SD", - "nyn" to "UG", - "om" to "ET", - "or" to "IN", - "pa" to "PK", - "pl" to "PL", - "ps" to "AF", - "pt" to "PT", - "pt-pt" to "PT", - "pt-br" to "BR", - "rm" to "CH", - "rn" to "BI", - "ro" to "RO", - "ru" to "RU", - "rw" to "RW", - "rof" to "TZ", - "rwk" to "TZ", - "saq" to "KE", - "sbp" to "TZ", - "seh" to "MZ", - "ses" to "ML", - "sg" to "CF", - "shi" to "MA", - "si" to "LK", - "sk" to "SK", - "sl" to "SI", - "sn" to "ZW", - "so" to "SO", - "sq" to "AL", - "sr" to "RS", - "sv" to "SE", - "sw" to "TZ", - "swc" to "CD", - "ta" to "IN", - "te" to "IN", - "teo" to "UG", - "th" to "TH", - "ti" to "ET", - "to" to "TO", - "tr" to "TR", - "twq" to "NE", - "tzm" to "MA", - "uk" to "UA", - "ur" to "PK", - "uz" to "UZ", - "vai" to "LR", - "vi" to "VN", - "vun" to "TZ", - "xog" to "UG", - "yav" to "CM", - "yo" to "NG", - "zh" to "CN", - "zu" to "ZA", - "tl" to "PH", - ) - - val languages = listOf( - Language639("Abkhaz", "аҧсуа бызшәа, аҧсшәа", "ab", "abk", "abk", "abk", "abks"), - Language639("Afar", "Afaraf", "aa", "aar", "aar", "aar", "aars"), - Language639("Afrikaans", "Afrikaans", "af", "afr", "afr", "afr", "afrs"), - Language639("Akan", "Akan", "ak", "aka", "aka", "aka", ""), - Language639("Albanian", "Shqip", "sq", "sqi", "", "sqi", ""), - Language639("Amharic", "አማርኛ", "am", "amh", "amh", "amh", ""), - Language639("Arabic", "العربية", "ar", "ara", "ara", "ara", ""), - Language639("Aragonese", "aragonés", "an", "arg", "arg", "arg", ""), - Language639("Armenian", "Հայերեն", "hy", "hye", "", "hye", ""), - Language639("Assamese", "অসমীয়া", "as", "asm", "asm", "asm", ""), - Language639("Avaric", "авар мацӀ, магӀарул мацӀ", "av", "ava", "ava", "ava", ""), - Language639("Avestan", "avesta", "ae", "ave", "ave", "ave", ""), - Language639("Aymara", "aymar aru", "ay", "aym", "aym", "aym", ""), - Language639("Azerbaijani", "azərbaycan dili", "az", "aze", "aze", "aze", ""), - Language639("Bambara", "bamanankan", "bm", "bam", "bam", "bam", ""), - Language639("Bashkir", "башҡорт теле", "ba", "bak", "bak", "bak", ""), - Language639("Basque", "euskara, euskera", "eu", "eus", "", "eus", ""), - Language639("Belarusian", "беларуская мова", "be", "bel", "bel", "bel", ""), - Language639("Bengali", "বাংলা", "bn", "ben", "ben", "ben", ""), - Language639("Bihari", "भोजपुरी", "bh", "bih", "bih", "", ""), - Language639("Bislama", "Bislama", "bi", "bis", "bis", "bis", ""), - Language639("Bosnian", "bosanski jezik", "bs", "bos", "bos", "bos", "boss"), - Language639("Breton", "brezhoneg", "br", "bre", "bre", "bre", ""), - Language639("Bulgarian", "български език", "bg", "bul", "bul", "bul", "buls"), - Language639("Burmese", "ဗမာစာ", "my", "mya", "", "mya", ""), - Language639("Catalan", "català", "ca", "cat", "cat", "cat", ""), - Language639("Chamorro", "Chamoru", "ch", "cha", "cha", "cha", ""), - Language639("Chechen", "нохчийн мотт", "ce", "che", "che", "che", ""), - Language639("Chichewa", "chiCheŵa, chinyanja", "ny", "nya", "nya", "nya", ""), - Language639("Chinese", "中文 (Zhōngwén), 汉语, 漢語", "zh", "zho", "", "zho", ""), - Language639("Chuvash", "чӑваш чӗлхи", "cv", "chv", "chv", "chv", ""), - Language639("Cornish", "Kernewek", "kw", "cor", "cor", "cor", ""), - Language639("Corsican", "corsu, lingua corsa", "co", "cos", "cos", "cos", ""), - Language639("Cree", "ᓀᐦᐃᔭᐍᐏᐣ", "cr", "cre", "cre", "cre", ""), - Language639("Croatian", "hrvatski jezik", "hr", "hrv", "hrv", "hrv", ""), - Language639("Czech", "čeština, český jazyk", "cs", "ces", "", "ces", ""), - Language639("Danish", "dansk", "da", "dan", "dan", "dan", ""), - Language639("Divehi", "ދިވެހި", "dv", "div", "div", "div", ""), - Language639("Dutch", "Nederlands, Vlaams", "nl", "nld", "", "nld", ""), - Language639("Dzongkha", "རྫོང་ཁ", "dz", "dzo", "dzo", "dzo", ""), - Language639("English", "English", "en", "eng", "eng", "eng", "engs"), - Language639("Esperanto", "Esperanto", "eo", "epo", "epo", "epo", ""), - Language639("Estonian", "eesti, eesti keel", "et", "est", "est", "est", ""), - Language639("Ewe", "Eʋegbe", "ee", "ewe", "ewe", "ewe", ""), - Language639("Faroese", "føroyskt", "fo", "fao", "fao", "fao", ""), - Language639("Fijian", "vosa Vakaviti", "fj", "fij", "fij", "fij", ""), - Language639("Finnish", "suomi, suomen kieli", "fi", "fin", "fin", "fin", ""), - Language639("French", "français, langue française", "fr", "fra", "", "fra", "fras"), - Language639("Fula", "Fulfulde, Pulaar, Pular", "ff", "ful", "ful", "ful", ""), - Language639("Galician", "galego", "gl", "glg", "glg", "glg", ""), - Language639("Georgian", "ქართული", "ka", "kat", "", "kat", ""), - Language639("German", "Deutsch", "de", "deu", "", "deu", "deus"), - Language639("Greek", "ελληνικά", "el", "ell", "", "ell", "ells"), - Language639("Guaraní", "Avañe'ẽ", "gn", "grn", "grn", "grn", ""), - Language639("Gujarati", "ગુજરાતી", "gu", "guj", "guj", "guj", ""), - Language639("Haitian", "Kreyòl ayisyen", "ht", "hat", "hat", "hat", ""), - Language639("Hausa", "(Hausa) هَوُسَ", "ha", "hau", "hau", "hau", ""), - Language639("Hebrew", "עברית", "he", "heb", "heb", "heb", ""), - Language639("Herero", "Otjiherero", "hz", "her", "her", "her", ""), - Language639("Hindi", "हिन्दी, हिंदी", "hi", "hin", "hin", "hin", "hins"), - Language639("Hiri Motu", "Hiri Motu", "ho", "hmo", "hmo", "hmo", ""), - Language639("Hungarian", "magyar", "hu", "hun", "hun", "hun", ""), - Language639("Interlingua", "Interlingua", "ia", "ina", "ina", "ina", ""), - Language639("Indonesian", "Bahasa Indonesia", "id", "ind", "ind", "ind", ""), - Language639( - "Interlingue", - "Originally called Occidental; then Interlingue after WWII", - "ie", - "ile", - "ile", - "ile", - "" - ), - Language639("Irish", "Gaeilge", "ga", "gle", "gle", "gle", ""), - Language639("Igbo", "Asụsụ Igbo", "ig", "ibo", "ibo", "ibo", ""), - Language639("Inupiaq", "Iñupiaq, Iñupiatun", "ik", "ipk", "ipk", "ipk", ""), - Language639("Ido", "Ido", "io", "ido", "ido", "ido", "idos"), - Language639("Icelandic", "Íslenska", "is", "isl", "", "isl", ""), - Language639("Italian", "italiano", "it", "ita", "ita", "ita", "itas"), - Language639("Inuktitut", "ᐃᓄᒃᑎᑐᑦ", "iu", "iku", "iku", "iku", ""), - Language639("Japanese", "日本語 (にほんご)", "ja", "jpn", "jpn", "jpn", ""), - Language639("Javanese", "ꦧꦱꦗꦮ", "jv", "jav", "jav", "jav", ""), - Language639("Kalaallisut", "kalaallisut, kalaallit oqaasii", "kl", "kal", "kal", "kal", ""), - Language639("Kannada", "ಕನ್ನಡ", "kn", "kan", "kan", "kan", ""), - Language639("Kanuri", "Kanuri", "kr", "kau", "kau", "kau", ""), - Language639("Kashmiri", "कश्मीरी, كشميري‎", "ks", "kas", "kas", "kas", ""), - Language639("Kazakh", "қазақ тілі", "kk", "kaz", "kaz", "kaz", ""), - Language639("Khmer", "ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ", "km", "khm", "khm", "khm", ""), - Language639("Kikuyu", "Gĩkũyũ", "ki", "kik", "kik", "kik", ""), - Language639("Kinyarwanda", "Ikinyarwanda", "rw", "kin", "kin", "kin", ""), - Language639("Kyrgyz", "Кыргызча, Кыргыз тили", "ky", "kir", "kir", "kir", ""), - Language639("Komi", "коми кыв", "kv", "kom", "kom", "kom", ""), - Language639("Kongo", "Kikongo", "kg", "kon", "kon", "kon", ""), - Language639("Korean", "한국어, 조선어", "ko", "kor", "kor", "kor", ""), - Language639("Kurdish", "Kurdî, كوردی‎", "ku", "kur", "kur", "kur", ""), - Language639("Kwanyama", "Kuanyama", "kj", "kua", "kua", "kua", ""), - Language639("Latin", "latine, lingua latina", "la", "lat", "lat", "lat", "lats"), - Language639("Luxembourgish", "Lëtzebuergesch", "lb", "ltz", "ltz", "ltz", ""), - Language639("Ganda", "Luganda", "lg", "lug", "lug", "lug", ""), - Language639("Limburgish", "Limburgs", "li", "lim", "lim", "lim", ""), - Language639("Lingala", "Lingála", "ln", "lin", "lin", "lin", ""), - Language639("Lao", "ພາສາລາວ", "lo", "lao", "lao", "lao", ""), - Language639("Lithuanian", "lietuvių kalba", "lt", "lit", "lit", "lit", ""), - Language639("Luba-Katanga", "Tshiluba", "lu", "lub", "lub", "lub", ""), - Language639("Latvian", "latviešu valoda", "lv", "lav", "lav", "lav", ""), - Language639("Manx", "Gaelg, Gailck", "gv", "glv", "glv", "glv", ""), - Language639("Macedonian", "македонски јазик", "mk", "mkd", "", "mkd", ""), - Language639("Malagasy", "fiteny malagasy", "mg", "mlg", "mlg", "mlg", ""), - Language639("Malay", "bahasa Melayu, بهاس ملايو‎", "ms", "msa", "", "msa", ""), - Language639("Malayalam", "മലയാളം", "ml", "mal", "mal", "mal", ""), - Language639("Maltese", "Malti", "mt", "mlt", "mlt", "mlt", ""), - Language639("Māori", "te reo Māori", "mi", "mri", "", "mri", ""), - Language639("Marathi", "मराठी", "mr", "mar", "mar", "mar", ""), - Language639("Marshallese", "Kajin M̧ajeļ", "mh", "mah", "mah", "mah", ""), - Language639("Mongolian", "Монгол хэл", "mn", "mon", "mon", "mon", ""), - Language639("Nauruan", "Dorerin Naoero", "na", "nau", "nau", "nau", ""), - Language639("Navajo", "Diné bizaad", "nv", "nav", "nav", "nav", ""), - Language639("Northern Ndebele", "isiNdebele", "nd", "nde", "nde", "nde", ""), - Language639("Nepali", "नेपाली", "ne", "nep", "nep", "nep", ""), - Language639("Ndonga", "Owambo", "ng", "ndo", "ndo", "ndo", ""), - Language639("Norwegian Bokmål", "Norsk bokmål", "nb", "nob", "nob", "nob", ""), - Language639("Norwegian Nynorsk", "Norsk nynorsk", "nn", "nno", "nno", "nno", ""), - Language639("Norwegian", "Norsk", "no", "nor", "nor", "nor", ""), - Language639("Nuosu", "ꆈꌠ꒿ Nuosuhxop", "ii", "iii", "iii", "iii", ""), - Language639("Southern Ndebele", "isiNdebele", "nr", "nbl", "nbl", "nbl", ""), - Language639("Occitan", "occitan, lenga d'òc", "oc", "oci", "oci", "oci", ""), - Language639("Ojibwe", "ᐊᓂᔑᓈᐯᒧᐎᓐ", "oj", "oji", "oji", "oji", ""), - Language639("Old Church Slavonic", "ѩзыкъ словѣньскъ", "cu", "chu", "chu", "chu", ""), - Language639("Oromo", "Afaan Oromoo", "om", "orm", "orm", "orm", ""), - Language639("Oriya", "ଓଡ଼ିଆ", "or", "ori", "ori", "ori", ""), - Language639("Ossetian", "ирон æвзаг", "os", "oss", "oss", "oss", ""), - Language639("Panjabi", "ਪੰਜਾਬੀ, پنجابی‎", "pa", "pan", "pan", "pan", ""), - Language639("Pāli", "पाऴि", "pi", "pli", "pli", "pli", ""), - Language639("Persian", "فارسی", "fa", "fas", "", "fas", ""), - Language639("Polish", "język polski, polszczyzna", "pl", "pol", "pol", "pol", "pols"), - Language639("Pashto", "پښتو", "ps", "pus", "pus", "pus", ""), - Language639("Portuguese", "português", "pt-pt", "por", "por", "por", ""), - // Addition to support Brazilian Portuguese properly, might break other things - Language639("Portuguese (Brazilian)", "português", "pt-br", "por", "por", "por", ""), - Language639("Quechua", "Runa Simi, Kichwa", "qu", "que", "que", "que", ""), - Language639("Romansh", "rumantsch grischun", "rm", "roh", "roh", "roh", ""), - Language639("Kirundi", "Ikirundi", "rn", "run", "run", "run", ""), - Language639("Reunion Creole", "Kréol Rénioné", "rc", "rcf", "rcf", "rcf", ""), - Language639("Romanian", "limba română", "ro", "ron", "", "ron", ""), - Language639("Russian", "Русский", "ru", "rus", "rus", "rus", ""), - Language639("Sanskrit", "संस्कृतम्", "sa", "san", "san", "san", ""), - Language639("Sardinian", "sardu", "sc", "srd", "srd", "srd", ""), - Language639("Sindhi", "सिन्धी, سنڌي، سندھی‎", "sd", "snd", "snd", "snd", ""), - Language639("Northern Sami", "Davvisámegiella", "se", "sme", "sme", "sme", ""), - Language639("Samoan", "gagana fa'a Samoa", "sm", "smo", "smo", "smo", ""), - Language639("Sango", "yângâ tî sängö", "sg", "sag", "sag", "sag", ""), - Language639("Serbian", "српски језик", "sr", "srp", "srp", "srp", ""), - Language639("Scottish Gaelic", "Gàidhlig", "gd", "gla", "gla", "gla", ""), - Language639("Shona", "chiShona", "sn", "sna", "sna", "sna", ""), - Language639("Sinhalese", "සිංහල", "si", "sin", "sin", "sin", ""), - Language639("Slovak", "slovenčina, slovenský jazyk", "sk", "slk", "", "slk", ""), - Language639("Slovene", "slovenski jezik, slovenščina", "sl", "slv", "slv", "slv", ""), - Language639("Somali", "Soomaaliga, af Soomaali", "so", "som", "som", "som", ""), - Language639("Southern Sotho", "Sesotho", "st", "sot", "sot", "sot", ""), - Language639("Spanish", "español", "es", "spa", "spa", "spa", ""), - Language639("Sundanese", "Basa Sunda", "su", "sun", "sun", "sun", ""), - Language639("Swahili", "Kiswahili", "sw", "swa", "swa", "swa", ""), - Language639("Swati", "SiSwati", "ss", "ssw", "ssw", "ssw", ""), - Language639("Swedish", "svenska", "sv", "swe", "swe", "swe", ""), - Language639("Tamil", "தமிழ்", "ta", "tam", "tam", "tam", ""), - Language639("Telugu", "తెలుగు", "te", "tel", "tel", "tel", ""), - Language639("Tajik", "тоҷикӣ, toçikī, تاجیکی‎", "tg", "tgk", "tgk", "tgk", ""), - Language639("Thai", "ไทย", "th", "tha", "tha", "tha", ""), - Language639("Tigrinya", "ትግርኛ", "ti", "tir", "tir", "tir", ""), - Language639("Tibetan Standard", "བོད་ཡིག", "bo", "bod", "", "bod", ""), - Language639("Turkmen", "Türkmen, Түркмен", "tk", "tuk", "tuk", "tuk", ""), - Language639("Tagalog", "Wikang Tagalog", "tl", "tgl", "tgl", "tgl", ""), - Language639("Tswana", "Setswana", "tn", "tsn", "tsn", "tsn", ""), - Language639("Tonga", "faka Tonga", "to", "ton", "ton", "ton", ""), - Language639("Turkish", "Türkçe", "tr", "tur", "tur", "tur", ""), - Language639("Tsonga", "Xitsonga", "ts", "tso", "tso", "tso", ""), - Language639("Tatar", "татар теле, tatar tele", "tt", "tat", "tat", "tat", ""), - Language639("Twi", "Twi", "tw", "twi", "twi", "twi", ""), - Language639("Tahitian", "Reo Tahiti", "ty", "tah", "tah", "tah", ""), - Language639("Uyghur", "ئۇيغۇرچە‎, Uyghurche", "ug", "uig", "uig", "uig", ""), - Language639("Ukrainian", "Українська", "uk", "ukr", "ukr", "ukr", ""), - Language639("Urdu", "اردو", "ur", "urd", "urd", "urd", ""), - Language639("Uzbek", "Oʻzbek, Ўзбек, أۇزبېك‎", "uz", "uzb", "uzb", "uzb", ""), - Language639("Venda", "Tshivenḓa", "ve", "ven", "ven", "ven", ""), - Language639("Vietnamese", "Tiếng Việt", "vi", "vie", "vie", "vie", ""), - Language639("Volapük", "Volapük", "vo", "vol", "vol", "vol", ""), - Language639("Walloon", "walon", "wa", "wln", "wln", "wln", ""), - Language639("Welsh", "Cymraeg", "cy", "cym", "", "cym", ""), - Language639("Wolof", "Wollof", "wo", "wol", "wol", "wol", ""), - Language639("Western Frisian", "Frysk", "fy", "fry", "fry", "fry", ""), - Language639("Xhosa", "isiXhosa", "xh", "xho", "xho", "xho", ""), - Language639("Yiddish", "ייִדיש", "yi", "yid", "yid", "yid", ""), - Language639("Yoruba", "Yorùbá", "yo", "yor", "yor", "yor", ""), - Language639("Zhuang", "Saɯ cueŋƅ, Saw cuengh", "za", "zha", "zha", "zha", ""), - Language639("Zulu", "isiZulu", "zu", "zul", "zul", "zul", ""), - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt new file mode 100644 index 000000000..c0068f91a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -0,0 +1,60 @@ +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 + +object SubtitleUtils { + + // Only these files are allowed, so no videos as subtitles + private val allowedExtensions = listOf( + ".vtt", ".srt", ".txt", ".ass", + ".ttml", ".sbv", ".dfxp" + ) + + fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { + val cleanDisplay = cleanDisplayName(info.displayName) + + val base = basePathToFile(context, info.basePath) + val folder = + base?.gotoDirectory(info.relativePath, createMissingDirectories = false) ?: return + val folderFiles = folder.listFiles() ?: return + + for (file in folderFiles) { + val name = file.name() ?: continue + if (!isMatchingSubtitle(name, info.displayName, cleanDisplay)) { + continue + } + if (file.delete() != true) { + Log.e("SubtitleDeletion", "Failed to delete subtitle file: $name") + } + } + } + + /** + * @param name the file name of the subtitle + * @param display the file name of the video + * @param cleanDisplay the cleanDisplayName of the video file name + */ + fun isMatchingSubtitle( + name: String, + display: String, + cleanDisplay: String + ): Boolean { + // Check if the file has a valid subtitle extension + val hasValidExtension = allowedExtensions.any { name.endsWith(it, ignoreCase = true) } + + // We can't have the exact same file as a subtitle + val isNotDisplayName = !name.equals(display, ignoreCase = true) + + // Check if the file name starts with a cleaned version of the display name + val startsWithCleanDisplay = cleanDisplayName(name).startsWith(cleanDisplay, ignoreCase = true) + + return hasValidExtension && isNotDisplayName && startsWithCleanDisplay + } + + fun cleanDisplayName(name: String): String { + return name.substringBeforeLast('.').trim() + } +} 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 71d3a1eff..6e74fa00a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.APIHolder.apis //import com.lagradost.cloudstream3.animeproviders.AniflixProvider import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import java.util.concurrent.TimeUnit object SyncUtil { @@ -71,10 +71,10 @@ object SyncUtil { val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text - val mapped = parseJson(response) + val mapped = tryParseJson(response) - val overrideMal = mapped?.malId ?: mapped?.Mal?.id ?: mapped?.Anilist?.malId - val overrideAnilist = mapped?.aniId ?: mapped?.Anilist?.id + 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() @@ -96,10 +96,8 @@ object SyncUtil { .mapNotNull { it.url }.toMutableList() if (type == "anilist") { // TODO MAKE BETTER - synchronized(apis) { - apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { - current.add("${it.mainUrl}/anime/$id") - } + apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { + current.add("${it.mainUrl}/anime/$id") } } return current @@ -135,8 +133,8 @@ object SyncUtil { @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("Mal") val mal: Mal?, + @JsonProperty("Anilist") val anilist: Anilist?, @JsonProperty("malUrl") val malUrl: String? ) @@ -169,4 +167,4 @@ object SyncUtil { @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String? ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt index dd9735380..8c50afee7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -3,9 +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) @@ -13,16 +14,59 @@ object TestingUtils { } } - class TestResultSearch(val results: List) : TestResult(true) - class TestResultLoad(val extractorData: String) : TestResult(true) + class Logger { + enum class LogLevel { + Normal, + Warning, + Error; + } - class TestResultProvider(success: Boolean, val log: String, val exception: Throwable?) : + data class Message(val level: LogLevel, val message: String) { + override fun toString(): String { + val level = when (this.level) { + LogLevel.Normal -> "" + LogLevel.Warning -> "Warning: " + LogLevel.Error -> "Error: " + } + return "$level$message" + } + } + + private val messageLog = mutableListOf() + + fun getRawLog(): List = messageLog + + fun log(message: String) { + messageLog.add(Message(LogLevel.Normal, message)) + } + + fun warn(message: String) { + messageLog.add(Message(LogLevel.Warning, message)) + } + + fun error(message: String) { + 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) + + class TestResultProvider( + success: Boolean, + val log: List, + val exception: Throwable? + ) : TestResult(success) @Throws(AssertionError::class, CancellationException::class) suspend fun testHomepage( api: MainAPI, - logger: (String) -> Unit + logger: Logger ): TestResult { if (api.hasMainPage) { try { @@ -31,22 +75,33 @@ object TestingUtils { api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages)) when { homepage == null -> { - logger.invoke("Homepage provider ${api.name} did not correctly load homepage!") + logger.error("Provider ${api.name} did not correctly load homepage!") } + homepage.items.isEmpty() -> { - logger.invoke("Homepage provider ${api.name} does not contain any items!") + logger.warn("Provider ${api.name} does not contain any homepage rows!") } + homepage.items.any { it.list.isEmpty() } -> { - logger.invoke("Homepage provider ${api.name} does not have any items on result!") + logger.warn("Provider ${api.name} does not have any items in a homepage row!") } } + val homePageList = homepage?.items?.flatMap { it.list } ?: emptyList() + return TestResultList(homePageList) } catch (e: Throwable) { - if (e is NotImplementedError) { - Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") - } else if (e is CancellationException) { - throw e + when (e) { + is NotImplementedError -> { + fail("Provider marked as hasMainPage, while in reality is has not been implemented") + } + + is CancellationException -> { + throw e + } + + else -> { + e.message?.let { logger.warn("Exception thrown when loading homepage: \"$it\"") } + } } - logError(e) } } return TestResult.Pass @@ -54,15 +109,17 @@ object TestingUtils { @Throws(AssertionError::class, CancellationException::class) private suspend fun testSearch( - api: MainAPI + api: MainAPI, + testQueries: List, + logger: Logger, ): TestResult { - val searchQueries = listOf("over", "iron", "guy") - val searchResults = searchQueries.firstNotNullOfOrNull { query -> + val searchResults = testQueries.firstNotNullOfOrNull { query -> try { - api.search(query).takeIf { !it.isNullOrEmpty() } + logger.log("Searching for: $query") + api.search(query, 1)?.items?.takeIf { it.isNotEmpty() } } catch (e: Throwable) { if (e is NotImplementedError) { - Assert.fail("Provider has not implemented search()") + fail("Provider has not implemented search()") } else if (e is CancellationException) { throw e } @@ -72,12 +129,11 @@ object TestingUtils { } return if (searchResults.isNullOrEmpty()) { - Assert.fail("Api ${api.name} did not return any valid search responses") + fail("Api ${api.name} did not return any search responses") TestResult.Fail // Should not be reached } else { - TestResultSearch(searchResults) + TestResultList(searchResults) } - } @@ -85,31 +141,27 @@ object TestingUtils { private suspend fun testLoad( api: MainAPI, result: SearchResponse, - logger: (String) -> Unit + logger: Logger ): TestResult { try { - Assert.assertEquals( - "Invalid apiName on SearchResponse on ${api.name}", - result.apiName, - api.name - ) + if (result.apiName != api.name) { + logger.warn("Wrong apiName on SearchResponse: ${api.name} != ${result.apiName}") + } val loadResponse = api.load(result.url) if (loadResponse == null) { - logger.invoke("Returned null loadResponse on ${result.url} on ${api.name}") + logger.error("Returned null loadResponse on ${result.url} on ${api.name}") return TestResult.Fail } - Assert.assertEquals( - "Invalid apiName on LoadResponse on ${api.name}", - loadResponse.apiName, - result.apiName - ) - Assert.assertTrue( - "Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}", - api.supportedTypes.contains(loadResponse.type) - ) + if (loadResponse.apiName != api.name) { + logger.warn("Wrong apiName on LoadResponse: ${api.name} != ${loadResponse.apiName}") + } + + if (!api.supportedTypes.contains(loadResponse.type)) { + logger.warn("Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}") + } val url = when (loadResponse) { is AnimeLoadResponse -> { @@ -117,39 +169,43 @@ object TestingUtils { loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() } if (gotNoEpisodes) { - logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}") + logger.error("Api ${api.name} got no episodes on ${loadResponse.url}") return TestResult.Fail } (loadResponse.episodes[loadResponse.episodes.keys.firstOrNull()])?.firstOrNull()?.data } + is MovieLoadResponse -> { val gotNoEpisodes = loadResponse.dataUrl.isBlank() if (gotNoEpisodes) { - logger.invoke("Api ${api.name} got no movie on ${loadResponse.url}") + logger.error("Api ${api.name} got no movie on ${loadResponse.url}") return TestResult.Fail } loadResponse.dataUrl } + is TvSeriesLoadResponse -> { val gotNoEpisodes = loadResponse.episodes.isEmpty() if (gotNoEpisodes) { - logger.invoke("Api ${api.name} got no episodes on ${loadResponse.url}") + logger.error("Api ${api.name} got no episodes on ${loadResponse.url}") return TestResult.Fail } loadResponse.episodes.firstOrNull()?.data } + is LiveStreamLoadResponse -> { loadResponse.dataUrl } + else -> { - logger.invoke("Unknown load response: ${loadResponse.javaClass.name}") + logger.error("Unknown load response: ${loadResponse.javaClass.name}") return TestResult.Fail } } ?: return TestResult.Fail - return TestResultLoad(url) + return TestResultLoad(url, loadResponse.type != TvType.CustomMedia) // val loadTest = testLoadResponse(api, load, logger) // if (loadTest is TestResultLoad) { @@ -164,7 +220,7 @@ object TestingUtils { // return TestResult(validResults) } catch (e: Throwable) { if (e is NotImplementedError) { - Assert.fail("Provider has not implemented load()") + fail("Provider has not implemented load()") } throw e } @@ -174,34 +230,35 @@ object TestingUtils { private suspend fun testLinkLoading( api: MainAPI, url: String?, - logger: (String) -> Unit + logger: Logger ): TestResult { - Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) + 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.invoke("Video loaded: ${link.name}") - Assert.assertTrue( + logger.log("Video loaded: ${link.name}") + assertTrue( "Api ${api.name} returns link with invalid url ${link.url}", link.url.length > 4 ) linksLoaded++ } if (success) { - logger.invoke("Links loaded: $linksLoaded") + logger.log("Links loaded: $linksLoaded") return TestResult(linksLoaded > 0) } else { - Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") + fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") } } catch (e: Throwable) { when (e) { is NotImplementedError -> { - Assert.fail("Provider has not implemented loadLinks()") + fail("Provider has not implemented loadLinks()") } + else -> { - logger.invoke("Failed link loading on ${api.name} using data: $url") + logger.error("Failed link loading on ${api.name} using data: $url") throw e } } @@ -212,56 +269,60 @@ object TestingUtils { fun getDeferredProviderTests( scope: CoroutineScope, providers: Array, - logger: (String) -> Unit, callback: (MainAPI, TestResultProvider) -> Unit ) { providers.forEach { api -> scope.launch { - var log = "" - fun addToLog(string: String) { - log += string + "\n" - logger.invoke(string) - } - fun getLog(): String { - return log.removeSuffix("\n") - } + val logger = Logger() val result = try { - addToLog("Trying ${api.name}") + logger.log("Trying ${api.name}") // Test Homepage - val homepage = testHomepage(api, logger).success - Assert.assertTrue("Homepage failed to load", homepage) + val homepage = testHomepage(api, logger) + assertTrue("Homepage failed to load", homepage.success) + val homePageList = (homepage as? TestResultList)?.results ?: emptyList() // Test Search Results - val searchResults = testSearch(api) - Assert.assertTrue("Failed to get search results", searchResults.success) - searchResults as TestResultSearch + val searchQueries = + // Use the random 3 home page results as queries since they are guaranteed to exist + (homePageList.shuffled(Random).take(3).map { it.name.split(" ").first() } + + // If home page is sparse then use generic search queries + listOf("over", "iron", "guy")).take(3) + + val searchResults = testSearch(api, searchQueries, logger) + assertTrue("Failed to get search results", searchResults.success) + searchResults as TestResultList // Test Load and LoadLinks // Only try the first 3 search results to prevent spamming val success = searchResults.results.take(3).any { searchResponse -> - addToLog("Testing search result: ${searchResponse.url}") - val loadResponse = testLoad(api, searchResponse, ::addToLog) + logger.log("Testing search result: ${searchResponse.url}") + val loadResponse = testLoad(api, searchResponse, logger) if (loadResponse !is TestResultLoad) { false } else { - testLinkLoading(api, loadResponse.extractorData, ::addToLog).success + if (loadResponse.shouldLoadLinks) { + testLinkLoading(api, loadResponse.extractorData, logger).success + } else { + logger.log("Skipping link loading test") + true + } } } if (success) { - logger.invoke("Success ${api.name}") - TestResultProvider(true, getLog(), null) + logger.log("Success ${api.name}") + TestResultProvider(true, logger.getRawLog(), null) } else { - logger.invoke("Error ${api.name}") - TestResultProvider(false, getLog(), null) + logger.error("Link loading failed") + TestResultProvider(false, logger.getRawLog(), null) } } catch (e: Throwable) { - TestResultProvider(false, getLog(), e) + TestResultProvider(false, logger.getRawLog(), e) } callback.invoke(api, result) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TextUtil.kt similarity index 62% rename from app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/TextUtil.kt index 24d568970..4f3a74737 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TextUtil.kt @@ -1,16 +1,13 @@ -package com.lagradost.cloudstream3.ui.result +package com.lagradost.cloudstream3.utils import android.content.Context import android.util.Log -import android.widget.ImageView import android.widget.TextView -import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.utils.AppContextUtils.html sealed class UiText { companion object { @@ -19,6 +16,13 @@ sealed class UiText { data class DynamicString(val value: String) : UiText() { override fun toString(): String = value + + override fun equals(other: Any?): Boolean { + if (other !is DynamicString) return false + return this.value == other.value + } + + override fun hashCode(): Int = value.hashCode() } class StringResource( @@ -27,6 +31,16 @@ sealed class UiText { ) : UiText() { override fun toString(): String = "resId = $resId\nargs = ${args.toList().map { "(${it::class} = $it)" }}" + override fun equals(other: Any?): Boolean { + if (other !is StringResource) return false + return this.resId == other.resId && this.args == other.args + } + + override fun hashCode(): Int { + var result = resId + result = 31 * result + args.hashCode() + return result + } } fun asStringNull(context: Context?): String? { @@ -59,59 +73,6 @@ sealed class UiText { } } -sealed class UiImage { - data class Image( - val url: String, - val headers: Map? = null, - @DrawableRes val errorDrawable: Int? = null - ) : UiImage() - - data class Drawable(@DrawableRes val resId: Int) : UiImage() -} - -fun ImageView?.setImage(value: UiImage?, fadeIn: Boolean = true) { - when (value) { - is UiImage.Image -> setImageImage(value, fadeIn) - is UiImage.Drawable -> setImageDrawable(value) - null -> { - this?.isVisible = false - } - } -} - -fun ImageView?.setImageImage(value: UiImage.Image, fadeIn: Boolean = true) { - if (this == null) return - this.isVisible = setImage(value.url, value.headers, value.errorDrawable, fadeIn) -} - -fun ImageView?.setImageDrawable(value: UiImage.Drawable) { - if (this == null) return - this.isVisible = true - this.setImage(UiImage.Drawable(value.resId)) -} - -@JvmName("imgNull") -fun img( - url: String?, - headers: Map? = null, - @DrawableRes errorDrawable: Int? = null -): UiImage? { - if (url.isNullOrBlank()) return null - return UiImage.Image(url, headers, errorDrawable) -} - -fun img( - url: String, - headers: Map? = null, - @DrawableRes errorDrawable: Int? = null -): UiImage { - return UiImage.Image(url, headers, errorDrawable) -} - -fun img(@DrawableRes drawable: Int): UiImage { - return UiImage.Drawable(drawable) -} - fun txt(value: String): UiText { return UiText.DynamicString(value) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt new file mode 100644 index 000000000..feecbe312 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt @@ -0,0 +1,164 @@ +package com.lagradost.cloudstream3.utils + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.net.toUri +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.PreviewProgram +import androidx.tvprovider.media.tv.TvContractCompat +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.base64Encode +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import java.net.URLEncoder + +const val PROGRAM_ID_LIST_KEY = "persistent_program_ids" + +object TvChannelUtils { + fun Context.saveProgramId(programId: Long) { + val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + val updated = (existing + programId).distinct() + setKey(PROGRAM_ID_LIST_KEY, updated) + } + fun Context.getStoredProgramIds(): List { + return getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + } + fun Context.removeProgramId(programId: Long) { + val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + val updated = existing.filter { it != programId } + setKey(PROGRAM_ID_LIST_KEY, updated) + } + + + fun getChannelId(context: Context, channelName: String): Long? { + return try { + context.contentResolver.query( + TvContractCompat.Channels.CONTENT_URI, + arrayOf( + TvContractCompat.Channels._ID, + TvContractCompat.Channels.COLUMN_DISPLAY_NAME + ), + null, + null, + null + )?.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong( + cursor.getColumnIndexOrThrow(TvContractCompat.Channels._ID) + ) + val name = cursor.getString( + cursor.getColumnIndexOrThrow(TvContractCompat.Channels.COLUMN_DISPLAY_NAME) + ) + if (name == channelName) return id + } + null + } + } catch (e: Exception) { + Log.e("TvChannelUtils", "Query failed: ${e.message}", e) + null + } + } + + /** Insert programs into a channel */ + @SuppressLint("RestrictedApi") + fun addPrograms(context: Context, channelId: Long, items: List) { + for (item in items) { + try { + val nameBase64 = base64Encode(item.apiName.toByteArray(Charsets.UTF_8)) + val urlBase64 = base64Encode(item.url.toByteArray(Charsets.UTF_8)) + val csshareUri = "$APP_STRING_SHARE:$nameBase64?$urlBase64" + val poster=item.posterUrl + val builder = PreviewProgram.Builder() + .setChannelId(channelId) + .setTitle(item.name) + .apply { + val scoreText = item.score?.toStringNull(0.1, 10, 1)?.let { + " - " + txt(R.string.rating_format, it).asString(context) + } ?: "" + setDescription("${item.apiName}$scoreText") + } + .setContentId(item.url) + .setType(TvContractCompat.PreviewPrograms.TYPE_MOVIE) + .setIntentUri(csshareUri.toUri()) + .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_2_3) + + // Validate poster URL before setting + if (!poster.isNullOrBlank() && poster.startsWith("http")) { + builder.setPosterArtUri(poster.toUri()) + + } + val program = builder.build() + + val uri = context.contentResolver.insert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + program.toContentValues() + ) + + if (uri != null) { + val programId = ContentUris.parseId(uri) + context.saveProgramId(programId) + Log.d("TvChannelUtils", "Inserted program ${item.name}, ID=$programId") + } else { + Log.e("TvChannelUtils", "Insert failed for ${item.name}") + } + + } catch (error: Exception) { + Log.e("TvChannelUtils", "Error inserting ${item.name}: $error") + } + } + } + + fun deleteStoredPrograms(context: Context) { + val programIds = context.getStoredProgramIds() + + for (id in programIds) { + val uri = ContentUris.withAppendedId(TvContractCompat.PreviewPrograms.CONTENT_URI, id) + try { + val rowsDeleted = context.contentResolver.delete(uri, null, null) + if (rowsDeleted > 0) { + context.removeProgramId(id) // Remove from persistent list + } else { + Log.w("ProgramDelete", "No program found for ID: $id") + } + } catch (e: Exception) { + Log.e("ProgramDelete", "Failed to delete program ID: $id", e) + } + } + + Log.d("ProgramDelete", "Finished deleting stored programs") + } + + fun createTvChannel(context: Context) { + val componentName = ComponentName(context, MainActivity::class.java) + val iconUri = "android.resource://${context.packageName}/mipmap/ic_launcher".toUri() + val inputId = TvContractCompat.buildInputId(componentName) + val channel = Channel.Builder() + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setAppLinkIconUri(iconUri) + .setDisplayName(context.getString(R.string.app_name)) + .setAppLinkIntent(Intent(Intent.ACTION_VIEW).apply { + data = "cloudstreamapp://open".toUri() + }) + .setInputId(inputId) + .build() + + val channelUri = context.contentResolver.insert( + TvContractCompat.Channels.CONTENT_URI, + channel.toContentValues() + ) + + channelUri?.let { + val channelId = ContentUris.parseId(it) + TvContractCompat.requestChannelBrowsable(context, channelId) + Log.d("TvChannelUtils", "Channel Created: $channelId") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index 038a2f110..c12674816 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -5,65 +5,84 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.AppOpsManager 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.view.* +import android.os.TransactionTooLargeException +import android.util.Log +import android.view.Gravity +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowManager import android.view.inputmethod.InputMethodManager -import android.widget.ImageView import android.widget.ListAdapter import android.widget.ListView +import android.widget.Toast.LENGTH_LONG import androidx.annotation.AttrRes import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes -import androidx.annotation.IdRes +import androidx.annotation.DimenRes import androidx.annotation.StyleRes import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.content.withStyledAttributes import androidx.core.graphics.alpha import androidx.core.graphics.blue -import androidx.core.graphics.drawable.toBitmapOrNull import androidx.core.graphics.green import androidx.core.graphics.red import androidx.core.view.marginBottom import androidx.core.view.marginLeft import androidx.core.view.marginRight import androidx.core.view.marginTop +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.load.model.GlideUrl -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.RequestOptions.bitmapTransform -import com.bumptech.glide.request.target.Target +import com.google.android.material.appbar.AppBarLayout import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.result.UiImage -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import jp.wasabeef.glide.transformations.BlurTransformation +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() @@ -71,7 +90,7 @@ object UIHelper { val Int.toDp: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt() val Float.toDp: Float get() = (this / Resources.getSystem().displayMetrics.density) - fun Activity.checkWrite(): Boolean { + fun Context.checkWrite(): Boolean { return (ContextCompat.checkSelfPermission( this, Manifest.permission.WRITE_EXTERNAL_STORAGE @@ -82,12 +101,18 @@ object UIHelper { || Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) } - fun populateChips(view: ChipGroup?, tags: List, @StyleRes style : Int = R.style.ChipFilled) { + fun populateChips( + view: ChipGroup?, + tags: List, + @StyleRes style: Int = R.style.ChipFilled, + @AttrRes textColor: Int? = R.attr.white, + ) { if (view == null) return view.removeAllViews() val context = view.context ?: return + val maxTags = tags.take(10) // Limited because they are too much - tags.forEach { tag -> + maxTags.forEach { tag -> val chip = Chip(context) val chipDrawable = ChipDrawable.createFromAttributes( context, @@ -101,7 +126,9 @@ object UIHelper { chip.isCheckable = false chip.isFocusable = false chip.isClickable = false - chip.setTextColor(context.colorFromAttribute(R.attr.white)) + textColor?.let { + chip.setTextColor(context.colorFromAttribute(it)) + } view.addView(chip) } } @@ -118,6 +145,35 @@ object UIHelper { ) } + fun clipboardHelper(label: UiText, text: CharSequence) { + val ctx = context ?: return + try { + ctx.let { + val clip = ClipData.newPlainText(label.asString(ctx), text) + val labelSuffix = txt(R.string.toast_copied).asString(ctx) + ctx.getSystemService()?.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + showToast("${label.asString(ctx)} $labelSuffix") + } + } + } catch (t: Throwable) { + Log.e("ClipboardService", "$t") + when (t) { + is SecurityException -> { + showToast(R.string.clipboard_permission_error) + } + + is TransactionTooLargeException -> { + showToast(R.string.clipboard_too_large) + } + + else -> { + showToast(R.string.clipboard_unknown_error, LENGTH_LONG) + } + } + } + } /** * Sets ListView height dynamically based on the height of the items. @@ -148,17 +204,15 @@ object UIHelper { listView.requestLayout() } - fun Context?.getSpanCount(): Int? { - val compactView = false - val spanCountLandscape = if (compactView) 2 else 6 - val spanCountPortrait = if (compactView) 1 else 3 - val orientation = this?.resources?.configuration?.orientation ?: return null + fun Context.getSpanCount(isHorizontal:Boolean=false): Int { +// val compactView = false + val spanCountLandscape = if (isHorizontal) 3 else 6 + val spanCountPortrait = if (isHorizontal) 2 else 3 + val orientation = resources.configuration.orientation return if (orientation == Configuration.ORIENTATION_LANDSCAPE) { spanCountLandscape - } else { - spanCountPortrait - } + } else spanCountPortrait } fun Fragment.hideKeyboard() { @@ -168,6 +222,14 @@ object UIHelper { } } + fun View?.setAppBarNoScrollFlagsOnTV() { + if (isLayout(TV or EMULATOR)) { + this?.updateLayoutParams { + scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL + } + } + } + fun Activity.hideKeyboard() { window?.decorView?.clearFocus() this.findViewById(android.R.id.content)?.rootView?.let { @@ -175,32 +237,95 @@ object UIHelper { } } - fun Activity?.navigate(@IdRes navigation: Int, arguments: Bundle? = null) { - try { - if (this is FragmentActivity) { - (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.navController?.navigate( - navigation, arguments - ) + 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" + try { + val intent = baseIntent ?: Intent() + intent.setClass(this, activity) + + if (args != null) { + intent.putExtras(args) + } + 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 typedArray = obtainStyledAttributes(intArrayOf(resource)) - val color = typedArray.getColor(0, 0) - typedArray.recycle() + val color = colorFromAttribute(resource) + return if (alphaFactor < 1f) adjustAlpha(color, alphaFactor) else color + } - if (alphaFactor < 1f) { - val alpha = (color.alpha * alphaFactor).roundToInt() - return Color.argb(alpha, color.red, color.green, color.blue) + @ColorInt + fun Context.colorFromAttribute(@AttrRes attribute: Int): Int { + var color = 0 + withStyledAttributes(attrs = intArrayOf(attribute)) { + color = getColor(0, 0) } - return color } + @ColorInt + fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val alpha = (color.alpha * factor).roundToInt() + return Color.argb(alpha, color.red, color.green, color.blue) + } + var createPaletteAsyncCache: HashMap = hashMapOf() fun createPaletteAsync(url: String, bitmap: Bitmap, callback: (Palette) -> Unit) { createPaletteAsyncCache[url]?.let { palette -> @@ -215,192 +340,19 @@ object UIHelper { } } - /*inline fun bindViewBinding( - inflater: LayoutInflater?, - container: ViewGroup?, - layout: Int - ): Pair { - return try { - val localInflater = inflater ?: container?.context?.let { LayoutInflater.from(it) } - ?: return null to txt( - R.string.unable_to_inflate, - "Requires inflater OR container" - )//throw IllegalArgumentException("Requires inflater OR container")) - - //println("methods: ${T::class.java.methods.map { it.name }}") - val bind = T::class.java.methods.first { it.name == "bind" } - //val inflate = T::class.java.methods.first { it.name == "inflate" } - val root = localInflater.inflate(layout, container, false) - bind.invoke(null, root) as T to null - } catch (t: Throwable) { - logError(t) - val message = txt(R.string.unable_to_inflate, t.message ?: "Primary constructor") - // if the desired layout is not found then we inflate the casted layout - /*try { - val localInflater = inflater ?: container?.context?.let { LayoutInflater.from(it) } - ?: return null to txt( - R.string.unable_to_inflate, - "Requires inflater OR container" - )//throw IllegalArgumentException("Requires inflater OR container")) - - // we don't know what method to use as there are 2, but first *should* always be true - return try { - val inflate = T::class.java.methods.first { it.name == "inflate" } - inflate.invoke(null, localInflater, container, false) as T - } catch (_: Throwable) { - val inflate = T::class.java.methods.last { it.name == "inflate" } - inflate.invoke(null, localInflater, container, false) as T - } to message - } catch (t: Throwable) { - logError(t) - }*/ - - null to message - } - }*/ - - fun ImageView?.setImage( - url: String?, - headers: Map? = null, - @DrawableRes - errorImageDrawable: Int? = null, - fadeIn: Boolean = true, - radius: Int = 0, - sample: Int = 3, - colorCallback: ((Palette) -> Unit)? = null - ): Boolean { - if (url.isNullOrBlank()) return false - this.setImage( - UiImage.Image(url, headers, errorImageDrawable), - errorImageDrawable, - fadeIn, - radius, - sample, - colorCallback - ) - return true - } - - fun ImageView?.setImage( - uiImage: UiImage?, - @DrawableRes - errorImageDrawable: Int? = null, - fadeIn: Boolean = true, - radius: Int = 0, - sample: Int = 3, - colorCallback: ((Palette) -> Unit)? = null, - ): Boolean { - if (this == null || uiImage == null) return false - - val (glideImage, identifier) = - (uiImage as? UiImage.Drawable)?.resId?.let { - it to it.toString() - } ?: (uiImage as? UiImage.Image)?.let { image -> - GlideUrl(image.url) { image.headers ?: emptyMap() } to image.url - } ?: return false - - return try { - var builder = GlideApp.with(this) - .load(glideImage) - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.ALL).let { req -> - if (fadeIn) - req.transition(DrawableTransitionOptions.withCrossFade()) - else req - } - - if (radius > 0) { - builder = builder.apply(bitmapTransform(BlurTransformation(radius, sample))) - } - - if (colorCallback != null) { - builder = builder.listener(object : RequestListener { - @SuppressLint("CheckResult") - override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - resource?.toBitmapOrNull() - ?.let { bitmap -> - createPaletteAsync( - identifier, - bitmap, - colorCallback - ) - } - return false - } - - @SuppressLint("CheckResult") - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - return false - } - }) - } - - val res = if (errorImageDrawable != null) - builder.error(errorImageDrawable).into(this) - else - builder.into(this) - res.clearOnDetach() - - true - } catch (e: Exception) { - logError(e) - false - } - } - - fun ImageView?.setImageBlur( - url: String?, - radius: Int, - sample: Int = 3, - headers: Map? = null - ) { - if (this == null || url.isNullOrBlank()) return - try { - val res = GlideApp.with(this) - .load(GlideUrl(url) { headers ?: emptyMap() }) - .apply(bitmapTransform(BlurTransformation(radius, sample))) - .transition( - DrawableTransitionOptions.withCrossFade() - ) - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .into(this) - res.clearOnDetach() - } catch (e: Exception) { - logError(e) - } - } - - fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { - val alpha = (Color.alpha(color) * factor).roundToInt() - val red = Color.red(color) - val green = Color.green(color) - val blue = Color.blue(color) - return Color.argb(alpha, red, green, blue) - } - - fun Context.colorFromAttribute(attribute: Int): Int { - val attributes = obtainStyledAttributes(intArrayOf(attribute)) - val color = attributes.getColor(0, 0) - attributes.recycle() - return color - } - fun Activity.hideSystemUI() { // Enables regular immersive mode. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + return + } + // For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE. // Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY + @Suppress("DEPRECATION") window.decorView.systemUiVisibility = ( View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY // Set the content to appear under the system bars so that the @@ -411,74 +363,25 @@ object UIHelper { // Hide the nav bar and status bar or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN - // or View.SYSTEM_UI_FLAG_LOW_PROFILE ) - // window.addFlags(View.KEEP_SCREEN_ON) } - fun FragmentActivity.popCurrentPage() { - this.onBackPressed() - /*val currentFragment = supportFragmentManager.fragments.lastOrNull { - it.isVisible - } ?: return - - supportFragmentManager.beginTransaction() - .setCustomAnimations( - R.anim.enter_anim, - R.anim.exit_anim, - R.anim.pop_enter, - R.anim.pop_exit - ) - .remove(currentFragment) - .commitAllowingStateLoss()*/ + 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 FragmentActivity.popCurrentPage(isInPlayer: Boolean, isInExpandedView: Boolean, isInResults: Boolean) { - val currentFragment = supportFragmentManager.fragments.lastOrNull { - it.isVisible - } - ?: //this.onBackPressed() - return -/* - if (tvActivity == null) { - requestedOrientation = if (settingsManager?.getBoolean("force_landscape", false) == true) { - ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - }*/ + fun Activity.setNavigationBarColorCompat(@AttrRes resourceId: Int) { + // edge-to-edge handles this + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return - // No fucked animations leaving the player :) - when { - isInPlayer -> { - supportFragmentManager.beginTransaction() - //.setCustomAnimations(R.anim.enter, R.anim.exit, R.anim.pop_enter, R.anim.pop_exit) - .remove(currentFragment) - .commitAllowingStateLoss() - } - isInExpandedView && !isInResults -> { - supportFragmentManager.beginTransaction() - .setCustomAnimations( - R.anim.enter_anim,//R.anim.enter_from_right, - R.anim.exit_anim,//R.anim.exit_to_right, - R.anim.pop_enter, - R.anim.pop_exit - ) - .remove(currentFragment) - .commitAllowingStateLoss() - } - else -> { - supportFragmentManager.beginTransaction() - .setCustomAnimations(R.anim.enter_anim, R.anim.exit_anim, R.anim.pop_enter, R.anim.pop_exit) - .remove(currentFragment) - .commitAllowingStateLoss() - } - } - }*/ + @Suppress("DEPRECATION") + window?.navigationBarColor = colorFromAttribute(resourceId) + } fun Context.getStatusBarHeight(): Int { - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { return 0 } @@ -490,17 +393,6 @@ object UIHelper { return result } - fun fixPaddingStatusbar(v: View?) { - if (v == null) return - val ctx = v.context ?: return - v.setPadding( - v.paddingLeft, - v.paddingTop + ctx.getStatusBarHeight(), - v.paddingRight, - v.paddingBottom - ) - } - fun fixPaddingStatusbarMargin(v: View?) { if (v == null) return val ctx = v.context ?: return @@ -525,6 +417,84 @@ object UIHelper { v.layoutParams = params } + fun fixSystemBarsPadding( + v: View, + @DimenRes heightResId: Int? = null, + @DimenRes widthResId: Int? = null, + padTop: Boolean = true, + padBottom: Boolean = true, + padLeft: Boolean = true, + padRight: Boolean = true, + overlayCutout: Boolean = true, + fixIme: Boolean = false + ) { + // edge-to-edge is very buggy on earlier versions so we just + // handle the status bar here instead. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + if (padTop) { + val ctx = v.context ?: return + v.updatePadding(top = ctx.getStatusBarHeight()) + } + return + } + + ViewCompat.setOnApplyWindowInsetsListener(v) { view, windowInsets -> + val leftCheck = if (view.isRtl()) padRight else padLeft + val rightCheck = if (view.isRtl()) padLeft else padRight + + val insetTypes = WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + if (fixIme) WindowInsetsCompat.Type.ime() else 0 + + val insets = windowInsets.getInsets(insetTypes) + + view.updatePadding( + left = if (leftCheck) insets.left else view.paddingLeft, + right = if (rightCheck) insets.right else view.paddingRight, + bottom = if (padBottom) insets.bottom else view.paddingBottom, + top = if (padTop) insets.top else view.paddingTop + ) + + heightResId?.let { + val heightPx = view.resources.getDimensionPixelSize(it) + view.updateLayoutParams { + height = heightPx + insets.bottom + } + } + + widthResId?.let { + val widthPx = view.resources.getDimensionPixelSize(it) + view.updateLayoutParams { + val startInset = if (view.isRtl()) insets.right else insets.left + width = if (startInset > 0) widthPx + startInset else widthPx + } + } + + if (overlayCutout && isLayout(PHONE)) { + // Draw a black overlay over the cutout. We do this so that + // it doesn't use the fragment background. We want it to + // appear as if the screen actually ends at cutout. + val cutout = windowInsets.displayCutout + if (cutout != null) { + val left = if (!leftCheck) 0 else cutout.safeInsetLeft + val right = if (!rightCheck) 0 else cutout.safeInsetRight + view.overlay.clear() + if (left > 0 || right > 0) { + view.overlay.add( + CutoutOverlayDrawable( + view, + leftCutout = left, + rightCutout = right + ) + ) + } + } + } + + WindowInsetsCompat.CONSUMED + } + } + fun Context.getNavigationBarHeight(): Int { var result = 0 val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") @@ -534,62 +504,56 @@ object UIHelper { return result } - fun Context?.IsBottomLayout(): Boolean { + fun Context?.isBottomLayout(): Boolean { if (this == null) return true val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) return settingsManager.getBoolean(getString(R.string.bottom_title_key), true) } - fun Activity.changeStatusBarState(hide: Boolean): Int { - return if (hide) { - window?.setFlags( - WindowManager.LayoutParams.FLAG_FULLSCREEN, - WindowManager.LayoutParams.FLAG_FULLSCREEN - ) - 0 - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) - this.getStatusBarHeight() + fun Activity.changeStatusBarState(hide: Boolean) { + try { + if (hide) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + controller.hide(WindowInsetsCompat.Type.statusBars()) + } else { + @Suppress("DEPRECATION") + window.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ) + } + } else { + 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) + } + } + } catch (t: Throwable) { + logError(t) } } // Shows the system bars by removing all the flags // except for the ones that make the content appear under the system bars. fun Activity.showSystemUI() { - window.decorView.systemUiVisibility = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + if (isLayout(EMULATOR)) { + controller.show(WindowInsetsCompat.Type.navigationBars()) + controller.hide(WindowInsetsCompat.Type.statusBars()) + } else controller.show(WindowInsetsCompat.Type.systemBars()) + return + } + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) - changeStatusBarState(isEmulatorSettings()) - - // window.clearFlags(View.KEEP_SCREEN_ON) - } - - fun Context.shouldShowPIPMode(isInPlayer: Boolean): Boolean { - return try { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - settingsManager?.getBoolean( - getString(R.string.pip_enabled_key), - true - ) ?: true && isInPlayer - } catch (e: Exception) { - logError(e) - false - } - } - - fun Context.hasPIPPermission(): Boolean { - val appOps = - getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - appOps.checkOpNoThrow( - AppOpsManager.OPSTR_PICTURE_IN_PICTURE, - android.os.Process.myUid(), - packageName - ) == AppOpsManager.MODE_ALLOWED - } else { - return true - } + changeStatusBarState(isLayout(EMULATOR)) } fun hideKeyboard(view: View?) { @@ -626,7 +590,13 @@ object UIHelper { onMenuItemClick: MenuItem.() -> Unit, ): PopupMenu { val ctw = ContextThemeWrapper(context, R.style.PopupMenu) - val popup = PopupMenu(ctw, this, Gravity.NO_GRAVITY, 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) @@ -650,7 +620,13 @@ object UIHelper { onMenuItemClick: MenuItem.() -> Unit, ): PopupMenu { val ctw = ContextThemeWrapper(context, R.style.PopupMenu) - val popup = PopupMenu(ctw, this, Gravity.NO_GRAVITY, 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) @@ -666,4 +642,39 @@ object UIHelper { popup.show() return popup } +} + +private class CutoutOverlayDrawable( + private val view: View, + private val leftCutout: Int, + private val rightCutout: Int, +) : Drawable() { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + style = Paint.Style.FILL + } + + override fun draw(canvas: Canvas) { + if (leftCutout > 0) canvas.drawRect( + 0f, + 0f, + leftCutout.toFloat(), + view.height.toFloat(), + paint + ) + if (rightCutout > 0) { + canvas.drawRect( + view.width - rightCutout.toFloat(), + 0f, view.width.toFloat(), + view.height.toFloat(), + paint + ) + } + } + + override fun setAlpha(alpha: Int) {} + override fun setColorFilter(colorFilter: ColorFilter?) {} + + @Suppress("OVERRIDE_DEPRECATION") + override fun getOpacity() = PixelFormat.OPAQUE } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt deleted file mode 100644 index d1614bc17..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.TvType -object VideoDownloadHelper { - data class DownloadEpisodeCached( - @JsonProperty("name") val name: String?, - @JsonProperty("poster") val poster: String?, - @JsonProperty("episode") val episode: Int, - @JsonProperty("season") val season: Int?, - @JsonProperty("id") val id: Int, - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("rating") val rating: Int?, - @JsonProperty("description") val description: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - ) - - 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("id") val id: Int, - @JsonProperty("cacheTime") val cacheTime: Long, - ) - - data class ResumeWatching( - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("episodeId") val episodeId: Int?, - @JsonProperty("episode") val episode: Int?, - @JsonProperty("season") val season: Int?, - @JsonProperty("updateTime") val updateTime: Long, - @JsonProperty("isFromDownload") val isFromDownload: Boolean, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt new file mode 100644 index 000000000..898c30a1c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt @@ -0,0 +1,132 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.getFolderPrefix +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.safefile.MediaFileContentType +import com.lagradost.safefile.SafeFile + +object DownloadFileManagement { + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" + internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + var tempName = name + for (c in RESERVED_CHARS) { + tempName = tempName.replace(c, ' ') + } + if (removeSpaces) tempName = tempName.replace(" ", "") + return tempName.replace(" ", " ").trim(' ') + } + + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ + internal fun getFolder( + context: Context, + relativePath: String, + basePath: String? + ): List>? { + val base = basePathToFile(context, basePath) + val folder = + base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null + + //if (folder.isDirectory() != false) return null + + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + internal fun basePathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + internal fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + internal fun getFileName( + context: Context, + metadata: DownloadObjects.DownloadEpisodeMetadata + ): String { + return getFileName(context, metadata.name, metadata.episode, metadata.season) + } + + internal fun getFileName( + context: Context, + epName: String?, + episode: Int?, + season: Int? + ): String { + // kinda ugly ik + return sanitizeFilename( + if (epName == null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" + } else { + "${context.getString(R.string.episode)} $episode" + } + } else { + if (episode != null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" + } else { + "${context.getString(R.string.episode)} $episode - $epName" + } + } else { + epName + } + } + ) + } + + + internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory( + relativePath, + createMissingDirectories = false + ) + ?.findFile(displayName) + } + + internal fun getFolder(currentType: TvType, titleName: String): String { + return if (currentType.isEpisodeBased()) { + val sanitizedFileName = sanitizeFilename(titleName) + "${currentType.getFolderPrefix()}/$sanitizedFileName" + } else currentType.getFolderPrefix() + } + + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * As of writing UniFile is used for everything but download directory on scoped storage. + * Special ContentResolver fuckery is needed for that as UniFile doesn't work. + * */ + fun getDefaultDir(context: Context): SafeFile? { + // See https://www.py4u.net/discuss/614761 + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt similarity index 54% rename from app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt rename to app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt index 948d7b8ab..7cb190667 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -1,68 +1,129 @@ -package com.lagradost.cloudstream3.utils +package com.lagradost.cloudstream3.utils.downloader + +import android.Manifest +import android.annotation.SuppressLint import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent -import android.content.* -import android.graphics.Bitmap -import android.net.Uri +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.util.Log +import android.widget.Toast import androidx.annotation.DrawableRes +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat import androidx.core.net.toUri import androidx.preference.PreferenceManager -import androidx.work.Data -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager -import com.bumptech.glide.load.model.GlideUrl -import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.IDownloadableMinimum import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R 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.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.VideoDownloadService +import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD +import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.M3u8Helper2 +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName +import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.storage.MediaFileContentType -import com.lagradost.cloudstream3.utils.storage.SafeFile +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getDefaultDir +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.toFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.CreateNotificationMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadEpisodeMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadItem +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadResumePackage +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadStatus +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfo +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfoResult +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.LazyStreamDownloadResponse +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.StreamData +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.appendAndDontOverride +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.cancel +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getEstimatedTimeLeft +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.join +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.safefile.SafeFile +import com.lagradost.safefile.closeQuietly import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import okhttp3.internal.closeQuietly import java.io.Closeable -import java.io.File import java.io.IOException import java.io.OutputStream -import java.net.URL -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 { - var maxConcurrentDownloads = 3 - private var currentDownloads = mutableListOf() + 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 + + 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" @@ -106,66 +167,6 @@ object VideoDownloadManager { Stop, } - interface IDownloadableMinimum { - val url: String - val referer: String - val headers: Map - } - - fun IDownloadableMinimum.getId(): Int { - return url.hashCode() - } - - 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 = @@ -182,77 +183,60 @@ object VideoDownloadManager { /** the process failed due to some reason, so we retry and also try the next mirror */ private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false) + /** The download only downloaded partial */ + private val DOWNLOAD_PARTIAL_SUCCESS = + DownloadStatus(retrySame = true, tryNext = false, success = true) + + /** 50MB minimum size */ + const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 50L + /** bad config, skip all mirrors as every call to download will have the same bad config */ private val DOWNLOAD_BAD_CONFIG = DownloadStatus(retrySame = false, tryNext = false, success = false) - private const val KEY_RESUME_PACKAGES = "download_resume" + const val KEY_RESUME_PACKAGES = "download_resume_2" const val KEY_DOWNLOAD_INFO = "download_info" - private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" + + /** A key to save all the downloads which have not yet started and those currently running, using [DownloadQueueWrapper] + * [KEY_RESUME_PACKAGES] can store keys which should not be automatically queued, unlike this key. + */ + const val KEY_RESUME_IN_QUEUE = "download_resume_queue_key" +// private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" val downloadStatus = HashMap() val downloadStatusEvent = Event>() val downloadDeleteEvent = Event() val downloadEvent = Event>() val downloadProgressEvent = Event>() - val downloadQueue = LinkedList() +// val downloadQueue = LinkedList() + + private var hasCreatedNotChannel = false - private var hasCreatedNotChanel = false private fun Context.createNotificationChannel() { - hasCreatedNotChanel = true - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = DOWNLOAD_CHANNEL_NAME //getString(R.string.channel_name) - val descriptionText = DOWNLOAD_CHANNEL_DESCRIPT//getString(R.string.channel_description) - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = NotificationChannel(DOWNLOAD_CHANNEL_ID, name, importance).apply { - description = descriptionText + 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) } - // Register the channel with the system - val notificationManager: NotificationManager = - this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) } } - ///** Will return IsDone if not found or error */ - //fun getDownloadState(id: Int): DownloadType { - // return try { - // downloadStatus[id] ?: DownloadType.IsDone - // } catch (e: Exception) { - // logError(e) - // DownloadType.IsDone - // } - //} - - private val cachedBitmaps = hashMapOf() - fun Context.getImageBitmapFromUrl(url: String, headers: Map? = null): Bitmap? { - try { - if (cachedBitmaps.containsKey(url)) { - return cachedBitmaps[url] - } - - val bitmap = GlideApp.with(this) - .asBitmap() - .load(GlideUrl(url) { headers ?: emptyMap() }) - .into(720, 720) - .get() - - if (bitmap != null) { - cachedBitmaps[url] = bitmap - } - return bitmap - } catch (e: Exception) { - logError(e) - return null - } - } /** * @param hlsProgress will together with hlsTotal display another notification if used, to lessen the confusion about estimated size. * */ - private suspend fun createNotification( + @SuppressLint("StringFormatInvalid") + private suspend fun createDownloadNotification( context: Context, source: String?, linkName: String?, @@ -268,7 +252,6 @@ object VideoDownloadManager { try { if (total <= 0) return null// crash, invalid data -// main { // DON'T WANT TO SLOW IT DOWN val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setAutoCancel(true) .setColorized(true) @@ -297,12 +280,8 @@ object VideoDownloadManager { data = source.toUri() flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - val pendingIntent: PendingIntent = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) - } else { - PendingIntent.getActivity(context, 0, intent, 0) - } + val pendingIntent = + PendingIntentCompat.getActivity(context, 0, intent, 0, false) builder.setContentIntent(pendingIntent) } @@ -322,7 +301,7 @@ object VideoDownloadManager { } val downloadFormat = context.getString(R.string.download_format) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (SDK_INT >= Build.VERSION_CODES.O) { if (ep.poster != null) { val poster = withContext(Dispatchers.IO) { context.getImageBitmapFromUrl(ep.poster) @@ -339,7 +318,7 @@ object VideoDownloadManager { val mbFormat = "%.1f MB" if (hlsProgress != null && hlsTotal != null) { - progressPercentage = hlsProgress.toLong() * 100 / hlsTotal + progressPercentage = hlsProgress * 100 / hlsTotal progressMbString = hlsProgress.toString() totalMbString = hlsTotal.toString() suffix = " - $mbFormat".format(progress / 1000000f) @@ -355,10 +334,15 @@ object VideoDownloadManager { " ($mbFormat/s)".format(bytesPerSecond.toFloat() / 1000000f) } else "" + val remainingTime = + if (state == DownloadType.IsDownloading) { + getEstimatedTimeLeft(context, bytesPerSecond, progress, total) + } else "" + val bigText = when (state) { DownloadType.IsDownloading, DownloadType.IsPaused -> { - (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString" + (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString $remainingTime" } DownloadType.IsPending -> { @@ -416,7 +400,7 @@ object VideoDownloadManager { builder.setContentText(txt) } - if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused || state == DownloadType.IsPending) && SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT if (state == DownloadType.IsDownloading) { @@ -428,6 +412,9 @@ object VideoDownloadManager { actionTypes.add(DownloadActionType.Resume) actionTypes.add(DownloadActionType.Stop) } + if (state == DownloadType.IsPending) { + actionTypes.add(DownloadActionType.Stop) + } // ADD ACTIONS for ((index, i) in actionTypes.withIndex()) { @@ -466,7 +453,7 @@ object VideoDownloadManager { } } - if (!hasCreatedNotChanel) { + if (!hasCreatedNotChannel) { context.createNotificationChannel() } @@ -474,7 +461,14 @@ object VideoDownloadManager { notificationCallback(ep.id, notification) with(NotificationManagerCompat.from(context)) { // notificationId is a unique int for each notification that you must define - notify(ep.id, notification) + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return null + } + notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) } return notification } catch (e: Exception) { @@ -483,67 +477,6 @@ object VideoDownloadManager { } } - private const val reservedChars = "|\\?*<\":>+[]/\'" - fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { - var tempName = name - for (c in reservedChars) { - tempName = tempName.replace(c, ' ') - } - if (removeSpaces) tempName = tempName.replace(" ", "") - return tempName.replace(" ", " ").trim(' ') - } - - /** - * Used for getting video player subs. - * @return List of pairs for the files in this format: - * */ - fun getFolder( - context: Context, - relativePath: String, - basePath: String? - ): List>? { - val base = basePathToFile(context, basePath) - val folder = base?.gotoDirectory(relativePath, false) ?: return null - if (folder.isDirectory() != false) return null - - return folder.listFiles() - ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } - } - - - data class CreateNotificationMetadata( - val type: DownloadType, - val bytesDownloaded: Long, - val bytesTotal: Long, - val hlsProgress: Long? = null, - val hlsTotal: Long? = null, - val bytesPerSecond: Long - ) - - data class StreamData( - private val fileLength: Long, - val file: SafeFile, - //val fileStream: OutputStream, - ) { - @Throws(IOException::class) - fun open(): OutputStream { - return file.openOutputStreamOrThrow(resume) - } - - @Throws(IOException::class) - fun openNew(): OutputStream { - return file.openOutputStreamOrThrow(false) - } - - fun delete(): Boolean { - return file.delete() - } - - val resume: Boolean get() = fileLength > 0L - val startAt: Long get() = if (resume) fileLength else 0L - val exists: Boolean get() = file.exists() == true - } - @Throws(IOException::class) fun setupStream( @@ -553,9 +486,9 @@ object VideoDownloadManager { extension: String, tryResume: Boolean, ): StreamData { - val (base, _) = context.getBasePath() return setupStream( - base ?: throw IOException("Bad config"), + context.getBasePath().first ?: getDefaultDir(context) + ?: throw IOException("Bad config"), name, folder, extension, @@ -565,7 +498,7 @@ object VideoDownloadManager { /** * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads. + * Used for initializing downloads and backups. * */ @Throws(IOException::class) fun setupStream( @@ -577,7 +510,8 @@ object VideoDownloadManager { ): StreamData { val displayName = getDisplayName(name, extension) - val subDir = baseFile.gotoDirectoryOrThrow(folder) + val subDir = baseFile.gotoDirectory(folder, createMissingDirectories = true) + ?: throw IOException("Cant create directory") val foundFile = subDir.findFile(displayName) val (file, fileLength) = if (foundFile == null || foundFile.exists() != true) { @@ -597,6 +531,7 @@ object VideoDownloadManager { /** This class handles the notifications, as well as the relevant key */ data class DownloadMetaData( private val id: Int?, + private val linkHash : Int, var bytesDownloaded: Long = 0, var bytesWritten: Long = 0, @@ -608,7 +543,7 @@ object VideoDownloadManager { private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, private var internalType: DownloadType = DownloadType.IsPending, - + val isHLS : Boolean, // how many segments that we have downloaded var hlsProgress: Int = 0, // how many segments that exist @@ -626,13 +561,17 @@ object VideoDownloadManager { lastDownloadedBytes = length } + /** Returns the appropriate failed status based on download progress */ + fun failedStatus() = if (this.bytesWritten > DOWNLOAD_PARTIAL_MIN_SIZE) + DOWNLOAD_PARTIAL_SUCCESS + else + DOWNLOAD_FAILED + val approxTotalBytes: Long get() = totalBytes ?: hlsTotal?.let { total -> (bytesDownloaded * (total / hlsProgress.toFloat())).toLong() } ?: bytesDownloaded - private val isHLS get() = hlsTotal != null - private var stopListener: (() -> Unit)? = null /** on cancel button pressed or failed invoke this once and only once */ @@ -653,8 +592,6 @@ object VideoDownloadManager { DownloadActionType.Stop -> { type = DownloadType.IsStopped - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - saveQueue() stopListener?.invoke() stopListener = null } @@ -669,11 +606,32 @@ object VideoDownloadManager { private fun updateFileInfo() { if (id == null) return downloadFileInfoTemplate?.let { template -> + /** This looks strange, but fixes an issue where we do an instant retry, and it fails immediately, + * eg. by turning off wifi */ + val totalBytesValue = if (approxTotalBytes <= bytesDownloaded) { + val prevInfo = getKey( + KEY_DOWNLOAD_INFO, + id.toString() + ) + + /** If this link is the same as the last cached video link metadata */ + if (prevInfo != null && prevInfo.linkHash == linkHash) { + /** Try to use totalBytes if it exists, otherwise the max of the prev data, + * and download size to ensure total >= downloaded */ + totalBytes ?: maxOf(prevInfo.totalBytes, bytesDownloaded) + } else { + approxTotalBytes + } + } else { + approxTotalBytes + } + setKey( KEY_DOWNLOAD_INFO, id.toString(), template.copy( - totalBytes = approxTotalBytes, + linkHash = linkHash, + totalBytes = totalBytesValue, extraInfo = if (isHLS) hlsWrittenProgress.toString() else null ) ) @@ -816,34 +774,12 @@ object VideoDownloadManager { } } - /** bytes have the size end-start where the byte range is [start,end) - * note that ByteArray is a pointer and therefore cant be stored without cloning it */ - data class LazyStreamDownloadResponse( - val bytes: ByteArray, - val startByte: Long, - val endByte: Long, - ) { - val size get() = endByte - startByte - - override fun toString(): String { - return "$startByte->$endByte" - } - - override fun equals(other: Any?): Boolean { - if (other !is LazyStreamDownloadResponse) return false - return other.startByte == startByte && other.endByte == endByte - } - - override fun hashCode(): Int { - return Objects.hash(startByte, endByte) - } - } data class LazyStreamDownloadData( private val url: String, private val headers: Map, private val referer: String, - /** This specifies where chunck i starts and ends, + /** This specifies where chunk i starts and ends, * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} * where out of bounds => bytes=${chuckStartByte[ i ]}- */ private val chuckStartByte: LongArray, @@ -851,6 +787,7 @@ object VideoDownloadManager { val downloadLength: Long?, val chuckSize: Long, val bufferSize: Int, + val isResumed: Boolean, ) { val size get() = chuckStartByte.size @@ -867,6 +804,7 @@ 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 @@ -885,7 +823,6 @@ object VideoDownloadManager { ) val requestStream = request.body.byteStream() - val buffer = ByteArray(bufferSize) var read: Int try { @@ -916,6 +853,7 @@ 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 @@ -924,22 +862,21 @@ object VideoDownloadManager { for (i in 0 until retries) { try { // in case - start = resolve(start, end, callback) + start = resolve(start, end, buffer, 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 (e: IllegalStateException) { + } catch (_: IllegalStateException) { return false - } catch (e: CancellationException) { + } catch (_: CancellationException) { return false - } catch (t: Throwable) { + } catch (_: Throwable) { continue } } return false } - } @Throws @@ -951,25 +888,85 @@ object VideoDownloadManager { /** how many bytes every connection should be, by default it is 10 MiB */ chuckSize: Long = (1 shl 20) * 10, /** maximum bytes in the buffer that responds */ - bufferSize: Int = DEFAULT_BUFFER_SIZE + bufferSize: Int = DEFAULT_BUFFER_SIZE, + /** how many bytes bytes it should require to use the parallel downloader instead, + * if we download a very small file we don't want it parallel */ + maximumSmallSize: Long = chuckSize * 2 ): LazyStreamDownloadData { // we don't want to make a separate connection for every 1kb require(chuckSize > 1000) - var contentLength = - app.head(url = url, headers = headers, referer = referer, verify = false).size + val headRequest = app.head(url = url, headers = headers, referer = referer, verify = false) + var contentLength = headRequest.size if (contentLength != null && contentLength <= 0) contentLength = null - var downloadLength: Long? = null - var totalLength: Long? = null + val hasRangeSupport = when (headRequest.headers["Accept-Ranges"]?.lowercase()?.trim()) { + // server has stated it has no support + "none" -> false + // server has stated it has support + "bytes" -> true + // 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 -> + Log.v(TAG, "Unknown Accept-Ranges tag: $range") + } + // as we don't poll the body this should be fine + val getRequest = app.get( + url, + headers = headers + mapOf( + "Range" to "bytes=0-${ + // 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) + } ?: 1023L + }" + ), + referer = referer, + verify = false + ) + // 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 + } + } + } - val ranges = if (contentLength == null) { + // supports range if status is partial content https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206 + getRequest.code == 206 + } + } + + Log.d( + TAG, + "Starting stream with url=$url, startByte=$startByte, contentLength=$contentLength, hasRangeSupport=$hasRangeSupport" + ) + + var downloadLength: Long? = null + + val ranges = if (!hasRangeSupport) { + // is the equivalent of [0..EOF] as we cant resume, nor can parallelize it + downloadLength = contentLength + LongArray(1) { 0 } + } else if (contentLength == null || contentLength < maximumSmallSize) { + if (contentLength != null) { + downloadLength = contentLength - startByte + } // is the equivalent of [startByte..EOF] as we don't know the size we can only do one // connection LongArray(1) { startByte } } else { downloadLength = contentLength - startByte - totalLength = contentLength // div with ceiling as // this makes the last part "unknown ending" and it will break at EOF // so eg startByte = 0, downloadLength = 13, chuckSize = 10 @@ -985,45 +982,16 @@ object VideoDownloadManager { referer = referer, chuckStartByte = ranges, downloadLength = downloadLength, - totalLength = totalLength, + totalLength = contentLength, chuckSize = chuckSize, - bufferSize = bufferSize + bufferSize = bufferSize, + // we have only resumed if we had a downloaded file and we can resume + isResumed = startByte > 0 && hasRangeSupport ) } - /** 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( context: Context, link: IDownloadableMinimum, @@ -1033,10 +1001,12 @@ object VideoDownloadManager { tryResume: Boolean, parentId: Int?, createNotificationCallback: (CreateNotificationMetadata) -> Unit, - parallelConnections: Int = 3 + parallelConnections: Int = 3, + /** how many bytes a valid file must be in bytes, + * this should be different for subtitles and video */ + minimumSize: Long = 100 ): DownloadStatus = withContext(Dispatchers.IO) { - // we cant download torrents with this implementation, aria2c might be used in the future - if (link.url.startsWith("magnet") || link.url.endsWith(".torrent") || parallelConnections < 1) { + if (parallelConnections < 1) { return@withContext DOWNLOAD_INVALID_INPUT } @@ -1047,6 +1017,8 @@ object VideoDownloadManager { bytesDownloaded = 0, createNotificationCallback = createNotificationCallback, id = parentId, + linkHash = link.url.hashCode(), + isHLS = false ) try { // get the file path @@ -1055,7 +1027,7 @@ object VideoDownloadManager { if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG // set up the download file - val stream = setupStream(baseFile, name, folder, extension, tryResume) + var stream = setupStream(baseFile, name, folder, extension, tryResume) fileStream = stream.open() @@ -1068,18 +1040,29 @@ 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", ) ) ) + // too short file, treat it as a invalid link + if (items.totalLength != null && items.totalLength < minimumSize) { + fileStream.closeQuietly() + metadata.onDelete() + stream.delete() + return@withContext DOWNLOAD_INVALID_INPUT + } + + // if we have an output stream that cant be resumed then we delete the entire file + // and set up the stream again + if (!items.isResumed && stream.startAt > 0) { + fileStream.closeQuietly() + stream.delete() + metadata.setResumeLength(0) + stream = setupStream(baseFile, name, folder, extension, false) + fileStream = stream.open() + } + metadata.totalBytes = items.totalLength metadata.type = DownloadType.IsDownloading metadata.setDownloadFileInfoTemplate( @@ -1176,13 +1159,29 @@ object VideoDownloadManager { } } - // this will take up the first available job and resolve + // Reuse a download buffer to decrease unnecessary alloc + val buffer = ByteArray(items.bufferSize) + + // 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 @@ -1193,7 +1192,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, callback = callback)) { + if (!items.resolveSafe(index, buffer = buffer, callback = callback)) { fileMutex.withLock { if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed @@ -1218,7 +1217,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } if (metadata.type == DownloadType.IsStopped) { @@ -1229,6 +1228,16 @@ object VideoDownloadManager { return@withContext DOWNLOAD_STOPPED } + // in case the head request lies about content-size, + // then we don't want shit output + if (metadata.bytesDownloaded < minimumSize) { + // we need to close before delete + fileStream.closeQuietly() + metadata.onDelete() + stream.delete() + return@withContext DOWNLOAD_INVALID_INPUT + } + metadata.type = DownloadType.IsDone return@withContext DOWNLOAD_SUCCESS } catch (e: IOException) { @@ -1238,11 +1247,11 @@ object VideoDownloadManager { throw e } catch (t: Throwable) { // some sort of network error, will error - + logError(t) // note that when failing we don't want to delete the file, // only user interaction has that power metadata.type = DownloadType.IsFailed - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } finally { fileStream?.closeQuietly() //requestStream?.closeQuietly() @@ -1264,7 +1273,9 @@ object VideoDownloadManager { val metadata = DownloadMetaData( createNotificationCallback = createNotificationCallback, - id = parentId + id = parentId, + linkHash = link.url.hashCode(), + isHLS = true ) var fileStream: OutputStream? = null try { @@ -1280,12 +1291,14 @@ object VideoDownloadManager { val displayName = getDisplayName(name, extension) val stream = setupStream(baseFile, name, folder, extension, startAt > 0) + if (!stream.resume) startAt = 0 fileStream = stream.open() // push the metadata metadata.setResumeLength(stream.startAt) metadata.hlsProgress = startAt + metadata.hlsWrittenProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( @@ -1300,13 +1313,12 @@ 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(listOf(m3u8)) + + val items = M3u8Helper2.hslLazy(m3u8, selectBest = true, requireAudio = true) metadata.hlsTotal = items.size metadata.type = DownloadType.IsDownloading @@ -1338,10 +1350,23 @@ object VideoDownloadManager { launch(Dispatchers.IO) { while (true) { if (!isActive) return@launch + + var isTooFarAhead = false fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch + + // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk + // 50MB limit + if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { + isTooFarAhead = true + } + } + + if (isTooFarAhead) { + delay(500) + continue } // mutex just in case, we never want this to fail due to multithreading @@ -1361,46 +1386,46 @@ object VideoDownloadManager { return@launch } - try { - fileMutex.lock() - // user pause - while (metadata.type == DownloadType.IsPaused) delay(100) - // if stopped then break to delete - if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch + fileMutex.withLock { + try { + // user pause + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then break to delete + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch - val segmentLength = bytes.size.toLong() - // send notification, no matter the actual write order - metadata.addSegment(segmentLength) + val segmentLength = bytes.size.toLong() + // send notification, no matter the actual write order + metadata.addSegment(segmentLength) - // directly write the bytes if you are first - if (metadata.hlsWrittenProgress == index) { - fileStream.write(bytes) + // directly write the bytes if you are first + if (metadata.hlsWrittenProgress == index) { + fileStream.write(bytes) - metadata.addBytesWritten(segmentLength) - metadata.setWrittenSegment(index) - } else { - // no need to clone as there will be no modification of this bytearray - pendingData[index] = bytes + metadata.addBytesWritten(segmentLength) + metadata.setWrittenSegment(index) + } else { + // no need to clone as there will be no modification of this bytearray + pendingData[index] = bytes + } + + // write the cached bytes submitted by other threads + while (true) { + val cache = + pendingData.remove(metadata.hlsWrittenProgress) ?: break + val cacheLength = cache.size.toLong() + + 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 + } } - - // 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 { - fileMutex.unlock() } } } @@ -1419,7 +1444,7 @@ object VideoDownloadManager { if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } if (metadata.type == DownloadType.IsStopped) { @@ -1435,7 +1460,7 @@ object VideoDownloadManager { } catch (t: Throwable) { logError(t) metadata.type = DownloadType.IsFailed - return@withContext DOWNLOAD_FAILED + return@withContext metadata.failedStatus() } finally { fileStream?.closeQuietly() metadata.close() @@ -1446,75 +1471,6 @@ object VideoDownloadManager { return "$name.$extension" } - /** - * Gets the default download path as an UniFile. - * Vital for legacy downloads, be careful about changing anything here. - * - * As of writing UniFile is used for everything but download directory on scoped storage. - * Special ContentResolver fuckery is needed for that as UniFile doesn't work. - * */ - fun getDefaultDir(context: Context): SafeFile? { - // See https://www.py4u.net/discuss/614761 - return SafeFile.fromMedia( - context, MediaFileContentType.Downloads - ) - } - - /** - * Turns a string to an UniFile. Used for stored string paths such as settings. - * Should only be used to get a download path. - * */ - private fun basePathToFile(context: Context, path: String?): SafeFile? { - return when { - path.isNullOrBlank() -> getDefaultDir(context) - path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) - else -> SafeFile.fromFile(context, File(path)) - } - } - - /** - * Base path where downloaded things should be stored, changes depending on settings. - * Returns the file and a string to be stored for future file retrieval. - * UniFile.filePath is not sufficient for storage. - * */ - fun Context.getBasePath(): Pair { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) - return basePathToFile(this, basePathSetting) to basePathSetting - } - - fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { - return getFileName(context, metadata.name, metadata.episode, metadata.season) - } - - private fun getFileName( - context: Context, - epName: String?, - episode: Int?, - season: Int? - ): String { - // kinda ugly ik - return sanitizeFilename( - if (epName == null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" - } else { - "${context.getString(R.string.episode)} $episode" - } - } else { - if (episode != null) { - if (season != null) { - "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" - } else { - "${context.getString(R.string.episode)} $episode - $epName" - } - } else { - epName - } - } - ) - } - private suspend fun downloadSingleEpisode( context: Context, source: String?, @@ -1524,6 +1480,11 @@ object VideoDownloadManager { notificationCallback: (Int, Notification) -> Unit, tryResume: Boolean = false, ): DownloadStatus { + // no support for these file formats + if (link.type == ExtractorLinkType.MAGNET || link.type == ExtractorLinkType.TORRENT || link.type == ExtractorLinkType.DASH) { + return DOWNLOAD_INVALID_INPUT + } + val name = getFileName(context, ep) // Make sure this is cancelled when download is done or cancelled. @@ -1535,7 +1496,7 @@ object VideoDownloadManager { val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { - createNotification( + createDownloadNotification( context, source, link.name, @@ -1552,128 +1513,56 @@ object VideoDownloadManager { } try { - if (link.isM3u8 || normalSafeApiCall { URL(link.url).path.endsWith(".m3u8") } == true) { - val startIndex = if (tryResume) { - context.getKey( - KEY_DOWNLOAD_INFO, - ep.id.toString(), - null - )?.extraInfo?.toIntOrNull() - } else null + when (link.type) { + ExtractorLinkType.M3U8 -> { + val startIndex = if (tryResume) { + context.getKey( + KEY_DOWNLOAD_INFO, + ep.id.toString(), + null + )?.extraInfo?.toIntOrNull() + } else null - return downloadHLS( - context, - link, - name, - folder ?: "", - ep.id, - startIndex, - callback - ) - } else { - return downloadThing( - context, - link, - name, - folder ?: "", - "mp4", - tryResume, - ep.id, - callback - ) + return downloadHLS( + context, + link, + name, + folder ?: "", + ep.id, + startIndex, + callback, parallelConnections = maxConcurrentConnections(context) + ) + } + + ExtractorLinkType.VIDEO -> { + return downloadThing( + context, + link, + name, + folder ?: "", + "mp4", + tryResume, + ep.id, + callback, + parallelConnections = maxConcurrentConnections(context), + /** We require at least 10 MB video files */ + minimumSize = (1 shl 20) * 10 + ) + } + + else -> throw IllegalArgumentException("Unsupported download type") } - } catch (t: Throwable) { + } catch (_: Throwable) { return DOWNLOAD_FAILED } finally { extractorJob.cancel() } } - suspend fun downloadCheck( - context: Context, notificationCallback: (Int, Notification) -> Unit, - ) { - if (!(currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0)) return - val pkg = downloadQueue.removeFirst() - val item = pkg.item - val id = item.ep.id - if (currentDownloads.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT - downloadEvent.invoke(id to DownloadActionType.Resume) - return - } - - currentDownloads.add(id) - try { - for (index in (pkg.linkIndex ?: 0) until item.links.size) { - val link = item.links[index] - val resume = pkg.linkIndex == index - - setKey( - KEY_RESUME_PACKAGES, - id.toString(), - DownloadResumePackage(item, index) - ) - - var connectionResult = - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ) - - if (connectionResult.retrySame) { - connectionResult = downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - true - ) - } - - if (connectionResult.success) { // SUCCESS - removeKey(KEY_RESUME_PACKAGES, id.toString()) - break - } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { - downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) - break - } - } - } catch (e: Exception) { - logError(e) - } finally { - currentDownloads.remove(id) - // Because otherwise notifications will not get caught by the work manager - downloadCheckUsingWorker(context) - } - - // return id - } - - /* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { - val res = getDownloadFileInfo(context, id) - if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) - return res - } - */ - fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? = - getDownloadFileInfo(context, id, removeKeys = true) - - private fun DownloadedFileInfo.toFile(context: Context): SafeFile? { - return basePathToFile(context, this.basePath)?.gotoDirectory(relativePath) - ?.findFile(displayName) - } - - private fun getDownloadFileInfo( + fun getDownloadFileInfo( context: Context, id: Int, - removeKeys: Boolean = false ): DownloadedFileInfoResult? { try { val info = @@ -1681,8 +1570,7 @@ object VideoDownloadManager { val file = info.toFile(context) // only delete the key if the file is not found - if (file == null || !file.existsOrThrow()) { - //if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) // TODO READD + if (file == null || file.exists() == false) { return null } @@ -1697,156 +1585,511 @@ object VideoDownloadManager { } } - fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { + fun deleteFilesAndUpdateSettings( + context: Context, + ids: Set, + scope: CoroutineScope, + onComplete: (Set) -> Unit = {} + ) { + scope.launchSafe(Dispatchers.IO) { + val deleteJobs = ids.map { id -> + async { + id to deleteFileAndUpdateSettings(context, id) + } + } + val results = deleteJobs.awaitAll() + + val (successfulResults, failedResults) = results.partition { it.second } + val successfulIds = successfulResults.map { it.first }.toSet() + + if (failedResults.isNotEmpty()) { + failedResults.forEach { (id, _) -> + // TODO show a toast if some failed? + Log.e("FileDeletion", "Failed to delete file with ID: $id") + } + } else { + Log.i("FileDeletion", "All files deleted successfully") + } + + onComplete.invoke(successfulIds) + } + } + + private fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { val success = deleteFile(context, id) if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) 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 - downloadEvent.invoke(id to DownloadActionType.Stop) - downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(id to DownloadType.IsStopped) - downloadDeleteEvent.invoke(id) - return info.toFile(context)?.delete() ?: false + val file = info.toFile(context) + + val isFileDeleted = file?.delete() == true || file?.exists() == false + + if (isFileDeleted) { + deleteMatchingSubtitles(context, info) + downloadEvent.invoke(id to DownloadActionType.Stop) + downloadProgressEvent.invoke(Triple(id, 0, 0)) + downloadStatusEvent.invoke(id to DownloadType.IsStopped) + downloadDeleteEvent.invoke(id) + } + + return isFileDeleted } fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } - suspend fun downloadFromResume( - context: Context, - pkg: DownloadResumePackage, - notificationCallback: (Int, Notification) -> Unit, - setKey: Boolean = true + fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? { + return context.getKey(KEY_RESUME_IN_QUEUE, id.toString()) + } + + fun getDownloadEpisodeMetadata( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): DownloadEpisodeMetadata { + return DownloadEpisodeMetadata( + episode.id, + episode.parentId, + sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } + + class EpisodeDownloadInstance( + val context: Context, + val downloadQueueWrapper: DownloadQueueWrapper ) { - if (!currentDownloads.any { it == pkg.item.ep.id } && !downloadQueue.any { it.item.ep.id == pkg.item.ep.id }) { - downloadQueue.addLast(pkg) - downloadCheck(context, notificationCallback) - if (setKey) saveQueue() - //ret - } else { - downloadEvent( - pkg.item.ep.id to DownloadActionType.Resume - ) - //null - } - } + private val TAG = "EpisodeDownloadInstance" + private var subtitleDownloadJob: Job? = null + private var downloadJob: Job? = null + private var linkLoadingJob: Job? = null - 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) - } - } + /** 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. - /*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 + + // 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) + } } } - return false - }*/ - suspend fun downloadEpisode( - context: Context?, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - notificationCallback: (Int, Notification) -> Unit, - ) { - if (context == null) return - if (links.isEmpty()) return - downloadFromResume( - context, - DownloadResumePackage(DownloadItem(source, folder, ep, links), null), - notificationCallback - ) - } + private suspend fun downloadFromResume( + downloadResumePackage: DownloadResumePackage, + notificationCallback: (Int, Notification) -> Unit, + ) { + val item = downloadResumePackage.item + val id = item.ep.id + if (currentDownloads.value.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT + downloadEvent.invoke(id to DownloadActionType.Resume) + return + } - /** Worker stuff */ - private fun startWork(context: Context, key: String) { - val req = OneTimeWorkRequest.Builder(DownloadFileWorkManager::class.java) - .setInputData( - Data.Builder() - .putString("key", key) - .build() + _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), ) - .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/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt new file mode 100644 index 000000000..25a9fdf2a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -0,0 +1,224 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.safefile.SafeFile +import java.io.IOException +import java.io.OutputStream +import java.util.Objects + +object DownloadObjects { + /** An item can either be something to resume or something new to start */ + data class DownloadQueueWrapper( + @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?, + @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?, + ) { + init { + assert(resumePackage != null || downloadItem != null) { + "ResumeID and downloadItem cannot both be null at the same time!" + } + } + + /** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */ + fun isCurrentlyDownloading(): Boolean { + return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } + } + + @JsonProperty("id") + val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id + + @JsonProperty("parentId") + val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId + } + + /** General data about the episode and show to start a download from. */ + data class DownloadQueueItem( + @JsonProperty("episode") val episode: ResultEpisode, + @JsonProperty("isMovie") val isMovie: Boolean, + @JsonProperty("resultName") val resultName: String, + @JsonProperty("resultType") val resultType: TvType, + @JsonProperty("resultPoster") val resultPoster: String?, + @JsonProperty("apiName") val apiName: String, + @JsonProperty("resultId") val resultId: Int, + @JsonProperty("resultUrl") val resultUrl: String, + @JsonProperty("links") val links: List? = null, + @JsonProperty("subs") val subs: List? = null, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(null, this) + } + } + + + abstract class DownloadCached( + @JsonProperty("id") open val id: Int, + ) + + data class DownloadEpisodeCached( + @JsonProperty("name") val name: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("season") val season: Int?, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("score") var score: Score? = null, + @JsonProperty("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } + + /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ + data class DownloadHeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) + + data class DownloadResumePackage( + @JsonProperty("item") val item: DownloadItem, + /** Tills which link should get resumed */ + @JsonProperty("linkIndex") val linkIndex: Int?, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(this, null) + } + } + + data class DownloadItem( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List, + ) + + /** Metadata for a specific episode and how to display it. */ + data class DownloadEpisodeMetadata( + @JsonProperty("id") val id: Int, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("mainName") val mainName: String, + @JsonProperty("sourceApiName") val sourceApiName: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("type") val type: TvType?, + ) + + + data class DownloadedFileInfo( + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("relativePath") val relativePath: String, + @JsonProperty("displayName") val displayName: String, + @JsonProperty("extraInfo") val extraInfo: String? = null, + @JsonProperty("basePath") val basePath: String? = null, // null is for legacy downloads. See getBasePath() + // Hash of the link associated with this DownloadFile, used so not override old data in the DownloadedFileInfo + @JsonProperty("linkHash") val linkHash : Int? = null + ) + + data class DownloadedFileInfoResult( + @JsonProperty("fileLength") val fileLength: Long, + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("path") val path: Uri, + ) + + + data class ResumeWatching( + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("episodeId") val episodeId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("updateTime") val updateTime: Long, + @JsonProperty("isFromDownload") val isFromDownload: Boolean, + ) + + + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) + + + data class CreateNotificationMetadata( + val type: VideoDownloadManager.DownloadType, + val bytesDownloaded: Long, + val bytesTotal: Long, + val hlsProgress: Long? = null, + val hlsTotal: Long? = null, + val bytesPerSecond: Long + ) + + data class StreamData( + private val fileLength: Long, + val file: SafeFile, + //val fileStream: OutputStream, + ) { + @Throws(IOException::class) + fun open(): OutputStream { + return file.openOutputStreamOrThrow(resume) + } + + @Throws(IOException::class) + fun openNew(): OutputStream { + return file.openOutputStreamOrThrow(false) + } + + fun delete(): Boolean { + return file.delete() == true + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + + + /** bytes have the size end-start where the byte range is [start,end) + * note that ByteArray is a pointer and therefore cant be stored without cloning it */ + data class LazyStreamDownloadResponse( + val bytes: ByteArray, + val startByte: Long, + val endByte: Long, + ) { + val size get() = endByte - startByte + + override fun toString(): String { + return "$startByte->$endByte" + } + + override fun equals(other: Any?): Boolean { + if (other !is LazyStreamDownloadResponse) return false + return other.startByte == startByte && other.endByte == endByte + } + + override fun hashCode(): Int { + return Objects.hash(startByte, endByte) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt new file mode 100644 index 000000000..f38664088 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt @@ -0,0 +1,250 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.util.Log +import androidx.core.content.ContextCompat +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadQueuePackage +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet + +// 1. Put a download on the queue +// 2. The queue manager starts a foreground service to handle the queue +// 3. The service starts work manager jobs to handle the downloads? +object DownloadQueueManager { + private const val TAG = "DownloadQueueManager" + const val QUEUE_KEY = "download_queue_key" + + /** Flow of all active queued download, no active downloads. + * This flow may see many changes, do not place expensive observers. + * downloadInstances is the flow keeping track of active downloads. + * @see com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances + */ + private val _queue: MutableStateFlow> by lazy { + /** Persistent queue */ + val currentValue = getKey>(QUEUE_KEY) ?: emptyArray() + MutableStateFlow(currentValue) + } + + val queue: StateFlow> by lazy { _queue } + + /** Start the queue, marks all queue objects as in progress. + * Note that this may run twice without the service restarting + * because MainActivity may be recreated. */ + fun init(context: Context) { + ioSafe { + _queue.collect { queue -> + setKey(QUEUE_KEY, queue) + } + } + + ioSafe startQueue@{ + // Do not automatically start the queue if safe mode is activated. + if (PluginManager.isSafeMode()) { + // Prevent misleading UI + VideoDownloadManager.cancelAllDownloadNotifications(context) + return@startQueue + } + + val resumeQueue = + getPreResumeIds().filterNot { + VideoDownloadManager.currentDownloads.value.contains(it) + } + .mapNotNull { id -> + getDownloadResumePackage(context, id)?.toWrapper() + ?: getDownloadQueuePackage(context, id) + } + + val newQueue = _queue.updateAndGet { localQueue -> + // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started + (resumeQueue + localQueue).distinctBy { it.id }.toTypedArray() + } + + // Once added to the queue they can be safely removed + removeKeys(KEY_RESUME_IN_QUEUE) + + // Make sure the download buttons display a pending status + newQueue.forEach { obj -> + setQueueStatus(obj.id, VideoDownloadManager.DownloadType.IsPending) + } + + if (newQueue.any()) { + startQueueService(context) + } + } + } + + /** Downloads not yet started or in progress. */ + private fun getPreResumeIds(): Set { + return getKeys(KEY_RESUME_IN_QUEUE)?.mapNotNull { + it.substringAfter("$KEY_RESUME_IN_QUEUE/").toIntOrNull() + }?.toSet() + ?: emptySet() + } + + /** Adds an object to the internal persistent queue. It does not re-add an existing item. @return true if successfully added */ + private fun add(downloadQueueWrapper: DownloadQueueWrapper): Boolean { + Log.d(TAG, "Download added to queue: $downloadQueueWrapper") + val newQueue = _queue.updateAndGet { localQueue -> + // Do not add the same episode twice + if (downloadQueueWrapper.isCurrentlyDownloading() || localQueue.any { it.id == downloadQueueWrapper.id }) { + return@updateAndGet localQueue + } + localQueue + downloadQueueWrapper + } + return newQueue.any { it.id == downloadQueueWrapper.id } + } + + /** Removes all objects with the same id from the internal persistent queue */ + private fun remove(id: Int) { + Log.d(TAG, "Download removed from the queue: $id") + _queue.update { localQueue -> + // The check is to prevent unnecessary updates + if (!localQueue.any { it.id == id }) { + return@update localQueue + } + + localQueue.filter { it.id != id }.toTypedArray() + } + } + + /** Removes all items and returns the previous queue */ + private fun removeAll(): Array { + Log.d(TAG, "Removed everything from queue") + return _queue.getAndUpdate { + emptyArray() + } + } + + private fun reorder(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + _queue.update { localQueue -> + val newIndex = newPosition.coerceIn(0, localQueue.size) + val id = downloadQueueWrapper.id + + val newQueue = localQueue.filter { it.id != id }.toMutableList().apply { + this.add(newIndex, downloadQueueWrapper) + }.toTypedArray() + + newQueue + } + } + + /** Start a real download from the first item in the queue */ + fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { + val first = queue.value.firstOrNull() ?: return null + + remove(first.id) + + val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) + + return downloadInstance + } + + /** Marks the item as in queue for the download button */ + private fun setQueueStatus(id: Int, status: VideoDownloadManager.DownloadType) { + downloadStatusEvent.invoke( + Pair( + id, + status + ) + ) + downloadStatus[id] = status + } + + private fun startQueueService(context: Context?) { + if (context == null) { + Log.d(TAG, "Cannot start download queue service, null context.") + return + } + // Do not restart the download queue service + if (DownloadQueueService.isRunning) { + return + } + ioSafe { + val intent = DownloadQueueService.getIntent(context) + ContextCompat.startForegroundService(context, intent) + } + } + + /** Cancels an active download or removes it from queue depending on where it is. */ + fun cancelDownload(id: Int) { + Log.d(TAG, "Cancelling download: $id") + + val currentInstance = downloadInstances.value.find { it.downloadQueueWrapper.id == id } + + if (currentInstance != null) { + currentInstance.cancelDownload() + } else { + removeFromQueue(id) + } + } + + /** Removes all queued items */ + fun removeAllFromQueue() { + removeAll().forEach { wrapper -> + setQueueStatus(wrapper.id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Removes all objects with the same id from the internal persistent queue */ + fun removeFromQueue(id: Int) { + ioSafe { + remove(id) + setQueueStatus(id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Will move the download queue wrapper to a new position in the queue. + * If the item does not exist it will also insert it. */ + fun reorderItem(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + ioSafe { + reorder(downloadQueueWrapper, newPosition) + } + } + + /** Add a new object to the queue. Will not queue completed downloads or current downloads. */ + fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) = safe { + val context = CloudStreamApp.context ?: return@safe + val fileInfo = getDownloadFileInfo(context, downloadQueueWrapper.id) + val isComplete = fileInfo != null && + // Assure no division by 0 + fileInfo.totalBytes > 0 && + // If more than 98% downloaded then do not add to queue + (fileInfo.fileLength.toFloat() / fileInfo.totalBytes.toFloat()) > 0.98f + // Do not queue completed files! + if (isComplete) return@safe + + if (add(downloadQueueWrapper)) { + setQueueStatus(downloadQueueWrapper.id, VideoDownloadManager.DownloadType.IsPending) + startQueueService(context) + } + } + + + /** Refreshes the queue flow with the same value, but copied. + * Good to run if the downloads are affected by some outside value change. */ + fun forceRefreshQueue() { + _queue.update { localQueue -> + localQueue.copyOf() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt new file mode 100644 index 000000000..9f2c31d9a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt @@ -0,0 +1,165 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.graphics.drawable.toBitmap +import coil3.Extras +import coil3.SingletonImageLoader +import coil3.asDrawable +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap + +/** Separate object with helper functions for the downloader */ +object DownloadUtils { + private val cachedBitmaps = ConcurrentHashMap() + internal fun Context.getImageBitmapFromUrl( + url: String, + headers: Map? = null + ): Bitmap? = safe { + cachedBitmaps[url]?.let { + return@safe it + } + + val imageLoader = SingletonImageLoader.get(this) + + val request = ImageRequest.Builder(this) + .data(url) + .apply { + headers?.forEach { (key, value) -> + extras[Extras.Key(key)] = value + } + } + .build() + + val bitmap = runBlocking { + val result = imageLoader.execute(request) + (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) + ?.toBitmap() + } + + bitmap?.let { + cachedBitmaps.putIfAbsent(url, it) + } + + return@safe bitmap + } + + //calculate the time + internal fun getEstimatedTimeLeft( + context: Context, + bytesPerSecond: Long, + progress: Long, + total: Long + ): String { + if (bytesPerSecond <= 0) return "" + val timeInSec = (total - progress) / bytesPerSecond + val hrs = timeInSec / 3600 + val mins = (timeInSec % 3600) / 60 + val secs = timeInSec % 60 + val timeFormated: UiText? = when { + hrs > 0 -> txt( + R.string.download_time_left_hour_min_sec_format, + hrs, + mins, + secs + ) + + mins > 0 -> txt( + R.string.download_time_left_min_sec_format, + mins, + secs + ) + + secs > 0 -> txt( + R.string.download_time_left_sec_format, + secs + ) + + else -> null + } + return timeFormated?.asString(context) ?: "" + } + + internal fun downloadSubtitle( + context: Context?, + link: ExtractorSubtitleLink, + fileName: String, + folder: String + ) { + ioSafe { + VideoDownloadManager.downloadThing( + context ?: return@ioSafe, + link, + "$fileName ${link.name}", + folder, + if (link.url.contains(".srt")) "srt" else "vtt", + false, + null, createNotificationCallback = {} + ) + } + } + + fun downloadSubtitle( + context: Context?, + link: SubtitleData, + meta: DownloadObjects.DownloadEpisodeMetadata, + ) { + context?.let { ctx -> + val fileName = getFileName(ctx, meta) + val folder = getFolder(meta.type ?: return, meta.mainName) + downloadSubtitle( + ctx, + ExtractorSubtitleLink(link.name, link.url, "", link.headers), + fileName, + folder + ) + } + } + + + /** Helper function to make sure duplicate attributes don't get overridden or inserted without lowercase cmp + * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) + * */ + internal fun Map.appendAndDontOverride(rhs: Map): Map { + val out = this.toMutableMap() + val current = this.keys.map { it.lowercase() } + for ((key, value) in rhs) { + if (current.contains(key.lowercase())) continue + out[key] = value + } + return out + } + + internal fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + internal suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt new file mode 100644 index 000000000..7c73a6889 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt @@ -0,0 +1,40 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Custom KSerializer for Android's [Uri] type. + * + * Uri is an Android platform type and cannot be annotated with @Serializable directly. + * Registering it in a SerializersModule globally would require a custom module passed to + * every Json instance, which adds hidden coupling. This serializer is also used sparingly + * across the codebase, so the overhead of a global registration isn't justified. + * Instead, we keep it explicit so that each usage site opts in intentionally and the + * serialization behavior remains visible. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = UriSerializer::class) + * val uri: Uri, + * ) + */ +object UriSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Uri) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uri { + return Uri.parse(decoder.decodeString()) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt deleted file mode 100644 index 526d31ca4..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/MediaFile.kt +++ /dev/null @@ -1,372 +0,0 @@ -package com.lagradost.cloudstream3.utils.storage - -import android.content.ContentResolver -import android.content.ContentUris -import android.content.ContentValues -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import androidx.annotation.RequiresApi -import com.hippo.unifile.UniRandomAccessFile -import com.lagradost.cloudstream3.mvvm.logError -import okhttp3.internal.closeQuietly -import java.io.File -import java.io.InputStream -import java.io.OutputStream - - -enum class MediaFileContentType { - Downloads, - Audio, - Video, - Images, -} - -// https://developer.android.com/training/data-storage/shared/media -fun MediaFileContentType.toPath(): String { - return when (this) { - MediaFileContentType.Downloads -> Environment.DIRECTORY_DOWNLOADS - MediaFileContentType.Audio -> Environment.DIRECTORY_MUSIC - MediaFileContentType.Video -> Environment.DIRECTORY_MOVIES - MediaFileContentType.Images -> Environment.DIRECTORY_DCIM - } -} - -fun MediaFileContentType.defaultPrefix(): String { - return Environment.getExternalStorageDirectory().absolutePath -} - -fun MediaFileContentType.toAbsolutePath(): String { - return defaultPrefix() + File.separator + - this.toPath() -} - -fun replaceDuplicateFileSeparators(path: String): String { - return path.replace(Regex("${File.separator}+"), File.separator) -} - -@RequiresApi(Build.VERSION_CODES.Q) -fun MediaFileContentType.toUri(external: Boolean): Uri { - val volume = if (external) MediaStore.VOLUME_EXTERNAL_PRIMARY else MediaStore.VOLUME_INTERNAL - return when (this) { - MediaFileContentType.Downloads -> MediaStore.Downloads.getContentUri(volume) - MediaFileContentType.Audio -> MediaStore.Audio.Media.getContentUri(volume) - MediaFileContentType.Video -> MediaStore.Video.Media.getContentUri(volume) - MediaFileContentType.Images -> MediaStore.Images.Media.getContentUri(volume) - } -} - -@RequiresApi(Build.VERSION_CODES.Q) -class MediaFile( - private val context: Context, - private val folderType: MediaFileContentType, - private val external: Boolean = true, - absolutePath: String, -) : SafeFile { - // this is the path relative to the download directory so "/hello/text.txt" = "hello/text.txt" is in fact "Download/hello/text.txt" - private val sanitizedAbsolutePath: String = - replaceDuplicateFileSeparators(absolutePath) - - // this is only a directory if the filepath ends with a / - private val isDir: Boolean = sanitizedAbsolutePath.endsWith(File.separator) - private val isFile: Boolean = !isDir - - // this is the relative path including the Download directory, so "/hello/text.txt" => "Download/hello" - private val relativePath: String = - replaceDuplicateFileSeparators(folderType.toPath() + File.separator + sanitizedAbsolutePath).substringBeforeLast( - File.separator - ) - - // "/hello/text.txt" => "text.txt" - private val namePath: String = sanitizedAbsolutePath.substringAfterLast(File.separator) - private val baseUri = folderType.toUri(external) - private val contentResolver: ContentResolver = context.contentResolver - - init { - // some standard asserts that should always be hold or else this class wont work - assert(!relativePath.endsWith(File.separator)) - assert(!(isDir && isFile)) - assert(!relativePath.contains(File.separator + File.separator)) - assert(!namePath.contains(File.separator)) - - if (isDir) { - assert(namePath.isBlank()) - } else { - assert(namePath.isNotBlank()) - } - } - - companion object { - private fun splitFilenameExt(name: String): Pair { - val split = name.indexOfLast { it == '.' } - if (split <= 0) return name to null - val ext = name.substring(split + 1 until name.length) - if (ext.isBlank()) return name to null - - return name.substring(0 until split) to ext - } - - private fun splitFilenameMime(name: String): Pair { - val (display, ext) = splitFilenameExt(name) - val mimeType = when (ext) { - - // Absolutely ridiculous, if text/vtt is used as mimetype scoped storage prevents - // downloading to /Downloads yet it works with null - - "vtt" -> null // "text/vtt" - "mp4" -> "video/mp4" - "srt" -> null // "application/x-subrip"//"text/plain" - else -> null - } - return display to mimeType - } - } - - private fun appendRelativePath(path: String, folder: Boolean): MediaFile? { - if (isFile) return null - - // VideoDownloadManager.sanitizeFilename(path.replace(File.separator, "")) - - // in case of duplicate path, aka Download -> Download - if(relativePath == path) return this - - val newPath = - sanitizedAbsolutePath + path + if (folder) File.separator else "" - - return MediaFile( - context = context, - folderType = folderType, - external = external, - absolutePath = newPath - ) - } - - private fun createUri(displayName: String? = namePath): Uri? { - if (displayName == null) return null - if (isFile) return null - val (name, mime) = splitFilenameMime(displayName) - - val newFile = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.TITLE, name) - if (mime != null) - put(MediaStore.MediaColumns.MIME_TYPE, mime) - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - } - return contentResolver.insert(baseUri, newFile) - } - - override fun createFile(displayName: String?): SafeFile? { - if (isFile || displayName == null) return null - query(displayName)?.uri ?: createUri(displayName) ?: return null - return appendRelativePath(displayName, false) //SafeFile.fromUri(context, ?: return null) - } - - override fun createDirectory(directoryName: String?): SafeFile? { - if (directoryName == null) return null - // we don't create a dir here tbh, just fake create it - return appendRelativePath(directoryName, true) - } - - private data class QueryResult( - val uri: Uri, - val lastModified: Long, - val length: Long, - ) - - @RequiresApi(Build.VERSION_CODES.Q) - private fun query(displayName: String = namePath): QueryResult? { - try { - //val (name, mime) = splitFilenameMime(fullName) - - val projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.MediaColumns.SIZE, - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath${File.separator}' AND ${MediaStore.MediaColumns.DISPLAY_NAME}='$displayName'" - - contentResolver.query( - baseUri, - projection, selection, null, null - )?.use { cursor -> - while (cursor.moveToNext()) { - val id = - cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - - return QueryResult( - uri = ContentUris.withAppendedId( - baseUri, id - ), - lastModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)), - length = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)), - ) - } - } - } catch (t: Throwable) { - logError(t) - } - - return null - } - - override fun uri(): Uri? { - return query()?.uri - } - - override fun name(): String? { - if (isDir) return null - return namePath - } - - override fun type(): String? { - TODO("Not yet implemented") - } - - override fun filePath(): String { - return replaceDuplicateFileSeparators(relativePath + File.separator + namePath) - } - - override fun isDirectory(): Boolean { - return isDir - } - - override fun isFile(): Boolean { - return isFile - } - - override fun lastModified(): Long? { - if (isDir) return null - return query()?.lastModified - } - - override fun length(): Long? { - if (isDir) return null - val length = query()?.length ?: return null - if(length <= 0) { - val inputStream : InputStream = openInputStream() ?: return null - return try { - inputStream.available().toLong() - } catch (t : Throwable) { - null - } finally { - inputStream.closeQuietly() - } - } - return length - } - - override fun canRead(): Boolean { - TODO("Not yet implemented") - } - - override fun canWrite(): Boolean { - TODO("Not yet implemented") - } - - private fun delete(uri: Uri): Boolean { - return contentResolver.delete(uri, null, null) > 0 - } - - override fun delete(): Boolean { - return if (isDir) { - (listFiles() ?: return false).all { - it.delete() - } - } else { - delete(uri() ?: return false) - } - } - - override fun exists(): Boolean { - if (isDir) return true - return query() != null - } - - override fun listFiles(): List? { - if (isFile) return null - try { - val projection = arrayOf( - MediaStore.MediaColumns.DISPLAY_NAME - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath${File.separator}'" - contentResolver.query( - baseUri, - projection, selection, null, null - )?.use { cursor -> - val out = ArrayList(cursor.count) - while (cursor.moveToNext()) { - val nameIdx = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) - if (nameIdx == -1) continue - val name = cursor.getString(nameIdx) - - appendRelativePath(name, false)?.let { new -> - out.add(new) - } - } - - out - } - } catch (t: Throwable) { - logError(t) - } - return null - } - - override fun findFile(displayName: String?, ignoreCase: Boolean): SafeFile? { - if (isFile || displayName == null) return null - - val new = appendRelativePath(displayName, false) ?: return null - if (new.exists()) { - return new - } - - return null//SafeFile.fromUri(context, query(displayName ?: return null)?.uri ?: return null) - } - - override fun renameTo(name: String?): Boolean { - TODO("Not yet implemented") - } - - override fun openOutputStream(append: Boolean): OutputStream? { - try { - // use current file - uri()?.let { - return contentResolver.openOutputStream( - it, - if (append) "wa" else "wt" - ) - } - - // create a new file if current is not found, - // as we know it is new only write access is needed - createUri()?.let { - return contentResolver.openOutputStream( - it, - "w" - ) - } - return null - } catch (t: Throwable) { - return null - } - } - - override fun openInputStream(): InputStream? { - try { - return contentResolver.openInputStream(uri() ?: return null) - } catch (t: Throwable) { - return null - } - } - - override fun createRandomAccessFile(mode: String?): UniRandomAccessFile? { - TODO("Not yet implemented") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt deleted file mode 100644 index 85a749631..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/SafeFile.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.lagradost.cloudstream3.utils.storage - -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Environment -import com.hippo.unifile.UniFile -import com.hippo.unifile.UniRandomAccessFile - -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream - -interface SafeFile { - companion object { - fun fromUri(context: Context, uri: Uri): SafeFile? { - return UniFileWrapper(UniFile.fromUri(context, uri) ?: return null) - } - - fun fromFile(context: Context, file: File?): SafeFile? { - if (file == null) return null - // because UniFile sucks balls on Media we have to do this - val absPath = file.absolutePath.removePrefix(File.separator) - for (value in MediaFileContentType.values()) { - val prefixes = listOf(value.toAbsolutePath(), value.toPath()).map { it.removePrefix(File.separator) } - for (prefix in prefixes) { - if (!absPath.startsWith(prefix)) continue - return fromMedia( - context, - value, - absPath.removePrefix(prefix).ifBlank { File.separator } - ) - } - } - - return UniFileWrapper(UniFile.fromFile(file) ?: return null) - } - - fun fromAsset( - context: Context, - filename: String? - ): SafeFile? { - return UniFileWrapper( - UniFile.fromAsset(context.assets, filename ?: return null) ?: return null - ) - } - - fun fromResource( - context: Context, - id: Int - ): SafeFile? { - return UniFileWrapper( - UniFile.fromResource(context, id) ?: return null - ) - } - - fun fromMedia( - context: Context, - folderType: MediaFileContentType, - path: String = File.separator, - external: Boolean = true, - ): SafeFile? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - //fromUri(context, folderType.toUri(external))?.findFile(folderType.toPath())?.gotoDirectory(path) - - return MediaFile( - context = context, - folderType = folderType, - external = external, - absolutePath = path - ) - } else { - fromFile( - context, - File( - (Environment.getExternalStorageDirectory().absolutePath + File.separator + - folderType.toPath() + File.separator + folderType).replace( - File.separator + File.separator, - File.separator - ) - ) - ) - } - - } - } - - /*val uri: Uri? get() = getUri() - val name: String? get() = getName() - val type: String? get() = getType() - val filePath: String? get() = getFilePath() - val isFile: Boolean? get() = isFile() - val isDirectory: Boolean? get() = isDirectory() - val length: Long? get() = length() - val canRead: Boolean get() = canRead() - val canWrite: Boolean get() = canWrite() - val lastModified: Long? get() = lastModified()*/ - - @Throws(IOException::class) - fun isFileOrThrow(): Boolean { - return isFile() ?: throw IOException("Unable to get if file is a file or directory") - } - - @Throws(IOException::class) - fun lengthOrThrow(): Long { - return length() ?: throw IOException("Unable to get file length") - } - - @Throws(IOException::class) - fun isDirectoryOrThrow(): Boolean { - return isDirectory() ?: throw IOException("Unable to get if file is a directory") - } - - @Throws(IOException::class) - fun filePathOrThrow(): String { - return filePath() ?: throw IOException("Unable to get file path") - } - - @Throws(IOException::class) - fun uriOrThrow(): Uri { - return uri() ?: throw IOException("Unable to get uri") - } - - @Throws(IOException::class) - fun renameOrThrow(name: String?) { - if (!renameTo(name)) { - throw IOException("Unable to rename to $name") - } - } - - @Throws(IOException::class) - fun openOutputStreamOrThrow(append: Boolean = false): OutputStream { - return openOutputStream(append) ?: throw IOException("Unable to open output stream") - } - - @Throws(IOException::class) - fun openInputStreamOrThrow(): InputStream { - return openInputStream() ?: throw IOException("Unable to open input stream") - } - - @Throws(IOException::class) - fun existsOrThrow(): Boolean { - return exists() ?: throw IOException("Unable get if file exists") - } - - @Throws(IOException::class) - fun findFileOrThrow(displayName: String?, ignoreCase: Boolean = false): SafeFile { - return findFile(displayName, ignoreCase) ?: throw IOException("Unable find file") - } - - @Throws(IOException::class) - fun gotoDirectoryOrThrow( - directoryName: String?, - createMissingDirectories: Boolean = true - ): SafeFile { - return gotoDirectory(directoryName, createMissingDirectories) - ?: throw IOException("Unable to go to directory $directoryName") - } - - @Throws(IOException::class) - fun listFilesOrThrow(): List { - return listFiles() ?: throw IOException("Unable to get files") - } - - - @Throws(IOException::class) - fun createFileOrThrow(displayName: String?): SafeFile { - return createFile(displayName) ?: throw IOException("Unable to create file $displayName") - } - - @Throws(IOException::class) - fun createDirectoryOrThrow(directoryName: String?): SafeFile { - return createDirectory( - directoryName ?: throw IOException("Unable to create file with invalid name") - ) - ?: throw IOException("Unable to create directory $directoryName") - } - - @Throws(IOException::class) - fun deleteOrThrow() { - if (!delete()) { - throw IOException("Unable to delete file") - } - } - - /** file.gotoDirectory("a/b/c") -> "file/a/b/c/" where a null or blank directoryName - * returns itself. createMissingDirectories specifies if the dirs should be created - * when travelling or break at a dir not found */ - fun gotoDirectory( - directoryName: String?, - createMissingDirectories: Boolean = true - ): SafeFile? { - if (directoryName == null) return this - - return directoryName.split(File.separatorChar).filter { it.isNotBlank() } - .fold(this) { file: SafeFile?, directory -> - // as MediaFile does not actually create a directory we can do this - if (createMissingDirectories || this is MediaFile) { - file?.createDirectory(directory) - } else { - val next = file?.findFile(directory) - - // we require the file to be a directory - if (next?.isDirectory() != true) { - null - } else { - next - } - } - } - } - - - fun createFile(displayName: String?): SafeFile? - fun createDirectory(directoryName: String?): SafeFile? - fun uri(): Uri? - fun name(): String? - fun type(): String? - fun filePath(): String? - fun isDirectory(): Boolean? - fun isFile(): Boolean? - fun lastModified(): Long? - fun length(): Long? - fun canRead(): Boolean - fun canWrite(): Boolean - fun delete(): Boolean - fun exists(): Boolean? - fun listFiles(): List? - - // fun listFiles(filter: FilenameFilter?): Array? - fun findFile(displayName: String?, ignoreCase: Boolean = false): SafeFile? - - fun renameTo(name: String?): Boolean - - /** Open a stream on to the content associated with the file */ - fun openOutputStream(append: Boolean = false): OutputStream? - - /** Open a stream on to the content associated with the file */ - fun openInputStream(): InputStream? - - /** Get a random access stuff of the UniFile, "r" or "rw" */ - fun createRandomAccessFile(mode: String?): UniRandomAccessFile? -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt deleted file mode 100644 index f15921696..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/storage/UniFileWrapper.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.lagradost.cloudstream3.utils.storage - -import android.net.Uri -import com.hippo.unifile.UniFile -import com.hippo.unifile.UniRandomAccessFile -import com.lagradost.cloudstream3.mvvm.logError -import okhttp3.internal.closeQuietly -import java.io.InputStream -import java.io.OutputStream - -private fun UniFile.toFile(): SafeFile { - return UniFileWrapper(this) -} - -fun safe(apiCall: () -> T): T? { - return try { - apiCall.invoke() - } catch (throwable: Throwable) { - logError(throwable) - null - } -} - -class UniFileWrapper(val file: UniFile) : SafeFile { - override fun createFile(displayName: String?): SafeFile? { - return file.createFile(displayName)?.toFile() - } - - override fun createDirectory(directoryName: String?): SafeFile? { - return file.createDirectory(directoryName)?.toFile() - } - - override fun uri(): Uri? { - return safe { file.uri } - } - - override fun name(): String? { - return safe { file.name } - } - - override fun type(): String? { - return safe { file.type } - } - - override fun filePath(): String? { - return safe { file.filePath } - } - - override fun isDirectory(): Boolean? { - return safe { file.isDirectory } - } - - override fun isFile(): Boolean? { - return safe { file.isFile } - } - - override fun lastModified(): Long? { - return safe { file.lastModified() } - } - - override fun length(): Long? { - return safe { - val len = file.length() - if (len <= 1) { - val inputStream = this.openInputStream() ?: return@safe null - try { - inputStream.available().toLong() - } finally { - inputStream.closeQuietly() - } - } else { - len - } - } - } - - override fun canRead(): Boolean { - return safe { file.canRead() } ?: false - } - - override fun canWrite(): Boolean { - return safe { file.canWrite() } ?: false - } - - override fun delete(): Boolean { - return safe { file.delete() } ?: false - } - - override fun exists(): Boolean? { - return safe { file.exists() } - } - - override fun listFiles(): List? { - return safe { file.listFiles()?.mapNotNull { it?.toFile() } } - } - - override fun findFile(displayName: String?, ignoreCase: Boolean): SafeFile? { - return safe { file.findFile(displayName, ignoreCase)?.toFile() } - } - - override fun renameTo(name: String?): Boolean { - return safe { file.renameTo(name) } ?: return false - } - - override fun openOutputStream(append: Boolean): OutputStream? { - return safe { file.openOutputStream(append) } - } - - override fun openInputStream(): InputStream? { - return safe { file.openInputStream() } - } - - override fun createRandomAccessFile(mode: String?): UniRandomAccessFile? { - return safe { file.createRandomAccessFile(mode) } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt new file mode 100644 index 000000000..0db90afea --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt +// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md +class AniSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + if (data !is AnimeLoadResponse) return null // Filter actual anime + + val malId = data.getMalId()?.toIntOrNull() ?: return null + val url = + "https://api.aniskip.com/v2/skip-times/$malId/${episode.episode}?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeDurationMs / 1000L}" + + val response = app.get(url).parsed() + + // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work + return response.results?.mapNotNull { stamp -> + val skipType = when (stamp.skipType) { + "op" -> SkipType.Opening + "ed" -> SkipType.Ending + "recap" -> SkipType.Recap + "mixed-ed" -> SkipType.MixedEnding + "mixed-op" -> SkipType.MixedOpening + else -> null + } ?: return@mapNotNull null + val end = (stamp.interval.endTime * 1000.0).toLong() + val start = (stamp.interval.startTime * 1000.0).toLong() + SkipStamp( + type = skipType, + startMs = start, + endMs = end, + ) + } + } + + data class AniSkipResponse( + @JsonSerialize val found: Boolean, + @JsonSerialize val results: List?, + @JsonSerialize val message: String?, + @JsonSerialize val statusCode: Int + ) + + data class Stamp( + @JsonSerialize val interval: AniSkipInterval, + @JsonSerialize val skipType: String, + @JsonSerialize val skipId: String, + @JsonSerialize val episodeLength: Double + ) + + data class AniSkipInterval( + @JsonSerialize val startTime: Double, + @JsonSerialize val endTime: Double + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt new file mode 100644 index 000000000..f9254576b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt @@ -0,0 +1,370 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap +import java.security.MessageDigest + +class AnimeSkipAuth : AuthAPI() { + override val name = "AnimeSkip" + override val inAppLoginRequirement: AuthLoginRequirement = + AuthLoginRequirement(password = true, username = true) + override val idPrefix = "anime-skip" + override val hasInApp = true + override val createAccountUrl = "https://anime-skip.com/account" + val baseClientId = "as1JgiMbW4wKfmTLWXS79iTDQFll76pk" + fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') + } + + data class LoginRoot( + @JsonProperty("data") + val data: LoginData, + ) + + data class LoginData( + @JsonProperty("login") + val login: Login, + ) + + data class Login( + @JsonProperty("authToken") + val authToken: String, + @JsonProperty("refreshToken") + val refreshToken: String, + @JsonProperty("account") + val account: Account, + ) + + data class ApiRoot( + @JsonProperty("data") + val data: ApiData, + ) + + data class ApiData( + @JsonProperty("myApiClients") + val myApiClients: List, + ) + + data class MyApiClient( + @JsonProperty("id") + val id: String, + ) + + data class Account( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + ) + + data class Payload( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + @JsonProperty("clientId") + val clientId: String, + ) + + override suspend fun user(token: AuthToken?): AuthUser? { + val payload = parseJson(token?.payload ?: return null) + return AuthUser( + name = payload.username, + id = payload.email.hashCode(), + profilePicture = payload.profileUrl + ) + } + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val hash = md5(form.password ?: return null) + val emailOrUserName = form.email ?: form.username ?: return null + + val loginQuery = """ + { + login(usernameEmail: "$emailOrUserName", passwordHash: "$hash") { + authToken + refreshToken + account { + profileUrl + username + email + } + } + } +""" + val loginRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to loginQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val authToken = loginRoot.data.login.authToken + val refreshToken = loginRoot.data.login.refreshToken + val account = loginRoot.data.login.account + + val clientQuery = """ + { + myApiClients { + id + } + } + """.trimIndent() + + val apiRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to clientQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "Authorization" to "Bearer $authToken", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val clientId = apiRoot.data.myApiClients.getOrNull(0)?.id + ?: throw ErrorLoadingException("No API token found") + + val payload = Payload( + profileUrl = account.profileUrl, + username = account.username, + email = account.email, + clientId = clientId, + ) + return AuthToken( + accessToken = authToken, + refreshToken = refreshToken, + payload = payload.toJson() + ) + } +} + +class AnimeSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + val auth = PlainAuthRepo(animeSkipApi) + //val clientId = "ZGfO0sMF3eCwLYf8yMSCJjlynwNGRXWE" + + companion object { + const val MIN_LENGTH: Int = 4 + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String?): String? = + name?.replace(strip, "")?.lowercase() + + private val asciiRegex = Regex("[^a-zA-Z0-9 ]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun asciiName(name: String?): String? = + name?.replace(asciiRegex, "")?.lowercase() + } + + data class Root( + @JsonProperty("data") + val data: Data, + ) + + data class Data( + @JsonProperty("searchShows") + val searchShows: List, + ) + + data class SearchShow( + @JsonProperty("name") + val name: String, + @JsonProperty("originalName") + val originalName: String?, + @JsonProperty("seasonCount") + val seasonCount: Long, + @JsonProperty("episodeCount") + val episodeCount: Long, + @JsonProperty("baseDuration") + val baseDuration: Double, + @JsonProperty("episodes") + val episodes: List, + ) + + data class Episode( + @JsonProperty("number") + val number: String?, + @JsonProperty("absoluteNumber") + val absoluteNumber: String?, + @JsonProperty("season") + val season: String?, + @JsonProperty("timestamps") + val timestamps: List, + ) + + data class Timestamp( + @JsonProperty("at") + val at: Double, + @JsonProperty("type") + val type: Type, + ) + + data class Type( + @JsonProperty("name") + val name: String, + ) + + val cache: ConcurrentHashMap = ConcurrentHashMap() + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val clientId : String = parseJson( + auth.authData()?.token?.payload ?: return null + ).clientId + + when (data) { + is AnimeLoadResponse, is TvSeriesLoadResponse -> { + /** Require episode based anime */ + } + + else -> return null + } + + val query = """{ + searchShows(search: "${data.name}", limit: 1) { + name + originalName + seasonCount + episodeCount + episodes { + number + absoluteNumber + season + baseDuration + timestamps { + at + type { + name + } + } + } + } +}""" + val root = cache[data.name] ?: run { + app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to query), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to clientId + ) + ) + .parsed().data.also { root -> + cache[data.name] = root + } + } + val show = root.searchShows.firstOrNull { show -> + /** Match ascii */ + val ascii1 = asciiName(data.name) + val ascii2 = asciiName(show.name) + if (ascii1 == ascii2 && (ascii1?.length ?: 0) > MIN_LENGTH) { + return@firstOrNull true + } + + if (data !is AnimeLoadResponse) { + return@firstOrNull false + } + + /** Match original name */ + val strip1 = stripName(show.originalName) + val strip2 = stripName(data.japName) + + /** Match english name*/ + val ascii3 = stripName(data.engName) + (strip1 == strip2 && (strip1?.length ?: 0) > MIN_LENGTH) || + (ascii2 == ascii3 && (ascii2?.length ?: 0) > MIN_LENGTH) + } ?: return null + + val showEpisode = when (data) { + is AnimeLoadResponse -> { + val episodeNumber = episode.episode.toString() + /** For anime, match on number */ + show.episodes.firstOrNull { + it.absoluteNumber == episodeNumber + } ?: show.episodes.firstOrNull { + it.number == episodeNumber + } + } + + is TvSeriesLoadResponse -> { + /** For tv-series, match on season + number */ + val seasonNumber = episode.season?.toString() + val episodeNumber = episode.episode.toString() + val episodeIndex = episode.totalEpisodeIndex.toString() + + show.episodes.firstOrNull { + it.season == seasonNumber && it.number == episodeNumber + } ?: show.episodes.firstOrNull { + it.absoluteNumber == episodeIndex + } + } + + else -> null + } ?: return null + + val result = ArrayList() + var pending: SkipStamp? = null + for (stamp in showEpisode.timestamps) { + val startMS = (stamp.at * 1000.0).toLong() + pending?.let { pending -> + result.add(pending.copy(endMs = startMS)) + } + val type = when (stamp.type.name) { + "Intro", "New Intro" -> SkipType.Intro + "Credits" -> SkipType.Credits + "Preview" -> SkipType.Preview + "Recap" -> SkipType.Recap + "Mixed Credits" -> SkipType.MixedEnding + "Filler", "Transition", "Branding", "Canon", "Title Card" -> null + else -> null + } + if (type == null) { + pending = null + continue + } + pending = SkipStamp(type, startMS, 0L) + } + pending?.let { pending -> + result.add(pending.copy(endMs = episodeDurationMs)) + /** Base duration = fucked */ + } + + return result + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt new file mode 100644 index 000000000..869515f43 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +class IntroDbSkip : SkipAPI() { + override val name = "IntroDb" + + override val supportedTypes = setOf(TvType.TvSeries, TvType.AsianDrama) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val season = episode.season ?: return null + val imdbId = data.getImdbId() ?: return null + + val url = + "https://api.introdb.app/segments?imdb_id=$imdbId&season=$season&episode=${episode.episode}" + val response = app.get(url).parsed() + + return listOfNotNull( + response.intro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Opening, + startMs = start, + endMs = end + ) + }, + response.recap?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Recap, + startMs = start, + endMs = end + ) + }, + response.outro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Ending, + startMs = start, + endMs = end + ) + } + ) + } + + + data class IntroDbResponse( + @JsonProperty("imdb_id") val imdbId: String?, + val season: Int?, + val episode: Int?, + val intro: Segment?, + val recap: Segment?, + val outro: Segment?, + ) + + data class Segment( + @JsonProperty("start_sec") val startSec: Double?, + @JsonProperty("end_sec") val endSec: Double?, + @JsonProperty("start_ms") val startMs: Long?, + @JsonProperty("end_ms") val endMs: Long?, + val confidence: Double?, + @JsonProperty("submission_count") val submissionCount: Int?, + @JsonProperty("updated_at") val updatedAt: String?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt new file mode 100644 index 000000000..60cc3ae1e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt @@ -0,0 +1,105 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import androidx.annotation.StringRes +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import java.util.concurrent.ConcurrentHashMap + + +enum class SkipType(@StringRes val res: Int) { + Opening(R.string.skip_type_op), + Ending(R.string.skip_type_ed), + Recap(R.string.skip_type_recap), + MixedOpening(R.string.skip_type_mixed_op), + MixedEnding(R.string.skip_type_mixed_ed), + Credits(R.string.skip_type_credits), + Intro(R.string.skip_type_intro), + Preview(R.string.skip_type_preview), +} + +data class SkipStamp( + val type: SkipType, + /** Start position in milliseconds of the skip, where it should start showing up */ + val startMs: Long, + /** End position in milliseconds of the skip, where it will skip to */ + val endMs: Long, + /** Custom visual label instead of using the type. Only use this for content not covered by SkipType */ + val label: String? = null, +) + +data class VideoSkipStamp( + val timestamp: SkipStamp, + val skipToNextEpisode: Boolean, + val source: String, +) { + val uiText = + if (skipToNextEpisode) txt(R.string.next_episode) else + txt( + R.string.skip_type_format, + timestamp.label?.let { txt(it) } ?: txt(timestamp.type.res) + ) +} + +abstract class SkipAPI { + open val name: String = "NONE" + + /** On what types SkipAPI should trigger on */ + abstract val supportedTypes: Set + + /** Get all video skip stamps of the associated episode */ + @Throws + open suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + ): List? { + throw NotImplementedError() + } + + companion object { + private val skipApis: List = listOf(AniSkip(), TheIntroDBSkip(), IntroDbSkip(), AnimeSkip()) + private val cachedStamps = ConcurrentHashMap>() + + /** Get all video timestamps from an episode */ + suspend fun videoStamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + hasNextEpisode: Boolean, + ): List { + cachedStamps[episode.id]?.let { list -> + return list + } + + for (api in skipApis) { + /** Unsupported type, so we do not waste a get call */ + if (!api.supportedTypes.contains(data.type)) { + continue + } + + /** Find first non-empty stamps */ + val stamps = safeAsync { api.stamps(data, episode, episodeDurationMs) } + if (stamps.isNullOrEmpty()) { + continue + } + + return stamps.map { stamp -> + VideoSkipStamp( + timestamp = stamp, + skipToNextEpisode = hasNextEpisode && episodeDurationMs - stamp.endMs < 20_000L, + source = api.name + ) + }.also { stamps -> + /** Put in cache, this is such small data, it should be fine to never clear it */ + cachedStamps[episode.id] = stamps + } + } + return emptyList() + } + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt new file mode 100644 index 000000000..cc2661cb0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt @@ -0,0 +1,76 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.app + +/** https://theintrodb.org/docs */ +class TheIntroDBSkip : SkipAPI() { + override val name = "TheIntroDB" + override val supportedTypes = setOf( + TvType.TvSeries, TvType.Cartoon, TvType.Anime, TvType.Movie, + TvType.AsianDrama + ) + + val mainUrl = "https://api.theintrodb.org" + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val idSuffix = + data.getTMDbId()?.let { tmdbId -> "tmdb_id=$tmdbId" } + ?: data.getImdbId()?.let { imdbId -> "imdb_id=$imdbId" } + ?: return null + + val url = if (data.isMovie()) { + "$mainUrl/v2/media?$idSuffix" + } else { + val season = episode.season ?: return null + "$mainUrl/v2/media?$idSuffix&season=$season&episode=${episode.episode}" + } + val root = app.get(url).parsed() + return arrayOf( + root.intro to SkipType.Intro, + root.credits to SkipType.Credits, + root.recap to SkipType.Recap, + root.preview to SkipType.Preview + ).map { (list, type) -> + list.map { stamp -> + SkipStamp( + type, + stamp.startMs ?: 0L, + stamp.endMs ?: episodeDurationMs + ) + } + }.flatten() + } + + data class Root( + @JsonProperty("tmdb_id") + val tmdbId: Long, + @JsonProperty("type") + val type: String, + @JsonProperty("intro") + val intro: List = emptyList(), + @JsonProperty("recap") + val recap: List = emptyList(), + @JsonProperty("credits") + val credits: List = emptyList(), + @JsonProperty("preview") + val preview: List = emptyList(), + ) + + data class Stamp( + @JsonProperty("start_ms") + val startMs: Long?, + @JsonProperty("end_ms") + val endMs: Long?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt index d4725d53e..c18ad39c6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -4,6 +4,8 @@ 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 @@ -18,9 +20,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + itemSpacing = getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -32,10 +34,12 @@ 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) { @@ -44,8 +48,10 @@ class FlowLayout : ViewGroup { //reset for new line currentChildHookPointx = 0 - currentChildHookPointy += childHeight + currentChildHookPointy += childHeight + itemSpacing } + + currentHeight = max(currentHeight, currentChildHookPointy + childHeight) val nextChildHookPointx = currentChildHookPointx + childWidth + if (childWidth == 0) 0 else itemSpacing @@ -99,9 +105,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - spacing = 0//t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + spacing = 0 + } } internal constructor(width: Int, height: Int) : super(width, height) { diff --git a/app/src/main/res/anim/nav_enter_anim.xml b/app/src/main/res/anim/nav_enter_anim.xml deleted file mode 100644 index 84fa9e978..000000000 --- a/app/src/main/res/anim/nav_enter_anim.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 970655147..000000000 --- a/app/src/main/res/anim/nav_exit_anim.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 84fa9e978..000000000 --- a/app/src/main/res/anim/nav_pop_enter.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ 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 deleted file mode 100644 index 970655147..000000000 --- a/app/src/main/res/anim/nav_pop_exit.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ 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 new file mode 100644 index 000000000..d2a6b6c4d --- /dev/null +++ b/app/src/main/res/color/black_button_ripple.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/color_primary_transparent.xml b/app/src/main/res/color/color_primary_transparent.xml new file mode 100644 index 000000000..e6d1f8c9e --- /dev/null +++ b/app/src/main/res/color/color_primary_transparent.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/color/item_select_color.xml b/app/src/main/res/color/item_select_color.xml index 3d69c540b..208afb18b 100644 --- a/app/src/main/res/color/item_select_color.xml +++ b/app/src/main/res/color/item_select_color.xml @@ -3,5 +3,5 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/color/item_select_color_tv.xml b/app/src/main/res/color/item_select_color_tv.xml new file mode 100644 index 000000000..3042fd588 --- /dev/null +++ b/app/src/main/res/color/item_select_color_tv.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/toggle_selector.xml b/app/src/main/res/color/toggle_selector.xml index 9bb16931e..a7c826044 100644 --- a/app/src/main/res/color/toggle_selector.xml +++ b/app/src/main/res/color/toggle_selector.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/animeskip.xml b/app/src/main/res/drawable/animeskip.xml new file mode 100644 index 000000000..8f1bb3105 --- /dev/null +++ b/app/src/main/res/drawable/animeskip.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/arrow_and_edge_24px.xml b/app/src/main/res/drawable/arrow_and_edge_24px.xml new file mode 100644 index 000000000..2d5f74e14 --- /dev/null +++ b/app/src/main/res/drawable/arrow_and_edge_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/arrow_or_edge_24px.xml b/app/src/main/res/drawable/arrow_or_edge_24px.xml new file mode 100644 index 000000000..0e80a074e --- /dev/null +++ b/app/src/main/res/drawable/arrow_or_edge_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/arrows_input_24px.xml b/app/src/main/res/drawable/arrows_input_24px.xml new file mode 100644 index 000000000..f4b60368b --- /dev/null +++ b/app/src/main/res/drawable/arrows_input_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_downloading_24.xml b/app/src/main/res/drawable/baseline_downloading_24.xml new file mode 100644 index 000000000..c6fd08a38 --- /dev/null +++ b/app/src/main/res/drawable/baseline_downloading_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_headphones_24.xml b/app/src/main/res/drawable/baseline_headphones_24.xml new file mode 100644 index 000000000..938b17ead --- /dev/null +++ b/app/src/main/res/drawable/baseline_headphones_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_skip_previous_24.xml b/app/src/main/res/drawable/baseline_skip_previous_24.xml new file mode 100644 index 000000000..9937885e7 --- /dev/null +++ b/app/src/main/res/drawable/baseline_skip_previous_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_color_both.xml b/app/src/main/res/drawable/bg_color_both.xml new file mode 100644 index 000000000..bb71f8731 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_both.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_bottom.xml b/app/src/main/res/drawable/bg_color_bottom.xml new file mode 100644 index 000000000..7c744f19f --- /dev/null +++ b/app/src/main/res/drawable/bg_color_bottom.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_center.xml b/app/src/main/res/drawable/bg_color_center.xml new file mode 100644 index 000000000..7cb437452 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_center.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_top.xml b/app/src/main/res/drawable/bg_color_top.xml new file mode 100644 index 000000000..45497d272 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_top.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_imdb_badge.xml b/app/src/main/res/drawable/bg_imdb_badge.xml new file mode 100644 index 000000000..de7a6704b --- /dev/null +++ b/app/src/main/res/drawable/bg_imdb_badge.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml new file mode 100644 index 000000000..b4701e42a --- /dev/null +++ b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bookmark_star_24px.xml b/app/src/main/res/drawable/bookmark_star_24px.xml new file mode 100644 index 000000000..81b400d92 --- /dev/null +++ b/app/src/main/res/drawable/bookmark_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/clear_all_24px.xml b/app/src/main/res/drawable/clear_all_24px.xml new file mode 100644 index 000000000..dbbc7dc9f --- /dev/null +++ b/app/src/main/res/drawable/clear_all_24px.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/cloud_2_solid.xml b/app/src/main/res/drawable/cloud_2_solid.xml new file mode 100644 index 000000000..3810b4bf6 --- /dev/null +++ b/app/src/main/res/drawable/cloud_2_solid.xml @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_line_horizontal.xml b/app/src/main/res/drawable/dashed_line_horizontal.xml new file mode 100644 index 000000000..737ff1959 --- /dev/null +++ b/app/src/main/res/drawable/dashed_line_horizontal.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/episodes_shadow.xml b/app/src/main/res/drawable/episodes_shadow.xml index b4cdd382e..a77cbf252 100644 --- a/app/src/main/res/drawable/episodes_shadow.xml +++ b/app/src/main/res/drawable/episodes_shadow.xml @@ -1,6 +1,8 @@ + android:centerColor="?attr/primaryBlackBackground" + android:centerX="0.2" + android:endColor="?attr/primaryBlackBackground" + android:startColor="@color/transparent" /> \ No newline at end of file diff --git a/app/src/main/res/drawable/example_qr.png b/app/src/main/res/drawable/example_qr.png new file mode 100644 index 000000000..764cb9660 Binary files /dev/null and b/app/src/main/res/drawable/example_qr.png differ diff --git a/app/src/main/res/drawable/go_back_30.xml b/app/src/main/res/drawable/go_back_30.xml index e57946b65..149990116 100644 --- a/app/src/main/res/drawable/go_back_30.xml +++ b/app/src/main/res/drawable/go_back_30.xml @@ -1,6 +1,7 @@ + + diff --git a/app/src/main/res/drawable/home_icon_outline_24.xml b/app/src/main/res/drawable/home_icon_outline_24.xml new file mode 100644 index 000000000..2d5f93a67 --- /dev/null +++ b/app/src/main/res/drawable/home_icon_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/home_icon_selector.xml b/app/src/main/res/drawable/home_icon_selector.xml new file mode 100644 index 000000000..2280cdd09 --- /dev/null +++ b/app/src/main/res/drawable/home_icon_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/hourglass_24.xml b/app/src/main/res/drawable/hourglass_24.xml new file mode 100644 index 000000000..7bd1ebbde --- /dev/null +++ b/app/src/main/res/drawable/hourglass_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_bug_report_24.xml b/app/src/main/res/drawable/ic_baseline_bug_report_24.xml deleted file mode 100644 index dad38dca6..000000000 --- a/app/src/main/res/drawable/ic_baseline_bug_report_24.xml +++ /dev/null @@ -1,5 +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 70db409b3..7dea8241e 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_edit_24.xml b/app/src/main/res/drawable/ic_baseline_edit_24.xml new file mode 100644 index 000000000..dba3e567c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_edit_24.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_equalizer_24.xml b/app/src/main/res/drawable/ic_baseline_equalizer_24.xml new file mode 100644 index 000000000..cd20ad156 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_equalizer_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_exit_24.xml b/app/src/main/res/drawable/ic_baseline_exit_24.xml new file mode 100644 index 000000000..6aebfabdc --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_exit_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_film_roll_24.xml b/app/src/main/res/drawable/ic_baseline_film_roll_24.xml new file mode 100644 index 000000000..941d936fa --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_film_roll_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file 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 new file mode 100644 index 000000000..66afaed2c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_folder_open_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_north_west_24.xml b/app/src/main/res/drawable/ic_baseline_north_west_24.xml new file mode 100644 index 000000000..c46eb4b0c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_north_west_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_open_in_new_24.xml b/app/src/main/res/drawable/ic_baseline_open_in_new_24.xml new file mode 100644 index 000000000..2651015ca --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_open_in_new_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_people_24.xml b/app/src/main/res/drawable/ic_baseline_people_24.xml new file mode 100644 index 000000000..2e7c9b070 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_people_24.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_replay_24.xml b/app/src/main/res/drawable/ic_baseline_replay_24.xml new file mode 100644 index 000000000..e247aa924 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_replay_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_restart_24.xml b/app/src/main/res/drawable/ic_baseline_restart_24.xml new file mode 100644 index 000000000..aed3a562c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_restart_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_resume_arrow.xml b/app/src/main/res/drawable/ic_baseline_resume_arrow.xml new file mode 100644 index 000000000..0326fbd49 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_resume_arrow.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml b/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml new file mode 100644 index 000000000..fc533a0e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml b/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml new file mode 100644 index 000000000..a8c43bbdb --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml b/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml new file mode 100644 index 000000000..452c4dd99 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_battery.xml b/app/src/main/res/drawable/ic_battery.xml new file mode 100644 index 000000000..24d0a77f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_filled_notifications_24dp.xml b/app/src/main/res/drawable/ic_filled_notifications_24dp.xml new file mode 100644 index 000000000..ed46f973d --- /dev/null +++ b/app/src/main/res/drawable/ic_filled_notifications_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fingerprint.xml b/app/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 000000000..5c96e5a54 --- /dev/null +++ b/app/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 000000000..71c2cbfcd --- /dev/null +++ b/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_network_stream.xml b/app/src/main/res/drawable/ic_network_stream.xml new file mode 100644 index 000000000..8e21fd25e --- /dev/null +++ b/app/src/main/res/drawable/ic_network_stream.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_offline_pin_24.xml b/app/src/main/res/drawable/ic_offline_pin_24.xml new file mode 100644 index 000000000..455006b31 --- /dev/null +++ b/app/src/main/res/drawable/ic_offline_pin_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_notifications_24dp.xml b/app/src/main/res/drawable/ic_outline_notifications_24dp.xml new file mode 100644 index 000000000..928ad0864 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_notifications_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..e61dcf1ce --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/kid_star_24px.xml b/app/src/main/res/drawable/kid_star_24px.xml new file mode 100644 index 000000000..2efe84195 --- /dev/null +++ b/app/src/main/res/drawable/kid_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/library_icon.xml b/app/src/main/res/drawable/library_icon.xml new file mode 100644 index 000000000..f62dceac0 --- /dev/null +++ b/app/src/main/res/drawable/library_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/library_icon_filled.xml b/app/src/main/res/drawable/library_icon_filled.xml new file mode 100644 index 000000000..eba49782c --- /dev/null +++ b/app/src/main/res/drawable/library_icon_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/library_icon_selector.xml b/app/src/main/res/drawable/library_icon_selector.xml new file mode 100644 index 000000000..9c6495bea --- /dev/null +++ b/app/src/main/res/drawable/library_icon_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/metadata_overlay_icon.xml b/app/src/main/res/drawable/metadata_overlay_icon.xml new file mode 100644 index 000000000..6d1b6510a --- /dev/null +++ b/app/src/main/res/drawable/metadata_overlay_icon.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/netflix_download_batch.xml b/app/src/main/res/drawable/netflix_download_batch.xml new file mode 100644 index 000000000..8ef633fd2 --- /dev/null +++ b/app/src/main/res/drawable/netflix_download_batch.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/notifications_icon_selector.xml b/app/src/main/res/drawable/notifications_icon_selector.xml new file mode 100644 index 000000000..9226029a4 --- /dev/null +++ b/app/src/main/res/drawable/notifications_icon_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline.xml b/app/src/main/res/drawable/outline.xml index 30077a984..7b436c7db 100644 --- a/app/src/main/res/drawable/outline.xml +++ b/app/src/main/res/drawable/outline.xml @@ -2,11 +2,9 @@ - - \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_15_gray.xml b/app/src/main/res/drawable/outline_big_15_gray.xml new file mode 100644 index 000000000..b94500279 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_15_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_20.xml b/app/src/main/res/drawable/outline_big_20.xml new file mode 100644 index 000000000..7faf8a889 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_20.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_20_gray.xml b/app/src/main/res/drawable/outline_big_20_gray.xml new file mode 100644 index 000000000..ebcdc0bf4 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_20_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_25_gray.xml b/app/src/main/res/drawable/outline_big_25_gray.xml new file mode 100644 index 000000000..ea5f31a1f --- /dev/null +++ b/app/src/main/res/drawable/outline_big_25_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_35_gray.xml b/app/src/main/res/drawable/outline_big_35_gray.xml new file mode 100644 index 000000000..ab18a1354 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_35_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_bookmark_add_24.xml b/app/src/main/res/drawable/outline_bookmark_add_24.xml new file mode 100644 index 000000000..a4e18af3f --- /dev/null +++ b/app/src/main/res/drawable/outline_bookmark_add_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/outline_drawable.xml b/app/src/main/res/drawable/outline_drawable.xml index 8eec2d0b8..16eba83cc 100644 --- a/app/src/main/res/drawable/outline_drawable.xml +++ b/app/src/main/res/drawable/outline_drawable.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_less.xml b/app/src/main/res/drawable/outline_drawable_less.xml index db74a0924..aa3a8d0df 100644 --- a/app/src/main/res/drawable/outline_drawable_less.xml +++ b/app/src/main/res/drawable/outline_drawable_less.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_less_inset.xml b/app/src/main/res/drawable/outline_drawable_less_inset.xml new file mode 100644 index 000000000..29096d867 --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_less_inset.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_round_20.xml b/app/src/main/res/drawable/outline_drawable_round_20.xml new file mode 100644 index 000000000..a2e8253b7 --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_round_20.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pin_ic.xml b/app/src/main/res/drawable/pin_ic.xml new file mode 100644 index 000000000..1425ff05a --- /dev/null +++ b/app/src/main/res/drawable/pin_ic.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/play_button.xml b/app/src/main/res/drawable/play_button.xml index 04886b6e5..ee3d47dfe 100644 --- a/app/src/main/res/drawable/play_button.xml +++ b/app/src/main/res/drawable/play_button.xml @@ -1,25 +1,19 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + android:name="vector" + android:width="842dp" + android:height="842dp" + android:viewportWidth="842" + android:viewportHeight="842"> + android:name="path" + android:pathData="M 421.44 17.5 C 336.15 17.5 253.011 44.513 184.01 94.646 C 115.009 144.778 63.626 215.5 37.27 296.616 C 10.914 377.732 10.914 465.148 37.27 546.264 C 63.626 627.38 115.009 698.102 184.01 748.234 C 253.011 798.367 336.15 825.38 421.44 825.38 C 506.73 825.38 589.869 798.367 658.87 748.234 C 727.871 698.102 779.254 627.38 805.61 546.264 C 831.966 465.148 831.966 377.732 805.61 296.616 C 779.254 215.5 727.871 144.778 658.87 94.646 C 589.869 44.513 506.73 17.5 421.44 17.5 Z" + android:fillColor="#B3000000" + android:strokeWidth="1"/> - + android:name="path_2" + android:pathData="M 598.91 419.24 L 333.91 266.24 L 333.91 572.24 L 598.91 419.24 Z" + android:fillColor="#ffffff" + android:strokeWidth="1"/> diff --git a/app/src/main/res/drawable/play_button_transparent.xml b/app/src/main/res/drawable/play_button_transparent.xml new file mode 100644 index 000000000..caa7041e6 --- /dev/null +++ b/app/src/main/res/drawable/play_button_transparent.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/player_button_tv_attr.xml b/app/src/main/res/drawable/player_button_tv_attr.xml index 4c90a64e9..ed83887d2 100644 --- a/app/src/main/res/drawable/player_button_tv_attr.xml +++ b/app/src/main/res/drawable/player_button_tv_attr.xml @@ -3,13 +3,13 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_button_tv_attr_no_bg.xml b/app/src/main/res/drawable/player_button_tv_attr_no_bg.xml index b9b927da6..0dd8c256a 100644 --- a/app/src/main/res/drawable/player_button_tv_attr_no_bg.xml +++ b/app/src/main/res/drawable/player_button_tv_attr_no_bg.xml @@ -3,7 +3,7 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_gradient_tv.xml b/app/src/main/res/drawable/player_gradient_tv.xml index 79bb3af5f..8077b418f 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/preview_seekbar_24.xml b/app/src/main/res/drawable/preview_seekbar_24.xml new file mode 100644 index 000000000..657f62470 --- /dev/null +++ b/app/src/main/res/drawable/preview_seekbar_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/rating_bg_color.xml b/app/src/main/res/drawable/rating_bg_color.xml index 60e62babe..4cf33aba0 100644 --- a/app/src/main/res/drawable/rating_bg_color.xml +++ b/app/src/main/res/drawable/rating_bg_color.xml @@ -1,6 +1,6 @@ - - + + - \ No newline at end of file + diff --git a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml new file mode 100644 index 000000000..d1360f948 --- /dev/null +++ b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_outline.xml b/app/src/main/res/drawable/rounded_outline.xml new file mode 100644 index 000000000..b85ace8ea --- /dev/null +++ b/app/src/main/res/drawable/rounded_outline.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/rounded_select_ripple.xml b/app/src/main/res/drawable/rounded_select_ripple.xml new file mode 100644 index 000000000..5dd7559b3 --- /dev/null +++ b/app/src/main/res/drawable/rounded_select_ripple.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/screen_rotation.xml b/app/src/main/res/drawable/screen_rotation.xml new file mode 100644 index 000000000..da0ac0fd5 --- /dev/null +++ b/app/src/main/res/drawable/screen_rotation.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/settings_icon_filled.xml b/app/src/main/res/drawable/settings_icon_filled.xml new file mode 100644 index 000000000..1d31bb7d0 --- /dev/null +++ b/app/src/main/res/drawable/settings_icon_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settings_icon_outline.xml b/app/src/main/res/drawable/settings_icon_outline.xml new file mode 100644 index 000000000..bdc9e98d3 --- /dev/null +++ b/app/src/main/res/drawable/settings_icon_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settings_icon_selector.xml b/app/src/main/res/drawable/settings_icon_selector.xml new file mode 100644 index 000000000..c54a9760d --- /dev/null +++ b/app/src/main/res/drawable/settings_icon_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/speedup.xml b/app/src/main/res/drawable/speedup.xml new file mode 100644 index 000000000..879ef852c --- /dev/null +++ b/app/src/main/res/drawable/speedup.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/subdl_logo_big.xml b/app/src/main/res/drawable/subdl_logo_big.xml new file mode 100644 index 000000000..12116eabc --- /dev/null +++ b/app/src/main/res/drawable/subdl_logo_big.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/sun_7_24.xml b/app/src/main/res/drawable/sun_7_24.xml new file mode 100644 index 000000000..26e3f43e8 --- /dev/null +++ b/app/src/main/res/drawable/sun_7_24.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/title_24px.xml b/app/src/main/res/drawable/title_24px.xml new file mode 100644 index 000000000..3e725ff7a --- /dev/null +++ b/app/src/main/res/drawable/title_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/video_frame.xml b/app/src/main/res/drawable/video_frame.xml new file mode 100644 index 000000000..19fcf26d0 --- /dev/null +++ b/app/src/main/res/drawable/video_frame.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/video_outline.xml b/app/src/main/res/drawable/video_outline.xml new file mode 100644 index 000000000..558c4ec3e --- /dev/null +++ b/app/src/main/res/drawable/video_outline.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-port/player_select_source_and_subs.xml b/app/src/main/res/layout-port/player_select_source_and_subs.xml new file mode 100644 index 000000000..4710473d4 --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_and_subs.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/player_select_source_priority.xml b/app/src/main/res/layout-port/player_select_source_priority.xml new file mode 100644 index 000000000..2cba9c869 --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_priority.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/subtitle_offset.xml b/app/src/main/res/layout-port/subtitle_offset.xml new file mode 100644 index 000000000..b6c4f61fd --- /dev/null +++ b/app/src/main/res/layout-port/subtitle_offset.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/who_is_watching_account_edit.xml b/app/src/main/res/layout/account_edit_dialog.xml similarity index 74% rename from app/src/main/res/layout/who_is_watching_account_edit.xml rename to app/src/main/res/layout/account_edit_dialog.xml index 74553517a..f52c8ea51 100644 --- a/app/src/main/res/layout/who_is_watching_account_edit.xml +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -1,4 +1,5 @@ + - - - - - - - - - - - + + + + + + + - + + - - + \ No newline at end of file diff --git a/app/src/main/res/layout/who_is_watching_account.xml b/app/src/main/res/layout/account_list_item.xml similarity index 57% rename from app/src/main/res/layout/who_is_watching_account.xml rename to app/src/main/res/layout/account_list_item.xml index afa1a2a77..3cbfc72fb 100644 --- a/app/src/main/res/layout/who_is_watching_account.xml +++ b/app/src/main/res/layout/account_list_item.xml @@ -4,13 +4,14 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/card_view" - android:layout_width="100dp" - android:layout_height="100dp" + android:layout_width="110dp" + android:layout_height="110dp" + android:layout_margin="10dp" android:animateLayoutChanges="true" - android:backgroundTint="?attr/primaryGrayBackground" - android:foreground="?attr/selectableItemBackgroundBorderless" + android:backgroundTint="@color/primaryGrayBackground" + android:focusable="true" + android:foreground="?attr/selectableItemBackground" app:cardCornerRadius="@dimen/rounded_image_radius" - android:layout_margin="5dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="1" app:layout_constraintEnd_toEndOf="parent" @@ -19,29 +20,40 @@ app:layout_constraintTop_toTopOf="parent"> + android:visibility="gone" + tools:visibility="visible" /> + + + android:textColor="@color/textColor" + android:textSize="16sp" + tools:text="Hello World!" /> \ No newline at end of file diff --git a/app/src/main/res/layout/who_is_watching_account_add.xml b/app/src/main/res/layout/account_list_item_add.xml similarity index 78% rename from app/src/main/res/layout/who_is_watching_account_add.xml rename to app/src/main/res/layout/account_list_item_add.xml index ed67e144e..dea64484f 100644 --- a/app/src/main/res/layout/who_is_watching_account_add.xml +++ b/app/src/main/res/layout/account_list_item_add.xml @@ -2,15 +2,15 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_managment.xml b/app/src/main/res/layout/account_managment.xml index 389a34066..e7afb382c 100644 --- a/app/src/main/res/layout/account_managment.xml +++ b/app/src/main/res/layout/account_managment.xml @@ -62,14 +62,16 @@ + android:id="@+id/account_switch_account" + android:text="@string/switch_account" + style="@style/SettingsItem" + android:focusable="true"/> + android:id="@+id/account_logout" + android:text="@string/logout" + style="@style/SettingsItem" + android:focusable="true"> diff --git a/app/src/main/res/layout/account_select_linear.xml b/app/src/main/res/layout/account_select_linear.xml new file mode 100644 index 000000000..b78c0d44c --- /dev/null +++ b/app/src/main/res/layout/account_select_linear.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_single.xml b/app/src/main/res/layout/account_single.xml index cbfb9f18f..c4f7fa394 100644 --- a/app/src/main/res/layout/account_single.xml +++ b/app/src/main/res/layout/account_single.xml @@ -1,10 +1,11 @@ + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:foreground="?android:attr/selectableItemBackgroundBorderless" + android:orientation="horizontal" + android:layout_height="wrap_content" + android:layout_width="match_parent" + android:focusable="true"> + android:id="@+id/account_profile_picture" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:ignore="ContentDescription" /> + android:foreground="@null" + android:id="@+id/account_name" + tools:text="Account 1" + style="@style/SettingsItem" /> diff --git a/app/src/main/res/layout/account_switch.xml b/app/src/main/res/layout/account_switch.xml index 659ad840a..ac6e41a60 100644 --- a/app/src/main/res/layout/account_switch.xml +++ b/app/src/main/res/layout/account_switch.xml @@ -7,18 +7,27 @@ android:layout_height="match_parent"> + android:id="@+id/account_list" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + android:background="?attr/primaryBlackBackground" + tools:listitem="@layout/account_single" + android:layout_width="match_parent" + android:layout_rowWeight="1" + android:layout_height="wrap_content" + android:focusable="true"/> + android:id="@+id/account_none" + android:text="@string/no_account" + style="@style/SettingsItem" + android:focusable="true"> + + + diff --git a/app/src/main/res/layout/activity_account_select.xml b/app/src/main/res/layout/activity_account_select.xml new file mode 100644 index 000000000..9f62d5601 --- /dev/null +++ b/app/src/main/res/layout/activity_account_select.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_easter_egg_monke.xml b/app/src/main/res/layout/activity_easter_egg_monke.xml deleted file mode 100644 index 9003cd211..000000000 --- a/app/src/main/res/layout/activity_easter_egg_monke.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - \ 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 b62908655..2483a3714 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -14,20 +14,21 @@ + + + + - + android:clickable="false" + android:focusable="false" + android:focusableInTouchMode="false" + android:importantForAccessibility="no" + android:src="@drawable/outline" + android:visibility="gone" /> \ No newline at end of file diff --git a/app/src/main/res/layout/add_account_input.xml b/app/src/main/res/layout/add_account_input.xml index ea48a80f0..4f96b109e 100644 --- a/app/src/main/res/layout/add_account_input.xml +++ b/app/src/main/res/layout/add_account_input.xml @@ -80,6 +80,7 @@ android:id="@+id/login_server_input" android:layout_width="match_parent" android:layout_height="wrap_content" + android:autofillHints="no" android:hint="@string/example_ip" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" @@ -96,7 +97,7 @@ android:layout_height="wrap_content" android:autofillHints="password" android:hint="@string/example_password" - android:inputType="textVisiblePassword" + android:inputType="textPassword" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" diff --git a/app/src/main/res/layout/add_remove_sites.xml b/app/src/main/res/layout/add_remove_sites.xml index 9ef6ad6a4..653f607f1 100644 --- a/app/src/main/res/layout/add_remove_sites.xml +++ b/app/src/main/res/layout/add_remove_sites.xml @@ -1,19 +1,21 @@ + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/add_site" + android:text="@string/add_site_pref" + android:focusable="true" + style="@style/SettingsItem"> + android:id="@+id/remove_site" + android:text="@string/remove_site_pref" + android:focusable="true" + style="@style/SettingsItem" /> \ No newline at end of file diff --git a/app/src/main/res/layout/add_repo_input.xml b/app/src/main/res/layout/add_repo_input.xml index 6f6b4d5bd..a8bdf2a38 100644 --- a/app/src/main/res/layout/add_repo_input.xml +++ b/app/src/main/res/layout/add_repo_input.xml @@ -72,7 +72,7 @@ android:inputType="text" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" - android:nextFocusDown="@id/site_url_input" + android:nextFocusDown="@id/repo_url_input" android:requiresFadingEdge="vertical" android:textColorHint="?attr/grayTextColor" tools:ignore="LabelFor" /> @@ -81,13 +81,13 @@ 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" android:nextFocusRight="@id/cancel_btt" - - android:nextFocusUp="@id/site_name_input" - android:nextFocusDown="@id/site_lang_input" + android:nextFocusUp="@id/repo_name_input" + android:nextFocusDown="@id/apply_btt" android:requiresFadingEdge="vertical" android:textColorHint="?attr/grayTextColor" tools:ignore="LabelFor" /> diff --git a/app/src/main/res/layout/add_site_input.xml b/app/src/main/res/layout/add_site_input.xml index 1c61f8b4d..519b790da 100644 --- a/app/src/main/res/layout/add_site_input.xml +++ b/app/src/main/res/layout/add_site_input.xml @@ -62,6 +62,7 @@ + xmlns:tools="http://schemas.android.com/tools" + android:nextFocusDown="@id/nginx_text_input" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/nginx_text_input" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + android:layout_marginBottom="60dp" + android:layout_marginHorizontal="10dp" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" + android:autofillHints="no" + android:inputType="text" + tools:text="nginx.com" + tools:ignore="LabelFor" /> + android:id="@+id/apply_btt_holder" + android:orientation="horizontal" + android:layout_gravity="bottom" + android:gravity="bottom|end" + android:layout_marginTop="-60dp" + android:layout_width="match_parent" + android:layout_height="60dp"> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + android:id="@+id/apply_btt" + style="@style/WhiteButton" /> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + android:id="@+id/cancel_btt" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/bottom_loading.xml b/app/src/main/res/layout/bottom_loading.xml index ab05889d0..1637aa5ad 100644 --- a/app/src/main/res/layout/bottom_loading.xml +++ b/app/src/main/res/layout/bottom_loading.xml @@ -1,33 +1,61 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> - + + + android:layout_height="wrap_content" + android:orientation="vertical"> + + + + + + + + + + + + + + + + - + android:id="@+id/progressBar" + style="@android:style/Widget.Material.ProgressBar.Horizontal" + android:layout_width="match_parent" + android:layout_height="15dp" + android:layout_gravity="center" + android:layout_marginBottom="-6.5dp" + android:indeterminate="true" + android:indeterminateTint="?attr/colorPrimary" + android:progressTint="?attr/colorPrimary" + android:visibility="gone" /> diff --git a/app/src/main/res/layout/bottom_resultview_preview.xml b/app/src/main/res/layout/bottom_resultview_preview.xml index ce41cb65b..3372fe7b1 100644 --- a/app/src/main/res/layout/bottom_resultview_preview.xml +++ b/app/src/main/res/layout/bottom_resultview_preview.xml @@ -41,17 +41,50 @@ android:layout_marginStart="10dp" android:orientation="vertical"> - - android:textStyle="bold" - tools:text="The Perfect Run"> + - + + + + + - + android:orientation="horizontal" + android:padding="7dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/bottom_selection_dialog.xml b/app/src/main/res/layout/bottom_selection_dialog.xml index 0532f2506..55ca6562e 100644 --- a/app/src/main/res/layout/bottom_selection_dialog.xml +++ b/app/src/main/res/layout/bottom_selection_dialog.xml @@ -1,58 +1,65 @@ + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> - + + + + + tools:text="Test" /> + + android:id="@+id/listview1" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:paddingTop="10dp" + android:dividerHeight="1dp" + android:requiresFadingEdge="vertical" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + tools:listitem="@layout/sort_bottom_single_choice" /> + android:id="@+id/apply_btt_holder" + android:layout_width="match_parent" + android:layout_height="60dp" + android:gravity="center_vertical|end" + android:orientation="horizontal"> + android:id="@+id/apply_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + style="@style/WhiteButton" /> + android:id="@+id/cancel_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/bottom_text_dialog.xml b/app/src/main/res/layout/bottom_text_dialog.xml new file mode 100644 index 000000000..01b4834d3 --- /dev/null +++ b/app/src/main/res/layout/bottom_text_dialog.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml index 54df59a83..4f7bdf74d 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" - app:cardCornerRadius="@dimen/rounded_image_radius" app:cardElevation="0dp"> @@ -25,39 +25,42 @@ 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 4d3b50dfe..92d0bd350 100644 --- a/app/src/main/res/layout/chromecast_subtitle_settings.xml +++ b/app/src/main/res/layout/chromecast_subtitle_settings.xml @@ -1,16 +1,21 @@ - + + + android:layout_height="match_parent"> - + android:layout_height="wrap_content" + android:orientation="vertical"> - - - - - + - - - - - - - - - - - - + + - + - - \ No newline at end of file + + diff --git a/app/src/main/res/layout/confirm_exit_dialog.xml b/app/src/main/res/layout/confirm_exit_dialog.xml new file mode 100644 index 000000000..c312e64e3 --- /dev/null +++ b/app/src/main/res/layout/confirm_exit_dialog.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/layout/custom_preference_category_material.xml b/app/src/main/res/layout/custom_preference_category_material.xml new file mode 100644 index 000000000..f5d78e835 --- /dev/null +++ b/app/src/main/res/layout/custom_preference_category_material.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/custom_preference_material.xml b/app/src/main/res/layout/custom_preference_material.xml new file mode 100644 index 000000000..c6685ee29 --- /dev/null +++ b/app/src/main/res/layout/custom_preference_material.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/custom_preference_widget_seekbar.xml b/app/src/main/res/layout/custom_preference_widget_seekbar.xml new file mode 100644 index 000000000..132091e5f --- /dev/null +++ b/app/src/main/res/layout/custom_preference_widget_seekbar.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/device_auth.xml b/app/src/main/res/layout/device_auth.xml new file mode 100644 index 000000000..38ff1325f --- /dev/null +++ b/app/src/main/res/layout/device_auth.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_online_subtitles.xml b/app/src/main/res/layout/dialog_online_subtitles.xml index 7803e2617..48dc48a04 100644 --- a/app/src/main/res/layout/dialog_online_subtitles.xml +++ b/app/src/main/res/layout/dialog_online_subtitles.xml @@ -22,25 +22,23 @@ android:orientation="vertical"> + - - + tools:ignore="UseCompoundDrawables" + android:padding="10dp"> + android:layout_marginEnd="10dp" + android:background="@drawable/search_background"> + - - - - - - - - + - + - - diff --git a/app/src/main/res/layout/download_child_episode.xml b/app/src/main/res/layout/download_child_episode.xml index fd845ee89..cb9c13d53 100644 --- a/app/src/main/res/layout/download_child_episode.xml +++ b/app/src/main/res/layout/download_child_episode.xml @@ -2,44 +2,56 @@ - + + + + + + + + + + - + + + + + + + + @@ -76,15 +86,27 @@ android:textColor="?attr/grayTextColor" tools:text="128MB / 237MB" /> - - + + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_header_episode.xml b/app/src/main/res/layout/download_header_episode.xml index 226c16321..7b8b2c91e 100644 --- a/app/src/main/res/layout/download_header_episode.xml +++ b/app/src/main/res/layout/download_header_episode.xml @@ -9,9 +9,22 @@ android:layout_marginTop="10dp" android:layout_marginEnd="10dp" android:foreground="@drawable/outline_drawable" + android:focusable="true" + android:nextFocusRight="@id/download_button" app:cardBackgroundColor="?attr/boxItemBackground" app:cardCornerRadius="@dimen/rounded_image_radius"> + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_queue_item.xml b/app/src/main/res/layout/download_queue_item.xml new file mode 100644 index 000000000..86562a513 --- /dev/null +++ b/app/src/main/res/layout/download_queue_item.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/empty_layout.xml b/app/src/main/res/layout/empty_layout.xml index 388e862b2..e128f7cec 100644 --- a/app/src/main/res/layout/empty_layout.xml +++ b/app/src/main/res/layout/empty_layout.xml @@ -1,18 +1,19 @@ - + - - \ No newline at end of file + + diff --git a/app/src/main/res/layout/extra_brightness_overlay.xml b/app/src/main/res/layout/extra_brightness_overlay.xml new file mode 100644 index 000000000..8f82121bb --- /dev/null +++ b/app/src/main/res/layout/extra_brightness_overlay.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_child_downloads.xml b/app/src/main/res/layout/fragment_child_downloads.xml index 9afaea0ba..0a7b42327 100644 --- a/app/src/main/res/layout/fragment_child_downloads.xml +++ b/app/src/main/res/layout/fragment_child_downloads.xml @@ -7,12 +7,68 @@ android:layout_height="match_parent" android:background="?attr/primaryGrayBackground" android:orientation="vertical" - tools:context=".ui.download.DownloadFragment"> + tools:context=".ui.download.DownloadChildFragment"> + android:background="?attr/primaryGrayBackground"> + + + + + +