diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml index 931db3bd9bf..f3590067312 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 250734cddb4..b56cdf8ed07 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 9c35ba56fc3..e18daebb30c 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: 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. + - 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 \ No newline at end of file diff --git a/.github/downloads.jpg b/.github/downloads.jpg deleted file mode 100644 index ca14a664a2c..00000000000 Binary files a/.github/downloads.jpg and /dev/null differ diff --git a/.github/home.jpg b/.github/home.jpg deleted file mode 100644 index 72370d3c98b..00000000000 Binary files a/.github/home.jpg and /dev/null differ diff --git a/.github/locales.py b/.github/locales.py new file mode 100644 index 00000000000..6127d9d806e --- /dev/null +++ b/.github/locales.py @@ -0,0 +1,65 @@ +import re +import glob +import requests +import lxml.etree as ET # builtin library doesn't preserve comments + + +SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt" +START_MARKER = "/* begin language list */" +END_MARKER = "/* end language list */" +XML_NAME = "app/src/main/res/values-b+" +ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" +INDENT = " "*4 + +iso_map = requests.get(ISO_MAP_URL, timeout=300).json() + +# Load settings file +src = open(SETTINGS_PATH, "r", encoding='utf-8').read() +before_src, rest = src.split(START_MARKER) +rest, after_src = rest.split(END_MARKER) + +# Load already added langs +languages = {} +for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): + name, iso = lang.groups() + languages[iso] = name + +# Add not yet added langs +for folder in glob.glob(f"{XML_NAME}*"): + iso = folder[len(XML_NAME):].replace("+", "-") + if iso not in languages.keys(): + entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found + languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple + +# 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(pairs) + + "\n" + + END_MARKER + + after_src +) + +# Go through each values.xml file and fix escaped \@string +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/") + with open(file, 'wb') as fp: + fp.write(b'\n') + tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) + except ET.ParseError as ex: + print(f"[{file}] {ex}") diff --git a/.github/player.jpg b/.github/player.jpg deleted file mode 100644 index f6959cf31d2..00000000000 Binary files a/.github/player.jpg and /dev/null differ diff --git a/.github/results.jpg b/.github/results.jpg deleted file mode 100644 index 4dbc9b8d48a..00000000000 Binary files a/.github/results.jpg and /dev/null differ diff --git a/.github/search.jpg b/.github/search.jpg deleted file mode 100644 index 784bec89255..00000000000 Binary files a/.github/search.jpg and /dev/null differ diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml index 83430766507..b5960d5d942 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -1,76 +1,92 @@ -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 11 - uses: actions/setup-java@v2 - with: - java-version: '11' - 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 }} - - 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 }} + 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 3c5caad789f..d67b8a519d7 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 11 - uses: actions/setup-java@v1 + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: - java-version: 11 + 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 index 28b737b3107..e354d657d50 100644 --- a/.github/workflows/issue_action.yml +++ b/.github/workflows/issue_action.yml @@ -4,17 +4,23 @@ on: issues: types: [opened] +permissions: + contents: read + issues: write + jobs: issue-moderator: 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 }} + - name: Similarity analysis + id: similarity uses: actions-cool/issues-similarity-analysis@v1 with: token: ${{ steps.generate_token.outputs.token }} @@ -24,7 +30,22 @@ jobs: ### Your issue looks similar to these issues: Please close if duplicate. comment-body: '${index}. ${similarity} #${number}' - - uses: actions/checkout@v2 + + - name: Label if possible duplicate + if: steps.similarity.outputs.similar-issues-found =='true' + uses: actions/github-script@v9 + 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@v6 + - name: Automatically close issues that dont follow the issue template uses: lucasbento/auto-close-issues@v1.0.2 with: @@ -33,6 +54,7 @@ jobs: @${issue.user.login}: hello! :wave: This issue is being automatically closed because it does not follow the issue template." closed-issues-label: "invalid" + - name: Check if issue mentions a provider id: provider_check env: @@ -42,6 +64,7 @@ jobs: pip3 install httpx RES="$(python3 ./check_issue.py)" echo "name=${RES}" >> $GITHUB_OUTPUT + - name: Comment if issue mentions a provider if: steps.provider_check.outputs.name != 'none' uses: actions-cool/issues-helper@v3 @@ -53,11 +76,23 @@ jobs: Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM). Found provider name: `${{ steps.provider_check.outputs.name }}` + + - name: Label if mentions provider + if: steps.provider_check.outputs.name != 'none' + uses: actions/github-script@v9 + 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 4ce7dba12d0..d9a20a04b2b 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 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + + - uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: - java-version: '11' - 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,15 +48,24 @@ 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 }} + 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 36199cd60ee..675ce3b2f77 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,22 +2,35 @@ name: Artifact Build on: [pull_request] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 11 - uses: actions/setup-java@v2 + - uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: - java-version: '11' - distribution: 'adopt' + distribution: temurin + java-version: 17 + - name: Grant execute permission for gradlew run: chmod +x gradlew + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + cache-read-only: false + - name: Run Gradle - run: ./gradlew assemblePrereleaseDebug + run: ./gradlew assemblePrereleaseDebug lint + - name: Upload Artifact - uses: actions/upload-artifact@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 new file mode 100644 index 00000000000..0a538d5d4da --- /dev/null +++ b/.github/workflows/update_locales.yml @@ -0,0 +1,46 @@ +name: Fix locale issues + +on: + push: + branches: [ master ] + paths: + - '**.xml' + workflow_dispatch: + +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@v2 + with: + app_id: ${{ secrets.GH_APP_ID }} + private_key: ${{ secrets.GH_APP_KEY }} + repository: "recloudstream/cloudstream" + + - uses: actions/checkout@v6 + with: + token: ${{ steps.generate_token.outputs.token }} + + - name: Install dependencies + run: pip3 install lxml requests + + - name: Edit files + run: python3 .github/locales.py + + - name: Commit to the repo + run: | + git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" + git config --local user.name "recloudstream[bot]" + git add . + # "echo" returns true so the build succeeds, even if no changed files + git commit -m 'chore(locales): fix locale issues' || echo + git push diff --git a/.gitignore b/.gitignore index 2ac6c9695ca..5fc9f0870b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -*.iml -.gradle /local.properties /.idea/caches /.idea/misc.xml @@ -11,6 +9,220 @@ .DS_Store /build /captures -.externalNativeBuild .cxx +.kotlin/* + +# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode + +### Android ### +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. + +### Java ### +# Compiled class file +*.class + +# Log file + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Kotlin ### +# Compiled class file + +# Log file + +# BlueJ files + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml + +### VisualStudioCode ### +.vscode/* + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +.navigation/ +*.ipr +*~ +*.swp + +# Keystore files + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# NDK +obj/ + +# IntelliJ IDEA +*.iws +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/workspace.xml +.idea/tasks.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml +.idea/assetWizardSettings.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/navEditor.xml + +# Legacy Eclipse project files +.classpath +.project +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 1eb497a9359..00000000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -CloudStream \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 7643783a82f..00000000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123c2b2..00000000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 5421743a9c2..00000000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/.idea/discord.xml b/.idea/discord.xml deleted file mode 100644 index d8e95616687..00000000000 --- a/.idea/discord.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 10c26704e22..00000000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index 333d49373bb..00000000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfbbc..00000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7282979ad72..00000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "githubPullRequests.ignoredPullRequestBranches": [ - "master" - ], - "java.configuration.updateBuildConfiguration": "interactive" -} \ No newline at end of file diff --git a/AI-POLICY.md b/AI-POLICY.md new file mode 100644 index 00000000000..5409393fb18 --- /dev/null +++ b/AI-POLICY.md @@ -0,0 +1,11 @@ +# AI Policy + +AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions. + +1. Always state any AI usage in pull requests and issues. + +2. Always test code before making a pull request. We do not want to test your AI generated code. + +3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI. + +4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions. diff --git a/README.md b/README.md index 0fef9758c26..c2492c5d821 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,111 @@ # 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 -+ Download and stream movies, tv-shows and anime ++ Phone and TV support + Chromecast ++ Extension system for personal customization + + + + +## Installation: -### Screenshots: +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/build.gradle.kts b/app/build.gradle.kts index fa1cabaa4c9..ae530192998 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,37 +1,95 @@ -import com.android.build.gradle.api.BaseVariantOutput -import org.jetbrains.dokka.gradle.DokkaTask -import java.io.ByteArrayOutputStream -import java.net.URL +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform +import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier +import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { - id("com.android.application") - id("kotlin-android") - id("kotlin-kapt") - id("kotlin-android-extensions") - id("org.jetbrains.dokka") + alias(libs.plugins.android.application) + alias(libs.plugins.dokka) } -val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" -val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() - -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 +val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) + +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 } + + // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491 + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } + + androidComponents { + onVariants { variant -> + variant.sources.assets?.addGeneratedSourceDirectory( + generateGitHash, + GenerateGitHashTask::outputDir + ) + } + } + signingConfigs { - 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") @@ -39,34 +97,36 @@ android { } } - compileSdk = 33 - buildToolsVersion = "30.0.3" + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.lagradost.cloudstream3" - minSdk = 21 - targetSdk = 33 - - versionCode = 55 - versionName = "3.3.0" - - resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 68 + versionName = "4.7.0" - resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") + manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() - resValue("bool", "is_prerelease", "false") + // Reads local.properties + val localProperties = gradleLocalProperties(rootDir, project.providers) + buildConfigField( + "long", + "BUILD_DATE", + "${System.currentTimeMillis()}" + ) buildConfigField( "String", - "BUILDDATE", - "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));" + "SIMKL_CLIENT_ID", + "\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\"" + ) + buildConfigField( + "String", + "SIMKL_CLIENT_SECRET", + "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - - kapt { - includeCompileClasspath = true - } } buildTypes { @@ -74,180 +134,203 @@ android { isDebuggable = false isMinifyEnabled = false isShrinkResources = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } debug { isDebuggable = true applicationIdSuffix = ".debug" - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } + 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() } } + compileOptions { isCoreLibraryDesugaringEnabled = true - - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.toVersion(javaTarget.target) + targetCompatibility = JavaVersion.toVersion(javaTarget.target) } - 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 } - 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.3") - testImplementation("org.json:json:20180813") - implementation("androidx.core:core-ktx:1.8.0") - implementation("androidx.appcompat:appcompat:1.4.2") // need target 32 for 1.5.0 - - // 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.5.1") - implementation("androidx.navigation:navigation-ui-ktx:2.5.1") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") - - //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") - - implementation("androidx.preference:preference-ktx:1.2.0") - - 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") - - implementation("jp.wasabeef:glide-transformations:4.3.0") - - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - - // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09") - - // Exoplayer - implementation("com.google.android.exoplayer:exoplayer:2.18.2") - implementation("com.google.android.exoplayer:extension-cast:2.18.2") - implementation("com.google.android.exoplayer:extension-mediasession:2.18.2") - implementation("com.google.android.exoplayer:extension-okhttp:2.18.2") - - //implementation("com.google.android.exoplayer:extension-leanback:2.14.0") - - // Bug reports - implementation("ch.acra:acra-core:5.8.4") - implementation("ch.acra:acra-toast:5.8.4") - - 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.7.1") - implementation("androidx.work:work-runtime-ktx:2.7.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.1") - // 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.3") - // debugImplementation because LeakCanary should only run in debug builds. - // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' - - // for shimmer when loading - implementation("com.facebook.shimmer:shimmer:0.5.0") + buildFeatures { + buildConfig = true + viewBinding = true + } - implementation("androidx.tvprovider:tvprovider:1.0.0") + 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 + } + } - // used for subtitle decoding https://github.com/albfernandez/juniversalchardet - implementation("com.github.albfernandez:juniversalchardet:2.4.0") + namespace = "com.lagradost.cloudstream3" +} - // slow af yt - //implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT") +dependencies { + // Testing + testImplementation(libs.junit) + testImplementation(libs.json) + androidTestImplementation(libs.core) + implementation(libs.junit.ktx) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.espresso.core) + + // Android Core & Lifecycle + implementation(libs.core.ktx) + implementation(libs.activity.ktx) + implementation(libs.annotation) + implementation(libs.appcompat) + implementation(libs.fragment.ktx) + implementation(libs.bundles.lifecycle) + implementation(libs.bundles.navigation) + implementation(libs.kotlinx.collections.immutable) + + // Design & UI + implementation(libs.preference.ktx) + implementation(libs.material) + implementation(libs.constraintlayout) + + // Coil Image Loading + implementation(libs.bundles.coil) + + // Media 3 (ExoPlayer) + implementation(libs.bundles.media3) + implementation(libs.video) + + // FFmpeg Decoding + implementation(libs.bundles.nextlib) + + // Anime-db for filler + implementation(libs.anime.db) + + // PlayBack + implementation(libs.colorpicker) // Subtitle Color Picker + implementation(libs.newpipeextractor) // For Trailers + implementation(libs.juniversalchardet) // Subtitle Decoding + + // 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 + + // Extensions & Other Libs + implementation(libs.jsoup) // HTML Parser + implementation(libs.rhino) // Run JavaScript + implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance + implementation(libs.safefile) // To Prevent the URI File Fu*kery + coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor + implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 + implementation(libs.jackson.module.kotlin) // JSON Parser + implementation(libs.zipline) + + // Torrent Support + implementation(libs.torrentserver) + + // Downloading & Networking + implementation(libs.work.runtime.ktx) + implementation(libs.nicehttp) // HTTP Lib + + implementation(project(":library")) +} - // newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L190 - implementation("com.github.TeamNewPipe:NewPipeExtractor:9ffdd0948b2ecd82655f5ff2a3e127b2b7695d5b") - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") +tasks.register("androidSourcesJar") { + archiveClassifier.set("sources") + from(android.sourceSets.getByName("main").java.directories) // Full Sources +} - // Library/extensions searching with Levenshtein distance - implementation("me.xdrop:fuzzywuzzy:1.4.0") +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.register("androidSourcesJar", Jar::class) { - archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.srcDirs) //full sources +// 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" } -// 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.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", + ) + } } -tasks.withType().configureEach { - moduleName.set("Cloudstream") +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" } } } -} \ No newline at end of file +} diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 00000000000..b2f5e8f2bc3 --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index 2a3e13e5695..4c5cdea5bee 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -1,173 +1,149 @@ package com.lagradost.cloudstream3 +import android.app.Activity +import android.os.Bundle +import android.os.PersistableBundle +import android.view.LayoutInflater +import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.Qualities +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 +import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding +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 +import com.lagradost.cloudstream3.databinding.RepositoryItemBinding +import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding +import com.lagradost.cloudstream3.databinding.SearchResultGridBinding +import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding +import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith + /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ +class TestApplication : Activity() { + override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + super.onCreate(savedInstanceState, persistentState) + } +} + @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { - //@Test - //fun useAppContext() { - // // Context of the app under test. - // val appContext = InstrumentationRegistry.getInstrumentation().targetContext - // assertEquals("com.lagradost.cloudstream3", appContext.packageName) - //} - - private fun getAllProviders(): List { - return APIHolder.allProviders //.filter { !it.usesWebView } + private fun getAllProviders(): Array { + println("Providers: ${APIHolder.allProviders.size}") + return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView } } - private suspend fun loadLinks(api: MainAPI, url: String?): Boolean { - Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) - if (url == null) return true - var linksLoaded = 0 - try { - val success = api.loadLinks(url, false, {}) { link -> - Assert.assertTrue( - "Api ${api.name} returns link with invalid Quality", - Qualities.values().map { it.value }.contains(link.quality) - ) - Assert.assertTrue( - "Api ${api.name} returns link with invalid url ${link.url}", - link.url.length > 4 - ) - linksLoaded++ - } - if (success) { - return linksLoaded > 0 - } - Assert.assertTrue("Api ${api.name} has returns false on .loadLinks", success) - } catch (e: Exception) { - if (e.cause is NotImplementedError) { - Assert.fail("Provider has not implemented .loadLinks") - } - logError(e) - } - return true + @Test + fun providersExist() { + Assert.assertTrue(getAllProviders().isNotEmpty()) + println("Done providersExist") } - private suspend fun testSingleProviderApi(api: MainAPI): Boolean { - val searchQueries = listOf("over", "iron", "guy") - var correctResponses = 0 - var searchResult: List? = null - for (query in searchQueries) { - val response = try { - api.search(query) - } catch (e: Exception) { - if (e.cause is NotImplementedError) { - Assert.fail("Provider has not implemented .search") - } - logError(e) - null - } - if (!response.isNullOrEmpty()) { - correctResponses++ - if (searchResult == null) { - searchResult = response - } - } + @Throws + private inline fun testAllLayouts( + activity: Activity, + vararg layouts: Int + ) { + + val bind = T::class.java.methods.first { it.name == "bind" } + val inflater = LayoutInflater.from(activity) + for (layout in layouts) { + val root = inflater.inflate(layout, null, false) + bind.invoke(null, root) } + } - if (correctResponses == 0 || searchResult == null) { - System.err.println("Api ${api.name} did not return any valid search responses") - return false - } + @Test + @Throws + fun layoutTest() { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity: MainActivity -> + // FragmentHomeHeadBinding and FragmentHomeHeadTvBinding CANT be the same + //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv) + //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv) - try { - var validResults = false - for (result in searchResult) { - Assert.assertEquals( - "Invalid apiName on response on ${api.name}", - result.apiName, - api.name - ) - val load = api.load(result.url) ?: continue - Assert.assertEquals( - "Invalid apiName on load on ${api.name}", - load.apiName, - result.apiName - ) - Assert.assertTrue( - "Api ${api.name} on load does not contain any of the supportedTypes", - api.supportedTypes.contains(load.type) - ) - when (load) { - is AnimeLoadResponse -> { - val gotNoEpisodes = - load.episodes.keys.isEmpty() || load.episodes.keys.any { load.episodes[it].isNullOrEmpty() } - - if (gotNoEpisodes) { - println("Api ${api.name} got no episodes on ${load.url}") - continue - } - - val url = (load.episodes[load.episodes.keys.first()])?.first()?.data - validResults = loadLinks(api, url) - if (!validResults) continue - } - is MovieLoadResponse -> { - val gotNoEpisodes = load.dataUrl.isBlank() - if (gotNoEpisodes) { - println("Api ${api.name} got no movie on ${load.url}") - continue - } - - validResults = loadLinks(api, load.dataUrl) - if (!validResults) continue - } - is TvSeriesLoadResponse -> { - val gotNoEpisodes = load.episodes.isEmpty() - if (gotNoEpisodes) { - println("Api ${api.name} got no episodes on ${load.url}") - continue - } - - validResults = loadLinks(api, load.episodes.first().data) - if (!validResults) continue - } - } - break - } - if(!validResults) { - System.err.println("Api ${api.name} did not load on any") - } + // main cant be tested + // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv) + // 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) - return validResults - } catch (e: Exception) { - if (e.cause is NotImplementedError) { - Assert.fail("Provider has not implemented .load") + // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv) + // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv) + + testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout) + testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout) + testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout) + + testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) + testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) + + testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) + testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) + + testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home) + testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home) + + testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search) + testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search) + + testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) + //testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) ??? fails ??? + + testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded) + testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded) + + + // 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_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) } - logError(e) - return false } } @Test - fun providersExist() { - Assert.assertTrue(getAllProviders().isNotEmpty()) - println("Done providersExist") - } - - @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", @@ -180,66 +156,20 @@ class ExampleInstrumentedTest { @Test fun providerCorrectHomepage() { runBlocking { - getAllProviders().amap { api -> - if (api.hasMainPage) { - try { - val homepage = api.getMainPage() - when { - homepage == null -> { - System.err.println("Homepage provider ${api.name} did not correctly load homepage!") - } - homepage.items.isEmpty() -> { - System.err.println("Homepage provider ${api.name} does not contain any items!") - } - homepage.items.any { it.list.isEmpty() } -> { - System.err.println ("Homepage provider ${api.name} does not have any items on result!") - } - } - } catch (e: Exception) { - if (e.cause is NotImplementedError) { - Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") - } - logError(e) - } - } + getAllProviders().toList().amap { api -> + TestingUtils.testHomepage(api, TestingUtils.Logger()) } } println("Done providerCorrectHomepage") } -// @Test -// fun testSingleProvider() { -// testSingleProviderApi(ThenosProvider()) -// } - @Test - fun providerCorrect() { + fun testAllProvidersCorrect() { runBlocking { - val invalidProvider = ArrayList>() - val providers = getAllProviders() - providers.amap { api -> - try { - println("Trying $api") - if (testSingleProviderApi(api)) { - println("Success $api") - } else { - System.err.println("Error $api") - invalidProvider.add(Pair(api, null)) - } - } catch (e: Exception) { - logError(e) - invalidProvider.add(Pair(api, e)) - } - } - if(invalidProvider.isEmpty()) { - println("No Invalid providers! :D") - } else { - println("Invalid providers are: ") - for (provider in invalidProvider) { - println("${provider.first}") - } - } + TestingUtils.getDeferredProviderTests( + this, + getAllProviders(), + ) { _, _ -> } } - println("Done providerCorrect") } } 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 7b05b711181..caed023d55f 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 871c4f69836..ee4c978f2be 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,65 @@ + + + + + + + + + + + + android:supportsPictureInPicture="true" /> + + + + + + + + + + + + + + + + @@ -114,7 +200,14 @@ + + + + + + + @@ -138,7 +231,7 @@ - + @@ -151,15 +244,11 @@ - - - + android:exported="false"> + @@ -167,14 +256,28 @@ + + + + + - - \ 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 198f0f4c2b3..bbe7d97debc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt @@ -1,208 +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 - - -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/u/0/d/e/1FAIpQLSe9Vff8oHGMRXcjgCXZwkjvx3eBdNpn4DzjO0FkcWEU1gEQpA/formResponse" - val data = mapOf( - "entry.1586460852" to errorContent.toJSON() - ) - - thread { // to not run it on main thread - runBlocking { - suspendSafeApiCall { - val post = app.post(url, data = data) - println("Report response: $post") - } - } - } - - runOnMainThread { // to run it on main looper - normalSafeApiCall { - Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show() - } - } - } +/** + * Deprecated alias for CloudStreamApp for backwards compatibility with plugins. + * Use CloudStreamApp instead. + */ +@Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), + level = DeprecationLevel.WARNING +) +class AcraApplication { + companion object { + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), + level = DeprecationLevel.WARNING + ) + val context get() = CloudStreamApp.context + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), + level = DeprecationLevel.WARNING + ) + fun removeKeys(folder: String): Int? = + CloudStreamApp.removeKeys(folder) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), + level = DeprecationLevel.WARNING + ) + fun setKey(path: String, value: T) = + CloudStreamApp.setKey(path, value) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"), + level = DeprecationLevel.WARNING + ) + fun setKey(folder: String, path: String, value: T) = + CloudStreamApp.setKey(folder, path, value) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(path: String, defVal: T?): T? = + CloudStreamApp.getKey(path, defVal) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(path: String): T? = + CloudStreamApp.getKey(path) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(folder: String, path: String): T? = + CloudStreamApp.getKey(folder, path) + + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"), + level = DeprecationLevel.WARNING + ) + inline fun getKey(folder: String, path: String, defVal: T?): T? = + CloudStreamApp.getKey(folder, path, defVal) + } } - -@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() - Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) { - val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) - startActivity(Intent.makeRestartActivityTask(intent!!.component)) - }) - } - - override fun attachBaseContext(base: Context?) { - super.attachBaseContext(base) - context = base - - initAcra { - //core configuration: - buildConfigClass = BuildConfig::class.java - reportFormat = StringFormat.JSON - - reportContent = arrayOf( - 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 { - /** 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 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() - ) - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt new file mode 100644 index 00000000000..a9cd9c01edd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -0,0 +1,181 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import com.lagradost.api.setContext +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser +import com.lagradost.cloudstream3.utils.AppDebug +import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.DataStore.removeKeys +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader +import kotlinx.coroutines.runBlocking +import java.io.File +import java.io.FileNotFoundException +import java.io.PrintStream +import java.lang.ref.WeakReference +import java.util.Locale +import kotlin.concurrent.thread +import kotlin.system.exitProcess + +class ExceptionHandler( + val errorFile: File, + val onError: (() -> Unit) +) : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(thread: Thread, error: Throwable) { + try { + val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + thread.threadId() + } else { + @Suppress("DEPRECATION") + thread.id + } + + PrintStream(errorFile).use { ps -> + ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") + ps.println("Fatal exception on thread ${thread.name} ($threadId)") + error.printStackTrace(ps) + } + } catch (_: FileNotFoundException) { + } + try { + onError() + } catch (_: Exception) { + } + exitProcess(1) + } +} + +class CloudStreamApp : Application(), SingletonImageLoader.Factory { + + override fun onCreate() { + super.onCreate() + // If we want to initialize Coil as early as possible, maybe when + // loading an image or GIF in a splash screen activity. + // buildImageLoader(applicationContext) + + ExceptionHandler(filesDir.resolve("last_error")) { + val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) + startActivity(Intent.makeRestartActivityTask(intent!!.component)) + }.also { + exceptionHandler = it + Thread.setDefaultUncaughtExceptionHandler(it) + } + + AppDebug.isDebug = BuildConfig.DEBUG + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + context = base + } + + override fun newImageLoader(context: PlatformContext): ImageLoader { + // Coil module will be initialized globally when first loadImage() is invoked. + return buildImageLoader(applicationContext) + } + + companion object { + var exceptionHandler: ExceptionHandler? = null + + /** Use to get Activity from Context. */ + tailrec fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + } + + private var _context: WeakReference? = null + var context + get() = _context?.get() + private set(value) { + _context = WeakReference(value) + setContext(WeakReference(value)) + } + + fun getKeyClass(path: String, valueType: Class): T? { + return context?.getKey(path, valueType) + } + + fun setKeyClass(path: String, value: T) { + context?.setKey(path, value) + } + + fun removeKeys(folder: String): Int? { + return context?.removeKeys(folder) + } + + fun setKey(path: String, value: T) { + context?.setKey(path, value) + } + + fun setKey(folder: String, path: String, value: T) { + context?.setKey(folder, path, value) + } + + inline fun getKey(path: String, defVal: T?): T? { + return context?.getKey(path, defVal) + } + + inline fun getKey(path: String): T? { + return context?.getKey(path) + } + + inline fun getKey(folder: String, path: String): T? { + return context?.getKey(folder, path) + } + + inline fun getKey(folder: String, path: String, defVal: T?): T? { + return context?.getKey(folder, path, defVal) + } + + fun getKeys(folder: String): List? { + return context?.getKeys(folder) + } + + fun removeKey(folder: String, path: String) { + context?.removeKey(folder, path) + } + + fun removeKey(path: String) { + context?.removeKey(path) + } + + /** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */ + fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) { + context?.openBrowser(url, fallbackWebView, fragment) + } + + /** Will fall back to WebView if in TV or emulator layout. */ + fun openBrowser(url: String, activity: FragmentActivity?) { + openBrowser( + url, + isLayout(TV or EMULATOR), + activity?.supportFragmentManager?.fragments?.lastOrNull() + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index b7415811570..ed0aaf9b761 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -1,15 +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.widget.TextView +import android.view.Gravity +import android.view.KeyEvent +import android.view.View +import android.view.View.NO_ID +import android.view.ViewGroup import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts @@ -18,44 +26,126 @@ import androidx.annotation.StringRes 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.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey +import com.google.android.material.chip.ChipGroup +import com.google.android.material.navigationrail.NavigationRailView +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.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.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 com.lagradost.cloudstream3.utils.UiText +import java.lang.ref.WeakReference +import java.util.Locale +import kotlin.math.max +import kotlin.math.min import org.schabi.newpipe.extractor.NewPipe -import java.util.* + +enum class FocusDirection { + Start, + End, + Up, + Down, +} object CommonActivity { + + private var _activity: WeakReference? = null + var activity + get() = _activity?.get() + private set(value) { + _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 + + // 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 canEnterPipMode: Boolean = false - var canShowPipMode: Boolean = false + var isPipDesired: Boolean = false var isInPIPMode: Boolean = false val onColorSelectedEvent = Event>() val onDialogDismissedEvent = Event() - var playerEventListener: ((PlayerEventType) -> Unit)? = null var keyEventListener: ((Pair) -> Boolean)? = null + var appliedTheme: Int = 0 + var appliedColor: Int = 0 + private var currentToast: Toast? = null - var currentToast: Toast? = null + fun showToast(@StringRes message: Int, duration: Int? = null) { + val act = activity ?: return + act.runOnUiThread { + showToast(act, act.getString(message), duration) + } + } + + fun showToast(message: String?, duration: Int? = null) { + val act = activity ?: return + act.runOnUiThread { + showToast(act, message, duration) + } + } + + fun showToast(message: UiText?, duration: Int? = null) { + val act = activity ?: return + if (message == null) return + act.runOnUiThread { + showToast(act, message.asString(act), duration) + } + } + + @MainThread fun showToast(act: Activity?, text: UiText, duration: Int) { if (act == null) return text.asStringNull(act)?.let { @@ -86,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() + try { + 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_TW" 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) @@ -129,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() { @@ -138,43 +241,38 @@ object CommonActivity { setLocale(this, localeCode) } - fun init(act: ComponentActivity?) { - if (act == null) return - //https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission - //https://developer.android.com/guide/topics/ui/picture-in-picture - canShowPipMode = - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT - 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 - - act.updateLocale() - act.updateTv() + fun init(act: Activity) { + setActivityInstance(act) + ioSafe { Torrent.deleteAllFiles() } + val componentActivity = activity as? ComponentActivity ?: return + + 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") @@ -185,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() } } @@ -204,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 } } @@ -216,24 +342,33 @@ 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 "AmoledLight" -> R.style.AmoledModeLight "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 @@ -242,211 +377,228 @@ 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 + "Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal + 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 ) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW } - private fun getNextFocus( - act: Activity?, + /** because we want closes find, aka when multiple have the same id, we go to parent + until the correct one is found */ + private fun localLook(from: View, id: Int): View? { + if (id == NO_ID) return null + var currentLook: View = from + // limit to 15 look depth + for (i in 0..15) { + currentLook.findViewById(id)?.let { return it } + currentLook = (currentLook.parent as? View) ?: break + } + return null + } + /*var currentLook: View = view + while (true) { + val tmpNext = currentLook.findViewById(nextId) + if (tmpNext != null) { + next = tmpNext + break + } + 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?, + view: View, + direction: FocusDirection, + nextId: Int, + depth: Int = 0 + ): View? { + if (nextId == NO_ID) return null + + // do an initial search for the view, in case the localLook is too deep we can use this as + // an early break and backup view + var next = + when (root) { + is Activity -> root.findViewById(nextId) + is View -> root.rootView.findViewById(nextId) + else -> null + } ?: 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.isNotEmpty() + } ?: false + if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null + + // if not shown then continue because we will "skip" over views to get to a replacement + 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) + } + + (when (next) { + is ChipGroup -> { + next.children.firstOrNull { it.isFocusable && it.isShown } + } + + is NavigationRailView -> { + next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home) + } + + else -> null + })?.let { + return it + } + + // nothing wrong with the view found, return it + return next + } + + /** recursively looks for a next focus up to a depth of 10, + * this is used to override the normal shit focus system + * because this application has a lot of invisible views that messes with some tv devices*/ + fun getNextFocus( + root: Any?, view: View?, direction: FocusDirection, depth: Int = 0 - ): Int? { - if (view == null || depth >= 10 || act == null) { + ): View? { + // if input is invalid let android decide + depth test to not crash if loop is found + if (view == null || depth >= 10 || root == null) { return null } - val nextId = when (direction) { - FocusDirection.Left -> { - view.nextFocusLeftId + var nextId = when (direction) { + FocusDirection.Start -> { + if (view.isRtl()) + view.nextFocusRightId + else + view.nextFocusLeftId } + FocusDirection.Up -> { view.nextFocusUpId } - FocusDirection.Right -> { - view.nextFocusRightId + + FocusDirection.End -> { + if (view.isRtl()) + view.nextFocusLeftId + else + view.nextFocusRightId } + FocusDirection.Down -> { view.nextFocusDownId } } - return if (nextId != -1) { - val next = act.findViewById(nextId) - //println("NAME: ${next.accessibilityClassName} | ${next?.isShown}" ) - - if (next?.isShown == false) { - getNextFocus(act, next, direction, depth + 1) - } else { - if (depth == 0) { - null - } else { - nextId - } - } - } else { - null + if (nextId == NO_ID) { + // if not specified then use forward id + nextId = view.nextFocusForwardId + // if view is still not found to next focus then return and let android decide + if (nextId == NO_ID) + return null } + return continueGetNextFocus(root, view, direction, nextId, depth) } - enum class FocusDirection { - Left, - Right, - Up, - Down, - } - 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 */ fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? { if (act == null) return null + val currentFocus = act.currentFocus + event?.keyCode?.let { keyCode -> - when (event.action) { - KeyEvent.ACTION_DOWN -> { - if (act.currentFocus != null) { - val next = when (keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus( - act, - act.currentFocus, - FocusDirection.Left - ) - KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus( - act, - act.currentFocus, - FocusDirection.Right - ) - KeyEvent.KEYCODE_DPAD_UP -> getNextFocus( - act, - act.currentFocus, - FocusDirection.Up - ) - KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus( - act, - act.currentFocus, - FocusDirection.Down - ) - - else -> null - } - - if (next != null && next != -1) { - val nextView = act.findViewById(next) - if (nextView != null) { - nextView.requestFocus() - keyEventListener?.invoke(Pair(event, true)) - return true - } - } - - when (keyCode) { - KeyEvent.KEYCODE_DPAD_CENTER -> { - if (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) { - UIHelper.showInputMethod(act.currentFocus?.findFocus()) - } - } - } - } - //println("Keycode: $keyCode") - //showToast( - // this, - // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", - // Toast.LENGTH_LONG - //) - } + if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let + val nextView = when (keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus( + act, + currentFocus, + FocusDirection.Start + ) + + KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus( + act, + currentFocus, + FocusDirection.End + ) + + KeyEvent.KEYCODE_DPAD_UP -> getNextFocus( + act, + currentFocus, + FocusDirection.Up + ) + + KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus( + act, + currentFocus, + FocusDirection.Down + ) + + else -> null + } + + // println("NEXT FOCUS : $nextView") + if (nextView != null) { + nextView.requestFocus() + keyEventListener?.invoke(Pair(event, true)) + return true } + + // TODO: Figure out why removing the check for SearchAutoComplete seems + // to break focus on TV as it shouldn't need to be used. + @SuppressLint("RestrictedApi") + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && + (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) + ) { + showInputMethod(act.currentFocus?.findFocus()) + } + + //println("Keycode: $keyCode") + //showToast( + // this, + // "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 + // consumed. used in video player if (keyEventListener?.invoke(Pair(event, false)) == true) { return true } return null } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt index 379a91e4c4a..8da7ca384b5 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) @@ -50,7 +51,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do companion object { private const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" private var instance: DownloaderTestImpl? = null /** @@ -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/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt deleted file mode 100644 index 1bcdf422e13..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt +++ /dev/null @@ -1,1517 +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.syncproviders.AccountManager.Companion.aniListApi -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.toJson -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf -import com.lagradost.cloudstream3.utils.ExtractorLink -import okhttp3.Interceptor -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/91.0.4472.124 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() { - 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) { - apis = apis + plugin - initMap(true) - } - - fun removePluginMapping(plugin: MainAPI) { - apis = apis.filter { it != plugin } - initMap(true) - } - - private fun initMap(forcedUpdate: Boolean = false) { - 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() - 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 - } - - fun Context.getApiSettings(): HashSet { - //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - - val hashSet = HashSet() - val activeLangs = getApiProviderLangSettings() - val hasUniversal = activeLangs.contains(AllLanguagesName) - hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) } - .map { it.name }) - - /*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 { - if (isTvSettings()) return false - 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 = apis.filter { hasUniversal || langs.contains(it.lang) } - .filter { api -> 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 - } -} - - -/* -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 - - 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 - } -} - -/** 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 } - } -} - -/** 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) -} - -// 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 - var isTrailersEnabled = true - - fun LoadResponse.isMovie(): Boolean { - return this.type.isMovieType() - } - - @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) } - } - - @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() - } - - fun LoadResponse.addAniListId(id: Int?) { - this.syncData[aniListIdPrefix] = (id ?: return).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 - } - - 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 - } - - 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) -} - - -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? -} - -@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 - -/** - * 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 - -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() diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 9a591580b53..8a98bd2972e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1,21 +1,44 @@ package com.lagradost.cloudstream3 -import android.content.ComponentName +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Dialog +import android.content.Context import android.content.Intent +import android.content.SharedPreferences import android.content.res.ColorStateList import android.content.res.Configuration +import android.graphics.Rect import android.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.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 +import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy @@ -24,202 +47,192 @@ import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController import androidx.preference.PreferenceManager -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.google.android.gms.cast.framework.* +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSnapHelper +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.Session +import com.google.android.gms.cast.framework.SessionManager +import com.google.android.gms.cast.framework.SessionManagerListener +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.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.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.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.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.AppUtils.isCastApiAvailable -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.ApkInstaller +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 kotlinx.android.synthetic.main.activity_main.* -import kotlinx.android.synthetic.main.fragment_result_swipe.* +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 +import java.lang.ref.WeakReference import java.net.URI import java.net.URLDecoder import java.nio.charset.Charset -import kotlin.reflect.KClass - - -//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, - "org.videolan.vlc.player.result", - "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) +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.system.exitProcess +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel + +class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { + companion object { + var activityResultLauncher: ActivityResultLauncher? = null -val resumeApps = arrayOf( - VLC, MPV, WEB_VIDEO -) + const val TAG = "MAINACT" + const val ANIMATED_OUTLINE: Boolean = false + var lastError: String? = null -// Short name for requests client to make it nicer to use + /** 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 + } + } -var app = Requests(responseParser = object : ResponseParser { - val mapper: ObjectMapper = jacksonObjectMapper().configure( - DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, - false - ) + private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" + const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" - override fun parse(text: String, kClass: KClass): T { - return mapper.readValue(text, kClass.java) - } + /** + * 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) - override fun parseSafe(text: String, kClass: KClass): T? { - return try { - mapper.readValue(text, kClass.java) - } catch (e: Exception) { - null + /** + * Add file to delete on Exit. + */ + fun deleteFileOnExit(file: File) { + filesToDelete = filesToDelete + file.path } - } - - override fun writeValueAsString(obj: Any): String { - return mapper.writeValueAsString(obj) - } -}).apply { - defaultHeaders = mapOf("user-agent" to USER_AGENT) -} - -class MainActivity : AppCompatActivity(), ColorPickerDialogListener { - companion object { - const val TAG = "MAINACT" /** * Setting this will automatically enter the query in the search @@ -228,7 +241,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { * * This is a very bad solution but I was unable to find a better one. **/ - private var nextSearchQuery: String? = null + var nextSearchQuery: String? = null /** * Fires every time a new batch of plugins have been loaded, no guarantee about how often this is run and on which thread @@ -241,6 +254,24 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { Event() // homepage api, used to speed up time to load for homepage val afterRepositoryLoadedEvent = Event() + // 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) * @param isWebview does not handle providers and opening download page if true. Can still add repos and login. @@ -248,11 +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")) { @@ -260,30 +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( - this@with, - 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 @@ -291,20 +325,50 @@ 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) { + } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) { + val query = str.substringAfter("$APP_STRING_SEARCH://") nextSearchQuery = - URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8") - nav_view.selectedItemId = R.id.navigation_search - } else if (safeURI(str)?.scheme == appStringResumeWatching) { + try { + URLDecoder.decode(query, "UTF-8") + } catch (t: Throwable) { + logError(t) + query + } + // Use both navigation views to support both layouts. + // It might be better to use the QuickSearch. + activity?.findViewById(R.id.nav_view)?.selectedItemId = + R.id.navigation_search + activity?.findViewById(R.id.nav_rail_view)?.selectedItemId = + R.id.navigation_search + } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) { + val uri = str.toUri() + val name = uri.getQueryParameter("name") + val url = URLDecoder.decode(uri.authority, "UTF-8") + + navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + LinkGenerator( + listOf(BasicLink(url, name)), + extract = true, + id = url.hashCode() + ), 0 + ) + ) + } 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 = @@ -315,15 +379,41 @@ 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 { - 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 + } + + synchronized(apis) { + for (api in apis) { + if (str.startsWith(api.mainUrl)) { + loadResult(str, api.name, "") + return true + } } } } @@ -331,6 +421,54 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } return false } + + + fun centerView(view: View?) { + if (view == null) return + try { + Log.v(TAG, "centerView: $view") + val r = Rect(0, 0, 0, 0) + view.getDrawingRect(r) + val x = r.centerX() + val y = r.centerY() + val dx = r.width() / 2 //screenWidth / 2 + val dy = screenHeight / 2 + val r2 = Rect(x - dx, y - dy, x + dx, y + dy) + view.requestRectangleOnScreen(r2, false) + // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) + } catch (_: Throwable) { + } + } + } + + + var lastPopup: SearchResponse? = null + var lastPopupJob: Job? = null + fun loadPopup(result: SearchResponse, load: Boolean = true) { + lastPopup = result + val syncName = syncViewModel.syncName(result.apiName) + + // 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) { @@ -344,6 +482,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 @@ -354,7 +493,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { this.hideKeyboard() // Fucks up anime info layout since that has its own layout - cast_mini_controller_holder?.isVisible = + binding?.castMiniControllerHolder?.isVisible = !listOf( R.id.navigation_results_phone, R.id.navigation_results_tv, @@ -364,9 +503,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { val isNavVisible = listOf( R.id.navigation_home, R.id.navigation_search, + R.id.navigation_library, 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, @@ -377,26 +518,93 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { R.id.navigation_settings_general, R.id.navigation_settings_extensions, R.id.navigation_settings_plugins, + R.id.navigation_test_providers, ).contains(destination.id) - val landscape = when (resources.configuration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> { - true + + /*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 && isLayout(TV or EMULATOR)) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0 + + if (!this.isLtr()) { + params.setMargins( + params.leftMargin, + params.topMargin, + push, + params.bottomMargin + ) + } else { + params.setMargins( + push, + params.topMargin, + params.rightMargin, + params.bottomMargin + ) } - Configuration.ORIENTATION_PORTRAIT -> { - false + + layoutParams = params + }*/ + + binding?.apply { + navRailView.isVisible = isNavVisible && isLandscape() + navView.isVisible = isNavVisible && !isLandscape() + navHostFragment.apply { + val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width) + layoutParams = + (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { + marginStart = + if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 + } } - else -> { - false + + /** + * 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 + } } } - - nav_view?.isVisible = isNavVisible && !landscape - nav_rail_view?.isVisible = isNavVisible && landscape } //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 { @@ -433,10 +641,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) @@ -445,9 +653,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { override fun onPause() { super.onPause() + + // Start any delayed updates + if (ApkInstaller.delayedInstaller?.startInstallation() == true) { + Toast.makeText(this, R.string.update_started, Toast.LENGTH_LONG).show() + } try { if (isCastApiAvailable()) { - mSessionManager.removeSessionManagerListener(mSessionManagerListener) + mSessionManager?.removeSessionManagerListener(mSessionManagerListener) //mCastSession = null } } catch (e: Exception) { @@ -455,19 +668,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - CommonActivity.dispatchKeyEvent(this, event)?.let { - return it - } - return 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() { @@ -475,54 +680,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 { - setPositiveButton(R.string.yes) { _, _ -> super.onBackPressed() } - setNegativeButton(R.string.no) { _, _ -> } - } - builder.show() - } - - private fun backPressed() { - this.window?.navigationBarColor = - this.colorFromAttribute(R.attr.primaryGrayBackground) - this.updateLocale() - this.updateLocale() + @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) - 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() + 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 } - } - override fun onBackPressed() { - ((supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.childFragmentManager?.primaryNavigationFragment as? IOnBackPressed)?.onBackPressed() - ?.let { runNormal -> - if (runNormal) backPressed() - } ?: run { - backPressed() - } + 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() } 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) } @@ -531,13 +739,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) @@ -550,56 +800,395 @@ 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 } } + private val pluginsLock = Mutex() private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { pluginsLock.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 - }) - } + synchronized(allProviders) { + // Load cloned sites after plugins have been loaded since clones depend on plugins. + try { + getKey>(USER_PROVIDER_API)?.let { list -> + list.forEach { custom -> + allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass } + ?.let { + allProviders.add( + it.javaClass.getDeclaredConstructor().newInstance() + .apply { + name = custom.name + lang = custom.lang + mainUrl = custom.url.trimEnd('/') + canBeOverridden = false + }) + } + } } + // it.hashCode() is not enough to make sure they are distinct + apis = + allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name } + APIHolder.apiMap = null + } catch (e: Exception) { + logError(e) } - // it.hashCode() is not enough to make sure they are distinct - apis = - allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name } - APIHolder.apiMap = null - } catch (e: Exception) { - logError(e) } } } } + 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] + 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: Dialog? = null + private var bottomPreviewBinding: BottomResultviewPreviewBinding? = null + private fun showPreviewPopupDialog(): BottomResultviewPreviewBinding { + val ret = (bottomPreviewBinding ?: run { + + val builder: Dialog + val layout: Int + + if (isLayout(PHONE)) { + builder = + BottomSheetDialog(this) + layout = R.layout.bottom_resultview_preview + } else { + builder = + Dialog(this, R.style.DialogHalfFullscreen) + layout = R.layout.bottom_resultview_preview_tv + // No way to do this in styles :( + builder.window?.setGravity(Gravity.CENTER_VERTICAL or Gravity.END) + } + + val root = layoutInflater.inflate(layout, null, false) + val binding = BottomResultviewPreviewBinding.bind(root) + + bottomPreviewBinding = binding + builder.setContentView(root) + builder.setOnDismissListener { + bottomPreviewPopup = null + bottomPreviewBinding = null + viewModel.clear() + } + builder.setCanceledOnTouchOutside(true) + builder.show() + bottomPreviewPopup = builder + binding + }) + + return ret + } + + var binding: ActivityMainBinding? = null + + object TvFocus { + data class FocusTarget( + val width: Int, + val height: Int, + val x: Float, + val y: Float, + ) { + companion object { + fun lerp(a: FocusTarget, b: FocusTarget, lerp: Float): FocusTarget { + val ilerp = 1 - lerp + return FocusTarget( + width = (a.width * ilerp + b.width * lerp).toInt(), + height = (a.height * ilerp + b.height * lerp).toInt(), + x = a.x * ilerp + b.x * lerp, + y = a.y * ilerp + b.y * lerp + ) + } + } + } + + var last: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f) + var current: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f) + + var focusOutline: WeakReference = WeakReference(null) + var lastFocus: WeakReference = WeakReference(null) + private val layoutListener: View.OnLayoutChangeListener = + View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + // shitty fix for layouts + lastFocus.get()?.apply { + updateFocusView( + this, same = true + ) + postDelayed({ + updateFocusView( + lastFocus.get(), same = false + ) + }, 300) + } + } + private val attachListener: View.OnAttachStateChangeListener = + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + updateFocusView(v) + } + + override fun onViewDetachedFromWindow(v: View) { + // removes the focus view but not the listener as updateFocusView(null) will remove the listener + 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 { + layoutParams = layoutParams?.apply { + width = target.width + height = target.height + } + + translationX = target.x + translationY = target.y + bringToFront() + } + } + + private var animator: ValueAnimator? = null + + /** if this is enabled it will keep the focus unmoving + * during listview move */ + private const val NO_MOVE_LIST: Boolean = false + + /** If this is enabled then it will try to move the + * listview focus to the left instead of center */ + private const val LEFTMOST_MOVE_LIST: Boolean = true + + private val reflectedScroll by lazy { + try { + RecyclerView::class.java.declaredMethods.firstOrNull { + it.name == "scrollStep" + }?.also { it.isAccessible = true } + } catch (t: Throwable) { + null + } + } + + @MainThread + fun updateFocusView(newFocus: View?, same: Boolean = false) { + val focusOutline = focusOutline.get() ?: return + val lastView = lastFocus.get() + val exactlyTheSame = lastView == newFocus && newFocus != null + if (!exactlyTheSame) { + lastView?.removeOnLayoutChangeListener(layoutListener) + lastView?.removeOnAttachStateChangeListener(attachListener) + (lastView?.parent as? RecyclerView)?.apply { + removeOnLayoutChangeListener(layoutListener) + //removeOnScrollListener(scrollListener) + } + } + + val wasGone = focusOutline.isGone + + val visible = + newFocus != null && newFocus.measuredHeight > 0 && newFocus.measuredWidth > 0 && newFocus.isShown && newFocus.tag != "tv_no_focus_tag" + focusOutline.isVisible = visible + + if (newFocus != null) { + lastFocus = WeakReference(newFocus) + val parent = newFocus.parent + var targetDx = 0 + if (parent is RecyclerView) { + val layoutManager = parent.layoutManager + if (layoutManager is LinearListLayout && layoutManager.orientation == LinearLayoutManager.HORIZONTAL) { + val dx = + LinearSnapHelper().calculateDistanceToFinalSnap(layoutManager, newFocus) + ?.get(0) + + if (dx != null) { + val rdx = if (LEFTMOST_MOVE_LIST) { + // this makes the item the leftmost in ltr, instead of center + val diff = + ((layoutManager.width - layoutManager.paddingStart - newFocus.measuredWidth) / 2) - newFocus.marginStart + dx + if (parent.isRtl()) { + -diff + } else { + diff + } + } else { + if (dx > 0) dx else 0 + } + + if (!NO_MOVE_LIST) { + parent.smoothScrollBy(rdx, 0) + } else { + val smoothScroll = reflectedScroll + if (smoothScroll == null) { + parent.smoothScrollBy(rdx, 0) + } else { + try { + // this is very fucked but because it is a protected method to + // be able to compute the scroll I use reflection, scroll, then + // scroll back, then smooth scroll and set the no move + val out = IntArray(2) + smoothScroll.invoke(parent, rdx, 0, out) + val scrolledX = out[0] + 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) { + parent.smoothScrollBy(rdx, 0) + } + } + } + } + } + } + + val out = IntArray(2) + newFocus.getLocationInWindow(out) + val (screenX, screenY) = out + var (x, y) = screenX.toFloat() to screenY.toFloat() + val (currentX, currentY) = focusOutline.translationX to focusOutline.translationY + + if (!newFocus.isLtr()) { + x = x - focusOutline.rootView.width + newFocus.measuredWidth + } + x -= targetDx + + // out of bounds = 0,0 + if (screenX == 0 && screenY == 0) { + focusOutline.isVisible = false + } + if (!exactlyTheSame) { + (newFocus.parent as? RecyclerView)?.apply { + addOnLayoutChangeListener(layoutListener) + //addOnScrollListener(scrollListener) + } + newFocus.addOnLayoutChangeListener(layoutListener) + newFocus.addOnAttachStateChangeListener(attachListener) + } + val start = FocusTarget( + x = currentX, + y = currentY, + width = focusOutline.measuredWidth, + height = focusOutline.measuredHeight + ) + val end = FocusTarget( + x = x, + y = y, + width = newFocus.measuredWidth, + height = newFocus.measuredHeight + ) + + // if they are the same within then snap, aka scrolling + 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 + setTargetPosition(end) + return + } + + // if running then "reuse" + if (animator?.isRunning == true) { + current = end + return + } else { + animator?.cancel() + } + + + last = start + current = end + + // if previously gone, then tp + if (wasGone) { + setTargetPosition(current) + return + } + + // animate between a and b + animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply { + startDelay = 0 + duration = 200 + addUpdateListener { animation -> + val animatedValue = animation.animatedValue as Float + val target = FocusTarget.lerp(last, current, minOf(animatedValue, 1.0f)) + setTargetPosition(target) + } + start() + } + + // post check + if (!same) { + newFocus.postDelayed({ + updateFocusView(lastFocus.get(), same = true) + }, 200) + } + + /* + + the following is working, but somewhat bad code code + + if (!wasGone) { + (focusOutline.parent as? ViewGroup)?.let { + TransitionManager.endTransitions(it) + TransitionManager.beginDelayedTransition( + it, + TransitionSet().addTransition(ChangeBounds()) + .addTransition(ChangeTransform()) + .setDuration(100) + ) + } + } + + focusOutline.layoutParams = focusOutline.layoutParams?.apply { + width = newFocus.measuredWidth + height = newFocus.measuredHeight + } + focusOutline.translationX = x.toFloat() + focusOutline.translationY = y.toFloat()*/ + } + } + } 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") - var lastError: String? = null - if (errorFile.exists() && errorFile.isFile) { - lastError = errorFile.readText(Charset.defaultCharset()) - errorFile.delete() - } + setLastError(this) val settingsForProvider = SettingsJson() settingsForProvider.enableAdult = @@ -608,29 +1197,160 @@ 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 (e: Exception) { - logError(e) + } catch (t: Throwable) { + logError(t) } window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + updateTv() + + // backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting? + safe { + val appVer = BuildConfig.VERSION_NAME + val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: "" + if (appVer != lastAppAutoBackup) { + setKey("VERSION_NAME", BuildConfig.VERSION_NAME) + if (lastAppAutoBackup.isEmpty()) return@safe + + safe { + backup(this) + } + safe { + // Recompile oat on new version + PluginManager.deleteAllOatFiles(this) + } + } + } - if (isTvSettings()) { - setContentView(R.layout.activity_main_tv) - } else { - setContentView(R.layout.activity_main) + // just in case, MAIN SHOULD *NEVER* BOOT LOOP CRASH + binding = try { + if (isLayout(TV or EMULATOR)) { + val newLocalBinding = ActivityMainTvBinding.inflate(layoutInflater, null, false) + setContentView(newLocalBinding.root) + + 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 + } + + 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 + } else { + val newLocalBinding = ActivityMainBinding.inflate(layoutInflater, null, false) + setContentView(newLocalBinding.root) + newLocalBinding + } + } catch (t: Throwable) { + showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG) + null + } + + binding?.apply { + fixSystemBarsPadding( + navView, + heightResId = R.dimen.nav_view_height, + padTop = false, + overlayCutout = false + ) + + fixSystemBarsPadding( + navRailView, + widthResId = R.dimen.nav_rail_view_width, + padRight = false, + padTop = false + ) } - changeStatusBarState(isEmulatorSettings()) + // 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) - if (lastError == null) { + 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()) { + main { + if (checkGithubConnectivity()) { + this.setKey(getString(R.string.jsdelivr_proxy_key), false) + } else { + this.setKey(getString(R.string.jsdelivr_proxy_key), true) + 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()) { + 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) @@ -642,24 +1362,37 @@ 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 - if (settingsManager.getBoolean( + //Automatically download not existing plugins, using mode specified. + val autoDownloadPlugin = AutoDownloadMode.getEnum( + settingsManager.getInt( getString(R.string.auto_download_plugins_key), - false + 0 + ) + ) ?: AutoDownloadMode.Disable + if (autoDownloadPlugin != AutoDownloadMode.Disable) { + PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( + this@MainActivity, + autoDownloadPlugin ) - ) { - PluginManager.downloadNotExistingPluginsAndLoad(this@MainActivity) } } 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) @@ -675,10 +1408,216 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { setNegativeButton("Ok") { _, _ -> } } - builder.show() + 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() + return@observeNullable + } + when (resource) { + is Resource.Failure -> { + showToast(R.string.error) + viewModel.clear() + hidePreviewPopupDialog() + } + + is Resource.Loading -> { + showPreviewPopupDialog().apply { + resultviewPreviewLoading.isVisible = true + resultviewPreviewResult.isVisible = false + resultviewPreviewLoadingShimmer.startShimmer() + } + } + + is Resource.Success -> { + val d = resource.value + showPreviewPopupDialog().apply { + resultviewPreviewLoading.isVisible = false + resultviewPreviewResult.isVisible = true + resultviewPreviewLoadingShimmer.stopShimmer() + + resultviewPreviewTitle.text = d.title + + resultviewPreviewMetaType.setText(d.typeText) + resultviewPreviewMetaYear.setText(d.yearText) + resultviewPreviewMetaDuration.setText(d.durationText) + resultviewPreviewMetaRating.setText(d.ratingText) + + resultviewPreviewDescription.setTextHtml(d.plotText) + if (isLayout(PHONE)) { + resultviewPreviewPoster.loadImage( + d.posterImage ?: d.posterBackgroundImage, + headers = d.posterHeaders + ) + } else { + resultviewPreviewPoster.loadImage( + d.posterBackgroundImage ?: d.posterImage, + headers = d.posterHeaders + ) + } + + setUserData(syncViewModel.userData.value) + setWatchStatus(viewModel.watchStatus.value) + setSubscribeStatus(viewModel.subscribeStatus.value) + + resultviewPreviewBookmark.setOnClickListener { + //viewModel.updateWatchStatus(WatchType.PLANTOWATCH) + if (isLocalList) { + val value = viewModel.watchStatus.value ?: WatchType.NONE + + 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() + } + } + } + + 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 = + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + builder.setMessage(d.plotText.asString(ctx).html()) + .setTitle(d.plotHeaderText.asString(ctx)) + .show() + } + } + + resultviewPreviewMoreInfo.setOnClickListener { + viewModel.clear() + hidePreviewPopupDialog() + lastPopup?.let { + loadSearchResult(it) + } + } + } + } + } + } + // ioSafe { // val plugins = // RepositoryParser.getRepoPlugins("https://raw.githubusercontent.com/recloudstream/TestPlugin/master/repo.json") @@ -691,15 +1630,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) + } } } } @@ -709,7 +1657,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { ioSafe { initAll() // No duplicates (which can happen by registerMainAPI) - apis = allProviders.distinctBy { it } + apis = synchronized(allProviders) { + allProviders.distinctBy { it } + } } // val navView: BottomNavigationView = findViewById(R.id.nav_view) @@ -722,12 +1672,18 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? -> // Intercept search and add a query + updateNavBar(navDestination) if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) { bundle?.apply { this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery) - nextSearchQuery = null } } + + if (navDestination.matchDestination(R.id.navigation_home)) { + attachBackPressedCallback("MainActivity") { + showConfirmExitDialog(settingsManager) + } + } else detachBackPressedCallback("MainActivity") } //val navController = findNavController(R.id.nav_host_fragment) @@ -740,24 +1696,183 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { .setPopExitAnim(R.anim.nav_pop_exit) .setPopUpTo(navController.graph.startDestination, false) .build()*/ - nav_view?.setupWithNavController(navController) - val nav_rail = findViewById(R.id.nav_rail_view) - nav_rail?.setupWithNavController(navController) - - nav_rail?.setOnItemSelectedListener { item -> - onNavDestinationSelected( - item, - navController - ) + + val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.colorPrimary, 0.1f)) + + binding?.navView?.apply { + itemRippleColor = rippleColor + itemActiveIndicatorColor = rippleColor + setupWithNavController(navController) + setOnItemSelectedListener { item -> + onNavDestinationSelected( + item, + navController + ) + } + } - nav_view?.setOnItemSelectedListener { item -> - onNavDestinationSelected( - item, - navController - ) + + binding?.navRailView?.apply { + if (isLayout(PHONE)) { + itemRippleColor = rippleColor + itemActiveIndicatorColor = rippleColor + } else { + val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f)) + val rippleColorTransparent = + ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f)) + itemSpacing = 12.toPx // expandedItemSpacing does not have an attr + itemRippleColor = rippleColorTransparent + itemActiveIndicatorColor = rippleColor + } + setupWithNavController(navController) + /*if (isLayout(TV or EMULATOR)) { + background?.alpha = 200 + } else { + background?.alpha = 255 + }*/ + + setOnItemSelectedListener { item -> + onNavDestinationSelected( + item, + navController + ) + } + + + fun noFocus(view: View) { + view.tag = view.context.getString(R.string.tv_no_focus_tag) + (view as? ViewGroup)?.let { + for (child in it.children) { + noFocus(child) + } + } + } + //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 + } } - navController.addOnDestinationChangedListener { _, destination, _ -> - updateNavBar(destination) + + 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() @@ -780,17 +1895,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { true }*/ - val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.colorPrimary, 0.1f)) - nav_view?.itemRippleColor = rippleColor - nav_rail?.itemRippleColor = rippleColor - nav_rail?.itemActiveIndicatorColor = rippleColor - nav_view?.itemActiveIndicatorColor = rippleColor if (!checkWrite()) { requestRW() if (checkWrite()) return } - CastButtonFactory.setUpMediaRouteButton(this, media_route_button) + //CastButtonFactory.setUpMediaRouteButton(this, media_route_button) // THIS IS CURRENTLY REMOVED BECAUSE HIGHER VERS OF ANDROID NEEDS A NOTIFICATION //if (!VideoDownloadManager.isMyServiceRunning(this, VideoDownloadKeepAliveService::class.java)) { @@ -831,7 +1941,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() @@ -857,14 +1967,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" - for (api in allProviders) { - providersAndroidManifestString += "\n" + synchronized(allProviders) { + for (api in allProviders) { + providersAndroidManifestString += "\n" + } } - println(providersAndroidManifestString) } @@ -874,13 +1985,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { runAutoUpdate() } + FcastManager().init(this, false) + APIRepository.dubStatusActive = getApiDubstatusSettings() try { // this ensures that no unnecessary space is taken loadCache() File(filesDir, "exoplayer").deleteRecursively() // old cache - File(cacheDir, "exoplayer").deleteOnExit() // current cache + deleteFileOnExit(File(cacheDir, "exoplayer")) // current cache } catch (e: Exception) { logError(e) } @@ -890,6 +2003,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { migrateResumeWatching() } + main { + val channelId = + TvChannelUtils.getChannelId(this@MainActivity, getString(R.string.app_name)) + if (channelId == null) { + Log.d("TvChannel", "Channel not found, creating") + TvChannelUtils.createTvChannel(this@MainActivity) + } else { + Log.d("TvChannel", "Channel ID: $channelId") + } + } + + getKey(USER_SELECTED_HOMEPAGE_API)?.let { homepage -> + DataStoreHelper.currentHomePage = homepage + removeKey(USER_SELECTED_HOMEPAGE_API) + } + try { if (getKey(HAS_DONE_SETUP_KEY, false) != true) { navController.navigate(R.id.navigation_setup_language) @@ -905,17 +2034,45 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } catch (e: Exception) { logError(e) - } finally { - setKey(HAS_DONE_SETUP_KEY, true) } // Used to check current focus for TV // main { // while (true) { -// delay(1000) +// delay(5000) // println("Current focus: $currentFocus") +// showToast(this, currentFocus.toString(), Toast.LENGTH_LONG) // } // } + 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 { + return try { + app.get( + "https://raw.githubusercontent.com/recloudstream/.github/master/connectivitycheck", + timeout = 5 + ).text.trim() == "ok" + } catch (t: Throwable) { + false + } } } 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 4695542752a..00000000000 --- 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 00000000000..a3c4040b5cf --- /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 00000000000..ac912cbeb41 --- /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 00000000000..4843b7617a2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -0,0 +1,201 @@ +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.MpvYTDLPackage +import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage +import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction +import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction +import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action +import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage +import com.lagradost.cloudstream3.actions.temp.VlcPackage +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.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.UiText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.Callable +import java.util.concurrent.FutureTask +import kotlin.reflect.jvm.jvmName + +object VideoClickActionHolder { + val allVideoClickActions = threadSafeListOf( + // Default + PlayInBrowserAction(), + CopyClipboardAction(), + ViewM3U8Action(), + PlayMirrorAction(), + // main support external apps + VlcPackage(), + MpvPackage(), + MpvExPackage(), + NextPlayerPackage(), + JustPlayerPackage(), + FcastAction(), + LibreTorrentPackage(), + BiglyBTPackage(), + // forks/backup apps + VlcNightlyPackage(), + WebVideoCastPackage(), + MpvYTDLPackage(), + MpvKtPackage(), + MpvKtPreviewPackage(), + // 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 00000000000..a7401c2ff70 --- /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 00000000000..3959bb9d324 --- /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 00000000000..d414b611783 --- /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 00000000000..7e89d7c8c44 --- /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 00000000000..20eb843c7ab --- /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 00000000000..11d1858c6e5 --- /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 00000000000..faae3921240 --- /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 00000000000..cd49eb994e0 --- /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/NextPlayerPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt new file mode 100644 index 00000000000..5d0923b8115 --- /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/PlayInBrowserAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt new file mode 100644 index 00000000000..bfd2926bf1c --- /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 00000000000..56512377bae --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt @@ -0,0 +1,65 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.player.ExtractorUri +import com.lagradost.cloudstream3.ui.player.GeneratorPlayer +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.VideoGenerator +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.txt + +class PlayMirrorAction : VideoClickAction() { + override val name = txt(R.string.episode_action_play_mirror) + + override val oneSource = true + + override val isPlayer = true + + override val sourceTypes: Set = LOADTYPE_INAPP + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + //Implemented a generator to handle the single + val activity = context as? Activity ?: return + val link = index?.let { result.links[it] } + val generatorMirror = object : VideoGenerator(listOf(video)) { + override val hasCache: Boolean = false + override val canSkipLoading: Boolean = false + override fun getId(index: Int): Int = video.id + + override suspend fun generateLinks( + clearCache: Boolean, + sourceTypes: Set, + callback: (Pair) -> Unit, + subtitleCallback: (SubtitleData) -> Unit, + offset: Int, + isCasting: Boolean + ): Boolean { + index?.let { callback(link to null) } + result.subs.forEach { subtitle -> subtitleCallback(subtitle) } + return true + } + } + + activity.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + generatorMirror, 0, result.syncData + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt new file mode 100644 index 00000000000..791566862e0 --- /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 00000000000..46b46a2c2fe --- /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 00000000000..963221bb343 --- /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 00000000000..1036a70557c --- /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 00000000000..e2cf4f002f6 --- /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 00000000000..326d111919b --- /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 00000000000..26f5cec5364 --- /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 b0051ba76f7..00000000000 --- 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 18198f44151..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt +++ /dev/null @@ -1,40 +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, - headers = mapOf("range" to "bytes=0-") - ) - ) - } - } - } - } - 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 7a62fb52454..00000000000 --- 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://asianembed.io" - 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 - } - } -} \ No newline at end of file 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 71fa7066bbe..00000000000 --- 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/DoodExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt deleted file mode 100644 index 7ec1fb2211b..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt +++ /dev/null @@ -1,68 +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 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" -} - - -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( - trueUrl, - 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 f813d7ea154..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt +++ /dev/null @@ -1,39 +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 - -open class Fastream: ExtractorApi() { - override var mainUrl = "https://fastream.to" - override var name = "Fastream" - override val requiresReferer = false - - - override suspend fun getUrl(url: String, referer: String?): List? { - val id = Regex("emb\\.html\\?(.*)\\=(enc|)").find(url)?.destructured?.component1() ?: return emptyList() - val sources = mutableListOf() - val response = app.post("$mainUrl/dl", - data = mapOf( - Pair("op","embed"), - Pair("file_code",id), - Pair("auto","1") - )).document - response.select("script").amap { script -> - if (script.data().contains("sources")) { - val m3u8regex = Regex("((https:|http:)\\/\\/.*\\.m3u8)") - val m3u8 = m3u8regex.find(script.data())?.value ?: return@amap - generateM3u8( - name, - m3u8, - mainUrl - ).forEach { link -> - sources.add(link) - } - } - } - 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 8e3dc7309d2..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt +++ /dev/null @@ -1,38 +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 - -open class Filesim : ExtractorApi() { - override val name = "Filesim" - override val mainUrl = "https://files.im" - 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("eval(function(p,a,c,k,e,d)")) { - val data = getAndUnpack(script.data()).substringAfter("sources:[").substringBefore("]") - tryParseJson>("[$data]")?.map { - M3u8Helper.generateM3u8( - name, - it.file, - "$mainUrl/", - ).forEach { m3uData -> sources.add(m3uData) } - } - } - } - } - 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/GMPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/GMPlayer.kt deleted file mode 100644 index 52c4509684c..00000000000 --- 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/GuardareStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt deleted file mode 100644 index f25cb5baf42..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt +++ /dev/null @@ -1,83 +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 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( - it.file + ".${it.type}", - 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 f5dde774d45..00000000000 --- 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://7njctn.neonime.watch" - 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://kotakanimeid.com" - 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? - ) - -} \ No newline at end of file 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 6e6f651671e..00000000000 --- 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 203a266c11c..00000000000 --- 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/Linkbox.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt deleted file mode 100644 index 73734e2a5ce..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt +++ /dev/null @@ -1,46 +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.getQualityFromName - -open class Linkbox : ExtractorApi() { - override val name = "Linkbox" - override val mainUrl = "https://www.linkbox.to" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List { - val id = url.substringAfter("id=") - val sources = mutableListOf() - - app.get("$mainUrl/api/open/get_url?itemId=$id", referer=url).parsedSafe()?.data?.rList?.map { link -> - sources.add( - ExtractorLink( - name, - name, - link.url, - url, - getQualityFromName(link.resolution) - ) - ) - } - - return sources - } - - data class RList( - @JsonProperty("url") val url: String, - @JsonProperty("resolution") val resolution: String?, - ) - - data class Data( - @JsonProperty("rList") val rList: List?, - ) - - data class Responses( - @JsonProperty("data") val data: Data?, - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt deleted file mode 100644 index 93a280ed54d..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt +++ /dev/null @@ -1,34 +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.getAndUnpack - -open class Mp4Upload : ExtractorApi() { - override var name = "Mp4Upload" - override var mainUrl = "https://www.mp4upload.com" - private val srcRegex = Regex("""player\.src\("(.*?)"""") - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List? { - with(app.get(url)) { - getAndUnpack(this.text).let { unpackedText -> - val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull() - srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link -> - return listOf( - ExtractorLink( - name, - name, - link, - url, - quality ?: Qualities.Unknown.value, - ) - ) - } - } - } - return null - } -} \ 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 44657196233..00000000000 --- 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://gogo-play.net" - 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 - } - } -} \ No newline at end of file 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 70e87fbf4ac..00000000000 --- 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/Pelisplus.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt deleted file mode 100644 index 45ec4c2f860..00000000000 --- 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/Solidfiles.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Solidfiles.kt deleted file mode 100644 index cc34781cf8d..00000000000 --- 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 8ef6c46381c..00000000000 --- 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.nl" -} - -open class SpeedoStream : ExtractorApi() { - override val name = "SpeedoStream" - override val mainUrl = "https://speedostream.com" - 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, - ) - - -} \ No newline at end of file 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 ece8dc4bb3b..00000000000 --- 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/Supervideo.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt deleted file mode 100644 index dd49d99457f..00000000000 --- 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 20bd69ba50f..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -class Cinestart: Tomatomatela() { - override var name = "Cinestart" - override var mainUrl = "https://cinestart.net" - override val details = "vr.php?v=" -} - -open class Tomatomatela : ExtractorApi() { - override var name = "Tomatomatela" - override var 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=" - override suspend fun getUrl(url: String, referer: String?): List? { - val link = url.replace("$mainUrl/embed.html#","$mainUrl/$details") - val server = app.get(link, allowRedirects = false).text - val json = parseJson(server) - if (json.status == 200) return listOf( - ExtractorLink( - name, - name, - json.file, - "", - Qualities.Unknown.value, - isM3u8 = false - ) - ) - return null - } -} 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 5109acc36b6..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Uqload.kt +++ /dev/null @@ -1,49 +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" -} - -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? { - val lang = url.substring(0, 2) - val flag = - if (lang == "vo") { - " \uD83C\uDDEC\uD83C\uDDE7" - } - else if (lang == "vf"){ - " \uD83C\uDDE8\uD83C\uDDF5" - } else { - "" - } - - val cleaned_url = if (lang == "ht") { // if url doesn't contain a flag and the url starts with http:// - url - } else { - url.substring(2, url.length) - } - with(app.get(cleaned_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 + flag, - link, - cleaned_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 b910f9dd1e9..00000000000 --- 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/src/$datahash", - referer = "https://source.vidsrc.me/" - ).url - } catch (e: Exception) { - "" - } - links - } else "" - } - - serverslist.amap { server -> - val linkfixed = server.replace("https://vidsrc.xyz/", "https://embedsito.com/") - if (linkfixed.contains("/pro")) { - 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 30a1d8fe62c..00000000000 --- 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 615cfd74f8a..00000000000 --- 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 7eb7fbacf02..00000000000 --- 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 12a76a9b2ca..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt +++ /dev/null @@ -1,32 +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 - -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 link = res.select("script").find { it.data().contains("const sources") }?.data() - ?.substringAfter("\"hls\": \"")?.substringBefore("\",") - - 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/VoeExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VoeExtractor.kt deleted file mode 100644 index ad3f01508b5..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VoeExtractor.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.getQualityFromName - -open class VoeExtractor : ExtractorApi() { - override val name: String = "Voe" - override val mainUrl: String = "https://voe.sx" - override val requiresReferer = false - - private data class ResponseLinks( - @JsonProperty("hls") val hls: String?, - @JsonProperty("mp4") val mp4: String?, - @JsonProperty("video_height") val label: Int? - //val type: String // Mp4 - ) - - override suspend fun getUrl(url: String, referer: String?): List { - val html = app.get(url).text - if (html.isNotBlank()) { - val src = html.substringAfter("const sources =").substringBefore(";") - // Remove last comma, it is not proper json otherwise - .replace("0,", "0") - // Make json use the proper quotes - .replace("'", "\"") - - //Log.i(this.name, "Result => (src) ${src}") - parseJson(src)?.let { voeLink -> - //Log.i(this.name, "Result => (voeLink) ${voeLink}") - - // Always defaults to the hls link, but returns the mp4 if null - val linkUrl = voeLink.hls ?: voeLink.mp4 - val linkLabel = voeLink.label?.toString() ?: "" - if (!linkUrl.isNullOrEmpty()) { - return listOf( - ExtractorLink( - name = this.name, - source = this.name, - url = linkUrl, - quality = getQualityFromName(linkLabel), - referer = url, - isM3u8 = voeLink.hls != null - ) - ) - } - } - } - return emptyList() - } -} \ 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 6cc486cd538..00000000000 --- 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 23704e90ab2..00000000000 --- 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 43c4eefb2c2..00000000000 --- 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/AnilistRedirector.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/AnilistRedirector.kt deleted file mode 100644 index 208db14b81e..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/AnilistRedirector.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.lagradost.cloudstream3.metaproviders - -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi -import com.lagradost.cloudstream3.utils.SyncUtil - -object SyncRedirector { - val syncApis = SyncApis - - suspend fun redirect(url: String, preferredUrl: String): String { - for (api in syncApis) { - if (url.contains(api.mainUrl)) { - val otherApi = when (api.name) { - aniListApi.name -> "anilist" - malApi.name -> "myanimelist" - else -> return url - } - - return SyncUtil.getUrlsFromId(api.getIdFromUrl(url), otherApi).firstOrNull { realUrl -> - realUrl.contains(preferredUrl) - } ?: run { - throw ErrorLoadingException("Page does not exist on $preferredUrl") - } - } - } - return url - } -} \ 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 e8ac18769a7..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt +++ /dev/null @@ -1,70 +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 by lazy { - 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/ArchComponentExt.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt deleted file mode 100644 index e5c03d64823..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt +++ /dev/null @@ -1,210 +0,0 @@ -package com.lagradost.cloudstream3.mvvm - -import android.util.Log -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import com.bumptech.glide.load.HttpException -import com.lagradost.cloudstream3.BuildConfig -import com.lagradost.cloudstream3.ErrorLoadingException -import kotlinx.coroutines.* -import java.io.InterruptedIOException -import java.net.SocketTimeoutException -import java.net.UnknownHostException -import javax.net.ssl.SSLHandshakeException -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -const val DEBUG_EXCEPTION = "THIS IS A DEBUG EXCEPTION!" -const val DEBUG_PRINT = "DEBUG PRINT" - -class DebugException(message: String) : Exception("$DEBUG_EXCEPTION\n$message") - -inline fun debugException(message: () -> String) { - if (BuildConfig.DEBUG) { - throw DebugException(message.invoke()) - } -} - -inline fun debugPrint(tag: String = DEBUG_PRINT, message: () -> String) { - if (BuildConfig.DEBUG) { - Log.d(tag, message.invoke()) - } -} - -inline fun debugWarning(message: () -> String) { - if (BuildConfig.DEBUG) { - logError(DebugException(message.invoke())) - } -} - -inline fun debugAssert(assert: () -> Boolean, message: () -> String) { - if (BuildConfig.DEBUG && assert.invoke()) { - throw DebugException(message.invoke()) - } -} - -inline fun debugWarning(assert: () -> Boolean, message: () -> String) { - if (BuildConfig.DEBUG && assert.invoke()) { - logError(DebugException(message.invoke())) - } -} - -fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) { - liveData.observe(this) { it?.let { t -> action(t) } } -} - -inline fun some(value: T?): Some { - return if (value == null) { - Some.None - } else { - Some.Success(value) - } -} - -sealed class Some { - data class Success(val value: T) : Some() - object None : Some() - - override fun toString(): String { - return when (this) { - is None -> "None" - is Success -> "Some(${value.toString()})" - } - } -} - -sealed class ResourceSome { - data class Success(val value: T) : ResourceSome() - object None : ResourceSome() - data class Loading(val data: Any? = null) : ResourceSome() -} - -sealed class Resource { - data class Success(val value: T) : Resource() - data class Failure( - val isNetworkError: Boolean, - val errorCode: Int?, - val errorResponse: Any?, //ResponseBody - val errorString: String, - ) : Resource() - - data class Loading(val url: String? = null) : Resource() -} - -fun logError(throwable: Throwable) { - Log.d("ApiError", "-------------------------------------------------------------------") - Log.d("ApiError", "safeApiCall: " + throwable.localizedMessage) - Log.d("ApiError", "safeApiCall: " + throwable.message) - throwable.printStackTrace() - Log.d("ApiError", "-------------------------------------------------------------------") -} - -fun normalSafeApiCall(apiCall: () -> T): T? { - return try { - apiCall.invoke() - } catch (throwable: Throwable) { - logError(throwable) - return null - } -} - -suspend fun suspendSafeApiCall(apiCall: suspend () -> T): T? { - return try { - apiCall.invoke() - } catch (throwable: Throwable) { - logError(throwable) - return null - } -} - -fun safeFail(throwable: Throwable): Resource { - val stackTraceMsg = - (throwable.localizedMessage ?: "") + "\n\n" + throwable.stackTrace.joinToString( - separator = "\n" - ) { - "${it.fileName} ${it.lineNumber}" - } - return Resource.Failure(false, null, null, stackTraceMsg) -} - -fun CoroutineScope.launchSafe( - context: CoroutineContext = EmptyCoroutineContext, - start: CoroutineStart = CoroutineStart.DEFAULT, - block: suspend CoroutineScope.() -> Unit -): Job { - val obj: suspend CoroutineScope.() -> Unit = { - try { - block() - } catch (throwable: Throwable) { - logError(throwable) - } - } - - return this.launch(context, start, obj) -} - -suspend fun safeApiCall( - apiCall: suspend () -> T, -): Resource { - return withContext(Dispatchers.IO) { - try { - Resource.Success(apiCall.invoke()) - } catch (throwable: Throwable) { - logError(throwable) - when (throwable) { - is NullPointerException -> { - for (line in throwable.stackTrace) { - if (line?.fileName?.endsWith("provider.kt", ignoreCase = true) == true) { - return@withContext Resource.Failure( - false, - null, - null, - "NullPointerException at ${line.fileName} ${line.lineNumber}\nSite might have updated or added Cloudflare/DDOS protection" - ) - } - } - safeFail(throwable) - } - is SocketTimeoutException, is InterruptedIOException -> { - Resource.Failure( - true, - null, - null, - "Connection Timeout\nPlease try again later." - ) - } - is HttpException -> { - Resource.Failure( - false, - throwable.statusCode, - null, - throwable.message ?: "HttpException" - ) - } - is UnknownHostException -> { - Resource.Failure(true, null, null, "Cannot connect to server, try again later.") - } - is ErrorLoadingException -> { - Resource.Failure( - true, - null, - null, - throwable.message ?: "Error loading, try again later." - ) - } - is NotImplementedError -> { - Resource.Failure(false, null, null, "This operation is not implemented.") - } - is SSLHandshakeException -> { - Resource.Failure( - true, - null, - null, - (throwable.message ?: "SSLHandshakeException") + "\nTry a VPN or DNS." - ) - } - else -> safeFail(throwable) - } - } - } -} \ 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 00000000000..482ec05fc1b --- /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 7dc8dba7104..9efa88a37f6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -5,10 +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.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 @@ -16,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("=") @@ -26,7 +32,10 @@ class CloudflareKiller : Interceptor { init { // Needs to clear cookies between sessions to generate new cookies. - CookieManager.getInstance().removeAllCookies(null) + safe { + // This can throw an exception on unsupported devices :( + CookieManager.getInstance().removeAllCookies(null) + } } val savedCookies: MutableMap> = mutableMapOf() @@ -35,7 +44,7 @@ class CloudflareKiller : Interceptor { * Gets the headers with cookies, webview user agent included! * */ fun getCookieHeaders(url: String): Headers { - val userAgentHeaders = WebViewResolver.webViewUserAgent?.let { + val userAgentHeaders = WebViewResolver.webViewUserAgent?.let { mapOf("user-agent" to it) } ?: emptyMap() @@ -44,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}" } @@ -60,7 +77,9 @@ class CloudflareKiller : Interceptor { } private fun getWebViewCookie(url: String): String? { - return CookieManager.getInstance()?.getCookie(url) + 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 55e092513aa..4127799e8f4 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 a1d84f6cdac..6234297d080 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -2,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 242baf59ee9..efa028d1427 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -2,66 +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 - */ - @Throws(Throwable::class) - open fun beforeUnload() { - } - - /** - * Used to register providers instances of MainAPI - * @param element MainAPI provider you want to register + * Used to register VideoClickAction instances + * @param element VideoClickAction 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 - 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 + synchronized(VideoClickActionHolder.allVideoClickActions) { + 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 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 c0bc4f1ff6d..eae14a6c0c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -1,49 +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.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" @@ -61,6 +80,7 @@ data class PluginData( @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { + @WorkerThread fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, @@ -75,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 ) } } @@ -129,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() } @@ -144,30 +180,35 @@ object PluginManager { return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray() } - private val LOCAL_PLUGINS_PATH = - Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/plugins" + private val CLOUD_STREAM_FOLDER = + Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/" + + 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() - private var loadedLocalPlugins = false - private val gson = Gson() + var loadedLocalPlugins = false + private set - private suspend fun maybeLoadPlugin(activity: Activity, file: File) { + var loadedOnlinePlugins = false + private set + + private suspend fun maybeLoadPlugin(context: Context, file: File) { val name = file.name if (file.extension == "zip" || file.extension == "cs3") { loadPlugin( - activity, + context, file, PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET) ) @@ -197,7 +238,7 @@ object PluginManager { // var allCurrentOutDatedPlugins: Set = emptySet() - suspend fun loadSinglePlugin(activity: Activity, apiName: String): Boolean { + suspend fun loadSinglePlugin(context: Context, apiName: String): Boolean { return (getPluginsOnline().firstOrNull { // Most of the time the provider ends with Provider which isn't part of the api name it.internalName.replace("provider", "", ignoreCase = true) == apiName @@ -207,7 +248,7 @@ object PluginManager { })?.let { savedData -> // OnlinePluginData(savedData, onlineData) loadPlugin( - activity, + context, File(savedData.filePath), savedData ) @@ -220,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 } @@ -250,16 +299,18 @@ 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) } else if (pluginData.isOutdated) { - downloadAndLoadPlugin( + downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, - File(pluginData.savedData.filePath) + File(pluginData.savedData.filePath), + true ).let { success -> if (success) updatedPlugins.add(pluginData.onlineData.second.name) @@ -270,9 +321,13 @@ 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 { + loadedOnlinePlugins = true afterPluginsLoadedEvent.invoke(false) // } @@ -284,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) { + * + * 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 } @@ -299,6 +365,8 @@ object PluginManager { // Iterate online repos and returns not downloaded plugins val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData -> val sitePlugin = onlineData.second + val tvtypes = sitePlugin.tvTypes ?: listOf() + //Don't include empty urls if (sitePlugin.url.isBlank()) { return@mapNotNull null @@ -313,22 +381,29 @@ object PluginManager { return@mapNotNull null } - //Omit lang not selected on language setting - val lang = sitePlugin.language ?: return@mapNotNull null - //If set to 'universal', don't skip any language - if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) { - return@mapNotNull null + //Omit non-NSFW if mode is set to NSFW only + if (mode == AutoDownloadMode.NsfwOnly) { + if (!tvtypes.contains(TvType.NSFW.name)) { + return@mapNotNull null + } } - //Log.i(TAG, "sitePlugin lang => $lang") - //Omit NSFW, if disabled - sitePlugin.tvTypes?.let { tvtypes -> - if (!settingsForProvider.enableAdult) { - if (tvtypes.contains(TvType.NSFW.name)) { - return@mapNotNull null - } + if (!settingsForProvider.enableAdult) { + if (tvtypes.contains(TvType.NSFW.name)) { + return@mapNotNull null } } + + //Omit lang not selected on language setting + if (mode == AutoDownloadMode.FilterByLang) { + val lang = sitePlugin.language ?: return@mapNotNull null + //If set to 'universal', don't skip any language + if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) { + return@mapNotNull null + } + //Log.i(TAG, "sitePlugin lang => $lang") + } + val savedData = PluginData( url = sitePlugin.url, internalName = sitePlugin.internalName, @@ -340,12 +415,14 @@ object PluginManager { } //Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}") - notDownloadedPlugins.apmap { pluginData -> - downloadAndLoadPlugin( + notDownloadedPlugins.amap { pluginData -> + downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, - pluginData.onlineData.first + pluginData.onlineData.first, + !pluginData.isDisabled ).let { success -> if (success) newDownloadPlugins.add(pluginData.onlineData.second.name) @@ -364,14 +441,29 @@ 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(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_loadAllOnlinePlugins(context: Context) { + assertNonRecursiveCallstack() + // Load all plugins as fast as possible! - (getPluginsOnline()).toList().apmap { pluginData -> + (getPluginsOnline()).toList().amap { pluginData -> loadPlugin( - activity, + context, File(pluginData.filePath), pluginData ) @@ -380,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(activity: Activity, 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() @@ -409,38 +516,101 @@ 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}") + + // 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()) + } - sortedPlugins?.sortedBy { it.name }?.apmap { file -> - maybeLoadPlugin(activity, file) + // 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 safe { + val folder = File(CLOUD_STREAM_FOLDER) + if (!folder.exists()) return@safe false + val files = folder.listFiles { _, name -> + name.equals("safe", ignoreCase = true) + } + files?.any() + } ?: false + } + /** * @return True if successful, false if not * */ - private suspend fun loadPlugin(activity: Activity, file: File, data: PluginData): Boolean { + private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean { val fileName = file.nameWithoutExtension val filePath = file.absolutePath currentlyLoading = fileName Log.i(TAG, "Loading plugin: $data") return try { - val loader = PathClassLoader(filePath, activity.classLoader) - var manifest: Plugin.Manifest + // 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: 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, BasePlugin.Manifest::class.java) } } @@ -450,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)) @@ -463,32 +635,38 @@ 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, - activity.resources.displayMetrics, - activity.resources.configuration + context.resources.displayMetrics, + context.resources.configuration ) } plugins[filePath] = pluginInstance classLoaders[loader] = pluginInstance urlPlugins[data.url ?: filePath] = pluginInstance - pluginInstance.load(activity) + 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( - activity, - activity.getString(R.string.plugin_load_fail).format(fileName), + // context.getActivity(), // we are not always on the main thread + context.getString(R.string.plugin_load_fail).format(fileName), Toast.LENGTH_LONG ) currentlyLoading = null @@ -496,7 +674,7 @@ object PluginManager { } } - private fun unloadPlugin(absolutePath: String) { + fun unloadPlugin(absolutePath: String) { Log.i(TAG, "Unloading plugin: $absolutePath") val plugin = plugins[absolutePath] if (plugin == null) { @@ -511,16 +689,34 @@ object PluginManager { } // remove all registered apis - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach { - removePluginMapping(it) + synchronized(APIHolder.apis) { + APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { + removePluginMapping(it) + } + } + synchronized(APIHolder.allProviders) { + APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } + } + + synchronized(extractorApis) { + extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } + } + + synchronized(VideoClickActionHolder.allVideoClickActions) { + VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename } + } + + synchronized(classLoaders) { + classLoaders.values.removeIf { v -> v == plugin } } - APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename } - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename } - classLoaders.values.removeIf { v -> v == plugin } + synchronized(plugins) { + plugins.remove(absolutePath) + } - plugins.remove(absolutePath) - urlPlugins.values.removeIf { v -> v == plugin } + synchronized(urlPlugins) { + urlPlugins.values.removeIf { v -> v == plugin } + } } /** @@ -547,49 +743,50 @@ object PluginManager { return File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}/${folderName}/$fileName.cs3") } - /** - * Used for fresh installs - * */ - suspend fun downloadAndLoadPlugin( + suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, - repositoryUrl: String + repositoryUrl: String, + loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) - downloadAndLoadPlugin(activity, pluginUrl, internalName, file) - return true + return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) } - /** - * Used for updates. - * - * Uses a file instead of repository url, as extensions can get moved it is better to directly - * update the files instead of getting the filepath from repo url. - * */ - private suspend fun downloadAndLoadPlugin( + suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, file: File, + loadPlugin: Boolean, ): Boolean { try { - unloadPlugin(file.absolutePath) - 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 loadPlugin( - activity, - newFile ?: return false, - PluginData( - internalName, - pluginUrl, - true, - newFile.absolutePath, - PLUGIN_VERSION_NOT_SET - ) + val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false + + val data = PluginData( + internalName, + pluginUrl, + true, + newFile.absolutePath, + PLUGIN_VERSION_NOT_SET ) + + return if (loadPlugin) { + unloadPlugin(file.absolutePath) + loadPlugin( + activity, + newFile, + data + ) + } else { + setPluginData(data) + true + } } catch (e: Exception) { logError(e) return false @@ -612,6 +809,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 @@ -663,9 +938,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) { @@ -673,4 +953,4 @@ object PluginManager { return null } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index 0f23782db52..07d6aaa37bc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,29 +1,37 @@ package com.lagradost.cloudstream3.plugins import android.content.Context +import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.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.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 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, @@ -57,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?, ) @@ -69,6 +79,34 @@ object RepositoryManager { val PREBUILT_REPOSITORIES: Array by lazy { getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } + private val GH_REGEX = + Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + + + /** Returns a SHA-256 string of the file content. + * Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/ + @WorkerThread + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + + file.inputStream().use { fis -> + val buffer = ByteArray(8192) + var read = fis.read(buffer) + while (read != -1) { + digest.update(buffer, 0, read) + read = fis.read(buffer) + } + } + return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) } + } + + /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ + fun convertRawGitUrl(url: String): String { + if (getKey(context!!.getString(R.string.jsdelivr_proxy_key)) != true) return url + val match = GH_REGEX.find(url) ?: return url + val (user, repo, rest) = match.destructured + return "https://cdn.jsdelivr.net/gh/$user/$repo@$rest" + } suspend fun parseRepoUrl(url: String): String? { val fixedUrl = url.trim() @@ -77,15 +115,16 @@ object RepositoryManager { } else if (fixedUrl.contains("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex())) { fixedUrl.replace("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex(), "").let { return@let if (!it.contains("^https?://".toRegex())) - "https://${it}" + "https://${it}" else fixedUrl } } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) { - suspendSafeApiCall { - app.get("https://l.cloudstream.cf/${fixedUrl}").let { - return@let if (it.isSuccessful && !it.url.startsWith("https://cutt.ly/branded-domains")) it.url - else app.get("https://cutt.ly/${fixedUrl}").let let2@{ it2 -> - return@let2 if (it2.isSuccessful) it2.url else null + safeAsync { + app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 -> + it2.headers["Location"]?.let { url -> + if (url.startsWith("https://cutt.ly/404")) return@safeAsync null + if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null + return@safeAsync url } } } @@ -93,16 +132,16 @@ object RepositoryManager { } suspend fun parseRepository(url: String): Repository? { - return suspendSafeApiCall { + return safeAsync { // Take manifestVersion and such into account later - app.get(url).parsedSafe() + app.get(convertRawGitUrl(url)).parsedSafe() } } private suspend fun parsePlugins(pluginUrls: String): List { // Take manifestVersion and such into account later return try { - val response = app.get(pluginUrls) + val response = app.get(convertRawGitUrl(pluginUrls)) // Normal parsed function not working? // return response.parsedSafe() tryParseJson>(response.text)?.toList() ?: emptyList() @@ -124,48 +163,53 @@ object RepositoryManager { }.flatten() } + suspend fun downloadPluginToFile( + context: Context, pluginUrl: String, - file: File + file: File, + expectedFileHash: String? ): File? { - return suspendSafeApiCall { - file.mkdirs() + return safeAsync { + val parentDir = file.parentFile ?: return@safeAsync null + parentDir.mkdirs() - // Overwrite if exists - if (file.exists()) { file.delete() } - file.createNewFile() + // Prevent corrupting the plugin file if the operation fails + val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) - val body = app.get(pluginUrl).okhttpResponse.body - write(body.byteStream(), file.outputStream()) - file - } - } + val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body - suspend fun downloadPluginToFile( - context: Context, - pluginUrl: String, - /** Filename without .cs3 */ - fileName: String, - folder: String - ): File? { - return suspendSafeApiCall { - val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER) - if (!extensionsDir.exists()) - extensionsDir.mkdirs() - - val newDir = File(extensionsDir, folder) - newDir.mkdirs() - - val newFile = File(newDir, "${fileName}.cs3") - // Overwrite if exists - if (newFile.exists()) { - newFile.delete() + 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 + ) } - newFile.createNewFile() - val body = app.get(pluginUrl).okhttpResponse.body - write(body.byteStream(), newFile.outputStream()) - newFile + file } } @@ -200,17 +244,16 @@ object RepositoryManager { extensionsDir, getPluginSanitizedFileName(repository.url) ) - PluginManager.deleteRepositoryData(file.absolutePath) - - file.delete() - } - 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) + // Unload all plugins, not using deletePlugin since we + // delete all data and files in deleteRepositoryData + safe { + file.listFiles { plugin: File -> + unloadPlugin(plugin.absolutePath) + false + } } + + PluginManager.deleteRepositoryData(file.absolutePath) } } 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 f099ad1a714..85a806f0b12 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -2,125 +2,97 @@ 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 -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main -import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -object VotingApi { // please do not cheat the votes lol - private const val LOGKEY = "VotingApi" - - enum class VoteType(val value: Int) { - UPVOTE(1), - DOWNVOTE(-1), - NONE(0) - } +object VotingApi { - private val apiDomain = "https://api.countapi.xyz" + private const val LOGKEY = "VotingApi" + private const val API_DOMAIN = "https://api.countify.xyz" - private fun transformUrl(url: String): String = // dont touch or all votes get reset + private fun transformUrl(url: String): String = MessageDigest .getInstance("SHA-256") .digest("${url}#funny-salt".toByteArray()) .fold("") { str, it -> str + "%02x".format(it) } - suspend fun SitePlugin.getVotes(): Int { - return getVotes(url) - } + suspend fun SitePlugin.getVotes(): Int = getVotes(url) + fun SitePlugin.hasVoted(): Boolean = hasVoted(url) + suspend fun SitePlugin.vote(): Int = vote(url) + fun SitePlugin.canVote(): Boolean = canVote(this.url) - suspend fun SitePlugin.vote(requestType: VoteType): Int { - return vote(url, requestType) - } + private val votesCache = mutableMapOf() - fun SitePlugin.getVoteType(): VoteType { - return getVoteType(url) + private suspend fun readVote(pluginUrl: String): Int { + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/get-total/$id" + Log.d(LOGKEY, "Requesting GET: $url") + return app.get(url).parsedSafe()?.count ?: 0 } - fun SitePlugin.canVote(): Boolean { - return canVote(this.url) + private suspend fun writeVote(pluginUrl: String): Boolean { + val id = transformUrl(pluginUrl) + val url = "$API_DOMAIN/increment/$id" + Log.d(LOGKEY, "Requesting POST: $url") + return app.post(url, emptyMap()) + .parsedSafe()?.count != null } - // Plugin url to Int - private val votesCache = mutableMapOf() - - suspend fun getVotes(pluginUrl: String): Int { - val url = "${apiDomain}/get/cs3-votes/${transformUrl(pluginUrl)}" - Log.d(LOGKEY, "Requesting: $url") - return votesCache[pluginUrl] ?: app.get(url).parsedSafe()?.value?.also { + suspend fun getVotes(pluginUrl: String): Int = + votesCache[pluginUrl] ?: readVote(pluginUrl).also { votesCache[pluginUrl] = it - } ?: (0.also { - ioSafe { - createBucket(pluginUrl) - } - }) - } - - fun getVoteType(pluginUrl: String): VoteType { - return getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE - } + } - private suspend fun createBucket(pluginUrl: String) { - val url = - "${apiDomain}/create?namespace=cs3-votes&key=${transformUrl(pluginUrl)}&value=0&update_lowerbound=-2&update_upperbound=2&enable_reset=0" - Log.d(LOGKEY, "Requesting: $url") - app.get(url) - } + 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, requestType: VoteType): Int { - // Prevent multiple requests at the same time. + + suspend fun vote(pluginUrl: String): Int { 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) } - val savedType: VoteType = - getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE - - val newType = if (requestType == savedType) VoteType.NONE else requestType - val changeValue = if (requestType == savedType) { - -requestType.value - } else if (savedType == VoteType.NONE) { - requestType.value - } else if (savedType != requestType) { - -savedType.value + requestType.value - } else 0 - - // Pre-emptively set vote key - setKey("cs3-votes/${transformUrl(pluginUrl)}", newType) - - val url = - "${apiDomain}/update/cs3-votes/${transformUrl(pluginUrl)}?amount=${changeValue}" - Log.d(LOGKEY, "Requesting: $url") - val res = app.get(url).parsedSafe()?.value - - if (res == null) { - // "Refund" key if the response is invalid - setKey("cs3-votes/${transformUrl(pluginUrl)}", savedType) - } else { - votesCache[pluginUrl] = res + if (hasVoted(pluginUrl)) { + main { + Toast.makeText( + context, + R.string.already_voted, + Toast.LENGTH_SHORT + ).show() + } + return getVotes(pluginUrl) } - return res ?: 0 + + if (writeVote(pluginUrl)) { + setKey("cs3-votes/${transformUrl(pluginUrl)}", true) + votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 + } + + return getVotes(pluginUrl) } } - 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 00000000000..f130831c6ed --- /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 00000000000..e07747a860c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt @@ -0,0 +1,279 @@ +package com.lagradost.cloudstream3.services + +import android.Manifest +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.MainActivity.Companion.setLastError +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugWarning +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.system.measureTimeMillis +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class DownloadQueueService : Service() { + companion object { + const val TAG = "DownloadQueueService" + const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue" + const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service" + const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification." + const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique + @Volatile + var isRunning = false + + fun getIntent( + context: Context, + ): Intent { + return Intent(context, DownloadQueueService::class.java) + } + + private val _downloadInstances: MutableStateFlow> = + MutableStateFlow(emptyList()) + + /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances. + * Completed or failed instances are automatically removed by the download queue service. + * + */ + val downloadInstances: StateFlow> = + _downloadInstances + + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + instances to queue + } + .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads -> + Triple(instances, queue, currentDownloads) + } + } + + + private val baseNotification by lazy { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = + PendingIntentCompat.getActivity(this, 0, intent, 0, false) + + val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0) + val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0) + + NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID) + .setOngoing(true) // Make it persistent + .setAutoCancel(false) + .setColorized(false) + .setOnlyAlertOnce(true) + .setSilent(true) + .setShowWhen(false) + // If low priority then the notification might not show :( + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(this.colorFromAttribute(R.attr.colorPrimary)) + .setContentText(activeDownloads) + .setSubText(activeQueue) + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.download_icon_load) + } + + + private fun updateNotification(context: Context, downloads: Int, queued: Int) { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + + val activeDownloads = + resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) + val activeQueue = + resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued) + + val newNotification = baseNotification + .setContentText(activeDownloads) + .setSubText(activeQueue) + .build() + + safe { + NotificationManagerCompat.from(context) + .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) + } + } + + // We always need to listen to events, even before the download is launched. + // Stopping link loading is an event which can trigger before downloading. + val downloadEventListener = { event: Pair -> + when (event.second) { + VideoDownloadManager.DownloadActionType.Stop -> { + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + removeKey(KEY_RESUME_IN_QUEUE, event.first.toString()) + DownloadQueueManager.cancelDownload(event.first) + } + + else -> {} + } + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + override fun onCreate() { + isRunning = true + val context: Context = this // To make code more readable + + Log.d(TAG, "Download queue service started.") + this.createNotificationChannel( + DOWNLOAD_QUEUE_CHANNEL_ID, + DOWNLOAD_QUEUE_CHANNEL_NAME, + DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION + ) + if (SDK_INT >= 29) { + startForeground( + DOWNLOAD_QUEUE_NOTIFICATION_ID, + baseNotification.build(), + FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build()) + } + + downloadEvent += downloadEventListener + + val queueJob = ioSafe { + // Ensure this is up to date to prevent race conditions with MainActivity launches + setLastError(context) + // Early return, to prevent waiting for plugins in safe mode + if (lastError != null) return@ioSafe + + // Try to ensure all plugins are loaded before starting the downloader. + // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough + val timeout = 15.seconds + val timeTaken = withTimeoutOrNull(timeout) { + measureTimeMillis { + while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) { + delay(100.milliseconds) + } + } + } + + debugWarning({ timeTaken == null || timeTaken > 3_000 }, { + "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms" + }) + debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) + + totalDownloadFlow + .debounce { (instances, queue) -> + // Filter away incorrect transient queue states. + // For example when we pop the queue and add a download instance there exists a transient state where + // there is no queue and no download instances (leading to an early exit) + if (instances.isEmpty() && queue.isEmpty()) { + 500.milliseconds + } else { + 0.milliseconds + } + } + .takeWhile { (instances, queue) -> + // Stop if destroyed + isRunning + // Run as long as there is a queue to process + && (instances.isNotEmpty() || queue.isNotEmpty()) + // Run as long as there are no app crashes + && lastError == null + } + .collect { (_, queue, currentDownloads) -> + // Remove completed or failed + val newInstances = _downloadInstances.updateAndGet { currentInstances -> + currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled } + } + + val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) + val currentInstanceCount = newInstances.size + + val newDownloads = minOf( + // Cannot exceed the max downloads + maxOf(0, maxDownloads - currentInstanceCount), + // Cannot start more downloads than the queue size + queue.size + ) + + // Cant start multiple downloads at once. If this is rerun it may start too many downloads. + if (newDownloads > 0) { + _downloadInstances.update { instances -> + val downloadInstance = DownloadQueueManager.popQueue(context) + if (downloadInstance != null) { + downloadInstance.startDownload() + instances + downloadInstance + } else { + instances + } + } + } + + // The downloads actually displayed to the user with a notification + val currentVisualDownloads = + currentDownloads.size + newInstances.count { + currentDownloads.contains(it.downloadQueueWrapper.id) + .not() + } + // Just the queue + val currentVisualQueue = queue.size + + updateNotification(context, currentVisualDownloads, currentVisualQueue) + } + } + + // Stop self regardless of job outcome + queueJob.invokeOnCompletion { throwable -> + if (throwable != null) { + logError(throwable) + } + safe { + stopSelf() + } + } + } + + override fun onDestroy() { + Log.d(TAG, "Download queue service stopped.") + downloadEvent -= downloadEventListener + isRunning = false + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY // We want the service restarted if its killed + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onTimeout(reason: Int) { + stopSelf() + Log.e(TAG, "Service stopped due to timeout: $reason") + } + +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt similarity index 76% 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 fc50bed5dd0..fa7754718b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt @@ -1,19 +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.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 @@ -21,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) @@ -47,32 +45,22 @@ class PackageInstallerService : Service() { .setSmallIcon(R.drawable.rdload) } - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = - NotificationChannel(UPDATE_CHANNEL_ID, UPDATE_CHANNEL_NAME, importance).apply { - description = UPDATE_CHANNEL_DESCRIPTION - } - - // Register the channel with the system - val notificationManager: NotificationManager = - this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(channel) - } - } - override fun onCreate() { - createNotificationChannel() - startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) + this.createNotificationChannel( + UPDATE_CHANNEL_ID, + UPDATE_CHANNEL_NAME, + UPDATE_CHANNEL_DESCRIPTION + ) + if (SDK_INT >= 29) + startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC) + else startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) } 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 { @@ -82,7 +70,7 @@ class PackageInstallerService : Service() { this@PackageInstallerService.cacheDir.listFiles()?.filter { it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix }?.forEach { - it.deleteOnExit() + deleteFileOnExit(it) } } @@ -94,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 @@ -114,6 +102,7 @@ class PackageInstallerService : Service() { } return true } catch (e: Exception) { + logError(e) updateNotificationProgress(0f, ApkInstaller.InstallProgressStatus.Failed) return false } @@ -146,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 = @@ -168,26 +157,26 @@ 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" const val UPDATE_CHANNEL_DESCRIPTION = "App updates notification channel" - const val UPDATE_NOTIFICATION_ID = -68454136 + const val UPDATE_NOTIFICATION_ID = -68454136 // Random unique fun getIntent( context: Context, @@ -197,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 new file mode 100644 index 00000000000..7134650ed4e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt @@ -0,0 +1,226 @@ +package com.lagradost.cloudstream3.services + +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import 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.getApiFromNameNull +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.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.downloader.DownloadUtils.getImageBitmapFromUrl +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit + +const val SUBSCRIPTION_CHANNEL_ID = "cloudstream3.subscriptions" +const val SUBSCRIPTION_WORK_NAME = "work_subscription" +const val SUBSCRIPTION_CHANNEL_NAME = "Subscriptions" +const val SUBSCRIPTION_CHANNEL_DESCRIPTION = "Notifications for new episodes on subscribed shows" +const val SUBSCRIPTION_NOTIFICATION_ID = 938712897 // Random unique + +class SubscriptionWorkManager(val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + companion object { + fun enqueuePeriodicWork(context: Context?) { + if (context == null) return + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val periodicSyncDataWork = + PeriodicWorkRequest.Builder(SubscriptionWorkManager::class.java, 6, TimeUnit.HOURS) + .addTag(SUBSCRIPTION_WORK_NAME) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + SUBSCRIPTION_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + periodicSyncDataWork + ) + + // Uncomment below for testing + +// val oneTimeSyncDataWork = +// OneTimeWorkRequest.Builder(SubscriptionWorkManager::class.java) +// .addTag(SUBSCRIPTION_WORK_NAME) +// .setConstraints(constraints) +// .build() +// +// WorkManager.getInstance(context).enqueue(oneTimeSyncDataWork) + } + } + + private val progressNotificationBuilder = + NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID) + .setAutoCancel(false) + .setColorized(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setContentTitle(context.getString(R.string.subscription_in_progress_notification)) + .setSmallIcon(com.google.android.gms.cast.framework.R.drawable.quantum_ic_refresh_white_24) + .setProgress(0, 0, true) + + private val updateNotificationBuilder = + NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID) + .setColorized(true) + .setOnlyAlertOnce(true) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big) + + private val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private fun updateProgress(max: Int, progress: Int, indeterminate: Boolean) { + notificationManager.notify( + SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder + .setProgress(max, progress, indeterminate) + .build() + ) + } + + override suspend fun doWork(): Result { + try { +// println("Update subscriptions!") + context.createNotificationChannel( + SUBSCRIPTION_CHANNEL_ID, + SUBSCRIPTION_CHANNEL_NAME, + SUBSCRIPTION_CHANNEL_DESCRIPTION + ) + + 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) + + 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() + } + } +} \ 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 be2fe75b05d..d63b18cdc97 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt @@ -1,11 +1,23 @@ package com.lagradost.cloudstream3.services - -import android.app.IntentService +import android.app.Service import android.content.Intent -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import android.os.IBinder +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) + + override fun onBind(intent: Intent?): IBinder? { + return null + } -class VideoDownloadService : IntentService("VideoDownloadService") { - override fun onHandleIntent(intent: Intent?) { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent != null) { val id = intent.getIntExtra("id", -1) val type = intent.getStringExtra("type") @@ -14,10 +26,20 @@ class VideoDownloadService : IntentService("VideoDownloadService") { "resume" -> VideoDownloadManager.DownloadActionType.Resume "pause" -> VideoDownloadManager.DownloadActionType.Pause "stop" -> VideoDownloadManager.DownloadActionType.Stop - else -> return + else -> return START_NOT_STICKY + } + + downloadScope.launch { + VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) } - VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) } } + + return START_NOT_STICKY + } + + override fun onDestroy() { + downloadScope.coroutineContext.cancel() + super.onDestroy() } -} \ No newline at end of file +} 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 77a1b0b588a..9e6f241fb95 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt @@ -1,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 - -interface AbstractSubProvider { - @WorkerThread - suspend fun search(query: SubtitleSearch): List? { - throw NotImplementedError() +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 + +/** + * 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 + } + + 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) } - @WorkerThread - suspend fun load(data: SubtitleEntity): String? { - throw NotImplementedError() + 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 f6424c4c7d2..685b499bb59 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 f09bf8fea01..3bc5f273397 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -1,134 +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 indexSubtitlesApi = IndexSubtitleApi() - val addic7ed = Addic7ed() - - // used to login via app intent - val OAuth2Apis - get() = listOf( - malApi, aniListApi - ) - - // this needs init with context and can be accessed in settings - val accountManagers - get() = listOf( - malApi, aniListApi, openSubtitlesApi, //nginxApi - ) - - // used for active syncing - val SyncApis - get() = listOf( - SyncRepo(malApi), SyncRepo(aniListApi) - ) - - 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" - - // 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 8b085bc0b83..83a7a09847c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -1,23 +1,282 @@ package com.lagradost.cloudstream3.syncproviders -interface AuthAPI { - val name: String - val icon: Int? +import android.util.Base64 +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID +import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed +import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.syncproviders.providers.LocalList +import com.lagradost.cloudstream3.syncproviders.providers.MALApi +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi +import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi +import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi +import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.txt +import me.xdrop.fuzzywuzzy.FuzzySearch +import java.net.URL +import java.security.SecureRandom +import java.util.Date +import java.util.concurrent.TimeUnit - val requiresLogin: Boolean +data class AuthLoginPage( + /** The website to open to authenticate */ + val url: String, + /** + * State/control code to verify against the redirectUrl to make sure the request is valid. + * This parameter will be saved, and then used in AuthAPI::login. + * */ + val payload: String? = null, +) - val createAccountUrl : String? +data class AuthToken( + /** + * This is the general access tokens/api token representing a logged in user. + * + * `Access tokens are the thing that applications use to make API requests on behalf of a user.` + * */ + @JsonProperty("accessToken") + val accessToken: String? = null, + /** + * For OAuth a special refresh token is issues to refresh the access token. + * */ + @JsonProperty("refreshToken") + val refreshToken: String? = null, + /** In UnixTime (sec) when it expires */ + @JsonProperty("accessTokenLifetime") + val accessTokenLifetime: Long? = null, + /** In UnixTime (sec) when it expires */ + @JsonProperty("refreshTokenLifetime") + val refreshTokenLifetime: Long? = null, + /** Sometimes AuthToken needs to be customized to store e.g. username/password, + * this acts as a catch all to store text or JSON data. */ + @JsonProperty("payload") + val payload: String? = null, +) { + fun isAccessTokenExpired(marginSec: Long = 10L) = + accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime - // don't change this as all keys depend on it - val idPrefix: String + fun isRefreshTokenExpired(marginSec: Long = 10L) = + refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime +} - // if this returns null then you are not logged in - fun loginInfo(): LoginInfo? - fun logOut() +data class AuthUser( + /** Account display-name, can also be email if name does not exist */ + @JsonProperty("name") + val name: String?, + /** Unique account identifier, + * if a subsequent login is done then it will be refused if another account with the same id exists*/ + @JsonProperty("id") + val id: Int, + /** Profile picture URL */ + @JsonProperty("profilePicture") + val profilePicture: String? = null, + /** Profile picture Headers of the URL */ + @JsonProperty("profilePictureHeader") + val profilePictureHeaders: Map? = null +) +/** + * Stores all information that should be used to authorize access. + * Be aware that token and user may change independently when a refresh is needed, + * and as such there should be no strong pairing between the two. + * + * Any local set/get key should use user.id.toString(), + * as token.accessToken (even hashed) is unsecure, and will rotate. + * */ +data class AuthData( + @JsonProperty("user") + val user: AuthUser, + @JsonProperty("token") + val token: AuthToken, +) + +data class AuthPinData( + val deviceCode: String, + val userCode: String, + /** QR Code url */ + val verificationUrl: String, + /** In seconds */ + val expiresIn: Int, + /** Check if the code has been verified interval */ + val interval: Int, +) + +/** The login field requirements to display to the user */ +data class AuthLoginRequirement( + val password: Boolean = false, + val username: Boolean = false, + val email: Boolean = false, + val server: Boolean = false, +) + +/** What the user responds to the AuthLoginRequirement */ +data class AuthLoginResponse( + @JsonProperty("password") + val password: String?, + @JsonProperty("username") + val username: String?, + @JsonProperty("email") + val email: String?, + @JsonProperty("server") + val server: String?, +) + +/** Stateless Authentication class used for all personalized content */ +abstract class AuthAPI { + open val name: String = "NONE" + open val idPrefix: String = "NONE" + + /** Drawable icon of the service */ + open val icon: Int? = null + + /** If this service requires an account to use */ + open val requiresLogin: Boolean = true + + /** Link to a website for creating a new account */ + open val createAccountUrl: String? = null + + /** The sensitive redirect URL from OAuth should contain "/redirectUrlIdentifier" to trigger the login */ + open val redirectUrlIdentifier: String? = null + + /** Has OAuth2 login support, including login, loginRequest and refreshToken */ + open val hasOAuth2: Boolean = false + + /** Has on device pin support, aka login with a QR code */ + open val hasPin: Boolean = false + + /** Has in app login support, aka login with a dialog */ + open val hasInApp: Boolean = false + + /** The requirements to login in app */ + open val inAppLoginRequirement: AuthLoginRequirement? = null + + companion object { + val unixTime: Long + get() = System.currentTimeMillis() / 1000L + val unixTimeMs: Long + get() = System.currentTimeMillis() + + fun splitRedirectUrl(redirectUrl: String): Map { + return splitQuery( + URL( + redirectUrl.replace(APP_STRING, "https").replace("/#", "?") + ) + ) + } + + fun generateCodeVerifier(): String { + // It is recommended to use a URL-safe string as code_verifier. + // See section 4 of RFC 7636 for more details. + val secureRandom = SecureRandom() + val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 + secureRandom.nextBytes(codeVerifierBytes) + return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=') + .replace("+", "-") + .replace("/", "_").replace("\n", "") + } + } + + /** Is this url a valid redirect url for this service? */ + @Throws + open fun isValidRedirectUrl(url: String): Boolean = + redirectUrlIdentifier != null && url.contains("/$redirectUrlIdentifier") + + /** OAuth2 login from a valid redirectUrl, and payload given in loginRequest */ + @Throws + open suspend fun login(redirectUrl: String, payload: String?): AuthToken? = + throw NotImplementedError() + + /** OAuth2 login request, asking the service to provide a url to open in the browser */ + @Throws + open fun loginRequest(): AuthLoginPage? = throw NotImplementedError() + + /** Pin login request, asking the service to provide an verificationUrl to display with a QR code */ + @Throws + open suspend fun pinRequest(): AuthPinData? = throw NotImplementedError() + + /** OAuth2 token refresh, this ensures that all token passed to other functions will be valid */ + @Throws + open suspend fun refreshToken(token: AuthToken): AuthToken? = throw NotImplementedError() + + /** Pin login, this will be called periodically while logging in to check if the pin has been verified by the user */ + @Throws + open suspend fun login(payload: AuthPinData): AuthToken? = throw NotImplementedError() + + /** In app login */ + @Throws + open suspend fun login(form: AuthLoginResponse): AuthToken? = throw NotImplementedError() + + /** Get the visible user account */ + @Throws + open suspend fun user(token: AuthToken?): AuthUser? = throw NotImplementedError() + + /** + * An optional security measure to make sure that even if an attacker gets ahold of the token, it will be invalid. + * + * Note that this will currently only be called *once* on logout, + * and as such any network issues it will fail silently, and the token will not be revoked. + **/ + @Throws + open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError() + + @Throws + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + fun toRepo(): AuthRepo = when (this) { + is SubtitleAPI -> SubtitleRepo(this) + is SyncAPI -> SyncRepo(this) + else -> throw NotImplementedError("Unknown inheritance from AuthAPI") + } + + @Suppress("DEPRECATION_ERROR") + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + fun loginInfo(): LoginInfo? { + return this.toRepo().authUser()?.let { user -> + LoginInfo( + profilePicture = user.profilePicture, + name = user.name, + accountIndex = -1, + ) + } + } + + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + @Suppress("DEPRECATION_ERROR") + return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow() + } + + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) class LoginInfo( val profilePicture: String? = null, val name: String?, val accountIndex: Int, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt new file mode 100644 index 00000000000..645a19e3a60 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -0,0 +1,168 @@ +package com.lagradost.cloudstream3.syncproviders + +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID +import com.lagradost.cloudstream3.utils.txt + +/** General-purpose repo */ +class PlainAuthRepo(api: AuthAPI) : AuthRepo(api) + +/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ +abstract class AuthRepo(open val api: AuthAPI) { + fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false + val idPrefix get() = api.idPrefix + val name get() = api.name + val icon get() = api.icon + val requiresLogin get() = api.requiresLogin + val createAccountUrl get() = api.createAccountUrl + val hasOAuth2 get() = api.hasOAuth2 + val hasPin get() = api.hasPin + val hasInApp get() = api.hasInApp + val inAppLoginRequirement get() = api.inAppLoginRequirement + val isAvailable get() = !api.requiresLogin || authUser() != null + + companion object { + private val oauthPayload: MutableMap = mutableMapOf() + } + + @Throws + protected suspend fun freshAuth(): AuthData? { + val data = authData() ?: return null + if (data.token.isAccessTokenExpired()) { + val newToken = api.refreshToken(data.token) ?: return null + val newAuth = AuthData(user = data.user, token = newToken) + refreshUser(newAuth) + return newAuth + } + return data + } + + @Throws + fun openOAuth2Page(): Boolean { + val page = api.loginRequest() ?: return false + synchronized(oauthPayload) { + oauthPayload.put(idPrefix, page.payload) + } + openBrowser(page.url) + return true + } + + fun openOAuth2PageWithToast() { + try { + if (!openOAuth2Page()) { + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } catch (t: Throwable) { + logError(t) + if (t is ErrorLoadingException && t.message != null) { + showToast(t.message) + return + } + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } + + suspend fun logout(from: AuthUser) { + val currentAccounts = AccountManager.accounts(idPrefix) + val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id } + if (newAccounts.size < currentAccounts.size) { + AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray()) + AccountManager.updateAccountsId(idPrefix, 0) + } + + for (oldAccount in oldAccounts) { + try { + api.invalidateToken(oldAccount.token) + } catch (_: NotImplementedError) { + // no-op + } catch (t: Throwable) { + logError(t) + } + } + } + + fun refreshUser(newAuth: AuthData) { + val currentAccounts = AccountManager.accounts(idPrefix) + val newAccounts = currentAccounts.map { + if (it.user.id == newAuth.user.id) { + newAuth + } else { + it + } + }.toTypedArray() + AccountManager.updateAccounts(idPrefix, newAccounts) + } + + fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix]?.let { id -> + AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id } + } + } + + fun authToken(): AuthToken? = authData()?.token + + fun authUser(): AuthUser? = authData()?.user + + val accounts + get() = synchronized(AccountManager.cachedAccounts) { + AccountManager.cachedAccounts[idPrefix] ?: emptyArray() + } + var accountId + get() = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID + } + set(value) { + AccountManager.updateAccountsId(idPrefix, value) + } + + @Throws + suspend fun pinRequest() = + api.pinRequest() + + @Throws + private suspend fun setupLogin(token: AuthToken): Boolean { + val user = api.user(token) ?: return false + + val newAccount = AuthData( + token = token, + user = user, + ) + + val currentAccounts = AccountManager.accounts(idPrefix) + if (currentAccounts.any { it.user.id == newAccount.user.id }) { + throw ErrorLoadingException("Already logged into this account") + } + + val newAccounts = currentAccounts + newAccount + AccountManager.updateAccounts(idPrefix, newAccounts) + AccountManager.updateAccountsId(idPrefix, user.id) + if (this is SyncRepo) { + requireLibraryRefresh = true + } + return true + } + + @Throws + suspend fun login(form: AuthLoginResponse): Boolean { + return setupLogin(api.login(form) ?: return false) + } + + @Throws + suspend fun login(payload: AuthPinData): Boolean { + return setupLogin(api.login(payload) ?: return false) + } + + @Throws + suspend fun login(redirectUrl: String): Boolean { + return setupLogin( + api.login( + redirectUrl, + synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt new file mode 100644 index 00000000000..5efb88e5b74 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -0,0 +1,14 @@ +package com.lagradost.cloudstream3.syncproviders + +/** Work in progress */ +abstract class BackupAPI : AuthAPI() { + open val filename : String = "cloudstream-backup.json" + + /** Get the backup file as a JSON string from the remote storage. Return null if not found/empty */ + @Throws + open suspend fun downloadFile(auth: AuthData?) : String? = throw NotImplementedError() + + /** Get the backup file as a JSON string from the remote storage. */ + @Throws + open suspend fun uploadFile(auth: AuthData?, data : String) : String? = throw NotImplementedError() +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt deleted file mode 100644 index 8b6fdf463cf..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders - -import androidx.annotation.WorkerThread - -interface InAppAuthAPI : AuthAPI { - data class LoginData( - val username: String? = null, - val password: String? = null, - val server: String? = null, - val email: String? = null, - ) - - // this is for displaying the UI - val requiresPassword: Boolean - val requiresUsername: Boolean - val requiresServer: Boolean - val requiresEmail: Boolean - - // if this is false we can assume that getLatestLoginData returns null and wont be called - // this is used in case for some reason it is not preferred to store any login data besides the "token" or encrypted data - val storesPasswordInPlainText: Boolean - - // return true if logged in successfully - suspend fun login(data: LoginData): Boolean - - // used to fill the UI if you want to edit any data about your login info - fun getLatestLoginData(): LoginData? -} - -abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), InAppAuthAPI { - override val requiresPassword = false - override val requiresUsername = false - override val requiresEmail = false - override val requiresServer = false - override val storesPasswordInPlainText = true - override val requiresLogin = true - - // runs on startup - @WorkerThread - open suspend fun initialize() { - } - - override fun logOut() { - throw NotImplementedError() - } - - override val idPrefix: String - get() = throw NotImplementedError() - - override val name: String - get() = throw NotImplementedError() - - override val icon: Int? = null - - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { - throw NotImplementedError() - } - - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { - throw NotImplementedError() - } - - override fun loginInfo(): AuthAPI.LoginInfo? { - throw NotImplementedError() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt deleted file mode 100644 index ef74edfcbd5..00000000000 --- 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 00000000000..a1149b5f8f8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt @@ -0,0 +1,37 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource + +/** + * Stateless subtitle class for external subtitles. + * + * All non-null `AuthToken` will be non-expired when each function is called. + */ +abstract class SubtitleAPI : AuthAPI() { + @WorkerThread + @Throws + open suspend fun search(auth: AuthData?, query: SubtitleSearch): List? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun load(auth: AuthData?, subtitle: SubtitleEntity): String? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun SubtitleResource.getResources(auth: AuthData?, subtitle: SubtitleEntity) { + this.addUrl(load(auth, subtitle)) + } + + @WorkerThread + @Throws + suspend fun resource(auth: AuthData?, subtitle: SubtitleEntity): SubtitleResource { + return SubtitleResource().apply { + this.getResources(auth, subtitle) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt new file mode 100644 index 00000000000..7a93f96f697 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt @@ -0,0 +1,89 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf + +/** Stateless safe abstraction of SubtitleAPI */ +class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { + companion object { + data class SavedSearchResponse( + val unixTime: Long, + val response: List, + val query: SubtitleSearch + ) + + data class SavedResourceResponse( + val unixTime: Long, + val response: SubtitleResource, + val query: SubtitleEntity + ) + + // maybe make this a generic struct? right now there is a lot of boilerplate + private val searchCache = threadSafeListOf() + private var searchCacheIndex: Int = 0 + private val resourceCache = threadSafeListOf() + private var resourceCacheIndex: Int = 0 + const val CACHE_SIZE = 20 + } + + @WorkerThread + suspend fun resource(data: SubtitleEntity): Result = runCatching { + synchronized(resourceCache) { + for (item in resourceCache) { + // 20 min save + if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { + return@runCatching item.response + } + } + } + + val returnValue = api.resource(freshAuth(), data) + synchronized(resourceCache) { + val add = SavedResourceResponse(unixTime, returnValue, data) + if (resourceCache.size > CACHE_SIZE) { + resourceCache[resourceCacheIndex] = add // rolling cache + resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE + } else { + resourceCache.add(add) + } + } + returnValue + } + + @WorkerThread + suspend fun search(query: SubtitleSearch): Result> { + return runCatching { + synchronized(searchCache) { + for (item in searchCache) { + // 120 min save + if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { + return@runCatching item.response + } + } + } + + val returnValue = + api.search(freshAuth(), query) ?: emptyList() + + // only cache valid return values + if (returnValue.isNotEmpty()) { + val add = SavedSearchResponse(unixTime, returnValue, query) + synchronized(searchCache) { + if (searchCache.size > CACHE_SIZE) { + searchCache[searchCacheIndex] = add // rolling cache + searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE + } else { + searchCache.add(add) + } + } + } + returnValue + } + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index 5aa56a02559..e5f9aca8493 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -1,79 +1,194 @@ -package com.lagradost.cloudstream3.syncproviders - -import com.lagradost.cloudstream3.* - -interface SyncAPI : OAuth2API { - val mainUrl: String - - /** - -1 -> None - 0 -> Watching - 1 -> Completed - 2 -> OnHold - 3 -> Dropped - 4 -> PlanToWatch - 5 -> ReWatching - */ - suspend fun score(id: String, status: SyncStatus): Boolean - - suspend fun getStatus(id: String): SyncStatus? - - suspend fun getResult(id: String): SyncResult? - - suspend fun search(name: String): List? - - 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 - - data class SyncStatus( - val status: Int, - /** 1-10 */ - val score: Int?, - val watchedEpisodes: Int?, - var isFavorite: Boolean? = null, - var maxEpisodes : Int? = null, - ) - - 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, - ) +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.UiText +import me.xdrop.fuzzywuzzy.FuzzySearch +import java.util.Date + +/** + * Stateless synchronization class, used for syncing status about a specific movie/show. + * + * All non-null `AuthToken` will be non-expired when each function is called. + */ +abstract class SyncAPI : AuthAPI() { + /** + * Set this to true if the user updates something on the list like watch status or score + **/ + open var requireLibraryRefresh: Boolean = true + open val mainUrl: String = "NONE" + + /** Currently unused, but will be used to correctly render the UI. + * This should specify what sync watch types can be used with this service. */ + open val supportedWatchTypes: Set = SyncWatchType.entries.toSet() + /** + * Allows certain providers to open pages from + * library links. + **/ + open val syncIdName: SyncIdName? = null + + /** Modify the current status of an item */ + @Throws + @WorkerThread + open suspend fun updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean = throw NotImplementedError() + + /** Get the current status of an item */ + @Throws + @WorkerThread + open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? = + throw NotImplementedError() + + /** Get metadata about an item */ + @Throws + @WorkerThread + open suspend fun load(auth: AuthData?, id: String): SyncResult? = throw NotImplementedError() + + /** Search this service for any results for a given query */ + @Throws + @WorkerThread + open suspend fun search(auth: AuthData?, query: String): List? = + throw NotImplementedError() + + /** Get the current library/bookmarks of this service */ + @Throws + @WorkerThread + open suspend fun library(auth: AuthData?): LibraryMetadata? = throw NotImplementedError() + + /** Helper function, may be used in the future */ + @Throws + open fun urlToId(url: String): String? = null + + data class SyncSearchResult( + override val name: String, + override val apiName: String, + var syncId: String, + override val url: String, + override var posterUrl: String?, + override var type: TvType? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override var id: Int? = null, + override var score: Score? = null, + ) : SearchResponse + + abstract class AbstractSyncStatus { + abstract var status: SyncWatchType + abstract var score: Score? + abstract var watchedEpisodes: Int? + abstract var isFavorite: Boolean? + abstract var maxEpisodes: Int? + } + + data class SyncStatus( + override var status: SyncWatchType, + override var score: Score?, + override var watchedEpisodes: Int?, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + ) : AbstractSyncStatus() + + data class SyncResult( + /**Used to verify*/ + var id: String, + + var totalEpisodes: Int? = null, + + var title: String? = null, + var publicScore: Score? = null, + /**In minutes*/ + var duration: Int? = null, + var synopsis: String? = null, + var airStatus: ShowStatus? = null, + var nextAiring: NextAiring? = null, + var studio: List? = null, + var genres: List? = null, + var synonyms: List? = null, + var trailers: List? = null, + var isAdult: Boolean? = null, + var posterUrl: String? = null, + var backgroundPosterUrl: String? = null, + + /** In unixtime */ + var startDate: Long? = null, + /** In unixtime */ + var endDate: Long? = null, + var recommendations: List? = null, + var nextSeason: SyncSearchResult? = null, + var prevSeason: SyncSearchResult? = null, + var actors: List? = null, + ) + + data class Page( + val title: UiText, var items: List + ) { + fun sort(method: ListSorting?, query: String? = null) { + items = when (method) { + ListSorting.Query -> + if (query != null) { + items.sortedBy { + -FuzzySearch.partialRatio( + query.lowercase(), it.name.lowercase() + ) + } + } else items + + ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) } + ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) } + ListSorting.AlphabeticalA -> items.sortedBy { it.name } + ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() + ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } + ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } + ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } + ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } + else -> items + } + } + } + + data class LibraryMetadata( + val allLibraryLists: List, + val supportedListSorting: Set + ) + + data class LibraryList( + val name: UiText, + val items: List + ) + + data class LibraryItem( + override val name: String, + override val url: String, + /** + * Unique unchanging string used for data storage. + * This should be the actual id when you change scores and status + * since score changes from library might get added in the future. + **/ + val syncId: String, + val episodesCompleted: Int?, + val episodesTotal: Int?, + val personalRating: Score?, + val lastUpdatedUnixTime: Long?, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override var posterHeaders: Map?, + override var quality: SearchQuality?, + val releaseDate: Date?, + override var id: Int? = null, + val plot: String? = null, + override var score: Score? = null, + val tags: List? = null + ) : SearchResponse } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt index b621e81a46c..de82624fc7e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -1,36 +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 - - suspend fun score(id: String, status: SyncAPI.SyncStatus): 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() } - } - - fun hasAccount() : Boolean { - return normalSafeApiCall { repo.loginInfo() != null } ?: false - } - - fun getIdFromUrl(url : String) : String = 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 507c5e2acf7..144efff99ce 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt @@ -1,108 +1,205 @@ package com.lagradost.cloudstream3.syncproviders.providers -import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.subtitles.AbstractSubApi -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName -class Addic7ed : AbstractSubApi { +class Addic7ed : SubtitleAPI() { override val name = "Addic7ed" override val idPrefix = "addic7ed" override val requiresLogin = false - override val icon: Nothing? = null - override val createAccountUrl: Nothing? = null - - override fun loginInfo(): Nothing? = null - - override fun logOut() {} companion object { - const val host = "https://www.addic7ed.com" + const val 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 d4742d94f2e..7a46b411376 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -1,85 +1,91 @@ package com.lagradost.cloudstream3.syncproviders.providers -import androidx.fragment.app.FragmentActivity +import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.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.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 fun logOut() { - removeAccountKeys() + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val sanitizer = splitRedirectUrl(redirectUrl) + val token = AuthToken( + accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"), + //refreshToken = sanitizer["refresh_token"], + accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), + ) + return token } - override fun authenticate(activity: FragmentActivity?) { - val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token" - openBrowser(request, activity) + // https://docs.anilist.co/guide/auth/ + override suspend fun refreshToken(token: AuthToken): AuthToken? { + // AniList access tokens are long-lived. They will remain valid for 1 year from the time they are issued. + // Refresh tokens are not currently supported. Once a token expires, you will need to re-authenticate your users. + return super.refreshToken(token) } - override suspend fun handleRedirect(url: String): Boolean { - val sanitizer = - splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR - val token = sanitizer["access_token"]!! - val expiresIn = sanitizer["expires_in"]!! - - val endTime = unixTime + expiresIn.toLong() + override suspend fun user(token: AuthToken?): AuthUser? { + val user = getUser(token ?: return null) + ?: throw ErrorLoadingException("Unable to fetch user data") - switchToNewAccount() - setKey(accountId, ANILIST_UNIXTIME_KEY, endTime) - setKey(accountId, ANILIST_TOKEN_KEY, token) - setKey(ANILIST_SHOULD_UPDATE_LIST, true) - val user = getUser() - return user != null + return AuthUser( + id = user.id, + name = user.name, + profilePicture = user.picture, + ) } - override fun getIdFromUrl(url: String): String { - return url.removePrefix("$mainUrl/anime/").removeSuffix("/") - } + override fun urlToId(url: String): String? = + url.removePrefix("$mainUrl/anime/").removeSuffix("/") + private fun getUrlFromId(id: Int): String { return "$mainUrl/anime/$id" } - override suspend fun search(name: String): List? { + override suspend fun search(auth : AuthData?, query: String): List? { val data = searchShows(name) ?: return null - return data.data?.Page?.media?.map { + return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, this.name, @@ -90,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(), @@ -132,15 +138,16 @@ 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), - recMedia.coverImage?.large ?: recMedia.coverImage?.medium + recMedia.coverImage?.extraLarge ?: recMedia.coverImage?.large + ?: recMedia.coverImage?.medium ) }, trailers = when (season.trailer?.site?.lowercase()?.trim()) { @@ -151,37 +158,39 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { + 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.SyncStatus): 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 + 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" - const val ANILIST_SHOULD_UPDATE_LIST: String = "anilist_should_update_list" private fun fixName(name: String): String { return name.lowercase(Locale.ROOT).replace(" ", "") @@ -219,7 +228,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { romaji } idMal - coverImage { medium large } + coverImage { medium large extraLarge } averageScore } } @@ -232,7 +241,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { format id idMal - coverImage { medium large } + coverImage { medium large extraLarge } averageScore title { english @@ -291,16 +300,14 @@ 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 { - malId ?: "NONE" == it.idMal.toString() + shows?.data?.page?.media?.find { + (malId ?: "NONE") == it.idMal.toString() }?.let { return it } val filtered = - shows?.data?.Page?.media?.filter { - ( - it.startDate.year ?: year.toString() == year.toString() - || year == null - ) + shows?.data?.page?.media?.filter { + (((it.startDate.year ?: year.toString()) == year.toString() + || year == null)) } filtered?.forEach { it.title.romaji?.let { romaji -> @@ -312,14 +319,14 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } // Changing names of these will show up in UI - enum class AniListStatusType(var value: Int) { - Watching(0), - Completed(1), - Paused(2), - Dropped(3), - Planning(4), - ReWatching(5), - None(-1) + enum class AniListStatusType(var value: Int, @StringRes val stringRes: Int) { + Watching(0, R.string.type_watching), + Completed(1, R.string.type_completed), + Paused(2, R.string.type_on_hold), + Dropped(3, R.string.type_dropped), + Planning(4, R.string.type_plan_to_watch), + ReWatching(5, R.string.type_re_watching), + None(-1, R.string.none) } fun fromIntToAnimeStatus(inp: Int): AniListStatusType {//= AniListStatusType.values().first { it.value == inp } @@ -335,7 +342,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } - fun convertAnilistStringToStatus(string: String): AniListStatusType { + fun convertAniListStringToStatus(string: String): AniListStatusType { return fromIntToAnimeStatus(aniListStatusString.indexOf(string)) } @@ -452,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) @@ -476,7 +469,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { mediaListEntry { progress status - score (format: POINT_10) + score (format: POINT_100) } title { english @@ -485,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, @@ -513,36 +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, @@ -575,7 +556,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { data class CoverImage( @JsonProperty("medium") val medium: String?, - @JsonProperty("large") val large: String? + @JsonProperty("large") val large: String?, + @JsonProperty("extraLarge") val extraLarge: String? ) data class Media( @@ -587,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?, @@ -602,7 +584,31 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("score") val score: Int, @JsonProperty("private") val private: Boolean, @JsonProperty("media") val media: Media - ) + ) { + fun toLibraryItem(): SyncAPI.LibraryItem { + return SyncAPI.LibraryItem( + // English title first + this.media.title.english ?: this.media.title.romaji + ?: this.media.synonyms.firstOrNull() + ?: "", + "https://anilist.co/anime/${this.media.id}/", + this.media.id.toString(), + this.progress, + this.media.episodes, + Score.from100(this.score), + this.updatedAt.toLong(), + "AniList", + TvType.Anime, + this.media.coverImage.extraLarge ?: this.media.coverImage.large + ?: this.media.coverImage.medium, + null, + null, + this.media.seasonYear.toYear(), + null, + plot = this.media.description, + ) + } + } data class Lists( @JsonProperty("status") val status: String?, @@ -614,43 +620,58 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class Data( - @JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection + @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection ) - fun getAnilistListCached(): Array? { - return getKey(ANILIST_CACHED_LIST) as? Array - } - - suspend fun getAnilistAnimeListSmart(): Array? { - if (getAuth() == null) return null - - if (checkToken()) return null - return if (getKey(ANILIST_SHOULD_UPDATE_LIST, true) == true) { - val list = getFullAnilistList()?.data?.MediaListCollection?.lists?.toTypedArray() + private suspend fun getAniListAnimeListSmart(auth: AuthData): Array? { + return if (requireLibraryRefresh) { + val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray() if (list != null) { - setKey(ANILIST_CACHED_LIST, list) - setKey(ANILIST_SHOULD_UPDATE_LIST, false) + setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list) } list } else { - getAnilistListCached() + getKey>( + ANILIST_CACHED_LIST, + auth.user.id.toString() + ) as? Array } } - private suspend fun getFullAnilistList(): FullAnilistList? { - var userID: Int? = null - /** WARNING ASSUMES ONE USER! **/ - getKeys(ANILIST_USER_KEY)?.forEach { key -> - getKey(key, null)?.let { - userID = it.id + 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() + } ?: emptyMap() + + // To fill empty lists when AniList does not return them + val baseMap = + AniListStatusType.entries.filter { it.value >= 0 }.associate { + it.stringRes to emptyList() } - } - val fixedUserID = userID ?: return null + return SyncAPI.LibraryMetadata( + (baseMap + list).map { SyncAPI.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 getFullAniListList(auth : AuthData): FullAnilistList? { + val userID = auth.user.id val mediaType = "ANIME" val query = """ - query (${'$'}userID: Int = $fixedUserID, ${'$'}MEDIA: MediaType = $mediaType) { + query (${'$'}userID: Int = $userID, ${'$'}MEDIA: MediaType = $mediaType) { MediaListCollection (userId: ${'$'}userID, type: ${'$'}MEDIA) { lists { status @@ -661,7 +682,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { startedAt { year month day } updatedAt progress - score + score (format: POINT_100) private media { @@ -677,7 +698,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { english romaji } - coverImage { medium } + coverImage { extraLarge large medium } synonyms nextAiringEpisode { timeUntilAiring @@ -689,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 { @@ -706,35 +727,66 @@ 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 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 = - """mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${ - aniListStatusString[maxOf( - 0, - type.value - )] - }, ${if (score != null) "${'$'}scoreRaw: Int = ${score * 10}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { - SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) { - id - status - progress - score - } + // Delete item if status type is None + if (type == AniListStatusType.None) { + // Get list ID for deletion + val idQuery = """ + query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) { + MediaList(userId: ${'$'}userId, mediaId: ${'$'}mediaId) { + id + } + } + """ + val response = postApi(auth.token, idQuery) + val listId = + tryParseJson(response)?.data?.mediaList?.id ?: return false + """ + mutation(${'$'}id: Int = $listId) { + DeleteMediaListEntry(id: ${'$'}id) { + deleted + } + } + """ + } else { + """mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${ + aniListStatusString[maxOf( + 0, + type.value + )] + }, ${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 + progress + score + } }""" - 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 { @@ -752,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 } @@ -777,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) @@ -797,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( @@ -832,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( @@ -962,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( @@ -977,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?, ) @@ -1009,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( @@ -1049,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( @@ -1082,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 7ec168da80f..00000000000 --- 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 2fc97477042..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt +++ /dev/null @@ -1,266 +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.network.CloudflareKiller -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://subscene.cyou" - 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").mapNotNull { 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")) - } - }.first() - } - 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 724d72163b0..29c3c0c1793 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -1,8 +1,680 @@ package com.lagradost.cloudstream3.syncproviders.providers + +import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.txt +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.Locale + +const val KITSU_MAX_SEARCH_LIMIT = 20 + +class KitsuApi: SyncAPI() { + override var name = "Kitsu" + override val idPrefix = "kitsu" + + private val apiUrl = "https://kitsu.io/api/edge" + private val fallbackApiUrl = "https://kitsu.app/api/edge" + private val oauthUrl = "https://kitsu.io/api/oauth" + private val fallbackOauthUrl = "https://kitsu.app/api/oauth" + override val hasInApp = true + override val mainUrl = "https://kitsu.app" + override val icon = R.drawable.kitsu_icon + override val syncIdName = SyncIdName.Kitsu + override val createAccountUrl = mainUrl + + override val supportedWatchTypes = setOf( + SyncWatchType.WATCHING, + SyncWatchType.COMPLETED, + SyncWatchType.PLANTOWATCH, + SyncWatchType.DROPPED, + SyncWatchType.ONHOLD, + SyncWatchType.NONE + ) + + override val inAppLoginRequirement = AuthLoginRequirement( + password = true, + email = true + ) + + private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + + try { + + val response = chain.proceed(request); + + if (response.isSuccessful) return response + + response.close() + + } catch (_: Exception) { + } + + val fallbackRequest: Request = request.newBuilder() + .url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl)) + .build() + + return chain.proceed(fallbackRequest) + + } + } + + private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl) + private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl) + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.email ?: return null + val password = form.password ?: return null + + val grantType = "password" + + val token = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to grantType, + "username" to username, + "password" to password + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessTokenLifetime = unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken, + ) + } + + override suspend fun refreshToken(token: AuthToken): AuthToken { + val res = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to "refresh_token", + "refresh_token" to token.refreshToken!! + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessToken = res.accessToken, + refreshToken = res.refreshToken, + accessTokenLifetime = unixTime + res.expiresIn.toLong() + ) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val user = app.get( + "$apiUrl/users?filter[self]=true", + headers = mapOf( + "Authorization" to "Bearer ${token?.accessToken ?: return null}" + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + if (user.data.isEmpty()) { + return null + } + + return AuthUser( + id = user.data[0].id.toInt(), + name = user.data[0].attributes.name, + profilePicture = user.data[0].attributes.avatar?.original + ) + } + + override suspend fun search(auth: AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") + val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth", + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + return res.data.map { + val attributes = it.attributes + + val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" + + SyncSearchResult( + title, + this.name, + it.id, + "$mainUrl/anime/${it.id}/", + attributes.posterImage?.large ?: attributes.posterImage?.medium + ) + } + } + + override suspend fun load(auth : AuthData?, id: String): SyncResult? { + val auth = auth?.token?.accessToken ?: return null + if (id.toIntOrNull() == null) { + return null + } + + data class KitsuResponse( + @field:JsonProperty(value = "data") + val data: KitsuNode, + ) + + val url = + "$apiUrl/anime/$id" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.attributes + + return SyncResult( + id = id, + totalEpisodes = anime.episodeCount, + title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), + publicScore = Score.from(anime.ratingTwenty.toString(), 20), + duration = anime.episodeLength, + synopsis = anime.synopsis, + airStatus = when(anime.status) { + "finished" -> ShowStatus.Completed + "current" -> ShowStatus.Ongoing + else -> null + }, + nextAiring = null, + studio = null, + genres = null, + trailers = null, + startDate = LocalDate.parse(anime.startDate).toEpochDay(), + endDate = LocalDate.parse(anime.endDate).toEpochDay(), + recommendations = null, + nextSeason =null, + prevSeason = null, + actors = null, + ) + + } + + override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? { + val accessToken = auth?.token?.accessToken ?: return null + val userId = auth.user.id + + val selectedFields = arrayOf("status","ratingTwenty", "progress") + + val url = + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $accessToken" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.firstOrNull()?.attributes + + if (anime == null) { + return SyncStatus( + score = null, + status = SyncWatchType.NONE, + isFavorite = null, + watchedEpisodes = null + ) + } + + return SyncStatus( + score = Score.from(anime.ratingTwenty.toString(), 20), + status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)), + isFavorite = null, + watchedEpisodes = anime.progress, + ) + } + suspend fun getAnimeIdByTitle(title: String): String? { + + val animeSelectedFields = arrayOf("titles","canonicalTitle") + val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + + val res = app.get(url, interceptor = apiFallbackInterceptor).parsed() + + return res.data.firstOrNull()?.id + + } + + override fun urlToId(url: String): String? = + Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first() + + override suspend fun updateStatus( + auth : AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { + + return setScoreRequest( + auth ?: return false, + id.toIntOrNull() ?: return false, + fromIntToAnimeStatus(newStatus.status), + newStatus.score?.toInt(20), + newStatus.watchedEpisodes + ) + } + + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: KitsuStatusType? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + + val libraryEntryId = getAnimeLibraryEntryId(auth, id) + + // Exists entry for anime in library + if (libraryEntryId != null) { + + // Delete anime from library + if (status == null || status == KitsuStatusType.None) { + + val res = app.delete( + "$apiUrl/library-entries/$libraryEntryId", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + interceptor = apiFallbackInterceptor + ) + + + return res.isSuccessful + + } + + return setScoreRequest( + auth, + libraryEntryId, + kitsuStatusAsString[maxOf(0, status.value)], + score, + numWatchedEpisodes + ) + + } + + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)], + ), + "relationships" to mapOf( + "anime" to mapOf( + "data" to mapOf( + "type" to "anime", + "id" to id.toString() + ) + ), + "user" to mapOf( + "data" to mapOf( + "type" to "users", + "id" to auth.user.id + ) + ) + ) + ) + ) + + val res = app.post( + "$apiUrl/library-entries", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor + ) + + return res.isSuccessful + + } + + @Suppress("UNCHECKED_CAST") + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: String? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "id" to id.toString(), + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to status + ) + ) + ) + + val res = app.patch( + "$apiUrl/library-entries/$id", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor + ) + + + return res.isSuccessful + + } + + private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? { + + val userId = auth.user.id + + val res = app.get( + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.firstOrNull() ?: return null + + return res.id.toInt() + + } + + override suspend fun library(auth : AuthData?): LibraryMetadata? { + val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy { + convertToStatus(it.attributes.status ?: "").stringRes + }?.mapValues { group -> + group.value.map { it.toLibraryItem() } + } ?: emptyMap() + + // To fill empty lists when Kitsu does not return them + val baseMap = + KitsuStatusType.entries.filter { it.value >= 0 }.associate { + it.stringRes to emptyList() + } + + return LibraryMetadata( + (baseMap + list).map { LibraryList(txt(it.key), it.value) }, + setOf( + ListSorting.AlphabeticalA, + ListSorting.AlphabeticalZ, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, + ListSorting.RatingHigh, + ListSorting.RatingLow, + ) + ) + } + + private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array? { + return if (requireLibraryRefresh) { + val list = getKitsuAnimeList(auth.token, auth.user.id) + setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list) + list + } else { + getKey>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array + } + } + + private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array { + + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","episodeCount") + val libraryEntriesSelectedFields = arrayOf("progress","rating","updatedAt", "status") + val limit = 500 + var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}" + + val fullList = mutableListOf() + + while (true) { + + val data: KitsuResponse = getKitsuAnimeListSlice(token, url) + + data.data.forEachIndexed { index, value -> + value.anime = data.included?.get(index) + } + + fullList.addAll(data.data) + + url = data.links?.next ?: break + } + + + return fullList.toTypedArray() + } + + private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse { + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer ${token.accessToken}", + ), + interceptor = apiFallbackInterceptor + ).parsed() + return res + } + + + data class ResponseToken( + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String, + ) + + data class KitsuNode( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuNodeAttributes, + /* User list anime node */ + @JsonProperty("relationships") val relationships: KitsuRelationships?, + var anime: KitsuAnimeData? + ) { + fun toLibraryItem(): LibraryItem { + + val animeItem = this.anime + + val numEpisodes = animeItem?.attributes?.episodeCount + + val startDate = animeItem?.attributes?.startDate + + val posterImage = animeItem?.attributes?.posterImage + + val canonicalTitle = animeItem?.attributes?.canonicalTitle + val titles = animeItem?.attributes?.titles + + val animeId = animeItem?.id + + val synopsis: String? = animeItem?.attributes?.synopsis + + return LibraryItem( + canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(), + "https://kitsu.app/anime/${animeId}/", + this.id, + this.attributes.progress, + numEpisodes, + Score.from(this.attributes.ratingTwenty.toString(), 20), + parseDateLong(this.attributes.updatedAt), + "Kitsu", + TvType.Anime, + posterImage?.large ?: posterImage?.medium, + null, + null, + plot = synopsis, + releaseDate = if (startDate == null) null else try { + Date.from( + Instant.from( + DateTimeFormatter.ofPattern(if (startDate.length == 4) "yyyy" else if (startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") + .parse(startDate) + ) + ) + } catch (_: RuntimeException) { + null + } + ) + } + + } + + data class KitsuAnimeAttributes( + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + ) + + data class KitsuAnimeData( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuAnimeAttributes, + ) + + + data class KitsuNodeAttributes( + /* General attributes */ + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + /* User attributes */ + @JsonProperty("name") val name: String?, + @JsonProperty("location") val location: String?, + @JsonProperty("createdAt") val createdAt: String?, + @JsonProperty("avatar") val avatar: KitsuUserAvatar?, + /* User list anime attributes */ + @JsonProperty("progress") val progress: Int?, + @JsonProperty("ratingTwenty") val ratingTwenty: Float?, + @JsonProperty("updatedAt") val updatedAt: String?, + @JsonProperty("status") val status: String?, + ) + + data class KitsuRelationships( + @JsonProperty("anime") val anime: KitsuRelationshipsAnime? + ) + + data class KitsuRelationshipsAnime( + @JsonProperty("links") val links: KitsuLinks? + ) + + data class KitsuPosterImage( + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, + ) + + data class KitsuTitles( + @JsonProperty("en_jp") val enJp: String?, + @JsonProperty("ja_jp") val jaJp: String? + ) + + data class KitsuUserAvatar( + @JsonProperty("original") val original: String? + ) + + data class KitsuLinks( + /* Pagination */ + @JsonProperty("first") val first: String?, + @JsonProperty("next") val next: String?, + @JsonProperty("last") val last: String?, + /* Relationships */ + @JsonProperty("related") val related: String? + ) + + data class KitsuResponse( + @JsonProperty("links") val links: KitsuLinks?, + @JsonProperty("data") val data: List, + /* When requesting related info (User library entry -> anime) */ + @JsonProperty("included") val included: List?, + ) + + + companion object { + + const val KITSU_CACHED_LIST: String = "kitsu_cached_list" + private fun parseDateLong(string: String?): Long? { + return try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( + string ?: return null + )?.time?.div(1000) + } catch (e: Exception) { + null + } + } + + private val kitsuStatusAsString = + arrayOf("current", "completed", "on_hold", "dropped", "planned") + private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType { + return when (inp) { + SyncWatchType.NONE -> KitsuStatusType.None + SyncWatchType.WATCHING -> KitsuStatusType.Watching + SyncWatchType.COMPLETED -> KitsuStatusType.Completed + SyncWatchType.ONHOLD -> KitsuStatusType.OnHold + SyncWatchType.DROPPED -> KitsuStatusType.Dropped + SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch + SyncWatchType.REWATCHING -> KitsuStatusType.Watching + } + } + + enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) { + Watching(0, R.string.type_watching), + Completed(1, R.string.type_completed), + OnHold(2, R.string.type_on_hold), + Dropped(3, R.string.type_dropped), + PlanToWatch(4, R.string.type_plan_to_watch), + None(-1, R.string.type_none) + } + + private fun convertToStatus(string: String): KitsuStatusType { + return when (string) { + "current" -> KitsuStatusType.Watching + "completed" -> KitsuStatusType.Completed + "on_hold" -> KitsuStatusType.OnHold + "dropped" -> KitsuStatusType.Dropped + "planned" -> KitsuStatusType.PlanToWatch + else -> KitsuStatusType.None + } + } + } +} // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md @@ -142,4 +814,4 @@ query { val canonical: String? = null ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt new file mode 100644 index 00000000000..8f0d7ca6dac --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt @@ -0,0 +1,92 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.lagradost.cloudstream3.R +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.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() { + override val name = "Local" + override val idPrefix = "local" + + override val icon: Int = R.drawable.ic_baseline_storage_24 + override val requiresLogin = false + override val createAccountUrl = null + override var requireLibraryRefresh = true + override val syncIdName = SyncIdName.LocalList + + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { + val watchStatusIds = ioWork { + getAllWatchStateIds()?.map { id -> + Pair(id, getResultWatchState(id)) + } + }?.distinctBy { it.first } ?: return null + + val list = ioWork { + 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()) + } + } + + 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 + } + + return LibraryMetadata( + 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, + + ) + ) + } +} \ 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 c08958ce44b..ba0195be6b8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -1,95 +1,137 @@ package com.lagradost.cloudstream3.syncproviders.providers -import android.util.Base64 -import androidx.fragment.app.FragmentActivity +import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.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 apiUrl = "https://api.myanimelist.net" - override val icon = R.drawable.mal_logo - override val requiresLogin = false + 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 syncIdName = SyncIdName.MyAnimeList override val createAccountUrl = "$mainUrl/register.php" - override fun logOut() { - 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 + + val currentCode = sanitizer["code"]!! + + val token = app.post( + "$mainUrl/v1/oauth2/token", + data = mapOf( + "client_id" to key, + "code" to currentCode, + "code_verifier" to payloadData.codeVerifier, + "grant_type" to "authorization_code" + ) + ).parsed() + return AuthToken( + accessTokenLifetime = unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken + ) } - private fun getAuth(): String? { - return getKey( - accountId, - MAL_TOKEN_KEY + override suspend fun user(token: AuthToken?): AuthUser? { + val user = app.get( + "$apiUrl/v2/users/@me", + headers = mapOf( + "Authorization" to "Bearer ${token?.accessToken ?: return null}" + ), cacheTime = 0 + ).parsed() + return AuthUser( + id = user.id, + name = user.name, + profilePicture = user.picture ) } - override suspend fun search(name: String): List { + override suspend fun search(auth : AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT" - val auth = getAuth() ?: return emptyList() val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", ), cacheTime = 0 - ).text - return parseJson(res).data.map { + ).parsed() + return res.data.map { val node = it.node SyncAPI.SyncSearchResult( node.title, 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.SyncStatus): 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 + fromIntToAnimeStatus(newStatus.status), + newStatus.score?.toInt(10), + newStatus.watchedEpisodes ) } @@ -167,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 } @@ -179,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 @@ -199,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) { @@ -229,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, ) } @@ -246,92 +295,83 @@ 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_SHOULD_UPDATE_LIST: String = "mal_should_update_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 - } - 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() - setKey(MAL_SHOULD_UPDATE_LIST, true) - return user != null + fun convertToStatus(string: String): MalStatusType { + return when (string) { + "watching" -> MalStatusType.Watching + "completed" -> MalStatusType.Completed + "on_hold" -> MalStatusType.OnHold + "dropped" -> MalStatusType.Dropped + "plan_to_watch" -> MalStatusType.PlanToWatch + else -> MalStatusType.None } } - return 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. + enum class MalStatusType(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 fromIntToAnimeStatus(inp: SyncWatchType): MalStatusType {//= AniListStatusType.values().first { it.value == inp } + return when (inp) { + SyncWatchType.NONE -> MalStatusType.None + SyncWatchType.WATCHING -> MalStatusType.Watching + SyncWatchType.COMPLETED -> MalStatusType.Completed + SyncWatchType.ONHOLD -> MalStatusType.OnHold + SyncWatchType.DROPPED -> MalStatusType.Dropped + SyncWatchType.PLANTOWATCH -> MalStatusType.PlanToWatch + SyncWatchType.REWATCHING -> MalStatusType.Watching + } + } - 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", "") + private fun parseDateLong(string: String?): Long? { + return try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( + string ?: return null + )?.time?.div(1000) + } catch (e: Exception) { + null + } + } + } + + 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) - } - } catch (e: Exception) { - e.printStackTrace() - } + 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) { - e.printStackTrace() - } - } + private var requestIdCounter = 0 + private val allTitles = hashMapOf() @@ -348,41 +388,69 @@ 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.listStatus?.numEpisodesWatched, + this.node.numEpisodes, + Score.from10(this.listStatus?.score), + parseDateLong(this.listStatus?.updatedAt), + "MAL", + TvType.Anime, + 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 + } + ) + } + } data class Paging( @JsonProperty("next") val next: String? @@ -405,33 +473,54 @@ 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 + 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.entries.filter { it.value >= 0 }.associate { + it.stringRes to emptyList() + } + + return SyncAPI.LibraryMetadata( + (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) }, + setOf( + ListSorting.AlphabeticalA, + ListSorting.AlphabeticalZ, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, + ListSorting.RatingHigh, + ListSorting.RatingLow, + ) + ) } - suspend fun getMalAnimeListSmart(): Array? { - if (getAuth() == null) return null - return if (getKey(MAL_SHOULD_UPDATE_LIST, true) == true) { - val list = getMalAnimeList() - setKey(MAL_CACHED_LIST, list) - setKey(MAL_SHOULD_UPDATE_LIST, false) + 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 { - getMalAnimeListCached() + getKey>(MAL_CACHED_LIST, auth.user.id.toString()) as? Array } } - private suspend fun getMalAnimeList(): Array { - checkMalToken() + 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() } @@ -440,156 +529,33 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { return fullList.toTypedArray() } - fun convertToStatus(string: String): MalStatusType { - return fromIntToAnimeStatus(malStatusAsString.indexOf(string)) - } - - 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 - } - - enum class MalStatusType(var value: Int) { - Watching(0), - Completed(1), - OnHold(2), - Dropped(3), - PlanToWatch(4), - None(-1) - } - - private fun fromIntToAnimeStatus(inp: Int): 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 - } - } - 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()) { @@ -606,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 @@ -629,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( @@ -641,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( @@ -658,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?, ) @@ -680,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 d5d6400ce6b..4b17fdb2920 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt @@ -2,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,126 +53,60 @@ 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 fun loginInfo(): AuthAPI.LoginInfo? { - getAuthKey()?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = null, - name = user.user, - accountIndex = accountIndex - ) - } - return null - } - - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { - val current = getAuthKey() ?: return null - return InAppAuthAPI.LoginData(username = current.user, current.pass) - } - - /* - Authorize app to connect to API, using username/password. - Required to run at startup. - Returns OAuth entity with valid access token. - */ - override suspend fun initialize() { - currentSession = getAuthKey() ?: return // just in case the following fails - initLogin(currentSession?.user ?: return, currentSession?.pass ?: return) + override 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 logOut() { - setAuthKey(null) - removeAccountKeys() - currentSession = getAuthKey() - } + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.username ?: return null + val password = form.password ?: return null - 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 queryText = query.query.replace(" ", "+") + val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 + val queryText = query.query val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 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=${URLEncoder.encode(queryText.lowercase(), StandardCharsets.UTF_8.toString())}&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) @@ -206,13 +146,13 @@ 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 ?: "" - val lang = fixLanguageReverse(attr.language)?: "" + ?: featureDetails?.parentTitle ?: attr.release ?: query.query + 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 new file mode 100644 index 00000000000..c4095e2d881 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -0,0 +1,1090 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import androidx.annotation.StringRes +import androidx.core.net.toUri +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SimklSyncServices +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mapper +import com.lagradost.cloudstream3.mvvm.debugPrint +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthPinData +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.AppUtils.toJson +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 : SyncAPI() { + override var name = "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 createAccountUrl = "$mainUrl/signup" + override val syncIdName = SyncIdName.Simkl + + /** Automatically adds simkl auth headers */ + // private val interceptor = HeaderInterceptor() + + /** + * This is required to override the reported last activity as simkl activites + * may not always update based on testing. + */ + private var lastScoreTime = -1L + + private object SimklCache { + private const val SIMKL_CACHE_KEY = "SIMKL_API_CACHE" + + 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? { + // Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject" + val type = mapper.typeFactory.constructParametricType( + SimklCacheWrapper::class.java, + T::class.java + ) + val cache = getKey(SIMKL_CACHE_KEY, path)?.let { + mapper.readValue>(it, type) + } + + 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 SIMKL_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'" + fun getUnixTime(string: String?): Long? { + return try { + SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { + this.timeZone = TimeZone.getTimeZone("UTC") + }.parse( + string ?: return null + )?.toInstant()?.epochSecond + } catch (e: Exception) { + logError(e) + return null + } + } + + /** 1409562611 -> 2014-09-01T09:10:11Z */ + fun getDateTime(unixTime: Long?): String? { + return try { + SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { + this.timeZone = TimeZone.getTimeZone("UTC") + }.format( + Date.from( + Instant.ofEpochSecond( + unixTime ?: return null + ) + ) + ) + } catch (e: Exception) { + null + } + } + + fun getPosterUrl(poster: String): String { + return "https://wsrv.nl/?url=https://simkl.in/posters/${poster}_m.webp" + } + + private fun getUrlFromId(id: Int): String { + return "https://simkl.com/shows/$id" + } + + enum class SimklListStatusType( + var value: Int, + @StringRes val stringRes: Int, + val originalName: String? + ) { + Watching(0, R.string.type_watching, "watching"), + Completed(1, R.string.type_completed, "completed"), + Paused(2, R.string.type_on_hold, "hold"), + Dropped(3, R.string.type_dropped, "dropped"), + Planning(4, R.string.type_plan_to_watch, "plantowatch"), + ReWatching(5, R.string.type_re_watching, "watching"), + None(-1, R.string.none, null); + + companion object { + fun fromString(string: String): SimklListStatusType? { + return SimklListStatusType.entries.firstOrNull { + it.originalName == string + } + } + } + } + + // ------------------- + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class TokenRequest( + @JsonProperty("code") val code: String, + @JsonProperty("client_id") val clientId: String = CLIENT_ID, + @JsonProperty("client_secret") val clientSecret: String = CLIENT_SECRET, + @JsonProperty("redirect_uri") val redirectUri: String = "$APP_STRING://simkl", + @JsonProperty("grant_type") val grantType: String = "authorization_code" + ) + + data class TokenResponse( + /** No expiration date */ + @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( + @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( + @JsonProperty("all") val all: String?, + @JsonProperty("tv_shows") val tvShows: UpdatedAt, + @JsonProperty("anime") val anime: UpdatedAt, + @JsonProperty("movies") val movies: UpdatedAt, + ) { + data class UpdatedAt( + @JsonProperty("all") val all: String?, + @JsonProperty("removed_from_list") val removedFromList: String?, + @JsonProperty("rated_at") val ratedAt: String?, + ) + } + + /** https://simkl.docs.apiary.io/#reference/tv/episodes/get-tv-show-episodes */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class EpisodeMetadata( + @JsonProperty("title") val title: String?, + @JsonProperty("description") val description: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("img") val img: String? + ) { + companion object { + fun convertToEpisodes(list: List?): List? { + return list?.map { + MediaObject.Season.Episode(it.episode) + } + } + + fun convertToSeasons(list: List?): List? { + return list?.filter { it.season != null }?.groupBy { + it.season + }?.mapNotNull { (season, episodes) -> + convertToEpisodes(episodes)?.let { MediaObject.Season(season!!, it) } + }?.ifEmpty { null } + } + } + } + + /** + * https://simkl.docs.apiary.io/#introduction/about-simkl-api/standard-media-objects + * Useful for finding shows from metadata + */ + @JsonInclude(JsonInclude.Include.NON_EMPTY) + open class MediaObject( + @JsonProperty("title") val title: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("ids") val ids: Ids?, + @JsonProperty("total_episodes") val totalEpisodes: Int? = null, + @JsonProperty("status") val status: String? = null, + @JsonProperty("poster") val poster: String? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("seasons") val seasons: List? = null, + @JsonProperty("episodes") val episodes: List? = null + ) { + fun hasEnded(): Boolean { + return status == "released" || status == "ended" + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Season( + @JsonProperty("number") val number: Int, + @JsonProperty("episodes") val episodes: List + ) { + data class Episode(@JsonProperty("number") val number: Int) + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class Ids( + @JsonProperty("simkl") val simkl: Int?, + @JsonProperty("imdb") val imdb: String? = null, + @JsonProperty("tmdb") val tmdb: String? = null, + @JsonProperty("mal") val mal: String? = null, + @JsonProperty("anilist") val anilist: String? = null, + ) { + companion object { + fun fromMap(map: Map): Ids { + return Ids( + simkl = map[SimklSyncServices.Simkl]?.toIntOrNull(), + imdb = map[SimklSyncServices.Imdb], + tmdb = map[SimklSyncServices.Tmdb], + mal = map[SimklSyncServices.Mal], + anilist = map[SimklSyncServices.AniList] + ) + } + } + } + + fun toSyncSearchResult(): SyncAPI.SyncSearchResult? { + return SyncAPI.SyncSearchResult( + this.title ?: return null, + "Simkl", + this.ids?.simkl?.toString() ?: return null, + getUrlFromId(this.ids.simkl), + this.poster?.let { getPosterUrl(it) }, + if (this.type == "movie") TvType.Movie else TvType.TvSeries + ) + } + } + + 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 ratedAt: String? = getDateTime(unixTime) + ) : MediaObject(title, year, ids) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + class StatusMediaObject( + @JsonProperty("title") title: String?, + @JsonProperty("year") year: Int?, + @JsonProperty("ids") ids: Ids?, + @JsonProperty("to") val to: String, + @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime) + ) : MediaObject(title, year, ids) + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + data class StatusRequest( + @JsonProperty("movies") val movies: List, + @JsonProperty("shows") val shows: List + ) + + /** https://simkl.docs.apiary.io/#reference/sync/get-all-items/get-all-items-in-the-user's-watchlist */ + data class AllItemsResponse( + @JsonProperty("shows") + val shows: List = emptyList(), + @JsonProperty("anime") + val anime: List = emptyList(), + @JsonProperty("movies") + val movies: List = emptyList(), + ) { + companion object { + fun merge(first: AllItemsResponse?, second: AllItemsResponse?): AllItemsResponse { + + // Replace the first item with the same id, or add the new item + fun MutableList.replaceOrAddItem(newItem: T, predicate: (T) -> Boolean) { + for (i in this.indices) { + if (predicate(this[i])) { + this[i] = newItem + return + } + } + this.add(newItem) + } + + // + fun merge( + first: List?, + second: List? + ): List { + return (first?.toMutableList() ?: mutableListOf()).apply { + second?.forEach { secondShow -> + this.replaceOrAddItem(secondShow) { + it.getIds().simkl == secondShow.getIds().simkl + } + } + } + } + + return AllItemsResponse( + merge(first?.shows, second?.shows), + merge(first?.anime, second?.anime), + merge(first?.movies, second?.movies), + ) + } + } + + interface Metadata { + val lastWatchedAt: String? + val status: String? + val userRating: Int? + val lastWatched: String? + val watchedEpisodesCount: Int? + val totalEpisodesCount: Int? + + fun getIds(): ShowMetadata.Show.Ids + fun toLibraryItem(): SyncAPI.LibraryItem + } + + data class MovieMetadata( + @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 { + return this.movie.ids + } + + override fun toLibraryItem(): SyncAPI.LibraryItem { + return SyncAPI.LibraryItem( + this.movie.title, + "https://simkl.com/tv/${movie.ids.simkl}", + movie.ids.simkl.toString(), + 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( + @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 + } + + override fun toLibraryItem(): SyncAPI.LibraryItem { + return SyncAPI.LibraryItem( + this.show.title, + "https://simkl.com/tv/${show.ids.simkl}", + show.ids.simkl.toString(), + 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( + @JsonProperty("title") val title: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("year") val year: Int?, + @JsonProperty("ids") val ids: Ids, + ) { + data class Ids( + @JsonProperty("simkl") val simkl: Int, + @JsonProperty("slug") val slug: String?, + @JsonProperty("imdb") val imdb: String?, + @JsonProperty("zap2it") val zap2it: String?, + @JsonProperty("tmdb") val tmdb: String?, + @JsonProperty("offen") val offen: String?, + @JsonProperty("tvdb") val tvdb: String?, + @JsonProperty("mal") val mal: String?, + @JsonProperty("anidb") val anidb: String?, + @JsonProperty("anilist") val anilist: String?, + @JsonProperty("traktslug") val traktslug: String? + ) { + fun matchesId(database: SimklSyncServices, id: String): Boolean { + return when (database) { + 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 + } + } + } + } + } + } + } + + /** + * Appends api keys to the requests + **/ + /*private inner class HeaderInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" } + return chain.proceed( + chain.request() + .newBuilder() + .addHeader("Authorization", "Bearer $token") + .addHeader("simkl-api-key", CLIENT_ID) + .build() + ) + } + }*/ + + private suspend fun getUser(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: SyncWatchType, + override var score: Score?, + val oldScore: Int?, + override var watchedEpisodes: Int?, + val episodeConstructor: SimklEpisodeConstructor, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + /** Save seen episodes separately to know the change from old to new. + * Required to remove seen episodes if count decreases */ + val oldEpisodes: Int, + val oldStatus: String? + ) : SyncAPI.AbstractSyncStatus() + + override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + if (auth == null) return null + val realIds = readIdFromString(id) + + // Key which assumes all ids are the same each time :/ + // 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) + } + } + } + + if (foundItem != null) { + return SimklSyncStatus( + status = foundItem.status?.let { + SyncWatchType.fromInternalId( + SimklListStatusType.fromString( + it + )?.value + ) + } + ?: return null, + 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 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 updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { + val parsedId = readIdFromString(id) + lastScoreTime = unixTime + val simklStatus = newStatus as? SimklSyncStatus + + 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)) + + + // Get episodes only when required + val episodes = simklStatus?.episodeConstructor?.getEpisodes() + + // All episodes if marked as completed + val watchedEpisodes = + if (newStatus.status.internalId == SimklListStatusType.Completed.value) { + episodes?.size + } else { + newStatus.watchedEpisodes + } + + builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes) + + requireLibraryRefresh = true + return builder.execute() + } + + + /** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */ + private suspend fun searchByIds(serviceMap: Map): Array? { + if (serviceMap.isEmpty()) return emptyArray() + + return app.get( + "$mainUrl/search/id", + params = mapOf("client_id" to CLIENT_ID) + serviceMap.map { (service, id) -> + service.originalName to id + } + ).parsedSafe() + } + + override suspend fun search(auth: AuthData?, query: String): List? { + return app.get( + "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) + ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } + } + + override fun loginRequest(): AuthLoginPage? { + val lastLoginState = BigInteger(130, SecureRandom()).toString(32) + val url = + "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}&state=$lastLoginState" + + return AuthLoginPage( + url = url, + payload = lastLoginState + ) + } + + override suspend fun load(auth: AuthData?, id: String): SyncResult? = null + + 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, + headers = getHeaders(auth.token) + ).parsedSafe() + } + + private suspend fun getActivities(token: AuthToken): ActivitiesResponse? { + return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe() + } + + private fun getSyncListCached(auth: AuthData): AllItemsResponse? { + return getKey(SIMKL_CACHED_LIST, auth.user.id.toString()) + } + + private suspend fun getSyncListSmart(auth: AuthData): AllItemsResponse? { + val activities = getActivities(auth.token) + val userId = auth.user.id.toString() + val lastCacheUpdate = getKey(SIMKL_CACHED_LIST_TIME, auth.user.id.toString()) + val lastRemoval = listOf( + activities?.tvShows?.removedFromList, + activities?.anime?.removedFromList, + activities?.movies?.removedFromList + ).maxOf { + getUnixTime(it) ?: -1 + } + val lastRealUpdate = + listOf( + activities?.tvShows?.all, + activities?.anime?.all, + activities?.movies?.all, + ).maxOf { + getUnixTime(it) ?: -1 + } + + debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" } + val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) { + debugPrint { "Full list update in ${this.name}." } + setKey(SIMKL_CACHED_LIST_TIME, userId, lastRemoval) + getSyncListSince(auth, null) + } else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) { + debugPrint { "Partial list update in ${this.name}." } + setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate) + AllItemsResponse.merge( + getSyncListCached(auth), + getSyncListSince(auth, lastCacheUpdate) + ) + } else { + debugPrint { "Cached list update in ${this.name}." } + getSyncListCached(auth) + } + debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } + + setKey(SIMKL_CACHED_LIST, userId, list) + + return list + } + + override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { + val list = getSyncListSmart(auth ?: return null) ?: return null + + val baseMap = + SimklListStatusType.entries + .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value } + .associate { + it.stringRes to emptyList() + } + + val syncMap = listOf(list.anime, list.movies, list.shows) + .flatten() + .groupBy { + it.status + } + .mapNotNull { (status, list) -> + val stringRes = + status?.let { SimklListStatusType.fromString(it)?.stringRes } + ?: return@mapNotNull null + val libraryList = list.map { it.toLibraryItem() } + stringRes to libraryList + }.toMap() + + return SyncAPI.LibraryMetadata( + (baseMap + syncMap).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( + ListSorting.AlphabeticalA, + ListSorting.AlphabeticalZ, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, + ListSorting.RatingHigh, + ListSorting.RatingLow, + ) + ) + } + + override fun urlToId(url: String): String? { + val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""") + return simklUrlRegex.find(url)?.groupValues?.get(1) ?: "" + } + + 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 != payload) return null + + val code = uri.getQueryParameter("code") ?: return null + val tokenResponse = app.post( + "$mainUrl/oauth/token", json = TokenRequest(code) + ).parsedSafe() ?: return null + + return AuthToken( + accessToken = tokenResponse.accessToken, + ) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val user = getUser(token ?: return null) + return AuthUser( + id = user.account.id, + name = user.user.name, + profilePicture = user.user.avatar + ) + } +} 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 00000000000..19122768e23 --- /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 00000000000..1f1e6de4450 --- /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 645cd573f14..93a79689e50 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.newSearchResponseList import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope.coroutineContext import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout class APIRepository(val api: MainAPI) { companion object { + // 2 minute timeout to prevent bad extensions/extractors from hogging the resources + // No real provider should take longer, so we hard kill them. + private const val DEFAULT_TIMEOUT = 120_000L + private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT + private const val MIN_TIMEOUT = 5_000L + var dubStatusActive = HashSet() val noneApi = object : MainAPI() { @@ -42,7 +57,11 @@ class APIRepository(val api: MainAPI) { private val cache = threadSafeListOf() 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) { @@ -67,52 +86,62 @@ 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 + synchronized(cache) { + for (item in cache) { + // 10 min save + if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { + return@withTimeout item.response + } } } - } - api.load(fixedUrl)?.also { response -> - 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) + 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 > CACHE_SIZE) { + cache[cacheIndex] = add // rolling cache + cacheIndex = (cacheIndex + 1) % CACHE_SIZE + } else { + cache.add(add) + } } - } - } ?: throw ErrorLoadingException() + } ?: throw ErrorLoadingException() + } } } - suspend fun search(query: String): 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 + ) + } } } @@ -122,41 +151,42 @@ class APIRepository(val api: MainAPI) { delay(delta) } - @OptIn(DelicateCoroutinesApi::class) 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() } + } } } } @@ -173,11 +203,13 @@ 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 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 00000000000..4ebb7564ca3 --- /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 00000000000..72955e7cf8c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt @@ -0,0 +1,278 @@ +package com.lagradost.cloudstream3.ui + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.LayoutRes +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceFragmentCompat +import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding +import com.lagradost.cloudstream3.utils.txt + +/** + * A base Fragment class that simplifies ViewBinding usage and handles view inflation safely. + * + * This class allows two modes of creating ViewBinding: + * 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes. + * 2. Bind: Using `bind()` on an existing root view. + * + * It also provides hooks for: + * - Safe initialization of the binding (`onBindingCreated`) + * - Automatic padding adjustment for system bars (`fixPadding`) + * - Optional layout resource selection via `pickLayout()` + * + * @param T The type of ViewBinding for this Fragment. + * @param bindingCreator The strategy used to create the binding instance. + */ +private interface BaseFragmentHelper { + val bindingCreator: BaseFragment.BindingCreator + + var _binding: T? + val binding: T? get() = _binding + + fun createBinding( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val layoutId = pickLayout() + val root: View? = layoutId?.let { inflater.inflate(it, container, false) } + _binding = try { + when (val creator = bindingCreator) { + is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false) + is BaseFragment.BindingCreator.Bind -> { + if (root != null) creator.fn(root) + else throw IllegalStateException("Root view is null for bind()") + } + } + } catch (t: Throwable) { + showToast( + txt(R.string.unable_to_inflate, t.message ?: ""), + Toast.LENGTH_LONG + ) + logError(t) + null + } + + return _binding?.root ?: root + } + + /** + * Called after the fragment's view has been created. + * + * This method is `final` to ensure that the binding is properly initialized and + * system bar padding adjustments are applied before any subclass logic runs. + * Subclasses should use [onBindingCreated] instead of overriding this method directly. + */ + fun onViewReady(view: View, savedInstanceState: Bundle?) { + fixLayout(view) + binding?.let { onBindingCreated(it, savedInstanceState) } + } + + /** + * Called when the binding is safely created and view is ready. + * Can be overridden to provide fragment-specific initialization. + * + * @param binding The safely created ViewBinding. + * @param savedInstanceState Saved state bundle or null. + */ + fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { + onBindingCreated(binding) + } + + /** + * Called when the binding is safely created and view is ready. + * Overload without savedInstanceState for convenience. + * + * @param binding The safely created ViewBinding. + */ + fun onBindingCreated(binding: T) {} + + /** + * Pick a layout resource ID for the fragment. + * + * Return `null` by default. Override to provide a layout resource when using + * `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`. + * + * @return Layout resource ID or null. + */ + @LayoutRes + fun pickLayout(): Int? = null + + /** + * Ensures the layout of the root view is correctly adjusted for the current configuration. + * + * This may include applying padding for system bars, adjusting insets, or performing other + * layout updates. `fixLayout` should remain idempotent, as it can be called multiple + * times on the same view, such as during configuration changes (e.g. device rotation) or when + * the view is recreated. + * + * @param view The root view to adjust. + */ + fun fixLayout(view: View) +} + +abstract class BaseFragment( + override val bindingCreator: BindingCreator +) : Fragment(), BaseFragmentHelper { + override var _binding: T? = null + + /** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */ + fun dispatchBackPressed() { + try { + activity?.onBackPressedDispatcher?.onBackPressed() + } catch (_: IllegalStateException) { + // FragmentManager is already executing transactions, so try again + delayedDispatchBackPressed(5) + } catch (t: Throwable) { + logError(t) + } + } + + /** Recursive back press when available */ + private fun delayedDispatchBackPressed(remaining: Int) { + if (remaining <= 0) return + binding?.root?.postDelayed({ + try { + activity?.onBackPressedDispatcher?.onBackPressed() + } catch (_: IllegalStateException) { + // FragmentManager is already executing transactions, so try again + delayedDispatchBackPressed(remaining - 1) + } catch (t: Throwable) { + logError(t) + } + }, 200) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** + * Called when the device configuration changes (e.g., orientation). + * Re-applies system bar padding fixes to the root view to ensure it + * readjusts for orientation changes. + */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + /** + * Sealed class representing the two strategies for creating a ViewBinding instance. + */ + sealed class BindingCreator { + + /** + * Use the standard inflate() method for creating the binding. + * + * @param fn Lambda that inflates the binding. + */ + class Inflate( + val fn: (LayoutInflater, ViewGroup?, Boolean) -> T + ) : BindingCreator() + + /** + * Use bind() on an existing root view to create the binding. This should + * be used if you are differing per device layouts, such as different + * layouts for TV and Phone. + * + * @param fn Lambda that binds the root view. + */ + class Bind( + val fn: (View) -> T + ) : BindingCreator() + } +} + +abstract class BaseDialogFragment( + override val bindingCreator: BaseFragment.BindingCreator +) : DialogFragment(), BaseFragmentHelper { + override var _binding: T? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** @see [BaseFragment.onConfigurationChanged] for documentation. */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +abstract class BaseBottomSheetDialogFragment( + override val bindingCreator: BaseFragment.BindingCreator +) : BottomSheetDialogFragment(), BaseFragmentHelper { + override var _binding: T? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** @see [BaseFragment.onConfigurationChanged] for documentation. */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setSystemBarsPadding() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + setSystemBarsPadding() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index 46ddce09c7b..f91d40f28e0 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,19 @@ 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.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 +30,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,7 +105,7 @@ data class MetadataHolder( class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { - private val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) + private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() init { @@ -232,12 +240,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 +285,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi var isLoadingMore = false + override fun onMediaStatusUpdated() { super.onMediaStatusUpdated() val meta = getCurrentMetaData() @@ -280,7 +304,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val currentDuration = remoteMediaClient?.streamDuration val currentPosition = remoteMediaClient?.approximateStreamPosition if (currentDuration != null && currentPosition != null) - DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration) + DataStoreHelper.setViewPosAndResume( + epData.id, + currentPosition, + currentDuration, + epData, + meta.episodes.getOrNull(index + 1) + ) } catch (t: Throwable) { logError(t) } @@ -294,14 +324,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) 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 64% 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 138084fce73..30235853887 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,11 +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, @@ -23,7 +26,7 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : GridLayoutManage } } - override fun onRequestChildFocus( + /*override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, child: View, @@ -31,13 +34,17 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : GridLayoutManage ): Boolean { // android.widget.FrameLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams return try { - val pos = maxOf(0, getPosition(focused!!) - 2) - parent.scrollToPosition(pos) + if(focused != null) { + // val pos = maxOf(0, getPosition(focused) - 2) // IDK WHY + val pos = getPosition(focused) + if(pos >= 0) parent.scrollToPosition(pos) + } + super.onRequestChildFocus(parent, state, child, focused) - } catch (e: Exception){ + } catch (e: Exception) { false } - } + }*/ // Allows moving right and left with focus https://gist.github.com/vganin/8930b41f55820ec49e4d override fun onInterceptFocusSearch(focused: View, direction: Int): View? { @@ -64,32 +71,47 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : GridLayoutManage val spanCount = this.spanCount val orientation = this.orientation - if (orientation == VERTICAL) { + // fixes arabic by inverting left and right layout focus + val correctDirection = if (this.isLayoutRTL) { when (direction) { + View.FOCUS_RIGHT -> View.FOCUS_LEFT + View.FOCUS_LEFT -> View.FOCUS_RIGHT + else -> direction + } + } else direction + + if (orientation == VERTICAL) { + when (correctDirection) { View.FOCUS_DOWN -> { return spanCount } + View.FOCUS_UP -> { return -spanCount } + View.FOCUS_RIGHT -> { return 1 } + View.FOCUS_LEFT -> { return -1 } } } else if (orientation == HORIZONTAL) { - when (direction) { + when (correctDirection) { View.FOCUS_DOWN -> { return 1 } + View.FOCUS_UP -> { return -1 } + View.FOCUS_RIGHT -> { return spanCount } + View.FOCUS_LEFT -> { return -spanCount } @@ -133,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 556ebd34eb8..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt +++ /dev/null @@ -1,96 +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 kotlinx.android.synthetic.main.activity_easter_egg_monke.* -import java.util.* - -class EasterEggMonke : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_easter_egg_monke) - - val handler = Handler(mainLooper) - lateinit var runnable: Runnable - runnable = Runnable { - shower() - handler.postDelayed(runnable, 300) - } - handler.postDelayed(runnable, 1000) - - } - - private fun shower() { - - val containerW = frame.width - val containerH = frame.height - var starW: Float = monke.width.toFloat() - var starH: Float = 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) - 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) { - 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 00000000000..9be86207759 --- /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/MiniControllerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt index aba6395f9ad..bd8541e6b26 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt @@ -7,12 +7,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.RelativeLayout +import androidx.core.content.withStyledAttributes import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.toPx -import java.lang.ref.WeakReference class MyMiniControllerFragment : MiniControllerFragment() { @@ -25,26 +25,15 @@ class MyMiniControllerFragment : MiniControllerFragment() { // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { - super.onInflate(context, attributeSet, bundle) - - // somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks???? if (currentColor == 0) { - WeakReference( - context.obtainStyledAttributes( - attributeSet, - R.styleable.CustomCast - ) - ).apply { - if (get() - ?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true - ) { - currentColor = - get() - ?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0 + context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { + if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { + currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) } - get()?.recycle() - }.clear() + } } + + super.onInflate(context, attributeSet, bundle) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -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 00000000000..12a5ae2a29e --- /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 eb4eb66656e..ec0ef5c6bfb 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 19e24f74123..0d951bf6a7a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -1,31 +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.R 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 kotlinx.android.synthetic.main.fragment_webview.* +import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository -class WebviewFragment : Fragment() { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) +class WebviewFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate) +) { + + override fun fixLayout(view: View) = Unit + + override fun onBindingCreated(binding: FragmentWebviewBinding) { val url = arguments?.getString(WEBVIEW_URL) ?: "".also { findNavController().popBackStack() } - web_view.webViewClient = object : WebViewClient() { + binding.webView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? @@ -41,23 +41,16 @@ class WebviewFragment : Fragment() { } } - WebViewResolver.webViewUserAgent = web_view.settings.userAgentString + binding.webView.apply { + WebViewResolver.webViewUserAgent = settings.userAgentString - web_view.addJavascriptInterface(RepoApi(activity), "RepoApi") - web_view.settings.javaScriptEnabled = true - web_view.settings.userAgentString = USER_AGENT - web_view.settings.domStorageEnabled = true -// WebView.setWebContentsDebuggingEnabled(true) + addJavascriptInterface(RepoApi(activity), "RepoApi") + settings.javaScriptEnabled = true + settings.userAgentString = USER_AGENT + settings.domStorageEnabled = true - web_view.loadUrl(url) - } - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_webview, container, false) + loadUrl(url) + } } companion object { @@ -70,8 +63,8 @@ class WebviewFragment : Fragment() { private class RepoApi(val activity: FragmentActivity?) { @JavascriptInterface - fun installRepo(repoUrl: String) { + fun installRepo(repoUrl: String) { activity?.loadRepository(repoUrl) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt new file mode 100644 index 00000000000..92d33d0f349 --- /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 00000000000..1d6b41e5baf --- /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 00000000000..ad323c7d124 --- /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 00000000000..eb907b34472 --- /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 00000000000..96eaf52a773 --- /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 00000000000..1b48143a635 --- /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 0069be3a5e7..dae70ebd7ad 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -1,26 +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.lagradost.cloudstream3.CommonActivity.showToast +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.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.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(activity: Activity?, click: DownloadClickEvent) { + 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 -> @@ -29,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 } } } @@ -49,18 +60,20 @@ object DownloadButtonSetup { ) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) - .show() + .show().setDefaultFocus() } catch (e: Exception) { logError(e) // ye you somehow fucked up formatting did you? } } } + 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) { @@ -70,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) @@ -79,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(act, R.string.delete, Toast.LENGTH_LONG) - } else { - showToast(act, 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/DownloadButtonViewHolder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonViewHolder.kt deleted file mode 100644 index 0096ff42063..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonViewHolder.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -interface DownloadButtonViewHolder { - var downloadButton : EasyDownloadButton - fun reattachDownloadButton() -} \ No newline at end of file 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 a541171bfe5..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.cardview.widget.CardView -import androidx.core.widget.ContentLoadingProgressBar -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.R -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 -import kotlinx.android.synthetic.main.download_child_episode.view.* -import java.util.* - -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: EasyDownloadButton.IMinimumData) - -class DownloadChildAdapter( - var cardList: List, - private val clickCallback: (DownloadClickEvent) -> Unit, -) : RecyclerView.Adapter() { - - private val mBoundViewHolders: HashSet = HashSet() - private fun getAllBoundViewHolders(): Set? { - return Collections.unmodifiableSet(mBoundViewHolders) - } - - fun killAdapter() { - getAllBoundViewHolders()?.forEach { view -> - view?.downloadButton?.dispose() - } - } - - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - } - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - mBoundViewHolders.remove(holder) - } - } - - override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.reattachDownloadButton() - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return DownloadChildViewHolder( - LayoutInflater.from(parent.context).inflate(R.layout.download_child_episode, parent, false), - clickCallback - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is DownloadChildViewHolder -> { - holder.bind(cardList[position]) - mBoundViewHolders.add(holder) - } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - class DownloadChildViewHolder - constructor( - itemView: View, - private val clickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder { - override var downloadButton = EasyDownloadButton() - - 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 - - private var localCard: VisualDownloadChildCached? = null - - fun bind(card: VisualDownloadChildCached) { - localCard = card - val d = card.data - - val posDur = getViewPos(d.id) - if (posDur != null) { - val visualPos = posDur.fixVisual() - progressBar.max = (visualPos.duration / 1000).toInt() - progressBar.progress = (visualPos.position / 1000).toInt() - progressBar.visibility = View.VISIBLE - } else { - progressBar.visibility = View.GONE - } - - title.text = title.context.getNameFull(d.name, d.episode, d.season) - title.isSelected = true // is needed for text repeating - - downloadButton.setUpButton( - card.currentBytes, - card.totalBytes, - progressBarDownload, - downloadImage, - extraInfo, - card.data, - clickCallback - ) - - holder.setOnClickListener { - clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d)) - } - } - - override fun reattachDownloadButton() { - downloadButton.dispose() - val card = localCard - if (card != null) { - downloadButton.setUpButton( - card.currentBytes, - card.totalBytes, - progressBarDownload, - downloadImage, - extraInfo, - card.data, - clickCallback - ) - } - } - } -} 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 477a18e078b..d44ea0020b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt @@ -1,27 +1,38 @@ 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.GridLayoutManager -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.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.android.synthetic.main.fragment_child_downloads.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -class DownloadChildFragment : Fragment() { +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV + +class DownloadChildFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate) +) { + + private val downloadViewModel: DownloadViewModel by activityViewModels() + companion object { - fun newInstance(headerName: String, folder: String) : Bundle { + fun newInstance(headerName: String, folder: String): Bundle { return Bundle().apply { putString("folder", folder) putString("name", headerName) @@ -30,77 +41,138 @@ class DownloadChildFragment : Fragment() { } override fun onDestroyView() { - (download_child_list?.adapter as DownloadChildAdapter?)?.killAdapter() - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } + activity?.detachBackPressedCallback("Downloads") + downloadViewModel.clearChildren() super.onDestroyView() } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return 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) + override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { + val folder = arguments?.getString("folder") + val name = arguments?.getString("name") + if (folder == null) { + dispatchBackPressed() + return + } + + context?.let { downloadViewModel.updateChildList(it, folder) } + + binding.downloadChildToolbar.apply { + title = name + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + dispatchBackPressed() } - }.sortedBy { it.data.episode + (it.data.season?: 0)*100000 } - if (eps.isEmpty()) { - activity?.onBackPressed() - return@main } - - (download_child_list?.adapter as DownloadChildAdapter? ?: return@main).cardList = eps - download_child_list?.adapter?.notifyDataSetChanged() + setAppBarNoScrollFlagsOnTV() } - } - private var downloadDeleteEventListener: ((Int) -> Unit)? = null + binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + observe(downloadViewModel.childCards) { cards -> + when (cards) { + is Resource.Success -> { + if (cards.value.isEmpty()) { + dispatchBackPressed() + } + (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) + } - val folder = arguments?.getString("folder") - val name = arguments?.getString("name") - if (folder == null) { - activity?.onBackPressed() // TODO FIX - return + else -> { + (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) + } + } } - context?.fixPaddingStatusbar(download_child_root) - download_child_toolbar.title = name - download_child_toolbar.setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - download_child_toolbar.setNavigationOnClickListener { - activity?.onBackPressed() + observe(downloadViewModel.selectedBytes) { + updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) } - val adapter: RecyclerView.Adapter = - DownloadChildAdapter( - ArrayList(), - ) { click -> - handleDownloadClick(activity, click) + + binding.apply { + btnDelete.setOnClickListener { view -> + downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) + } + + btnCancel.setOnClickListener { + downloadViewModel.cancelSelection() } - downloadDeleteEventListener = { id: Int -> - val list = (download_child_list?.adapter as DownloadChildAdapter?)?.cardList - if (list != null) { - if (list.any { it.data.id == id }) { - updateList(folder) + btnToggleAll.setOnClickListener { + val allSelected = downloadViewModel.isAllChildrenSelected() + if (allSelected) { + downloadViewModel.clearSelectedItems() + } else { + downloadViewModel.selectAllChildren() } } } - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } + 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) + } + ) - download_child_list.adapter = adapter - download_child_list.layoutManager = GridLayoutManager(context, 1) + binding.downloadChildList.apply { + setHasFixedSize(true) + setItemViewCacheSize(20) + this.adapter = adapter + setLinearListLayout( + isHorizontal = false, + nextRight = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + } + } - updateList(folder) + 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 b2286c9952f..abc432ef959 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -1,52 +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.databinding.FragmentDownloadsBinding +import com.lagradost.cloudstream3.databinding.StreamInputBinding +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel +import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LinkGenerator -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlinx.android.synthetic.main.fragment_downloads.* -import kotlinx.android.synthetic.main.stream_input.* -import android.text.format.Formatter.formatShortFileSize -import androidx.core.widget.doOnTextChanged -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +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( @@ -57,199 +70,318 @@ class DownloadFragment : Fragment() { this.layoutParams = param } - private fun setList(list: List) { - main { - (download_list?.adapter as DownloadHeaderAdapter?)?.cardList = list - download_list?.adapter?.notifyDataSetChanged() - } - } - override fun onDestroyView() { - if (downloadDeleteEventListener != null) { - VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!! - downloadDeleteEventListener = null - } - (download_list?.adapter as DownloadHeaderAdapter?)?.killAdapter() + activity?.detachBackPressedCallback("Downloads") super.onDestroyView() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - downloadsViewModel = - ViewModelProvider(this)[DownloadViewModel::class.java] - - return 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) { - text_no_downloads.text = it - } - observe(downloadsViewModel.headerCards) { - setList(it) - download_loading.isVisible = false + 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(downloadsViewModel.availableBytes) { - download_free_txt?.text = - getString(R.string.storage_size_format).format( - getString(R.string.free_storage), - formatShortFileSize(view.context, it) - ) - download_free?.setLayoutWidth(it) + + observe(downloadViewModel.availableBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.free_storage, + binding.downloadFreeTxt, + binding.downloadFree + ) } - observe(downloadsViewModel.usedBytes) { - download_used_txt?.text = - getString(R.string.storage_size_format).format( - getString(R.string.used_storage), - formatShortFileSize(view.context, it) - ) - download_used?.setLayoutWidth(it) - download_storage_appbar?.isVisible = it > 0 - } - observe(downloadsViewModel.downloadBytes) { - download_app_txt?.text = - getString(R.string.storage_size_format).format( - getString(R.string.app_storage), - formatShortFileSize(view.context, it) - ) - download_app?.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 - ) - } - } + observe(downloadViewModel.usedBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.used_storage, + binding.downloadUsedTxt, + binding.downloadUsed + ) - }, - { downloadClickEvent -> - if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter - handleDownloadClick(activity, downloadClickEvent) - if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) { - context?.let { ctx -> - 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 + } + } - downloadDeleteEventListener = { id -> - val list = (download_list?.adapter as DownloadHeaderAdapter?)?.cardList - if (list != null) { - if (list.any { it.data.id == id }) { - context?.let { ctx -> - setList(ArrayList()) - downloadsViewModel.updateList(ctx) - } + 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 - download_list?.adapter = adapter - download_list?.layoutManager = GridLayoutManager(context, 1) - download_stream_button?.isGone = isTvSettings() - download_stream_button?.setOnClickListener { - val dialog = - Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom) - dialog.setContentView(R.layout.stream_input) + if (selection == null) { + activity?.detachBackPressedCallback("Downloads") + return@observeNullable + } + activity?.attachBackPressedCallback("Downloads") { + downloadViewModel.cancelSelection() + } + updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) - dialog.show() + 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) + } - // If user has clicked the switch do not interfere - var preventAutoSwitching = false - dialog.hls_switch?.setOnClickListener { - preventAutoSwitching = true + 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, + nextDown = R.id.download_queue_button, + ) + } - fun activateSwitchOnHls(text: String?) { - dialog.hls_switch?.isChecked = normalSafeApiCall { - URI(text).path?.substringAfterLast(".")?.contains("m3u") - } == true + binding.apply { + openLocalVideoButton.apply { + isGone = isLayout(TV) + setOnClickListener { openLocalVideo() } + } + downloadStreamButton.apply { + isGone = isLayout(TV) + setOnClickListener { showStreamInputDialog(it.context) } } - dialog.stream_referer?.doOnTextChanged { text, _, _, _ -> - if (!preventAutoSwitching) - activateSwitchOnHls(text?.toString()) + downloadQueueButton.setOnClickListener { + activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue) } - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( - 0 - )?.text?.toString()?.let { copy -> - val fixedText = copy.trim() - dialog.stream_url?.setText(fixedText) - activateSwitchOnHls(fixedText) + 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) } + } - dialog.apply_btt?.setOnClickListener { - val url = dialog.stream_url.text?.toString() - if (url.isNullOrEmpty()) { - showToast(activity, R.string.error_invalid_url, Toast.LENGTH_SHORT) - } else { - val referer = dialog.stream_referer.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(url), - extract = true, - referer = referer, - isM3u8 = dialog.hls_switch?.isChecked - ) - ) + R.id.action_navigation_downloads_to_navigation_download_child, + DownloadChildFragment.newInstance(click.data.name, folder) ) - - dialog.dismissSafe(activity) } } - dialog.cancel_btt?.setOnClickListener { - dialog.dismissSafe(activity) + DOWNLOAD_ACTION_LOAD_RESULT -> { + activity?.loadResult(click.data.url, click.data.apiName, click.data.name) } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - download_list?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - val dy = scrollY - oldScrollY - if (dy > 0) { //check for scroll down - download_stream_button?.shrink() // hide - } else if (dy < -5) { - download_stream_button?.extend() // show - } + } + + 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) } } - downloadsViewModel.updateList(requireContext()) - context?.fixPaddingStatusbar(download_root) + 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 29bb303af78..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt +++ /dev/null @@ -1,180 +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 android.widget.ImageView -import android.widget.TextView -import androidx.cardview.widget.CardView -import androidx.core.widget.ContentLoadingProgressBar -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import kotlinx.android.synthetic.main.download_header_episode.view.* -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() { - - private val mBoundViewHolders: HashSet = HashSet() - private fun getAllBoundViewHolders(): Set? { - return Collections.unmodifiableSet(mBoundViewHolders) - } - - fun killAdapter() { - getAllBoundViewHolders()?.forEach { view -> - view?.downloadButton?.dispose() - } - } - - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - } - } - - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - mBoundViewHolders.remove(holder) - } - } - - override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.reattachDownloadButton() - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return DownloadHeaderViewHolder( - LayoutInflater.from(parent.context).inflate(R.layout.download_header_episode, parent, false), - clickCallback, - movieClickCallback - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is DownloadHeaderViewHolder -> { - holder.bind(cardList[position]) - mBoundViewHolders.add(holder) - } - } - } - - override fun getItemCount(): Int { - return cardList.size - } - - class DownloadHeaderViewHolder - constructor( - itemView: View, - private val clickCallback: (DownloadHeaderClickEvent) -> Unit, - private val movieClickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder { - override var downloadButton = EasyDownloadButton() - - 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 - private var localCard: VisualDownloadHeaderCached? = null - - @SuppressLint("SetTextI18n") - fun bind(card: VisualDownloadHeaderCached) { - localCard = card - val d = card.data - - poster?.setImage(d.poster) - poster?.setOnClickListener { - clickCallback.invoke(DownloadHeaderClickEvent(1, d)) - } - - title.text = d.name - val mbString = formatShortFileSize(itemView.context, card.totalBytes) - - //val isMovie = d.type.isMovieType() - if (card.child != null) { - downloadBar.visibility = View.VISIBLE - downloadImage.visibility = View.VISIBLE - normalImage.visibility = View.GONE - /*setUpButton( - card.currentBytes, - card.totalBytes, - downloadBar, - downloadImage, - extraInfo, - card.child, - movieClickCallback - )*/ - - holder.setOnClickListener { - movieClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, card.child)) - } - } else { - downloadBar.visibility = View.GONE - downloadImage.visibility = View.GONE - normalImage.visibility = View.VISIBLE - - try { - extraInfo.text = - extraInfo.context.getString(R.string.extra_info_format).format( - card.totalDownloads, - if (card.totalDownloads == 1) extraInfo.context.getString(R.string.episode) else extraInfo.context.getString( - R.string.episodes - ), - mbString - ) - } catch (t : Throwable) { - // you probably formatted incorrectly - extraInfo.text = "Error" - logError(t) - } - - - holder.setOnClickListener { - clickCallback.invoke(DownloadHeaderClickEvent(0, d)) - } - } - } - - override fun reattachDownloadButton() { - downloadButton.dispose() - val card = localCard - if (card?.child != null) { - downloadButton.setUpButton( - card.currentBytes, - card.totalBytes, - downloadBar, - downloadImage, - extraInfo, - card.child, - movieClickCallback - ) - } - } - } -} 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 3a74a715c97..0d35d5670f5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -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) } + 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() + } + } + + 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) + } + + + fun removeRedundantEpisodeKeys(context: Context, keys: List>) { + val settingsManager = context.getSharedPrefs() + ioSafe { + settingsManager.edit { + keys.forEach { (parentId, childId) -> + Log.i(TAG, "Removing download episode key: ${parentId}/${childId}") + val oldPath = getFolderName( + getFolderName( + DOWNLOAD_EPISODE_CACHE, + parentId.toString() + ), + childId.toString() + ) + val newPath = getFolderName( + getFolderName( + DOWNLOAD_EPISODE_CACHE_BACKUP, + parentId.toString() + ), + childId.toString() + ) + + val oldPref = settingsManager.getString(oldPath, null) + // Cowardly future backup solution in case the key removal fails in some edge case. + // This and all backup keys may be removed in a future update if the key removal is proven to be robust. + this.putString(newPath, oldPref) + this.remove(oldPath) + } + } + } + } + + 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 = HashMap() + val totalBytesUsedByChild = mutableMapOf() // parentId : bytes - val currentBytesUsedByChild = HashMap() + val currentBytesUsedByChild = mutableMapOf() // parentId : downloadsCount - val totalDownloads = HashMap() + val totalDownloads = mutableMapOf() + val redundantDownloads = mutableListOf>() + children.forEach { child -> + val childFile = getDownloadFileInfo(context, child.id) - // Gets all children downloads - withContext(Dispatchers.IO) { - for (c in children) { - val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue + 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 - if (childFile.fileLength <= 1) continue - val len = childFile.totalBytes - val flen = childFile.fileLength + 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 - } + 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 + ) + } - val cached = withContext(Dispatchers.IO) { // wont fetch useless keys - totalDownloads.entries.filter { it.value > 0 }.mapNotNull { - context.getKey( - DOWNLOAD_HEADER_CACHE, - it.key.toString() - ) + 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) } + } + + 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) + } - _headerCards.postValue(visual) + 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/EasyDownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt deleted file mode 100644 index 778784326f2..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.lagradost.cloudstream3.ui.download - -import android.animation.ObjectAnimator -import android.text.format.Formatter.formatShortFileSize -import android.view.View -import android.view.animation.DecelerateInterpolator -import android.widget.ImageView -import android.widget.TextView -import androidx.core.view.isGone -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.Coroutines -import com.lagradost.cloudstream3.utils.IDisposable -import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons -import com.lagradost.cloudstream3.utils.VideoDownloadManager - -class EasyDownloadButton : IDisposable { - interface IMinimumData { - val id: Int - } - - private var _clickCallback: ((DownloadClickEvent) -> Unit)? = null - private var _imageChangeCallback: ((Pair) -> Unit)? = null - - override fun dispose() { - try { - _clickCallback = null - _imageChangeCallback = null - downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent -= it } - downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent -= it } - } catch (e: Exception) { - e.printStackTrace() - } - } - - private var downloadProgressEventListener: ((Triple) -> Unit)? = null - private var downloadStatusEventListener: ((Pair) -> Unit)? = - null - - fun setUpMaterialButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - downloadButton: MaterialButton, - textView: TextView?, - data: IMinimumData, - clickCallback: (DownloadClickEvent) -> Unit, - ) { - setUpDownloadButton( - setupCurrentBytes, - setupTotalBytes, - progressBar, - textView, - data, - downloadButton, - { - downloadButton.setIconResource(it.first) - downloadButton.text = it.second - }, - clickCallback - ) - } - - fun setUpMoreButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - downloadImage: ImageView, - textView: TextView?, - textViewProgress: TextView?, - clickableView: View, - isTextPercentage: Boolean, - data: IMinimumData, - clickCallback: (DownloadClickEvent) -> Unit, - ) { - setUpDownloadButton( - setupCurrentBytes, - setupTotalBytes, - progressBar, - textViewProgress, - data, - clickableView, - { (image, text) -> - downloadImage.isVisible = textViewProgress?.isGone ?: true - downloadImage.setImageResource(image) - textView?.text = text - }, - clickCallback, isTextPercentage - ) - } - - fun setUpButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - downloadImage: ImageView, - textView: TextView?, - data: IMinimumData, - clickCallback: (DownloadClickEvent) -> Unit, - ) { - setUpDownloadButton( - setupCurrentBytes, - setupTotalBytes, - progressBar, - textView, - data, - downloadImage, - { - downloadImage.setImageResource(it.first) - }, - clickCallback - ) - } - - private fun setUpDownloadButton( - setupCurrentBytes: Long?, - setupTotalBytes: Long?, - progressBar: ContentLoadingProgressBar, - textView: TextView?, - data: IMinimumData, - downloadView: View, - downloadImageChangeCallback: (Pair) -> Unit, - clickCallback: (DownloadClickEvent) -> Unit, - isTextPercentage: Boolean = false - ) { - _clickCallback = clickCallback - _imageChangeCallback = downloadImageChangeCallback - var lastState: VideoDownloadManager.DownloadType? = null - var currentBytes = setupCurrentBytes ?: 0 - var totalBytes = setupTotalBytes ?: 0 - var needImageUpdate = true - - fun changeDownloadImage(state: VideoDownloadManager.DownloadType) { - lastState = state - if (currentBytes <= 0) needImageUpdate = true - val img = if (currentBytes > 0) { - when (state) { - VideoDownloadManager.DownloadType.IsPaused -> Pair( - R.drawable.ic_baseline_play_arrow_24, - R.string.download_paused - ) - VideoDownloadManager.DownloadType.IsDownloading -> Pair( - R.drawable.netflix_pause, - R.string.downloading - ) - else -> Pair(R.drawable.ic_baseline_delete_outline_24, R.string.downloaded) - } - } else { - Pair(R.drawable.netflix_download, R.string.download) - } - _imageChangeCallback?.invoke( - Pair( - img.first, - downloadView.context.getString(img.second) - ) - ) - } - - fun fixDownloadedBytes(setCurrentBytes: Long, setTotalBytes: Long, animate: Boolean) { - currentBytes = setCurrentBytes - totalBytes = setTotalBytes - - if (currentBytes == 0L) { - changeDownloadImage(VideoDownloadManager.DownloadType.IsStopped) - textView?.visibility = View.GONE - progressBar.visibility = View.GONE - } else { - if (lastState == VideoDownloadManager.DownloadType.IsStopped) { - changeDownloadImage(VideoDownloadManager.getDownloadState(data.id)) - } - textView?.visibility = View.VISIBLE - progressBar.visibility = View.VISIBLE - val currentMbString = formatShortFileSize(textView?.context, setCurrentBytes) - val totalMbString = formatShortFileSize(textView?.context, setTotalBytes) - - textView?.text = - if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else - textView?.context?.getString(R.string.download_size_format) - ?.format(currentMbString, totalMbString) - - progressBar.let { bar -> - bar.max = (setTotalBytes / 1000).toInt() - - if (animate) { - val animation: ObjectAnimator = ObjectAnimator.ofInt( - bar, - "progress", - bar.progress, - (setCurrentBytes / 1000).toInt() - ) - animation.duration = 500 - animation.setAutoCancel(true) - animation.interpolator = DecelerateInterpolator() - animation.start() - } else { - bar.progress = (setCurrentBytes / 1000).toInt() - } - } - } - } - - fixDownloadedBytes(currentBytes, totalBytes, false) - changeDownloadImage(VideoDownloadManager.getDownloadState(data.id)) - - downloadProgressEventListener = { downloadData: Triple -> - if (data.id == downloadData.first) { - if (downloadData.second != currentBytes || downloadData.third != totalBytes) { // TO PREVENT WASTING UI TIME - Coroutines.runOnMainThread { - fixDownloadedBytes(downloadData.second, downloadData.third, true) - changeDownloadImage(VideoDownloadManager.getDownloadState(data.id)) - } - } - } - } - - downloadStatusEventListener = - { downloadData: Pair -> - if (data.id == downloadData.first) { - if (lastState != downloadData.second || needImageUpdate) { // TO PREVENT WASTING UI TIME - Coroutines.runOnMainThread { - changeDownloadImage(downloadData.second) - } - } - } - } - - downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent += it } - downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent += it } - - downloadView.setOnClickListener { - if (currentBytes <= 0 || totalBytes <= 0) { - _clickCallback?.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data)) - } else { - val list = arrayListOf( - Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), - Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file), - ) - - // DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone && - if ((currentBytes * 100 / totalBytes) < 98) { - list.add( - if (lastState == VideoDownloadManager.DownloadType.IsDownloading) - Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download) - else - Pair(DOWNLOAD_ACTION_RESUME_DOWNLOAD, R.string.popup_resume_download) - ) - } - - it.popupMenuNoIcons( - list - ) { - _clickCallback?.invoke(DownloadClickEvent(itemId, data)) - } - } - } - - downloadView.setOnLongClickListener { - clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data)) - return@setOnLongClickListener true - } - } -} 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 new file mode 100644 index 00000000000..382a770cd28 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt @@ -0,0 +1,219 @@ +package com.lagradost.cloudstream3.ui.download.button + +import android.content.Context +import android.text.format.Formatter.formatShortFileSize +import android.util.AttributeSet +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.LayoutRes +import androidx.core.view.isVisible +import androidx.core.widget.ContentLoadingProgressBar +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.mainWork +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager + +typealias DownloadStatusTell = VideoDownloadManager.DownloadType + +data class DownloadMetadata( + var id: Int, + var downloadedLength: Long, + var totalLength: Long, + var status: DownloadStatusTell? = null +) { + val progressPercentage: Long + get() = if (downloadedLength < 1024) 0 else maxOf( + 0, + minOf(100, (downloadedLength * 100L) / (totalLength + 1)) + ) +} + +abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : + FrameLayout(context, attributeSet) { + + var persistentId: Int? = null // used to save sessions + + lateinit var progressBar: ContentLoadingProgressBar + var progressText: TextView? = null + + /* val gid: String? get() = sessionIdToGid[persistentId] + + // used for resuming data + var _lastRequestOverride: UriRequest? = null + var lastRequest: UriRequest? + get() = _lastRequestOverride ?: sessionIdToLastRequest[persistentId] + set(value) { + _lastRequestOverride = value + } + + var files: List = emptyList() */ + protected var isZeroBytes: Boolean = true + + fun inflate(@LayoutRes layout: Int) { + inflate(context, layout, this) + } + + init { + @Suppress("LeakingThis") + resetViewData() + } + + var doSetProgress = true + + open fun resetViewData() { + // lastRequest = null + progressText = null + isZeroBytes = true + doSetProgress = true + persistentId = null + } + + var currentMetaData: DownloadMetadata = + DownloadMetadata(0, 0, 0, null) + + fun setPersistentId(id: Int) { + persistentId = id + currentMetaData.id = id + + if (!doSetProgress) return + val appContext = context.applicationContext + + ioSafe { + val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id) + mainWork { + if (savedData != null) { + val downloadedBytes = savedData.fileLength + val totalBytes = savedData.totalBytes + + setProgress(downloadedBytes, totalBytes) + applyMetaData(id, downloadedBytes, totalBytes) + } + } + } + } + + 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 { + val steps = 10000L + progressBar.max = steps.toInt() + // div by zero error and 1 byte off is ok impo + + val progress = (downloadedBytes * steps / (totalBytes + 1L)).toInt() + + val animation = ProgressBarAnimation( + progressBar, + progressBar.progress.toFloat(), + progress.toFloat() + ).apply { + fillAfter = true + duration = + if (progress > progressBar.progress) // we don't want to animate backward changes in progress + 100 + else + 0L + } + + if (isZeroBytes) { + progressText?.isVisible = false + } else { + 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) + } + } + } + + progressBar.startAnimation(animation) + } + } + + fun downloadStatusEvent(data: Pair) { + val (id, status) = data + if (id == persistentId) { + currentMetaData.status = status + setStatus(status) + } + } + + /*fun downloadDeleteEvent(data: Int) { + + }*/ + + /*fun downloadEvent(data: Pair) { + val (id, action) = data + + }*/ + + fun downloadProgressEvent(data: Triple) { + val (id, bytesDownloaded, bytesTotal) = data + if (id == persistentId) { + currentMetaData.downloadedLength = bytesDownloaded + currentMetaData.totalLength = bytesTotal + + setProgress(bytesDownloaded, bytesTotal) + } + } + + override fun onAttachedToWindow() { + VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent + // VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent + // VideoDownloadManager.downloadEvent += ::downloadEvent + VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent + + val pid = persistentId + if (pid != null) { + // refresh in case of onDetachedFromWindow -> onAttachedToWindow while still being ??????? + setPersistentId(pid) + } + + super.onAttachedToWindow() + } + + override fun onDetachedFromWindow() { + VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent + // VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent + // VideoDownloadManager.downloadEvent -= ::downloadEvent + VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent + + super.onDetachedFromWindow() + } + + /** + * No checks required. Arg will always include a download with current id + * */ + abstract fun updateViewOnDownload(metadata: DownloadMetadata) + + /** + * Get a clean slate again, might be useful in recyclerview? + * */ + abstract fun resetView() +} 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 new file mode 100644 index 00000000000..91c5dd72ce3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt @@ -0,0 +1,60 @@ +package com.lagradost.cloudstream3.ui.download.button + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.core.view.isVisible +import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects + +class DownloadButton(context: Context, attributeSet: AttributeSet) : + PieFetchButton(context, attributeSet) { + + 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?) { + mainText?.post { + val txt = when (status) { + DownloadStatusTell.IsPaused -> R.string.download_paused + DownloadStatusTell.IsDownloading -> R.string.downloading + DownloadStatusTell.IsDone -> R.string.downloaded + else -> R.string.download + } + mainText?.setText(txt) + } + super.setStatus(status) + + } + + override fun setDefaultClickListener( + card: DownloadObjects.DownloadEpisodeCached, + textView: TextView?, + callback: (DownloadClickEvent) -> Unit + ) { + this.setDefaultClickListener( + this.findViewById(R.id.download_movie_button), + textView, + card, + callback + ) + } + + @SuppressLint("SetTextI18n") + override fun updateViewOnDownload(metadata: DownloadMetadata) { + super.updateViewOnDownload(metadata) + + val isVis = metadata.progressPercentage > 0 + progressText?.isVisible = isVis + if (isVis) + progressText?.text = "${metadata.progressPercentage}%" + } +} \ No newline at end of file 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 new file mode 100644 index 00000000000..f6f8a5ff846 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt @@ -0,0 +1,361 @@ +package com.lagradost.cloudstream3.ui.download.button + +import android.content.Context +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 +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES + +open class PieFetchButton(context: Context, attributeSet: AttributeSet) : + BaseFetchButton(context, attributeSet) { + + private var waitingAnimation: Int = 0 + private var animateWaiting: Boolean = false + private var activeOutline: Int = 0 + private var nonActiveOutline: Int = 0 + + private var iconInit: Int = 0 + private var iconError: Int = 0 + private var iconComplete: Int = 0 + private var iconActive: Int = 0 + private var iconWaiting: Int = 0 + private var iconRemoved: Int = 0 + private var iconPaused: Int = 0 + private var hideWhenIcon: Boolean = true + + var progressDrawable: Int = 0 + + var overrideLayout: Int? = null + + companion object { + val fillArray = arrayOf( + R.drawable.circular_progress_bar_clockwise, + R.drawable.circular_progress_bar_counter_clockwise, + R.drawable.circular_progress_bar_small_to_large, + R.drawable.circular_progress_bar_top_to_bottom, + ) + } + + private var progressBarBackground: View + var statusView: ImageView + + open fun onInflate() {} + + init { + context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) { + try { + inflate( + overrideLayout ?: getResourceId( + R.styleable.PieFetchButton_download_layout, + R.layout.download_button_view + ) + ) + } 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" + ) + throw e + } + + animateWaiting = getBoolean( + R.styleable.PieFetchButton_download_animate_waiting, + true + ) + hideWhenIcon = getBoolean( + 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 + ) + iconInit = getResourceId( + R.styleable.PieFetchButton_download_icon_init, R.drawable.netflix_download + ) + iconError = getResourceId( + R.styleable.PieFetchButton_download_icon_paused, R.drawable.download_icon_error + ) + iconComplete = getResourceId( + 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 + ) + iconActive = getResourceId( + R.styleable.PieFetchButton_download_icon_active, 0 // R.drawable.download_icon_load + ) + iconWaiting = getResourceId( + R.styleable.PieFetchButton_download_icon_waiting, 0 + ) + iconRemoved = getResourceId( + R.styleable.PieFetchButton_download_icon_removed, R.drawable.netflix_download + ) + + val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0) + progressDrawable = getResourceId( + R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] + ) + } + + 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 + while (context is ContextWrapper) { + if (context is Activity) { + return context + } + context = context.baseContext + } + return null + } + + fun callback(event : DownloadClickEvent) { + handleDownloadClick( + getActivity(), + event + ) + }*/ + + protected fun setDefaultClickListener( + view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached, + callback: (DownloadClickEvent) -> Unit + ) { + this.progressText = textView + this.setPersistentId(card.id) + view.setOnClickListener { + if (isZeroBytes) { + val localQueue = queue.value + val localInstances = downloadInstances.value + val id = card.id + + // If the download is already in queue or active downloads, provide an option to cancel it + if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) { + it.popupMenuNoIcons( + arrayListOf( + Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel), + ) + ) { + callback(DownloadClickEvent(itemId, card)) + } + } else { + // Otherwise just start a download instantly + removeKey(KEY_RESUME_PACKAGES, card.id.toString()) + callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) + } + } else { + val list = arrayListOf( + Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), + Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file), + ) + + currentMetaData.apply { + // DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone && + if (progressPercentage < 98) { + list.add( + if (status == VideoDownloadManager.DownloadType.IsDownloading) + Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download) + else + Pair( + DOWNLOAD_ACTION_RESUME_DOWNLOAD, + R.string.popup_resume_download + ) + ) + } + } + + + it.popupMenuNoIcons( + list + ) { + callback(DownloadClickEvent(itemId, card)) + // callback.invoke(DownloadClickEvent(itemId, data)) + } + } + } + + view.setOnLongClickListener { + callback(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, card)) + + // clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data)) + return@setOnLongClickListener true + } + } + + open fun setDefaultClickListener( + card: DownloadObjects.DownloadEpisodeCached, + textView: TextView?, + callback: (DownloadClickEvent) -> Unit + ) { + setDefaultClickListener(this, textView, card, callback) + } + + /* open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List) { + this.setOnClickListener { + when (this.currentStatus) { + null -> { + setStatus(DownloadStatusTell.IsPending) + ioThread { + val request = requestGetter.invoke(this) + if (request.size == 1) { + performDownload(request.first()) + } else if (request.isNotEmpty()) { + performFailQueueDownload(request) + } + } + } + DownloadStatusTell.Paused -> { + resumeDownload() + } + DownloadStatusTell.Active -> { + pauseDownload() + } + DownloadStatusTell.Error -> { + redownload() + } + 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 + + // 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) + } + } + } else { + progressBarBackground.post { + setStatusInternal(status) + } + } + } + + override fun resetView() { + setStatus(null) + currentMetaData = DownloadMetadata(0, 0, 0, null) + isZeroBytes = true + doSetProgress = true + progressBar.progress = 0 + } + + override fun updateViewOnDownload(metadata: DownloadMetadata) { + + val newStatus = metadata.status + + if (newStatus == null) { + resetView() + return + } + + val isDone = + newStatus == DownloadStatusTell.IsDone || (metadata.downloadedLength > 1024 && metadata.downloadedLength + 1024 >= metadata.totalLength) + + if (isDone) + setStatus(DownloadStatusTell.IsDone) + else { + setProgress(metadata.downloadedLength, metadata.totalLength) + setStatus(newStatus) + } + } + + 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/button/ProgressBarAnimation.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/ProgressBarAnimation.kt new file mode 100644 index 00000000000..11818a7e9d1 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/ProgressBarAnimation.kt @@ -0,0 +1,18 @@ +package com.lagradost.cloudstream3.ui.download.button + +import android.view.animation.Animation +import android.view.animation.Transformation +import android.widget.ProgressBar + +class ProgressBarAnimation( + private val progressBar: ProgressBar, + private val from: Float, + private val to: Float +) : + Animation() { + override fun applyTransformation(interpolatedTime: Float, t: Transformation?) { + super.applyTransformation(interpolatedTime, t) + val value = from + (to - from) * interpolatedTime + progressBar.progress = value.toInt() + } +} \ 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 00000000000..877fcfea857 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt @@ -0,0 +1,274 @@ +package com.lagradost.cloudstream3.ui.download.queue + + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO + +/** An item in the adapter can either be a separator or a real item. + * isCurrentlyDownloading is used to fully update items as opposed to just moving them. */ +class DownloadAdapterItem(val item: DownloadQueueWrapper?) { + val isSeparator = item == null +} + + +class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter( + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.item?.id == b.item?.id }, + contentSame = { a, b -> + a.item == b.item + }) +) { + var currentDownloads = 0 + + companion object { + val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG" + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = DownloadQueueItemBinding.inflate(inflater, parent, false) + return ViewHolderState(binding) + } + + override fun onBindContent( + holder: ViewHolderState, + item: DownloadAdapterItem, + position: Int + ) { + when (val binding = holder.view) { + is DownloadQueueItemBinding -> { + if (item.item == null) { + holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG + bindSeparator(binding) + } else { + holder.itemView.tag = null + bind(binding, item.item) + } + } + } + } + + fun submitQueue(newQueue: DownloadAdapterQueue) { + val index = newQueue.currentDownloads.size + val current = newQueue.currentDownloads + val queue = newQueue.queue + currentDownloads = current.size + + val newList = + (current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList() + .apply { + // Only add the separator if it actually separates something + if (index < this.size) { + add(index, DownloadAdapterItem(null)) + } + } + submitList(newList) + } + + fun bindSeparator(binding: DownloadQueueItemBinding) { + binding.apply { + separatorHolder.isGone = false + downloadChildEpisodeHolder.isGone = true + } + } + + fun bind( + binding: DownloadQueueItemBinding, + queueWrapper: DownloadQueueWrapper, + ) { + val context = binding.root.context + + binding.apply { + separatorHolder.isGone = true + downloadChildEpisodeHolder.isGone = false + + // Only set the child-text if child and parent are not the same + // This prevents setting movie titles twice + if (queueWrapper.id != queueWrapper.parentId) { + val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName + downloadChildEpisodeTextExtra.text = mainName + } else { + downloadChildEpisodeTextExtra.text = null + } + + downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank() + + val status = VideoDownloadManager.downloadStatus[queueWrapper.id] + + downloadButton.setOnClickListener { view -> + val episodeCached = + getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString()) + ) + + val downloadInfo = context.getKey( + KEY_DOWNLOAD_INFO, + queueWrapper.id.toString() + ) + + val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading() + + val actionList = arrayListOf>() + + if (isCurrentlyDownloading && episodeCached != null) { + // KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything + if (downloadInfo != null) { + actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file)) + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + } + + val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id] + + when (currentStatus) { + VideoDownloadManager.DownloadType.IsDownloading -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_PAUSE_DOWNLOAD, + R.string.popup_pause_download + ) + ) + } + + VideoDownloadManager.DownloadType.IsPaused -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_RESUME_DOWNLOAD, + R.string.popup_resume_download + ) + ) + } + + else -> {} + } + + view.popupMenuNoIcons( + actionList + ) { + handleDownloadClick(DownloadClickEvent(itemId, episodeCached)) + } + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + + view.popupMenuNoIcons( + actionList + ) { + when (itemId) { + DOWNLOAD_ACTION_CANCEL_PENDING -> { + DownloadQueueManager.cancelDownload(queueWrapper.id) + } + } + } + } + } + + downloadButton.resetView() + downloadButton.setStatus(status) + downloadButton.setPersistentId(queueWrapper.id) + + downloadChildEpisodeText.apply { + val name = queueWrapper.downloadItem?.episode?.name + ?: queueWrapper.resumePackage?.item?.ep?.name + val episode = + queueWrapper.downloadItem?.episode?.episode + ?: queueWrapper.resumePackage?.item?.ep?.episode + val season = + queueWrapper.downloadItem?.episode?.season + ?: queueWrapper.resumePackage?.item?.ep?.season + text = context.getNameFull(name, episode, season) + isSelected = true // Needed for text repeating + } + } + } +} + + +class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) : + ItemTouchHelper( + DragAndDropTouchHelperCallback(adapter) + ) + +private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) : + ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val item = adapter.getItem(viewHolder.absoluteAdapterPosition) + val isDownloading = item.item?.isCurrentlyDownloading() == true + val dragFlags = if (item.isSeparator || isDownloading) { + 0 + } else { + ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down + } + + val swipeFlags = 0 // Disable swipe functionality + return makeMovementFlags(dragFlags, swipeFlags) + } + + override fun onMove( + recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPosition = source.absoluteAdapterPosition + val toPosition = target.absoluteAdapterPosition + val separatorPosition = adapter.currentDownloads + + val toPositionNoSeparator = + if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition + + if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) { + return false + } else { + adapter.getItem(fromPosition).item?.let { downloadQueueInfo -> + DownloadQueueManager.reorderItem( + downloadQueueInfo, + toPositionNoSeparator - 1 + ) + } + } + + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + + } + + override fun isLongPressDragEnabled(): Boolean { + return true // Enable drag with long press + } + + override fun isItemViewSwipeEnabled(): Boolean { + return false // Disable swipe by default + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt new file mode 100644 index 00000000000..071d8913d2a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt @@ -0,0 +1,79 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.fragment.app.activityViewModels +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.txt + + +class DownloadQueueFragment : + BaseFragment(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) { + private val queueViewModel: DownloadQueueViewModel by activityViewModels() + + override fun onBindingCreated(binding: FragmentDownloadQueueBinding) { + val adapter = DownloadQueueAdapter(this@DownloadQueueFragment) + val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all) + + observe(queueViewModel.childCards) { cards -> + val size = cards.queue.size + cards.currentDownloads.size + val isEmptyQueue = size == 0 + binding.downloadQueueList.isGone = isEmptyQueue + binding.textNoQueue.isGone = !isEmptyQueue + clearQueueItem?.isVisible = !isEmptyQueue + + adapter.submitQueue(cards) + } + + binding.apply { + downloadQueueToolbar.apply { + title = txt(R.string.download_queue).asString(context) + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + dispatchBackPressed() + } + } + setAppBarNoScrollFlagsOnTV() + clearQueueItem?.setOnMenuItemClickListener { + AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setTitle(R.string.cancel_all) + .setMessage(R.string.cancel_queue_message) + .setPositiveButton(R.string.yes) { _, _ -> + DownloadQueueManager.removeAllFromQueue() + } + .setNegativeButton(R.string.no) { _, _ -> + }.show() + + true + } + } + + downloadQueueList.adapter = adapter + + // Drag and drop + val helper = DragAndDropTouchHelper(adapter) + helper.attachToRecyclerView(downloadQueueList) + } + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt new file mode 100644 index 00000000000..fc384cb4ebf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +data class DownloadAdapterQueue( + val currentDownloads: List, + val queue: List, +) + +class DownloadQueueViewModel : ViewModel() { + private val _childCards = MutableLiveData() + val childCards: LiveData = _childCards + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + val current = instances.map { it.downloadQueueWrapper } + DownloadAdapterQueue(current, queue.toList()) + }.combine(VideoDownloadManager.currentDownloads) { total, _ -> + // We want to update the flow when currentDownloads updates, but we do not care about its value + total + } + + init { + viewModelScope.launch { + totalDownloadFlow.collect { queue -> + updateChildList(queue) + } + } + } + + fun updateChildList(downloads: DownloadAdapterQueue) { + _childCards.postValue(downloads) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt index b90a4e43ee7..43f6d19ff6a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt @@ -1,146 +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.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 -import kotlinx.android.synthetic.main.home_result_grid.view.background_card -import kotlinx.android.synthetic.main.home_result_grid_expanded.view.* - -class HomeChildItemAdapter( - val cardList: MutableList, - private val overrideLayout: Int? = null, - private val nextFocusUp: Int? = null, - private val nextFocusDown: Int? = null, - private val clickCallback: (SearchClickCallback) -> Unit, -) : - RecyclerView.Adapter() { - var isHorizontal: Boolean = false - var hasNext: Boolean = false - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val layout = overrideLayout - ?: if (parent.context.IsBottomLayout()) R.layout.home_result_grid_expanded else R.layout.home_result_grid +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() + } + } + } +} - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), - clickCallback, - itemCount, - nextFocusUp, - nextFocusDown, - isHorizontal - ) +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 onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.itemCount = itemCount // i know ugly af - holder.bind(cardList[position], position) + 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, +) : + BaseAdapter( + id, diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.url == b.url && a.name == b.name + }, + contentSame = { a, b -> + a == b + }) + ) { + var hasNext: Boolean = false + var isHorizontal: Boolean = false + set(value) { + field = value + updateCachedPosterSize() + } + + private fun updateCachedPosterSize() { + setWidth = if (!isHorizontal) { + minPosterSize + } else { + maxPosterSize + } + setHeight = if (!isHorizontal) { + maxPosterSize + } else { + minPosterSize } } - override fun getItemCount(): Int { - return cardList.size + init { + updateCachedPosterSize() } - override fun getItemId(position: Int): Long { - return (cardList[position].id ?: position).toLong() + 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) } - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - HomeChildDiffCallback(this.cardList, newList) - ) + companion object { + // The vast majority of the lag comes from creating the view + // This simply shares the views between all HomeChildItemAdapter + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 20) } + + var minPosterSize: Int = 0 + var maxPosterSize: Int = 0 + + fun updatePosterSize(context: Context, value: Int? = null) { + val scale = value ?: PreferenceManager.getDefaultSharedPreferences(context) + ?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0 + // Scale by +10% per step + val mul = 1.0f + scale * 0.1f + minPosterSize = (114.toPx.toFloat() * mul).toInt() + maxPosterSize = (180.toPx.toFloat() * mul).toInt() + } - cardList.clear() - cardList.addAll(newList) + fun updateLayoutParms(layout: FrameLayout, width: Int, height: Int) { + val params = layout.layoutParams + if (params.height == height && params.width == width) return - diffResult.dispatchUpdatesTo(this) - } + params.width = width + params.height = height - class CardViewHolder - constructor( - itemView: View, - private val clickCallback: (SearchClickCallback) -> Unit, - var itemCount: Int, - private val nextFocusUp: Int? = null, - private val nextFocusDown: Int? = null, - private val isHorizontal: Boolean = false - ) : - RecyclerView.ViewHolder(itemView) { - - fun bind(card: SearchResponse, position: Int) { - - // TV focus fixing - val nextFocusBehavior = when (position) { - 0 -> true - itemCount - 1 -> false - else -> null - } + layout.layoutParams = params + } + } - (itemView.image_holder ?: itemView.background_card)?.apply { - val min = 114.toPx - val max = 180.toPx - - layoutParams = - layoutParams.apply { - width = if (!isHorizontal) { - min - } else { - max - } - height = if (!isHorizontal) { - max - } else { - min - } - } + 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) - SearchResultBuilder.bind( - clickCallback, - card, - position, - itemView, - nextFocusBehavior, - nextFocusUp, - nextFocusDown - ) - itemView.tag = position - - if (position == 0) { // to fix tv - itemView.background_card?.nextFocusLeftId = R.id.nav_rail_view + if (isFirstItem) { // to fix tv + binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view + } } - //val ani = ScaleAnimation(0.9f, 1.0f, 0.9f, 1f) - //ani.fillAfter = true - //ani.duration = 200 - //itemView.startAnimation(ani) } } -} - -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 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 + ) - 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 + holder.itemView.tag = position + } +} 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 8e2ca6afe34..b68ef59625c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -5,125 +5,91 @@ 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.appcompat.widget.SearchView -import androidx.core.content.ContextCompat.getDrawable +import androidx.core.net.toUri 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.fragment.app.activityViewModels import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import androidx.transition.ChangeBounds -import androidx.transition.TransitionManager -import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback 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.getApiFromNameNull -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.APIHolder.getId -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.databinding.FragmentHomeBinding +import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding +import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding +import com.lagradost.cloudstream3.databinding.TvtypesChipsBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis +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.AutofitRecyclerView -import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment -import com.lagradost.cloudstream3.ui.result.ResultViewModel2.Companion.updateWatchStatus -import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST -import com.lagradost.cloudstream3.ui.result.setLinearListLayout -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.setMaxViewPoolSize +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.DataStoreHelper.deleteAllBookmarkedData -import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds -import com.lagradost.cloudstream3.utils.DataStoreHelper.removeLastWatched -import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState -import com.lagradost.cloudstream3.utils.Event -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes +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.fixPaddingStatusbarView -import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount +import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.UIHelper.setImageBlur -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API -import com.lagradost.cloudstream3.widget.CenterZoomLayoutManager -import kotlinx.android.synthetic.main.fragment_home.* -import kotlinx.android.synthetic.main.fragment_home.home_api_fab -import kotlinx.android.synthetic.main.fragment_home.home_bookmarked_child_recyclerview -import kotlinx.android.synthetic.main.fragment_home.home_bookmarked_holder -import kotlinx.android.synthetic.main.fragment_home.home_change_api_loading -import kotlinx.android.synthetic.main.fragment_home.home_loaded -import kotlinx.android.synthetic.main.fragment_home.home_loading -import kotlinx.android.synthetic.main.fragment_home.home_loading_error -import kotlinx.android.synthetic.main.fragment_home.home_loading_shimmer -import kotlinx.android.synthetic.main.fragment_home.home_loading_statusbar -import kotlinx.android.synthetic.main.fragment_home.home_master_recycler -import kotlinx.android.synthetic.main.fragment_home.home_plan_to_watch_btt -import kotlinx.android.synthetic.main.fragment_home.home_provider_meta_info -import kotlinx.android.synthetic.main.fragment_home.home_provider_name -import kotlinx.android.synthetic.main.fragment_home.home_reload_connection_open_in_browser -import kotlinx.android.synthetic.main.fragment_home.home_reload_connectionerror -import kotlinx.android.synthetic.main.fragment_home.home_type_completed_btt -import kotlinx.android.synthetic.main.fragment_home.home_type_dropped_btt -import kotlinx.android.synthetic.main.fragment_home.home_type_on_hold_btt -import kotlinx.android.synthetic.main.fragment_home.home_type_watching_btt -import kotlinx.android.synthetic.main.fragment_home.home_watch_child_recyclerview -import kotlinx.android.synthetic.main.fragment_home.home_watch_holder -import kotlinx.android.synthetic.main.fragment_home.home_watch_parent_item_title -import kotlinx.android.synthetic.main.fragment_home.result_error_text -import kotlinx.android.synthetic.main.fragment_home_tv.* -import kotlinx.android.synthetic.main.fragment_search.* -import kotlinx.android.synthetic.main.home_episodes_expanded.* -import kotlinx.android.synthetic.main.tvtypes_chips.* -import kotlinx.android.synthetic.main.tvtypes_chips.view.* -import java.util.* - - -const val HOME_BOOKMARK_VALUE_LIST = "home_bookmarked_last_list" -const val HOME_PREF_HOMEPAGE = "home_pref_homepage" - -class HomeFragment : Fragment() { +import com.lagradost.cloudstream3.utils.UIHelper.toPx + +private const val TAG = "HomeFragment" + +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, @@ -140,37 +106,65 @@ class HomeFragment : Fragment() { val errorProfilePic = errorProfilePics.random() - fun Activity.loadHomepageList( - item: HomePageList, - deleteCallback: (() -> Unit)? = null, - ) { - loadHomepageList( - expand = HomeViewModel.ExpandableHomepageList(item, 1, false), - deleteCallback = deleteCallback, - expandCallback = null - ) - } + //fun Activity.loadHomepageList( + // item: HomePageList, + // deleteCallback: (() -> Unit)? = null, + //) { + // loadHomepageList( + // expand = HomeViewModel.ExpandableHomepageList(item, 1, false), + // deleteCallback = deleteCallback, + // expandCallback = null + // ) + //} + + // 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, - expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null - ) { + expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null, + dismissCallback: (() -> Unit), + ): BottomSheetDialog { val context = this val bottomSheetDialogBuilder = BottomSheetDialog(context) - bottomSheetDialogBuilder.setContentView(R.layout.home_episodes_expanded) - val title = bottomSheetDialogBuilder.findViewById(R.id.home_expanded_text)!! + val binding: HomeEpisodesExpandedBinding = HomeEpisodesExpandedBinding.inflate( + bottomSheetDialogBuilder.layoutInflater, + null, + false + ) + bottomSheetDialogBuilder.setContentView(binding.root) + //val title = bottomSheetDialogBuilder.findViewById(R.id.home_expanded_text)!! + + //title.findViewTreeLifecycleOwner().lifecycle.addObserver() + val item = expand.list - title.text = item.name - val recycle = - bottomSheetDialogBuilder.findViewById(R.id.home_expanded_recycler)!! - val titleHolder = - bottomSheetDialogBuilder.findViewById(R.id.home_expanded_drag_down)!! - - val delete = bottomSheetDialogBuilder.home_expanded_delete - delete.isGone = deleteCallback == null + binding.homeExpandedText.text = item.name + // val recycle = + // bottomSheetDialogBuilder.findViewById(R.id.home_expanded_recycler)!! + //val titleHolder = + // bottomSheetDialogBuilder.findViewById(R.id.home_expanded_drag_down)!! + + // main { + //(bottomSheetDialogBuilder.ownerActivity as androidx.fragment.app.FragmentActivity?)?.supportFragmentManager?.fragments?.lastOrNull()?.viewLifecycleOwner?.apply { + // println("GOT LIFE: lifecycle $this") + // this.lifecycle.addObserver(object : DefaultLifecycleObserver { + // override fun onResume(owner: LifecycleOwner) { + // super.onResume(owner) + // println("onResume!!!!") + // bottomSheetDialogBuilder?.ownShow() + // } + + // override fun onStop(owner: LifecycleOwner) { + // super.onStop(owner) + // bottomSheetDialogBuilder?.ownHide() + // } + // }) + //} + // } + //val delete = bottomSheetDialogBuilder.home_expanded_delete + binding.homeExpandedDelete.isGone = deleteCallback == null if (deleteCallback != null) { - delete.setOnClickListener { + binding.homeExpandedDelete.setOnClickListener { try { val builder: AlertDialog.Builder = AlertDialog.Builder(context) val dialogClickListener = @@ -180,11 +174,12 @@ class HomeFragment : Fragment() { deleteCallback.invoke() bottomSheetDialogBuilder.dismissSafe(this) } + DialogInterface.BUTTON_NEGATIVE -> {} } } - builder.setTitle(R.string.delete_file) + builder.setTitle(R.string.clear_history) .setMessage( context.getString(R.string.delete_message).format( item.name @@ -192,32 +187,35 @@ class HomeFragment : Fragment() { ) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) - .show() + .show().setDefaultFocus() } catch (e: Exception) { logError(e) // ye you somehow fucked up formatting did you? } } } - - titleHolder.setOnClickListener { + binding.homeExpandedDragDown.setOnClickListener { bottomSheetDialogBuilder.dismissSafe(this) } // Span settings - recycle.spanCount = currentSpan - - recycle.adapter = SearchAdapter(item.list.toMutableList(), recycle) { callback -> - handleSearchClickCallback(this, callback) - if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) { - bottomSheetDialogBuilder.dismissSafe(this) + binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) + binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool) + binding.homeExpandedRecycler.adapter = + 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 } - }.apply { - hasNext = expand.hasNext - } - recycle.addOnScrollListener(object : RecyclerView.OnScrollListener() { + binding.homeExpandedRecycler.addOnScrollListener(object : + RecyclerView.OnScrollListener() { var expandCount = 0 val name = expand.list.name @@ -236,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) } } } @@ -244,23 +242,28 @@ class HomeFragment : Fragment() { } }) - val spanListener = { span: Int -> - recycle.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 bottomSheetDialogBuilder.setOnDismissListener { + dismissCallback.invoke() configEvent -= spanListener } //(recycle.adapter as SearchAdapter).notifyDataSetChanged() bottomSheetDialogBuilder.show() + return bottomSheetDialogBuilder } - fun getPairList( + private fun getPairList( anime: Chip?, cartoons: Chip?, tvs: Chip?, @@ -268,36 +271,39 @@ 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)), ) } - private fun getPairList(header: ChipGroup) = getPairList( - header.home_select_anime, - header.home_select_cartoons, - header.home_select_tv_series, - header.home_select_documentaries, - header.home_select_movies, - header.home_select_asian, - header.home_select_livestreams, - header.home_select_nsfw, - header.home_select_others + private fun getPairList(header: TvtypesChipsBinding) = getPairList( + header.homeSelectAnime, + header.homeSelectCartoons, + header.homeSelectTvSeries, + header.homeSelectDocumentaries, + header.homeSelectMovies, + header.homeSelectAsian, + header.homeSelectLivestreams, + header.homeSelectTorrents, + header.homeSelectNsfw, + header.homeSelectOthers ) - fun validateChips(header: ChipGroup?, validTypes: List) { + fun validateChips(header: TvtypesChipsBinding?, validTypes: List) { if (header == null) return val pairList = getPairList(header) for ((button, types) in pairList) { @@ -306,20 +312,31 @@ class HomeFragment : Fragment() { } } - fun updateChips(header: ChipGroup?, selectedTypes: List) { + fun updateChips(header: TvtypesChipsBinding?, selectedTypes: List) { if (header == null) return 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) } } } fun bindChips( - header: ChipGroup?, + header: TvtypesChipsBinding?, selectedTypes: List, validTypes: List, callback: (List) -> Unit + ) { + bindChips(header, selectedTypes, validTypes, callback, null, null) + } + + fun bindChips( + header: TvtypesChipsBinding?, + selectedTypes: List, + validTypes: List, + callback: (List) -> Unit, + nextFocusDown: Int?, + nextFocusUp: Int? ) { if (header == null) return val pairList = getPairList(header) @@ -327,6 +344,17 @@ class HomeFragment : Fragment() { val isValid = validTypes.any { types.contains(it) } button?.isVisible = isValid button?.isChecked = isValid && selectedTypes.any { types.contains(it) } + button?.isFocusable = true + if (isLayout(TV)) { + button?.isFocusableInTouchMode = true + } + + if (nextFocusDown != null) + button?.nextFocusDownId = nextFocusDown + + if (nextFocusUp != null) + button?.nextFocusUpId = nextFocusUp + button?.setOnCheckedChangeListener { _, _ -> val list = ArrayList() for ((sbutton, vvalidTypes) in pairList) { @@ -349,7 +377,13 @@ class HomeFragment : Fragment() { BottomSheetDialog(this) builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - builder.setContentView(R.layout.home_select_mainpage) + val binding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( + builder.layoutInflater, + null, + false + ) + + builder.setContentView(binding.root) builder.show() builder.let { dialog -> val isMultiLang = getApiProviderLangSettings().let { set -> @@ -360,27 +394,44 @@ 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 cancelBtt = dialog.findViewById(R.id.cancel_btt) - val applyBtt = dialog.findViewById(R.id.apply_btt) + val preSelectedTypes = DataStoreHelper.homePreference.toMutableList() - cancelBtt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { dialog.dismissSafe() } - applyBtt?.setOnClickListener { + binding.applyBtt.setOnClickListener { if (currentApiName != selectedApiName) { currentApiName?.let(callback) } dialog.dismissSafe() } + var pinnedphashset = DataStoreHelper.pinnedProviders.toHashSet() + val listView = dialog.findViewById(R.id.listview1) - val arrayAdapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice) + + val arrayAdapter = object : ArrayAdapter( + this, R.layout.sort_bottom_single_provider_choice, + mutableListOf() + ) { + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup + ): View { + val view = convertView ?: LayoutInflater.from(context) + .inflate(R.layout.sort_bottom_single_provider_choice, parent, false) + val titleText = view.findViewById(R.id.text1) + val pinIcon = view.findViewById(R.id.pinicon) + val name = getItem(position) + titleText?.text = name + val isPinned = + pinnedphashset.contains(currentValidApis[position].name) + pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE + return view + } + } listView?.adapter = arrayAdapter listView?.choiceMode = AbsListView.CHOICE_MODE_SINGLE @@ -388,21 +439,39 @@ class HomeFragment : Fragment() { if (currentValidApis.isNotEmpty()) { currentApiName = currentValidApis[i].name //to switch to apply simply remove this - currentApiName?.let(callback) + currentApiName.let(callback) dialog.dismissSafe() } } fun updateList() { - 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 } @@ -411,9 +480,24 @@ 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( - dialog.home_select_group, + binding.tvtypesChipsScroll.tvtypesChips, preSelectedTypes, validAPIs.flatMap { it.supportedTypes }.distinct() ) { list -> @@ -427,34 +511,74 @@ class HomeFragment : Fragment() { } private val homeViewModel: HomeViewModel by activityViewModels() + private val accountViewModel: AccountViewModel by activityViewModels() + + fun addMovies(cards: List) { + val ctx = context ?: run { + Log.e(TAG, "Context is null, aborting addMovies") + return + } + + try { + val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) + if (existingId != null) { + Log.d(TAG, "Channel ID: $existingId") + + 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) - val layout = - if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home - return inflater.inflate(layout, container, false) - } - - private fun toggleMainVisibility(visible: Boolean) { - home_main_poster_recyclerview?.isVisible = visible + bottomSheetDialog?.ownShow() + return super.onCreateView(inflater, container, savedInstanceState) } - @SuppressLint("NotifyDataSetChanged") // we need to notify to change poster - private fun fixGrid() { - activity?.getSpanCount()?.let { - currentSpan = it - } - configEvent.invoke(currentSpan) + override fun onDestroyView() { + (activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress") + bottomSheetDialog?.ownHide() + super.onDestroyView() } private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> - homeViewModel.loadAndCancel(api) + homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) } /*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf() @@ -465,701 +589,298 @@ 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 - override fun onResume() { - super.onResume() - reloadStored() - afterPluginsLoadedEvent += ::afterPluginsLoaded - mainPluginsLoadedEvent += ::afterMainPluginsLoaded - } + private var bottomSheetDialog: BottomSheetDialog? = null + private var homeMasterAdapter: HomeParentItemAdapterPreview? = null - override fun onStop() { - afterPluginsLoadedEvent -= ::afterPluginsLoaded - mainPluginsLoadedEvent -= ::afterMainPluginsLoaded - super.onStop() - } + var lastSavedHomepage: String? = null - private fun reloadStored() { - homeViewModel.loadResumeWatching() - val list = EnumSet.noneOf(WatchType::class.java) - getKey(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let { - list.addAll(it) + fun saveHomepageToTV(page: Map) { + // No need to update for phone + if (isLayout(PHONE)) { + return } - homeViewModel.loadStoredData(list) - } - - private fun afterMainPluginsLoaded(unused: Boolean = false) { - loadHomePage(false) - } - - private fun afterPluginsLoaded(forceReload: Boolean) { - loadHomePage(forceReload) - } - - private fun loadHomePage(forceReload: Boolean) { - val apiName = context?.getKey(USER_SELECTED_HOMEPAGE_API) - - if (homeViewModel.apiName.value != apiName || apiName == null || forceReload) { - //println("Caught home: " + homeViewModel.apiName.value + " at " + apiName) - homeViewModel.loadAndCancel(apiName, forceReload) + 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 } - } - - /*private fun handleBack(poppedFragment: Boolean) { - if (poppedFragment) { - reloadStored() + 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) } - }*/ - - private fun focusCallback(card: SearchResponse) { - home_focus_text?.text = card.name - home_blur_poster?.setImageBlur(card.posterUrl, 50) } - private fun homeHandleSearch(callback: SearchClickCallback) { - if (callback.action == SEARCH_ACTION_FOCUSED) { - focusCallback(callback.card) - } else { - handleSearchClickCallback(activity, callback) - } - } + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padTop = false, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) - private var currentApiName: String? = null - private var toggleRandomButton = false + // Fix grid + configEvent.invoke() + } @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixGrid() - - home_change_api?.setOnClickListener(apiChangeClickListener) - home_change_api_loading?.setOnClickListener(apiChangeClickListener) - home_api_fab?.setOnClickListener(apiChangeClickListener) - home_random?.setOnClickListener { - if (listHomepageItems.isNotEmpty()) { - activity.loadSearchResult(listHomepageItems.random()) - } - } - - //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) - home_random?.visibility = View.GONE + override fun onBindingCreated(binding: FragmentHomeBinding) { + context?.let { HomeChildItemAdapter.updatePosterSize(it) } + (activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { + handleTvBackPress(this) } - - observe(homeViewModel.preview) { preview -> - // Always reset the padding, otherwise the will move lower and lower - // home_fix_padding?.setPadding(0, 0, 0, 0) - home_fix_padding?.let { v -> - val params = v.layoutParams - params.height = 0 - v.layoutParams = params - } - - when (preview) { - is Resource.Success -> { - home_preview?.isVisible = true - (home_preview_viewpager?.adapter as? HomeScrollAdapter)?.apply { - if (!setItems(preview.value.second, preview.value.first)) { - home_preview_viewpager?.setCurrentItem(0, false) + binding.apply { + //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) + //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) + homeApiFab.setOnClickListener(apiChangeClickListener) + homeApiFab.setOnLongClickListener { + if (currentApiName == noneApi.name) return@setOnLongClickListener false + homeViewModel.loadAndCancel(currentApiName, forceReload = true, fromUI = true) + showToast(R.string.action_reload, Toast.LENGTH_SHORT) + true + } + homeChangeApi.setOnClickListener(apiChangeClickListener) + homeSwitchAccount.setOnClickListener { + activity?.showAccountSelectLinear() + } + + 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 + ) + showToast(R.string.action_reload, Toast.LENGTH_SHORT) + true + } + + homePreviewSearchButton.setOnClickListener { _ -> + // Open blank screen. + homeViewModel.queryTextSubmit("") + } + + homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (isLayout(PHONE)) { + // Fab is only relevant to Phone + if (dy > 0) { //check for scroll down + homeApiFab.shrink() // hide + homeRandom.shrink() + } else if (dy < -5) { + if (isLayout(PHONE)) { + homeApiFab.extend() // show + homeRandom.extend() + } + } + } else { + // Header scrolling is only relevant to TV/Emulator + + val view = recyclerView.findViewHolderForAdapterPosition(0)?.itemView + val scrollParent = binding.homeApiHolder + + if (view == null) { + // The first view is not visible, so we can assume we have scrolled past it + scrollParent.isVisible = false + } else { + // A bit weird, but this is a major limitation we are working around here + // 1. We cant have a real parent to the recyclerview as android cant layout that without lagging + // 2. We cant put the view in the recyclerview, as it should always be shown + // 3. We cant mirror the view in the recyclerview as then it causes focus issues when swaping out the mirror view + // + // This means that if we want to have a parent view to the recyclerview we are out of luck + // Instead this uses getLocationInWindow to calculate how much the view should be scrolled + // as recyclerView has no scrollY (always 0) + // + // Then it manually "scrolls" it to the correct position + // + // Hopefully getLocationInWindow acts correctly on all devices + val rect = IntArray(2) + view.getLocationInWindow(rect) + scrollParent.isVisible = true + scrollParent.translationY = rect[1].toFloat() - 60.toPx } - // home_preview_viewpager?.setCurrentItem(1000, false) } - - //.also { - //home_preview_viewpager?.adapter = - //} - } - else -> { - (home_preview_viewpager?.adapter as? HomeScrollAdapter)?.setItems( - listOf(), - false - ) - home_preview?.isVisible = false - context?.fixPaddingStatusbarView(home_fix_padding) + super.onScrolled(recyclerView, dx, dy) } - } - } + }) - val searchText = - home_search?.findViewById(androidx.appcompat.R.id.search_src_text) - searchText?.context?.getResourceColor(R.attr.white)?.let { color -> - searchText.setTextColor(color) - searchText.setHintTextColor(color) } - home_preview_viewpager?.apply { - setPageTransformer(HomeScrollTransformer()) - val callback: OnPageChangeCallback = object : OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - - // home_search?.isIconified = true - //home_search?.isVisible = true - //home_search?.clearFocus() - - (home_preview_viewpager?.adapter as? HomeScrollAdapter)?.apply { - if (position >= itemCount - 1 && hasMoreItems) { - hasMoreItems = false // dont make two requests - homeViewModel.loadMoreHomeScrollResponses() - } - - getItem(position) - ?.apply { - home_preview_title_holder?.let { parent -> - TransitionManager.beginDelayedTransition(parent, ChangeBounds()) - } - - // home_preview_tags?.text = tags?.joinToString(" • ") ?: "" - // home_preview_tags?.isGone = tags.isNullOrEmpty() - // home_preview_image?.setImage(posterUrl, posterHeaders) - // home_preview_title?.text = name - - home_preview_play?.setOnClickListener { - activity?.loadResult(url, apiName, START_ACTION_RESUME_LATEST) - //activity.loadSearchResult(url, START_ACTION_RESUME_LATEST) - } - home_preview_info?.setOnClickListener { - activity?.loadResult(url, apiName) - //activity.loadSearchResult(random) - } - // very ugly code, but I dont care - val watchType = DataStoreHelper.getResultWatchState(this.getId()) - home_preview_bookmark?.setText(watchType.stringRes) - home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds( - null, - getDrawable(home_preview_bookmark.context, watchType.iconRes), - null, - null - ) - home_preview_bookmark?.setOnClickListener { fab -> - activity?.showBottomDialog( - WatchType.values() - .map { fab.context.getString(it.stringRes) } - .toList(), - DataStoreHelper.getResultWatchState(this.getId()).ordinal, - fab.context.getString(R.string.action_add_to_bookmarks), - showApply = false, - {}) { - val newValue = WatchType.values()[it] - home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds( - null, - getDrawable( - home_preview_bookmark.context, - newValue.iconRes - ), - null, - null - ) - home_preview_bookmark?.setText(newValue.stringRes) - - updateWatchStatus(this, newValue) - reloadStored() - } - } - - } - } - } - } - registerOnPageChangeCallback(callback) - adapter = HomeScrollAdapter() + //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.homeRandom.visibility = View.GONE + binding.homeRandomButtonTv.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> currentApiName = apiName - // setKey(USER_SELECTED_HOMEPAGE_API, apiName) - home_api_fab?.text = apiName - home_provider_name?.text = apiName - try { - home_search?.queryHint = getString(R.string.search_hint_site).format(apiName) - } catch (e: Exception) { - logError(e) - } - home_provider_meta_info?.isVisible = false - - getApiFromNameNull(apiName)?.let { currentApi -> - val typeChoices = listOf( - Pair(R.string.movies, listOf(TvType.Movie)), - Pair(R.string.tv_series, listOf(TvType.TvSeries)), - Pair(R.string.documentaries, listOf(TvType.Documentary)), - Pair(R.string.cartoons, listOf(TvType.Cartoon)), - Pair(R.string.anime, listOf(TvType.Anime, TvType.OVA, TvType.AnimeMovie)), - Pair(R.string.torrent, listOf(TvType.Torrent)), - Pair(R.string.asian_drama, listOf(TvType.AsianDrama)), - ).filter { item -> currentApi.supportedTypes.any { type -> item.second.contains(type) } } - home_provider_meta_info?.text = - typeChoices.joinToString(separator = ", ") { getString(it.first) } - home_provider_meta_info?.isVisible = true + binding.apply { + homeApiFab.text = apiName + homeChangeApi.text = apiName + homePreviewReloadProvider.isGone = (apiName == noneApi.name) + homePreviewSearchButton.isGone = (apiName == noneApi.name) } } - home_main_poster_recyclerview?.adapter = - HomeChildItemAdapter( - mutableListOf(), - R.layout.home_result_big_grid, - nextFocusUp = home_main_poster_recyclerview?.nextFocusUpId, - nextFocusDown = home_main_poster_recyclerview?.nextFocusDownId - ) { callback -> - homeHandleSearch(callback) - } - home_main_poster_recyclerview?.setLinearListLayout() - observe(homeViewModel.randomItems) { items -> - if (items.isNullOrEmpty()) { - toggleMainVisibility(false) - } else { - val tempAdapter = home_main_poster_recyclerview?.adapter as? HomeChildItemAdapter? - // no need to reload if it has the same data - if (tempAdapter != null && tempAdapter.cardList == items) { - toggleMainVisibility(true) - return@observe - } - - val randomSize = items.size - tempAdapter?.updateList(items) - if (!isTvSettings()) { - home_main_poster_recyclerview?.post { - (home_main_poster_recyclerview?.layoutManager as CenterZoomLayoutManager?)?.let { manager -> - manager.updateSize(forceUpdate = true) - if (randomSize > 2) { - manager.scrollToPosition(randomSize / 2) - manager.snap { dx -> - home_main_poster_recyclerview?.post { - // this is the best I can do, fuck android for not including instant scroll - home_main_poster_recyclerview?.smoothScrollBy(dx, 0) - } - } + observe(homeViewModel.page) { data -> + binding.apply { + when (data) { + is Resource.Success -> { + val d = data.value + (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map { + it.copy( + list = it.list.copy(list = it.list.list.toMutableList()) + ) + }) + + saveHomepageToTV(d) + + homeLoading.isVisible = false + homeLoadingError.isVisible = false + homeMasterRecycler.isVisible = true + homeLoadingShimmer.stopShimmer() + //home_loaded?.isVisible = true + if (toggleRandomButton) { + val distinct = d.values + .flatMap { it.list.list } + .distinctBy { it.url } + val hasItems = distinct.isNotEmpty() + val isPhone = isLayout(PHONE) + val randomClickListener = View.OnClickListener { + distinct.randomOrNull()?.let { activity.loadSearchResult(it) } } - } - } - } else { - items.firstOrNull()?.let { - focusCallback(it) - } - } - toggleMainVisibility(true) - } - } - - home_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - QuickSearchFragment.pushSearch(activity, query, currentApiName?.let { arrayOf(it) }) - - return true - } - - override fun onQueryTextChange(newText: String): Boolean { - //searchViewModel.quickSearch(newText) - return true - } - }) - observe(homeViewModel.page) { data -> - when (data) { - is Resource.Success -> { - home_loading_shimmer?.stopShimmer() - - val d = data.value - val mutableListOfResponse = mutableListOf() - listHomepageItems.clear() - - // println("ITEMCOUNT: ${d.values.size} ${home_master_recycler?.adapter?.itemCount}") - (home_master_recycler?.adapter as? ParentItemAdapter?)?.updateList( - d.values.toMutableList(), - home_master_recycler - ) - - home_loading?.isVisible = false - home_loading_error?.isVisible = false - home_loaded?.isVisible = true - if (toggleRandomButton) { - //Flatten list - d.values.forEach { dlist -> - mutableListOfResponse.addAll(dlist.list.list) + homeRandom.isVisible = isPhone && hasItems + homeRandom.setOnClickListener(randomClickListener) + homeRandomButtonTv.isVisible = !isPhone && hasItems + homeRandomButtonTv.setOnClickListener(randomClickListener) + } else { + homeRandom.isGone = true + homeRandomButtonTv.isGone = true } - listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url }) - home_random?.isVisible = listHomepageItems.isNotEmpty() - } else { - home_random?.isGone = true } - } - is Resource.Failure -> { - home_loading_shimmer?.stopShimmer() - - result_error_text.text = data.errorString - - home_reload_connectionerror.setOnClickListener(apiChangeClickListener) - home_reload_connection_open_in_browser.setOnClickListener { view -> - val validAPIs = apis//.filter { api -> api.hasMainPage } + is Resource.Failure -> { + homeLoadingShimmer.stopShimmer() + homeReloadConnectionerror.setOnClickListener(apiChangeClickListener) + homeReloadConnectionOpenInBrowser.setOnClickListener { view -> + val validAPIs = apis//.filter { api -> api.hasMainPage } - view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api -> - Pair( - index, - api.name - ) - }) { - try { - val i = Intent(Intent.ACTION_VIEW) - i.data = Uri.parse(validAPIs[itemId].mainUrl) - startActivity(i) - } catch (e: Exception) { - logError(e) + view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api -> + Pair( + index, + api.name + ) + }) { + try { + val i = Intent(Intent.ACTION_VIEW) + i.data = validAPIs[itemId].mainUrl.toUri() + startActivity(i) + } catch (e: Exception) { + logError(e) + } } } - } - - home_loading?.isVisible = false - home_loading_error?.isVisible = true - home_loaded?.isVisible = false - } - is Resource.Loading -> { - (home_master_recycler?.adapter as? ParentItemAdapter?)?.updateList(listOf()) - home_loading_shimmer?.startShimmer() - home_loading?.isVisible = true - home_loading_error?.isVisible = false - home_loaded?.isVisible = false - } - } - } - - val toggleList = listOf( - Pair(home_type_watching_btt, WatchType.WATCHING), - Pair(home_type_completed_btt, WatchType.COMPLETED), - Pair(home_type_dropped_btt, WatchType.DROPPED), - Pair(home_type_on_hold_btt, WatchType.ONHOLD), - Pair(home_plan_to_watch_btt, WatchType.PLANTOWATCH), - ) - val currentSet = getKey(HOME_BOOKMARK_VALUE_LIST) - ?.map { WatchType.fromInternalId(it) }?.toSet() ?: emptySet() - - for ((chip, watch) in toggleList) { - chip.isChecked = currentSet.contains(watch) - chip?.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - homeViewModel.loadStoredData( - setOf(watch) - // If we filter all buttons then two can be checked at the same time - // Revert this if you want to go back to multi selection -// toggleList.filter { it.first?.isChecked == true }.map { it.second }.toSet() - ) - } - // Else if all are unchecked -> Do not load data - else if (toggleList.all { it.first?.isChecked != true }) { - homeViewModel.loadStoredData(emptySet()) - } - } - /*chip?.setOnClickListener { - - - homeViewModel.loadStoredData(EnumSet.of(watch)) - } - - chip?.setOnLongClickListener { itemView -> - val list = EnumSet.noneOf(WatchType::class.java) - itemView.context.getKey(HOME_BOOKMARK_VALUE_LIST) - ?.map { WatchType.fromInternalId(it) }?.let { - list.addAll(it) - } - - if (list.contains(watch)) { - list.remove(watch) - } else { - list.add(watch) - } - homeViewModel.loadStoredData(list) - return@setOnLongClickListener true - }*/ - } - - observe(homeViewModel.availableWatchStatusTypes) { availableWatchStatusTypes -> - context?.setKey( - HOME_BOOKMARK_VALUE_LIST, - availableWatchStatusTypes.first.map { it.internalId }.toIntArray() - ) - - for (item in toggleList) { - val watch = item.second - item.first?.apply { - isVisible = availableWatchStatusTypes.second.contains(watch) - isSelected = availableWatchStatusTypes.first.contains(watch) - } - } - - /*home_bookmark_select?.setOnClickListener { - it.popupMenuNoIcons(availableWatchStatusTypes.second.map { type -> - Pair( - type.internalId, - type.stringRes - ) - }) { - homeViewModel.loadStoredData(it.context, WatchType.fromInternalId(this.itemId)) - } - } - home_bookmarked_parent_item_title?.text = getString(availableWatchStatusTypes.first.stringRes)*/ - } - - observe(homeViewModel.bookmarks) { (isVis, bookmarks) -> - home_bookmarked_holder.isVisible = isVis - - (home_bookmarked_child_recyclerview?.adapter as? HomeChildItemAdapter?)?.updateList( - bookmarks - ) - home_bookmarked_child_more_info?.setOnClickListener { - activity?.loadHomepageList( - HomePageList( - getString(R.string.error_bookmarks_text), //home_bookmarked_parent_item_title?.text?.toString() ?: getString(R.string.error_bookmarks_text), - bookmarks - ) - ) { - deleteAllBookmarkedData() - homeViewModel.loadStoredData(null) - } - } - } + homeLoading.isVisible = false + homeLoadingError.isVisible = true + homeMasterRecycler.isInvisible = true - observe(homeViewModel.resumeWatching) { resumeWatching -> - home_watch_holder?.isVisible = resumeWatching.isNotEmpty() - (home_watch_child_recyclerview?.adapter as? HomeChildItemAdapter?)?.updateList( - resumeWatching - ) + // Based on https://github.com/recloudstream/cloudstream/pull/1438 + val hasNoNetworkConnection = context?.isNetworkAvailable() == false + val isNetworkError = data.isNetworkError - if (isTrueTvSettings()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ioSafe { - activity?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult }) - } - } - } + // Show the downloads button if we have any sort of network shenanigans + homeReloadConnectionGoToDownloads.isVisible = + hasNoNetworkConnection || isNetworkError - home_watch_child_more_info?.setOnClickListener { - activity?.loadHomepageList( - HomePageList( - home_watch_parent_item_title?.text?.toString() - ?: getString(R.string.continue_watching), - resumeWatching - ) - ) { - deleteAllResumeStateIds() - homeViewModel.loadResumeWatching() - } - } - } + // Only hide the open in browser button if we know this is not network shenanigans related to cs3 + homeReloadConnectionOpenInBrowser.isGone = hasNoNetworkConnection - home_bookmarked_child_recyclerview.adapter = HomeChildItemAdapter( - ArrayList(), - nextFocusUp = home_bookmarked_child_recyclerview?.nextFocusUpId, - nextFocusDown = home_bookmarked_child_recyclerview?.nextFocusDownId - ) { callback -> - if (callback.action == SEARCH_ACTION_SHOW_METADATA) { - activity?.showOptionSelectStringRes( - callback.view, - callback.card.posterUrl, - listOf( - R.string.action_open_watching, - R.string.action_remove_from_bookmarks, - ), - listOf( - R.string.action_open_play, - R.string.action_open_watching, - R.string.action_remove_from_bookmarks - ) - ) { (isTv, actionId) -> - fun play() { - activity.loadSearchResult(callback.card, START_ACTION_RESUME_LATEST) - reloadStored() - } + resultErrorText.text = if (hasNoNetworkConnection) { + getString(R.string.no_internet_connection) + } else { + data.errorString + } - fun remove() { - setResultWatchState(callback.card.id, WatchType.NONE.internalId) - reloadStored() - } + homeReloadConnectionGoToDownloads.setOnClickListener { + activity.navigate(R.id.navigation_downloads) + } - fun info() { - handleSearchClickCallback( - activity, - SearchClickCallback( - SEARCH_ACTION_LOAD, - callback.view, - -1, - callback.card - ) - ) - reloadStored() + (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { + submitList(null) + clearState() + } } - if (isTv) { - when (actionId) { - 0 -> { - play() - } - 1 -> { - info() - } - 2 -> { - remove() - } - } - } else { - when (actionId) { - 0 -> { - info() - } - 1 -> { - remove() - } + is Resource.Loading -> { + homeLoadingShimmer.startShimmer() + homeLoading.isVisible = true + homeLoadingError.isVisible = false + homeMasterRecycler.isInvisible = true + (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { + submitList(null) + clearState() } + //home_loaded?.isVisible = false } } - } else { - homeHandleSearch(callback) } } - home_watch_child_recyclerview.setLinearListLayout() - home_bookmarked_child_recyclerview.setLinearListLayout() - - home_watch_child_recyclerview?.adapter = HomeChildItemAdapter( - ArrayList(), - nextFocusUp = home_watch_child_recyclerview?.nextFocusUpId, - nextFocusDown = home_watch_child_recyclerview?.nextFocusDownId - ) { callback -> - if (callback.action == SEARCH_ACTION_SHOW_METADATA) { - activity?.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) -> - fun play() { - activity.loadSearchResult(callback.card, START_ACTION_RESUME_LATEST) - reloadStored() - } - fun remove() { - val card = callback.card - if (card is DataStoreHelper.ResumeWatchingResult) { - removeLastWatched(card.parentId) - reloadStored() - } - } + observeNullable(homeViewModel.popup) { item -> + if (item == null) { + bottomSheetDialog?.dismissSafe() + bottomSheetDialog = null + return@observeNullable + } - fun info() { - handleSearchClickCallback( - activity, - SearchClickCallback( - SEARCH_ACTION_LOAD, - callback.view, - -1, - callback.card - ) - ) - reloadStored() - } + // don't recreate + if (bottomSheetDialog != null) { + return@observeNullable + } - if (isTv) { - when (actionId) { - 0 -> { - play() - } - 1 -> { - info() - } - 2 -> { - remove() - } - } - } else { - when (actionId) { - 0 -> { - info() - } - 1 -> { - remove() - } - } - } + val (items, delete) = item - } - } else { - homeHandleSearch(callback) - } + bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = { + homeViewModel.expandAndReturn(it) + }, dismissCallback = { + homeViewModel.popup(null) + bottomSheetDialog = null + }, deleteCallback = delete) } - //context?.fixPaddingStatusbarView(home_statusbar) - context?.fixPaddingStatusbar(home_padding) - context?.fixPaddingStatusbar(home_loading_statusbar) - - home_master_recycler.adapter = - ParentItemAdapter(mutableListOf(), { callback -> - homeHandleSearch(callback) - }, { item -> - activity?.loadHomepageList(item, expandCallback = { - homeViewModel.expandAndReturn(it) - }) - }, { name -> - homeViewModel.expand(name) - }) - home_master_recycler.setLinearListLayout() - home_master_recycler?.setMaxViewPoolSize(0, Int.MAX_VALUE) - home_master_recycler.layoutManager = object : LinearLayoutManager(context) { - override fun supportsPredictiveItemAnimations(): Boolean { - return false - } - } // GridLayoutManager(context, 1).also { it.supportsPredictiveItemAnimations() } - - reloadStored() - loadHomePage(false) - - home_loaded.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { v, _, scrollY, _, oldScrollY -> - val dy = scrollY - oldScrollY - if (dy > 0) { //check for scroll down - home_api_fab?.shrink() // hide - home_random?.shrink() - } else if (dy < -5) { - if (!isTvSettings()) { - home_api_fab?.extend() // show - home_random?.extend() - } - } - }) + homeViewModel.reloadStored() + homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false) + //loadHomePage(false) // nice profile pic on homepage - home_profile_picture_holder?.isVisible = false + //home_profile_picture_holder?.isVisible = false // just in case - if (isTvSettings()) { - home_api_fab?.isVisible = false - home_change_api?.isVisible = true - if (isTrueTvSettings()) { - home_change_api_loading?.isVisible = true - home_change_api_loading?.isFocusable = true - home_change_api_loading?.isFocusableInTouchMode = true - home_change_api?.isFocusable = true - home_change_api?.isFocusableInTouchMode = true - } - // home_bookmark_select?.isFocusable = true - // home_bookmark_select?.isFocusableInTouchMode = true - } else { - home_api_fab?.isVisible = true - home_change_api?.isVisible = false - home_change_api_loading?.isVisible = false - } - for (syncApi in OAuth2Apis) { - val login = syncApi.loginInfo() + //TODO READD THIS + /*for (syncApi in OAuth2Apis) { + val login = SyncAPI2.loginInfo() val pic = login?.profilePicture if (home_profile_picture?.setImage( pic, @@ -1169,6 +890,46 @@ class HomeFragment : Fragment() { home_profile_picture_holder?.isVisible = true break } + }*/ + } + + 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 23ab81ceb7e..6bdd1bf492f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -1,183 +1,141 @@ 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.FrameLayout -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.ui.result.LinearListLayout +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 kotlinx.android.synthetic.main.homepage_parent.view.* - - -class ParentItemAdapter( - private var items: MutableList, +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, + val view: View, + val position: Int, + val response: LoadResponse +) + +open class ParentItemAdapter( + 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, i: Int): ParentViewHolder { - //println("onCreateViewHolder $i") - val layout = - if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent - return ParentViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), - clickCallback, - moreInfoClickCallback, - expandCallback - ) +) : BaseAdapter( + id, + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.list.name == b.list.name }, + contentSame = { a, b -> + a.list.list == b.list.list + }) +) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 4) } } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - //println("onBindViewHolder $position") - - when (holder) { - is ParentViewHolder -> { - holder.bind(items[position]) - } + 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 getItemCount(): Int { - return items.size - } - override fun getItemId(position: Int): Long { - return items[position].list.name.hashCode().toLong() + override fun restore(state: Bundle) { + (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( + state.getSafeParcelable("value") + ) + } } - @JvmName("updateListHomePageList") - fun updateList(newList: List) { - updateList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) } - .toMutableList()) + override fun submitList( + list: Collection?, + commitCallback: Runnable? + ) { + super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) } - @JvmName("updateListExpandableHomepageList") - fun updateList( - newList: MutableList, - recyclerView: RecyclerView? = null + override fun onUpdateContent( + holder: ViewHolderState, + item: HomeViewModel.ExpandableHomepageList, + position: Int ) { - // 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 - diffResult.dispatchUpdatesTo(object : 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) - } - - override fun onChanged(position: Int, count: Int, payload: Any?) { - // 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 viewHolder = getChildViewHolder(getChildAt(i)) - val absolutePosition = viewHolder.absoluteAdapterPosition - if (absolutePosition >= position && absolutePosition < position + count) { - val expand = items.getOrNull(absolutePosition) ?: continue - if (viewHolder is ParentViewHolder) { - missingUpdates -= absolutePosition - 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) { - mAdapter.notifyItemChanged(i, payload) - } - } ?: run { // in case we don't have a nice - mAdapter.notifyItemRangeChanged(position, count, payload) - } - } - }) - - //diffResult.dispatchUpdatesTo(this) + val binding = holder.view + if (binding !is HomepageParentBinding) return + (binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list) } - class ParentViewHolder - constructor( - itemView: View, - private val clickCallback: (SearchClickCallback) -> Unit, - private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, - private val expandCallback: ((String) -> Unit)? = null, - ) : - RecyclerView.ViewHolder(itemView) { - val title: TextView = itemView.home_parent_item_title - val recyclerView: RecyclerView = itemView.home_child_recyclerview - private val moreInfo: FrameLayout? = itemView.home_child_more_info - - 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(), + 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 = 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() } - } - 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() - title.text = info.name + homeChildRecyclerview.setLinearListLayout( + isHorizontal = true, + nextLeft = startFocus, + nextRight = endFocus, + ) + 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 @@ -201,26 +159,35 @@ class ParentItemAdapter( }) //(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged() - - moreInfo?.setOnClickListener { - moreInfoClickCallback.invoke(expand) + if (isLayout(PHONE)) { + homeChildMoreInfo.setOnClickListener { + moreInfoClickCallback.invoke(item) + } } } } -} -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 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 + } - override fun getOldListSize() = oldList.size + 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) + } - override fun getNewListSize() = newList.size + return ParentItemHolder(binding) + } +} - 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 new file mode 100644 index 00000000000..959806e566c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -0,0 +1,804 @@ +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.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 +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.databinding.FragmentHomeHeadBinding +import com.lagradost.cloudstream3.databinding.FragmentHomeHeadTvBinding +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugException +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog +import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear +import com.lagradost.cloudstream3.ui.account.AccountViewModel +import com.lagradost.cloudstream3.ui.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.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( + private val viewModel: HomeViewModel, + 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) + + if (binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR)) { + binding.homeBookmarkParentItemMoreInfo.isVisible = true + + val marginInDp = 50 + val density = binding.horizontalScrollChips.context.resources.displayMetrics.density + val marginInPixels = (marginInDp * density).toInt() + + 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 onBindHeader(holder: ViewHolderState) { + (holder as? HeaderViewHolder)?.bind() + } + + override fun onViewDetachedFromWindow(holder: ViewHolderState) { + when (holder) { + is HeaderViewHolder -> { + holder.onViewDetachedFromWindow() + } + } + } + + override fun onViewAttachedToWindow(holder: ViewHolderState) { + when (holder) { + is HeaderViewHolder -> { + holder.onViewAttachedToWindow() + } + } + } + + private class HeaderViewHolder( + val binding: ViewBinding, + val viewModel: HomeViewModel, + accountViewModel: AccountViewModel, + ) : + ViewHolderState(binding) { + + override fun save(): Bundle = + Bundle().apply { + putParcelable( + "resumeRecyclerView", + resumeRecyclerView.layoutManager?.onSaveInstanceState() + ) + 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 + ) + ) + ) + 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 val bookmarkAdapter = HomeChildItemAdapter( + id = "bookmarkAdapter".hashCode(), + nextFocusUp = itemView.nextFocusUpId, + nextFocusDown = itemView.nextFocusDownId + ) { callback -> + if (callback.action != SEARCH_ACTION_SHOW_METADATA) { + 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, + listOf( + R.string.action_open_watching, + R.string.action_remove_from_bookmarks, + ), + listOf( + R.string.action_open_play, + R.string.action_open_watching, + R.string.action_remove_from_bookmarks + ) + ) { (isTv, actionId) -> + when (actionId + if (isTv) 0 else 1) { // play + 0 -> { + viewModel.click( + SearchClickCallback( + START_ACTION_RESUME_LATEST, + callback.view, + -1, + callback.card + ) + ) + } + + 1 -> { // info + viewModel.click( + SearchClickCallback( + SEARCH_ACTION_LOAD, + callback.view, + -1, + callback.card + ) + ) + } + + 2 -> { // remove + DataStoreHelper.setResultWatchState( + callback.card.id, + WatchType.NONE.internalId + ) + viewModel.reloadStored() + } + } + } + */ + } + + 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 val resumeHolder: View = itemView.findViewById(R.id.home_watch_holder) + private val resumeRecyclerView: RecyclerView = + itemView.findViewById(R.id.home_watch_child_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 val headProfilePic: ImageView? = itemView.findViewById(R.id.home_head_profile_pic) + private val headProfilePicCard: View? = + itemView.findViewById(R.id.home_head_profile_padding) + + private val alternateHeadProfilePic: ImageView? = + itemView.findViewById(R.id.alternate_home_head_profile_pic) + private val alternateHeadProfilePicCard: View? = + itemView.findViewById(R.id.alternate_home_head_profile_padding) + + private val topPadding: View? = itemView.findViewById(R.id.home_padding) + + private val alternativeAccountPadding: View? = + itemView.findViewById(R.id.alternative_account_padding) + + private val homeNonePadding: View = itemView.findViewById(R.id.home_none_padding) + + fun onSelect(item: LoadResponse, position: Int) { + (binding as? FragmentHomeHeadTvBinding)?.apply { + homePreviewDescription.isGone = item.plot.isNullOrBlank() + homePreviewDescription.text = item.plot?.html() ?: "" + + val scoreText = item.score?.toStringNull(0.1, 10, 1, false) + + scoreText?.let { score -> + homePreviewScore.text = + homePreviewScore.context.getString(R.string.extension_rating, score) + + // while it should never fail, we do this just in case + val rating = score.toDoubleOrNull() ?: item.score?.toDouble() ?: 0.0 + + val color = when { + rating < 5.0 -> "#eb2f2f".toColorInt() // Red + rating < 8.0 -> "#eda009".toColorInt() // Yellow + else -> "#3bb33b".toColorInt() // Green + } + homePreviewScore.backgroundTintList = + android.content.res.ColorStateList.valueOf(color) + } + homePreviewScore.isGone = scoreText == null + + item.year?.let { year -> + homePreviewYear.text = year.toString() + } + homePreviewYear.isGone = item.year == null + + 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() + + homePreviewInfoBtt.setOnClickListener { view -> + viewModel.click( + LoadClickCallback(0, view, position, item) + ) + } + } + (binding as? FragmentHomeHeadBinding)?.apply { + //homePreviewImage.setImage(item.posterUrl, item.posterHeaders) + + homePreviewPlay.setOnClickListener { view -> + viewModel.click( + LoadClickCallback( + START_ACTION_RESUME_LATEST, + view, + position, + item + ) + ) + } + + homePreviewInfo.setOnClickListener { view -> + viewModel.click( + LoadClickCallback(0, view, position, item) + ) + } + + // very ugly code, but I don't care + val id = item.getId() + val watchType = + DataStoreHelper.getResultWatchState(id) + homePreviewBookmark.setText(watchType.stringRes) + homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds( + null, + ContextCompat.getDrawable( + homePreviewBookmark.context, + watchType.iconRes + ), + null, + null + ) + + homePreviewBookmark.setOnClickListener { fab -> + fab.context.getActivity()?.showBottomDialog( + 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.entries[it] + + 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) + } + } + } + } + } + + 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.getItemOrNull(position) ?: return + onSelect(item, position) + } + } + + fun onViewDetachedFromWindow() { + previewViewpager.unregisterOnPageChangeCallback(previewCallback) + } + + private val toggleList = listOf>( + Pair(itemView.findViewById(R.id.home_type_watching_btt), WatchType.WATCHING), + Pair(itemView.findViewById(R.id.home_type_completed_btt), WatchType.COMPLETED), + Pair(itemView.findViewById(R.id.home_type_dropped_btt), WatchType.DROPPED), + Pair(itemView.findViewById(R.id.home_type_on_hold_btt), WatchType.ONHOLD), + Pair(itemView.findViewById(R.id.home_plan_to_watch_btt), WatchType.PLANTOWATCH), + ) + + 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 + ) + + fixPaddingStatusbarMargin(topPadding) + + for ((chip, watch) in toggleList) { + chip.isChecked = false + chip.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + viewModel.loadStoredData(setOf(watch)) + } + // Else if all are unchecked -> Do not load data + else if (toggleList.all { !it.first.isChecked }) { + viewModel.loadStoredData(emptySet()) + } + } + } + + 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 -> + view.context.selectHomepage(viewModel.repo?.name) { api -> + viewModel.loadAndCancel(api, forceReload = true, fromUI = true) + } + } + homePreviewReloadProvider.setOnClickListener { + viewModel.loadAndCancel( + viewModel.apiName.value ?: noneApi.name, + forceReload = true, + fromUI = true + ) + showToast(R.string.action_reload, Toast.LENGTH_SHORT) + true + } + homePreviewSearchButton.setOnClickListener { _ -> + // Open blank screen. + viewModel.queryTextSubmit("") + }*/ + + // A workaround to the focus problem of always centering the view on focus + // as that causes higher android versions to stretch the ui when switching between shows + var lastFocusTimeoutMs = 0L + homePreviewInfoBtt.setOnFocusChangeListener { view, hasFocus -> + val lastFocusMs = lastFocusTimeoutMs + // Always reset timer, as we only want to update + // it if we have not interacted in half a second + lastFocusTimeoutMs = System.currentTimeMillis() + if (!hasFocus) return@setOnFocusChangeListener + if (lastFocusMs + 500L < System.currentTimeMillis()) { + MainActivity.centerView(view) + } + } + + homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) return@setOnFocusChangeListener + previewViewpager.setCurrentItem(previewViewpager.currentItem + 1, true) + homePreviewInfoBtt.requestFocus() + } + + homePreviewHiddenPrevFocus.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) return@setOnFocusChangeListener + if (previewViewpager.currentItem <= 0) { + //Focus the Home item as the default focus will be the header item + (activity as? MainActivity)?.binding?.navRailView?.findViewById( + R.id.navigation_home + )?.requestFocus() + } else { + previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true) + binding.homePreviewInfoBtt.requestFocus() + //binding.homePreviewPlayBtt.requestFocus() + } + } + } + + (binding as? FragmentHomeHeadBinding)?.apply { + homeSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + viewModel.queryTextSubmit(query) + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + viewModel.queryTextChange(newText) + return true + } + }) + } + } + + private fun updatePreview(preview: Resource>>) { + if (preview is Resource.Success) { + homeNonePadding.apply { + val params = layoutParams + params.height = 0 + layoutParams = params + } + } else fixPaddingStatusbarView(homeNonePadding) + + when (preview) { + is Resource.Success -> { + previewAdapter.submitList(preview.value.second) + previewAdapter.hasMoreItems = preview.value.first + /*if (!.setItems( + preview.value.second, + preview.value.first + ) + ) { + // this might seam weird and useless, however this prevents a very weird andrid bug were the viewpager is not rendered properly + // I have no idea why that happens, but this is my ducktape solution + previewViewpager.setCurrentItem(0, false) + previewViewpager.beginFakeDrag() + previewViewpager.fakeDragBy(1f) + previewViewpager.endFakeDrag() + previewCallback.onPageSelected(0) + //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.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 + } + } + } + + private fun updateResume(resumeWatching: List) { + resumeHolder.isVisible = resumeWatching.isNotEmpty() + resumeAdapter.submitList(resumeWatching) + + 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( + title.text.toString(), + resumeWatching, + false + ), 1, false + ), + deleteCallback = { + viewModel.deleteResumeWatching() + } + ) + } + } + } + + private fun updateBookmarks(data: Pair>) { + val (visible, list) = data + bookmarkHolder.isVisible = visible + bookmarkAdapter.submitList(list) + + 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 + .mapNotNull { it.text }.joinToString() + + viewModel.popup( + HomeViewModel.ExpandableHomepageList( + HomePageList( + textSum, + 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 7ed074dcb20..e42e774b5ba 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt @@ -1,94 +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 com.lagradost.cloudstream3.LoadResponse -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.home_scroll_view.view.* - - -class HomeScrollAdapter : RecyclerView.Adapter() { - private var items: MutableList = mutableListOf() +import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding +import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage + +class HomeScrollAdapter( + val callback: ((View, Int, LoadResponse) -> Unit) +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.uniqueUrl == b.uniqueUrl && a.name == b.name +})) { 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) + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = if (isLayout(TV or EMULATOR)) { + HomeScrollViewTvBinding.inflate(inflater, parent, false) + } else { + HomeScrollViewBinding.inflate(inflater, parent, false) + } - return isSame + return ViewHolderState(binding) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(R.layout.home_scroll_view, parent, false), - ) - } + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is HomeScrollViewBinding -> { + clearImage(binding.homeScrollPreview) + } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(items[position]) + is HomeScrollViewTvBinding -> { + clearImage(binding.homeScrollPreview) } } } - class CardViewHolder - constructor( - itemView: View, - ) : - RecyclerView.ViewHolder(itemView) { + override fun onBindContent( + holder: ViewHolderState, + item: LoadResponse, + position: Int, + ) { + val binding = holder.view + + val posterUrl = item.backgroundPosterUrl ?: item.posterUrl + + 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() + + bindLogo( + url = item.logoUrl, + headers = item.posterHeaders, + titleView = binding.homeScrollPreviewTitle, + logoView = binding.homePreviewLogo + ) + } - fun bind(card: LoadResponse) { - card.apply { - val isHorizontal = - itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val posterUrl = if (isHorizontal) backgroundPosterUrl ?: posterUrl else posterUrl - ?: backgroundPosterUrl - itemView.home_scroll_preview_tags?.text = tags?.joinToString(" • ") ?: "" - itemView.home_scroll_preview_tags?.isGone = tags.isNullOrEmpty() - itemView.home_scroll_preview?.setImage(posterUrl, posterHeaders) - itemView.home_scroll_preview_title?.text = name + 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 3bb196e227d..e0609c0e57b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -1,39 +1,61 @@ package com.lagradost.cloudstream3.ui.home +import android.os.Build 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.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.mvvm.* +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.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.mvvm.logError +import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.APIRepository 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.search.SEARCH_ACTION_FOCUSED +import com.lagradost.cloudstream3.ui.search.SearchClickCallback +import com.lagradost.cloudstream3.ui.search.SearchHelper +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.* -import kotlin.collections.set +import java.util.EnumSet +import java.util.concurrent.CopyOnWriteArrayList class HomeViewModel : ViewModel() { companion object { @@ -45,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) @@ -72,18 +109,31 @@ class HomeViewModel : ViewModel() { } } - private var repo: APIRepository? = null + 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(apis.first { it.hasMainPage }) + return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) } private val _availableWatchStatusTypes = @@ -95,14 +145,20 @@ 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 val preview: LiveData>>> = _preview - fun loadResumeWatching() = viewModelScope.launchSafe { + private fun loadResumeWatching() = viewModelScope.launchSafe { val resumeWatchingResult = getResumeWatching() + 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) + } + } resumeWatchingResult?.let { _resumeWatching.postValue(it) } @@ -115,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) { @@ -128,6 +184,8 @@ class HomeViewModel : ViewModel() { currentWatchTypes.remove(WatchType.NONE) if (currentWatchTypes.size <= 0) { + DataStoreHelper.homeBookmarkedList = intArrayOf() + _availableWatchStatusTypes.postValue(setOf() to setOf()) _bookmarks.postValue(Pair(false, ArrayList())) return@launchSafe } @@ -135,12 +193,13 @@ class HomeViewModel : ViewModel() { val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first()) //if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first()) + 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) } @@ -151,8 +210,11 @@ class HomeViewModel : ViewModel() { } private var onGoingLoad: Job? = null - private fun loadAndCancel(api: MainAPI?) { + private var isCurrentlyLoadingName: String? = null + private fun loadAndCancel(api: MainAPI) { + //println("loaded ${api.name}") onGoingLoad?.cancel() + isCurrentlyLoadingName = api.name onGoingLoad = load(api) } @@ -254,121 +316,237 @@ class HomeViewModel : ViewModel() { } } - private fun load(api: MainAPI?) = ioSafe { - repo = if (api != null) { + private fun load(api: MainAPI): Job = ioSafe { + repo = //if (api != null) { APIRepository(api) - } else { - autoloadRepo() - } + //} else { + // autoloadRepo() + //} _apiName.postValue(repo?.name) _randomItems.postValue(listOf()) - if (repo?.hasMainPage == true) { - _page.postValue(Resource.Loading()) - _preview.postValue(Resource.Loading()) - addJob?.cancel() - - when (val data = repo?.getMainPage(1, null)) { - is Resource.Success -> { - try { - expandable.clear() - data.value.forEach { home -> - home?.items?.forEach { list -> - val filteredList = - context?.filterHomePageListByFilmQuality(list) ?: list - expandable[list.name] = - ExpandableHomepageList(filteredList, 1, home.hasNext) - } + if (repo?.hasMainPage != true) { + _page.postValue(Resource.Success(emptyMap())) + _preview.postValue(Resource.Failure(false, "No homepage")) + return@ioSafe + } + + + _page.postValue(Resource.Loading()) + _preview.postValue(Resource.Loading()) + // cancel the current preview expand as that is no longer relevant + addJob?.cancel() + + when (val data = repo?.getMainPage(1, null)) { + is Resource.Success -> { + try { + expandable.clear() + data.value.forEach { home -> + home?.items?.forEach { list -> + val filteredList = + context?.filterHomePageListByFilmQuality(list) ?: list + expandable[list.name] = + ExpandableHomepageList( + filteredList.copy( + list = CopyOnWriteArrayList( + filteredList.list + ) + ), 1, home.hasNext + ) } + } - val items = data.value.mapNotNull { it?.items }.flatten() + val items = data.value.mapNotNull { it?.items }.flatten() - previewResponses.clear() - previewResponsesAdded.clear() + previewResponses.clear() + previewResponsesAdded.clear() - //val home = data.value - if (items.isNotEmpty()) { - val currentList = - items.shuffled().filter { it.list.isNotEmpty() } - .flatMap { it.list } - .distinctBy { it.url } - .toList() + //val home = data.value + if (items.isNotEmpty()) { + val currentList = + items.shuffled().filter { it.list.isNotEmpty() } + .flatMap { it.list } + .distinctBy { it.url }.toList() - if (currentList.isNotEmpty()) { - val randomItems = - context?.filterSearchResultByFilmQuality(currentList.shuffled()) - ?: currentList.shuffled() + if (currentList.isNotEmpty()) { + val randomItems = + context?.filterSearchResultByFilmQuality(currentList.shuffled()) + ?: currentList.shuffled() - updatePreviewResponses( - previewResponses, - previewResponsesAdded, - randomItems, - 3 - ) + updatePreviewResponses( + previewResponses, + previewResponsesAdded, + randomItems, + 3 + ) - _randomItems.postValue(randomItems) - currentShuffledList = randomItems - } + _randomItems.postValue(randomItems) + currentShuffledList = randomItems } - if (previewResponses.isEmpty()) { - _preview.postValue( - Resource.Failure( - false, - null, - null, - "No homepage responses" - ) + } + if (previewResponses.isEmpty()) { + _preview.postValue( + Resource.Failure( + false, + "No homepage responses" ) - } else { - _preview.postValue(Resource.Success((previewResponsesAdded.size < currentShuffledList.size) to previewResponses)) - } - _page.postValue(Resource.Success(expandable)) - } catch (e: Exception) { - _randomItems.postValue(emptyList()) - logError(e) + ) + } else { + _preview.postValue(Resource.Success((previewResponsesAdded.size < currentShuffledList.size) to previewResponses)) } + _page.postValue(Resource.Success(expandable)) + } catch (e: Exception) { + _randomItems.postValue(emptyList()) + logError(e) } - is Resource.Failure -> { - _page.postValue(data!!) - } - else -> Unit } - } else { - _page.postValue(Resource.Success(emptyMap())) - _preview.postValue(Resource.Failure(false, null, null, "No homepage")) + + is Resource.Failure -> { + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") + _page.postValue(data!!) + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") + _preview.postValue(data!!) + } + + else -> Unit + } + isCurrentlyLoadingName = null + } + + fun click(callback: SearchClickCallback) { + if (callback.action != SEARCH_ACTION_FOCUSED) { + SearchHelper.handleSearchClickCallback(callback) } } - fun loadAndCancel(preferredApiName: String?, forceReload: Boolean = true) = - viewModelScope.launchSafe { + private val _popup = MutableLiveData Unit)?>?>(null) + val popup: LiveData Unit)?>?> = _popup + + fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) { + if (list == null) + _popup.postValue(null) + else + _popup.postValue(list to deleteCallback) + } + + private fun bookmarksUpdated(unused: Boolean) { + reloadStored() + } + + private fun afterPluginsLoaded(forceReload: Boolean) { + loadAndCancel(DataStoreHelper.currentHomePage, forceReload) + } + + private fun afterMainPluginsLoaded(unused: Boolean = 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() + } + + fun queryTextSubmit(query: String) { + QuickSearchFragment.pushSearch( + query, + repo?.name?.let { arrayOf(it) }) + } + + fun queryTextChange(newText: String) { + // do nothing + } + + fun loadStoredData() { + val list = EnumSet.noneOf(WatchType::class.java) + 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.response.name, load.action) + } + + // only save the key if it is from UI, as we don't want internal functions changing the setting + fun loadAndCancel( + preferredApiName: String?, + forceReload: Boolean = true, + fromUI: Boolean = false + ) = + ioSafe { + //println("trying to load $preferredApiName") // Since plugins are loaded in stages this function can get called multiple times. // The issue with this is that the homepage may be fetched multiple times while the first request is loading - val api = getApiFromNameNull(preferredApiName) - if (!forceReload && api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true) { - return@launchSafe + // api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true + val currentPage = page.value + + // if we don't need to reload and we have a valid homepage or currently loading the same thing then return + val currentLoading = isCurrentlyLoadingName + if (!forceReload && (currentPage is Resource.Success && currentPage.value.isNotEmpty() || (currentLoading != null && currentLoading == preferredApiName))) { + return@ioSafe } + val api = getApiFromNameNull(preferredApiName) if (preferredApiName == noneApi.name) { - setKey(USER_SELECTED_HOMEPAGE_API, noneApi.name) + // just set to random + 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 + // then use nothing val validAPIs = context?.filterProviderByPreferredMedia() if (validAPIs.isNullOrEmpty()) { - // Do not set USER_SELECTED_HOMEPAGE_API when there is no plugins loaded loadAndCancel(noneApi) } else { val apiRandom = validAPIs.random() loadAndCancel(apiRandom) - setKey(USER_SELECTED_HOMEPAGE_API, apiRandom.name) + if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name } - // If the plugin isn't loaded yet. (Does not set the key) } else if (api == null) { - loadAndCancel(noneApi) + // API is not found aka not loaded or removed, post the loading + // progress if waiting for plugins, otherwise nothing + if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) { + loadAndCancel(noneApi) + } else { + _page.postValue(Resource.Loading()) + if (preferredApiName != null) + _apiName.postValue(preferredApiName) + } } else { - setKey(USER_SELECTED_HOMEPAGE_API, api.name) + // if the api is found, then set it to it and save key + 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 new file mode 100644 index 00000000000..6e28c128d1c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -0,0 +1,572 @@ +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.View +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.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.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.utils.txt +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA +import com.lagradost.cloudstream3.ui.settings.Globals.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.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.action_default), + Provider(R.string.none), + Browser(R.string.browser), + Search(R.string.search), + None(R.string.none), +} + +/** Used to store how the user wants to open said poster */ +data class LibraryOpener( + val openType: LibraryOpenerType, + val providerData: ProviderLibraryData?, +) + +data class ProviderLibraryData( + val apiName: String +) + +class LibraryFragment : BaseFragment( + BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) +) { + companion object { + fun newInstance() = LibraryFragment() + + /** + * Store which page was last seen when exiting the fragment and returning + **/ + const val VIEWPAGER_ITEM_KEY = "viewpager_item" + } + + private val libraryViewModel: LibraryViewModel by activityViewModels() + + private var toggleRandomButton = false + + 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 -> + outState.putInt(VIEWPAGER_ITEM_KEY, currentItem) + } + super.onSaveInstanceState(outState) + } + + 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.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) + } + } + + val searchCallback = Runnable { + val newText = binding.mainSearch.query.toString() + libraryViewModel.sort(ListSorting.Query, newText) + } + + binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + libraryViewModel.sort(ListSorting.Query, query) + return true + } + + // This is required to prevent the first text change + // When this is attached it'll immediately send a onQueryTextChange("") + // Which we do not want + var hasInitialized = false + override fun onQueryTextChange(newText: String?): Boolean { + if (!hasInitialized) { + hasInitialized = true + return true + } + + 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 { + val items = libraryViewModel.availableApiNames + val currentItem = libraryViewModel.currentApiName.value + + activity?.showBottomDialog( + items, + items.indexOf(currentItem), + txt(R.string.select_library).asString(it.context), + false, + {}) { index -> + val selectedItem = items.getOrNull(index) ?: return@showBottomDialog + libraryViewModel.switchList(selectedItem) + } + } + + //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 + **/ + fun Activity.showPluginSelectionDialog( + key: String, + 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 baseOptions = listOf( + LibraryOpenerType.Default, + LibraryOpenerType.None, + LibraryOpenerType.Browser, + LibraryOpenerType.Search + ) + + val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders + + val savedSelection = getKey("$currentAccount/$LIBRARY_FOLDER", key) + val selectedIndex = + when { + savedSelection == null -> 0 + // If provider + savedSelection.openType == LibraryOpenerType.Provider + && savedSelection.providerData?.apiName != null -> { + availableProviders.indexOf(savedSelection.providerData.apiName) + .takeIf { it != -1 } + ?.plus(baseOptions.size) ?: 0 + } + // Else base option + else -> baseOptions.indexOf(savedSelection.openType) + } + + this.showBottomDialog( + items, + selectedIndex, + txt(R.string.open_with).asString(this), + false, + {}, + ) { + val savedData = if (it < baseOptions.size) { + LibraryOpener( + baseOptions[it], + null + ) + } else { + LibraryOpener( + LibraryOpenerType.Provider, + ProviderLibraryData(items[it]) + ) + } + + setKey( + "$currentAccount/$LIBRARY_FOLDER", + key, + savedData, + ) + } + } + + binding.providerSelector.setOnClickListener { + val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener + activity?.showPluginSelectionDialog(syncName.name, syncName) + } + + binding.viewpager.setPageTransformer(LibraryScrollTransformer()) + + 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" + }) + + 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 -> { + loadLibraryItem(syncName, syncId, searchClickCallback.card) + } + } + } + + binding.apply { + viewpager.offscreenPageLimit = 2 + viewpager.reduceDragSensitivity() + searchBar.setExpanded(true) + } + + val startLoading = Runnable { + binding.apply { + gridview.numColumns = root.context.getSpanCount() + gridview.adapter = + context?.let { LoadingPosterAdapter(it, 6 * 3) } + libraryLoadingOverlay.isVisible = true + libraryLoadingShimmer.startShimmer() + emptyListTextview.isVisible = false + } + } + + val stopLoading = Runnable { + binding.apply { + gridview.adapter = null + libraryLoadingOverlay.isVisible = false + libraryLoadingShimmer.stopShimmer() + } + } + + val handler = Handler(Looper.getMainLooper()) + + observe(libraryViewModel.pages) { resource -> + when (resource) { + is Resource.Success -> { + handler.removeCallbacks(startLoading) + val pages = resource.value + val showNotice = pages.all { it.items.isEmpty() } + + binding.apply { + emptyListTextview.isVisible = showNotice + if (showNotice) { + if (libraryViewModel.availableApiNames.size > 1) { + emptyListTextview.setText(R.string.empty_library_logged_in_message) + } else { + emptyListTextview.setText(R.string.empty_library_no_accounts_message) + } + } + + (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( + 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: + // loading -> show old viewpager -> black screen -> show new viewpager + handler.postDelayed(stopLoading, 300) + + savedInstanceState?.getInt(VIEWPAGER_ITEM_KEY)?.let { currentPos -> + if (currentPos < 0) return@let + viewpager.setCurrentItem(currentPos, false) + // Using remove() sets the key to 0 instead of removing it + savedInstanceState.putInt(VIEWPAGER_ITEM_KEY, -1) + } + + // Since the animation to scroll multiple items is so much its better to just hide + // the viewpager a bit while the fastest animation is running + fun hideViewpager(distance: Int) { + if (distance < 3) return + + val hideAnimation = AlphaAnimation(1f, 0f).apply { + duration = distance * 50L + fillAfter = true + } + val showAnimation = AlphaAnimation(0f, 1f).apply { + duration = distance * 50L + startOffset = distance * 100L + fillAfter = true + } + viewpager.startAnimation(hideAnimation) + viewpager.startAnimation(showAnimation) + } + + TabLayoutMediator( + libraryTabLayout, + 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 + 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 + }) + } + } + + is Resource.Loading -> { + // Only start loading after 200ms to prevent loading cached lists + handler.postDelayed(startLoading, 200) + } + + is Resource.Failure -> { + stopLoading.run() + // No user indication it failed :( + // TODO + } + } + } + + 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) { + 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) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt new file mode 100644 index 00000000000..c3cee183582 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt @@ -0,0 +1,17 @@ +package com.lagradost.cloudstream3.ui.library + +import android.view.View +import androidx.viewpager2.widget.ViewPager2 +import com.lagradost.cloudstream3.R +import kotlin.math.roundToInt + +class LibraryScrollTransformer : ViewPager2.PageTransformer { + override fun transformPage(page: View, position: Float) { + val padding = (-position * page.width).roundToInt() + page.findViewById(R.id.page_recyclerview).setPadding( + padding, 0, + -padding, 0 + ) + } +} + 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 new file mode 100644 index 00000000000..38f7fcf9dcb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt @@ -0,0 +1,146 @@ +package com.lagradost.cloudstream3.ui.library + +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.throwAbleToResource +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.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), + RatingHigh(R.string.sort_rating_desc), + RatingLow(R.string.sort_rating_asc), + UpdatedNew(R.string.sort_updated_new), + 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 + + private val _currentApiName: MutableLiveData = MutableLiveData("") + val currentApiName: LiveData = _currentApiName + + private val availableSyncApis + get() = AccountManager.syncApis.filter { it.isAvailable } + + var currentSyncApi = availableSyncApis.let { allApis -> + val lastSelection = getKey("$currentAccount/$LAST_SYNC_API_KEY") + availableSyncApis.firstOrNull { it.name == lastSelection } ?: allApis.firstOrNull() + } + private set(value) { + field = value + setKey("$currentAccount/$LAST_SYNC_API_KEY", field?.name) + } + + val availableApiNames: List + get() = availableSyncApis.map { it.name } + + var sortingMethods = emptyList() + private set + + var currentSortingMethod: ListSorting? = sortingMethods.firstOrNull() + private set + + fun switchList(name: String) { + currentSyncApi = availableSyncApis[availableApiNames.indexOf(name)] + _currentApiName.postValue(currentSyncApi?.name) + reloadPages(true) + } + + 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 + DataStoreHelper.librarySortingMode = method.ordinal + + items.forEach { page -> + page.sort(method, query) + } + _pages.postValue(Resource.Success(items)) + } + + fun reloadPages(forceReload: Boolean) { + // Only skip loading if its not forced and pages is not empty + if (!forceReload && (pages.value as? Resource.Success)?.value?.isNotEmpty() == true && + currentSyncApi?.requireLibraryRefresh != true + ) return + + ioSafe { + currentSyncApi?.let { repo -> + _currentApiName.postValue(repo.name) + _pages.postValue(Resource.Loading()) + val libraryResource = repo.library() + val err = libraryResource.exceptionOrNull() + if (err != null) { + _pages.postValue(throwAbleToResource(err)) + return@let + } + val library = libraryResource.getOrNull() + if (library == null) { + _pages.postValue(Resource.Failure(false, "Unable to fetch library")) + return@let + } + + sortingMethods = library.supportedListSorting.toList() + repo.requireLibraryRefresh = false + + val pages = library.allLibraryLists.map { + SyncAPI.Page( + it.name, + it.items + ) + } + + 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) + } + } + } + } + + init { + MainActivity.reloadLibraryEvent += ::reloadPages + } + + override fun onCleared() { + MainActivity.reloadLibraryEvent -= ::reloadPages + super.onCleared() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt new file mode 100644 index 00000000000..160fbe2be2f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt @@ -0,0 +1,29 @@ +package com.lagradost.cloudstream3.ui.library + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import com.lagradost.cloudstream3.R + +class LoadingPosterAdapter(context: Context, private val itemCount: Int) : + BaseAdapter() { + private val inflater: LayoutInflater = LayoutInflater.from(context) + + override fun getCount(): Int { + return itemCount + } + + override fun getItem(position: Int): Any? { + return null + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + return convertView ?: inflater.inflate(R.layout.loading_poster_dynamic, parent, false) + } +} \ No newline at end of file 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 new file mode 100644 index 00000000000..066cf468d20 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt @@ -0,0 +1,81 @@ +package com.lagradost.cloudstream3.ui.library + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.core.view.isVisible +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 kotlin.math.roundToInt + +class PageAdapter( + private val resView: AutofitRecyclerView, + val clickCallback: (SearchClickCallback) -> Unit +) : + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + if (a.id != null || b.id != null) { + a.id == b.id + } else { + a.name == b.name && a.url == b.url + } + })) { + private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt() + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchResultGridExpandedBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> { + clearImage(binding.imageView) + } + } + } + + override fun onBindContent( + holder: ViewHolderState, + item: SyncAPI.LibraryItem, + position: Int + ) { + val binding = holder.view as? SearchResultGridExpandedBinding ?: return + + /** 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 + } + + 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 new file mode 100644 index 00000000000..68b6eb2735a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt @@ -0,0 +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.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( + val scrollCallback: (isScrollingDown: Boolean) -> Unit, + val clickCallback: (SearchClickCallback) -> Unit +) : 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 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) + } + + override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { + val binding = holder.view + if (binding !is LibraryViewpagerPageBinding) return + + 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)?.submitList(item.items) + // scrollToPosition(0) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + val diff = scrollY - oldScrollY + + //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) + } + } + 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 + } + } + } + } + } +} \ 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 21047db3e3e..e5a460b9a02 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -1,49 +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.support.v4.media.session.MediaSessionCompat -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.widget.Toast -import androidx.annotation.LayoutRes +import android.widget.ImageView +import androidx.annotation.OptIn import androidx.annotation.StringRes -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.media.session.MediaButtonReceiver -import androidx.preference.PreferenceManager -import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.PlaybackException -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout -import com.google.android.exoplayer2.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.media3.common.util.UnstableApi +import androidx.media3.session.MediaSession +import androidx.media3.ui.SubtitleView +import androidx.viewbinding.ViewBinding 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 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 kotlinx.android.synthetic.main.fragment_player.* -import kotlinx.android.synthetic.main.player_custom_layout.* +import com.lagradost.cloudstream3.ui.BaseFragment enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), @@ -63,425 +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 { + // Stored pre-initialization so subclasses can set them before onBindingCreated. + private var _player: IPlayer = CS3IPlayer() - @LayoutRes - protected 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() - } + var player: IPlayer + get() = playerHostView?.player ?: _player + set(value) { + _player = value + playerHostView?.player = value + } - open fun prevEpisode() { - throw NotImplementedError() - } + val subView: SubtitleView? get() = playerHostView?.subView + val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay - open fun playerPositionChanged(posDur: Pair) { - throw NotImplementedError() - } + /** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */ + val playerView: androidx.media3.ui.PlayerView? + get() = playerHostView?.exoPlayerView - open fun playerDimensionsLoaded(widthHeight: Pair) { - throw NotImplementedError() - } + var currentPlayerStatus: CSPlayerLoading + get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering + set(value) { playerHostView?.currentPlayerStatus = value } - open fun subtitlesChanged() { - throw NotImplementedError() - } + 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. - open fun embeddedSubtitlesFetched(subtitles: List) { + override fun nextEpisode() { throw NotImplementedError() } - open fun onTracksInfoChanged() { + override fun prevEpisode() { throw NotImplementedError() } - open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { - + override fun playerPositionChanged(position: Long, duration: Long) { + throw NotImplementedError() } - open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { - + override fun playerDimensionsLoaded(width: Int, height: Int) { + throw NotImplementedError() } - open fun exitedPipMode() { + override fun subtitlesChanged() { throw NotImplementedError() } - private fun keepScreenOn(on: Boolean) { - if (on) { - activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } + override fun embeddedSubtitlesFetched(subtitles: List) { + throw NotImplementedError() } - 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) { - player_pause_play_holder_holder?.isVisible = false - player_buffering?.isVisible = true - } else { - player_pause_play_holder_holder?.isVisible = true - player_buffering?.isVisible = false - - if (wasPlaying != isPlaying) { - player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) - val drawable = player_pause_play?.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) { - player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) - } - } else { - player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) - } - } - - canEnterPipMode = isPlayingRightNow && hasPipModeSupport - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInPIPMode) { - activity?.let { act -> - PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow) - } - } + override fun onTracksInfoChanged() { + throw NotImplementedError() } - private var pipReceiver: BroadcastReceiver? = null - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { - try { - isInPIPMode = isInPictureInPictureMode - if (isInPictureInPictureMode) { - // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. - player_holder?.alpha = 0f - 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. - player_holder?.alpha = 1f - exitedPipMode() - pipReceiver?.let { - activity?.unregisterReceiver(it) - } - activity?.hideSystemUI() - this.view?.let { UIHelper.hideKeyboard(it) } - } - } catch (e: Exception) { - logError(e) - } + override fun exitedPipMode() { + throw NotImplementedError() } - open fun hasNextMirror(): Boolean { + override fun hasNextMirror(): Boolean { throw NotImplementedError() } - open fun nextMirror() { + override fun nextMirror() { throw NotImplementedError() } - private fun requestAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.requestLocalAudioFocus(AppUtils.getFocusRequest()) - } + /** Delegates to [PlayerView.playerError] by default; override to customize. */ + override fun playerError(exception: Throwable) { + playerHostView?.playerError(exception) } - open fun playerError(exception: Exception) { - fun showToast(message: String, gotoNext: Boolean = false) { - if (gotoNext && hasNextMirror()) { - showToast( - activity, - message, - Toast.LENGTH_SHORT - ) - nextMirror() - } else { - showToast( - activity, - context?.getString(R.string.no_links_found_toast) + "\n" + message, - Toast.LENGTH_LONG - ) - activity?.popCurrentPage() - } - } + /** 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 - 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 - ) - } - } - } + playerHostView = PlayerView(ctx) + playerHostView?.player = _player + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerHostView?.initialize() } - private fun onSubStyleChanged(style: SaveCaptionStyle) { - if (player is CS3IPlayer) { - player.updateSubtitleStyle(style) - } - } - - private fun playerUpdated(player: Any?) { - if (player is ExoPlayer) { - context?.let { ctx -> - val mediaButtonReceiver = ComponentName(ctx, MediaButtonReceiver::class.java) - MediaSessionCompat(ctx, "Player", mediaButtonReceiver, null).let { media -> - //media.setCallback(mMediaSessionCallback) - //media.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS) - val mediaSessionConnector = MediaSessionConnector(media) - mediaSessionConnector.setPlayer(player) - media.isActive = true - mMediaSessionCompat = media - } - } - - // Necessary for multiple combined videos - player_view?.setShowMultiWindowTimeBar(true) - player_view?.player = player - player_view?.performClick() - } - } - - private var mediaSessionConnector: MediaSessionConnector? = null - private var mMediaSessionCompat: MediaSessionCompat? = 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 = player_view?.findViewById(R.id.exo_subtitles) - subStyle = SubtitlesFragment.getCurrentSavedStyle() - player.initSubtitles(subView, subtitle_holder, 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 - ), - ) - }*/ + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode) + playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) } override fun onDestroy() { - playerEventListener = null - keyEventListener = null - canEnterPipMode = false - 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 - } - player_view?.resizeMode = type - - if (showToast) - showToast(activity, 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? { - return inflater.inflate(layout, container, false) + fun nextResize() { + playerHostView?.nextResize() + } + + open fun resize(resize: PlayerResize, showToast: Boolean) { + playerHostView?.resize(resize, showToast) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 8f7e06f9711..aa44b92359b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1,45 +1,120 @@ +@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.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 +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +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.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.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.google.android.exoplayer2.* -import com.google.android.exoplayer2.C.* -import com.google.android.exoplayer2.database.StandaloneDatabaseProvider -import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource -import com.google.android.exoplayer2.source.* -import com.google.android.exoplayer2.text.TextRenderer -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector -import com.google.android.exoplayer2.trackselection.TrackSelectionOverride -import com.google.android.exoplayer2.trackselection.TrackSelector -import com.google.android.exoplayer2.ui.SubtitleView -import com.google.android.exoplayer2.upstream.DataSource -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource -import com.google.android.exoplayer2.upstream.HttpDataSource -import com.google.android.exoplayer2.upstream.cache.CacheDataSource -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor -import com.google.android.exoplayer2.upstream.cache.SimpleCache -import com.google.android.exoplayer2.util.MimeTypes -import com.google.android.exoplayer2.video.VideoSize 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.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_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_UUID +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.WIDEVINE_UUID +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import kotlinx.coroutines.delay +import okhttp3.Interceptor +import org.chromium.net.CronetEngine import java.io.File +import java.security.SecureRandom +import java.util.UUID +import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession @@ -47,16 +122,40 @@ import javax.net.ssl.SSLSession const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" -/** Cache */ +/** toleranceBeforeUs – The maximum time that the actual position seeked to may precede the + * requested seek position, in microseconds. Must be non-negative. */ +const val toleranceBeforeUs = 300_000L + +/** + * toleranceAfterUs – The maximum time that the actual position seeked to may exceed the requested + * seek position, in microseconds. Must be non-negative. + */ +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 @@ -72,13 +171,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 @@ -88,107 +200,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 + eventHandler = null + if (isPlayerActive) { + isPlayerActive = false + activePlayers -= 1 + releaseCronetEngine() + } } + @AnyThread override fun initCallbacks( - playerUpdated: (Any?) -> Unit, - updateIsPlaying: ((Pair) -> Unit)?, - requestAutoFocus: (() -> Unit)?, - playerError: ((Exception) -> Unit)?, - playerDimensionsLoaded: ((Pair) -> Unit)?, + @MainThread eventHandler: ((PlayerEvent) -> 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 + this.eventHandler = eventHandler + if (!isPlayerActive) { + isPlayerActive = true + activePlayers += 1 + } } - // I know, this is not a perfect solution, however it works for fixing subs - private fun reloadSubs() { - exoPlayer?.applicationLooper?.let { - try { - Handler(it).post { - try { - seekTime(1L) - } catch (e: Exception) { - logError(e) - } - } - } catch (e: Exception) { - logError(e) - } - } + 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, @@ -197,7 +275,8 @@ class CS3IPlayer : IPlayer { startPosition: Long?, subtitles: Set, subtitle: SubtitleData?, - autoPlay: Boolean? + autoPlay: Boolean?, + preview: Boolean, ) { Log.i(TAG, "loadPlayer") if (sameEpisode) { @@ -216,11 +295,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) { @@ -228,7 +327,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 @@ -241,7 +340,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 } } } @@ -274,44 +377,47 @@ class CS3IPlayer : IPlayer { ?: return } - override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) { + override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { preferredAudioTrackLanguage = trackLanguage - - if (id != null) { - val audioTrack = - exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_AUDIO } - ?.getTrack(id) - - if (audioTrack != null) { - exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters - ?.buildUpon() - ?.setOverrideForType( - TrackSelectionOverride( - audioTrack.first, - audioTrack.second + id?.let { trackId -> + val trackFormatIndex = formatIndex ?: 0 + exoPlayer?.currentTracks?.groups + ?.filter { it.type == TRACK_TYPE_AUDIO } + ?.find { group -> + group.getFormats().any { (format, _) -> + format.id == trackId + } + } + ?.let { group -> + exoPlayer?.trackSelectionParameters + ?.buildUpon() + ?.setOverrideForType( + TrackSelectionOverride( + group.mediaTrackGroup, + trackFormatIndex + ) ) - ) - ?.build() - ?: return - return - } + ?.build() + } + ?.let { newParams -> + exoPlayer?.trackSelectionParameters = newParams + return + } } - + // Fallback to language-based selection exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setPreferredAudioLanguage(trackLanguage) - ?.build() - ?: return + ?.build() ?: return } - /** * Gets all supported formats in a list * */ private fun List.getFormats(): List> { - return this.map { + return this.flatMap { it.getFormats() - }.flatten() + } } private fun Tracks.Group.getFormats(): List> { @@ -322,39 +428,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 ) } @@ -364,68 +497,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() - .setPreferredTextLanguage(null) - .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) { - 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? { @@ -436,6 +566,12 @@ class CS3IPlayer : IPlayer { } } + override fun getAspectRatio(): Rational? { + return exoPlayer?.videoFormat?.let { format -> + Rational(format.width, format.height) + } + } + override fun updateSubtitleStyle(style: SaveCaptionStyle) { subtitleHelper.setSubStyle(style) } @@ -446,22 +582,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 } @@ -469,23 +625,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() } @@ -495,55 +657,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 } - private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { - val provider = getApiFromNameNull(link.source) - val interceptor = provider?.getVideoInterceptor(link) + fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? { + // Fast case, no need to recreate it + cronetEngine?.let { + return it + } + + // https://gist.github.com/ShivamKumarJha/3c8398b47053ae05112d2a8f8b5de531 + return try { + val cacheDirectory = File(context.cacheDir, "CronetEngine") + cacheDirectory.deleteRecursively() + if (!cacheDirectory.exists()) { + cacheDirectory.mkdirs() + } + CronetEngine.Builder(context) + .enableBrotli(true) + .enableHttp2(true) + .enableQuic(true) + .setStoragePath(cacheDirectory.absolutePath) + .setLibraryLoader(null) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, diskCacheSize) + .build().also { buildEngine -> + Log.d( + TAG, + "Created CronetEngine with cache at ${cacheDirectory.absolutePath}" + ) + cronetEngine = buildEngine + } + } catch (t: Throwable) { + logError(t) + // Something went wrong, so we use the backup okhttp + null + } + } + + private fun createVideoSource( + link: ExtractorLink, + engine: CronetEngine?, + interceptor: Interceptor?, + ): HttpDataSource.Factory { + val userAgent = link.headers.entries.find { + it.key.equals("User-Agent", ignoreCase = true) + }?.value ?: 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) } - val headers = mapOf( - "referer" to link.referer, - "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" - ) + link.headers // Adds the headers from the provider, e.g Authorization + // Do no include empty referer, if the provider wants those they can use the header map. + val refererMap = + if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) + + // These are extra headers the browser like to insert, not sure if we want to include them + // for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue. + val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) @@ -551,57 +830,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 ) @@ -628,12 +869,7 @@ class CS3IPlayer : IPlayer { private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector { val trackSelector = DefaultTrackSelector(context) - trackSelector.parameters = DefaultTrackSelector.ParametersBuilder(context) - // .setRendererDisabled(C.TRACK_TYPE_VIDEO, true) - .setRendererDisabled(C.TRACK_TYPE_TEXT, true) - // Experimental, I think this causes issues with audio track init 5001 -// .setTunnelingEnabled(true) - .setDisabledTextTrackSelectionFlags(C.TRACK_TYPE_TEXT) + trackSelector.parameters = trackSelector.buildUponParameters() // This will not force higher quality videos to fail // but will make the m3u8 pick the correct preferred .setMaxVideoSize(Int.MAX_VALUE, maxVideoHeight ?: Int.MAX_VALUE) @@ -642,160 +878,96 @@ 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).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 - ) - ) - .setLoadControl( - DefaultLoadControl.Builder() - .setTargetBufferBytes( - if (cacheSize <= 0) { - DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES - } else { - if (cacheSize > Int.MAX_VALUE) Int.MAX_VALUE else cacheSize.toInt() - } - ) - .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 < lastTimeStamp.endMs) { + if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) { return lastTimeStamp } } return null } - fun updatedTime(writePosition: Long? = null) { - getCurrentTimestamp(writePosition)?.let { timestamp -> - onTimestampInvoked?.invoke(timestamp) + fun updatedTime( + writePosition: Long? = null, + source: PlayerEventSource = PlayerEventSource.Player + ) { + val position = writePosition ?: exoPlayer?.currentPosition + + getCurrentTimestamp(position)?.let { timestamp -> + event(TimestampInvokedEvent(timestamp, source)) } - val position = writePosition ?: exoPlayer?.currentPosition 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() } + CSPlayerEvent.ToggleMute -> { if (volume <= 0) { //is muted @@ -806,33 +978,400 @@ class CS3IPlayer : IPlayer { volume = 0f } } + 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_UUID -> { + // 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_UUID, + PLAYREADY_UUID -> { + // 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) } } @@ -840,12 +1379,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(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 ) @@ -864,34 +1404,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() @@ -906,28 +1468,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 @@ -937,6 +1508,7 @@ class CS3IPlayer : IPlayer { Player.STATE_READY -> { onRenderFirst() } + else -> {} } @@ -946,23 +1518,19 @@ class CS3IPlayer : IPlayer { Player.STATE_READY -> { } + 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 } } @@ -972,12 +1540,38 @@ class CS3IPlayer : IPlayer { // If the Network fails then ignore the exception if the duration is set. // This is to switch mirrors automatically if the stream has not been fetched, but // allow playing the buffer without internet as then the duration is fetched. - if (error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED - && exoPlayer?.duration != TIME_UNSET - ) { - exoPlayer?.prepare() - } else { - playerError?.invoke(error) + when { + error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED + && exoPlayer?.duration != TIME_UNSET -> { + exoPlayer?.prepare() + } + + error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> { + // Re-initialize player at the current live window default position. + exoPlayer?.seekToDefaultPosition() + exoPlayer?.prepare() + } + + // PlaylistStuckException usually happens when the player position is ahead of the live window. + // Seek to the default location in that case + error.cause is HlsPlaylistTracker.PlaylistStuckException -> { + val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0 + + // Seek to live head + val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0 + + if (aheadOfLive > 100) { + exoPlayer?.seekTo(position - aheadOfLive) + } else { + exoPlayer?.seekToDefaultPosition() + } + exoPlayer?.prepare() + } + + + else -> { + event(ErrorEvent(error)) + } } super.onPlayerError(error) @@ -990,7 +1584,7 @@ class CS3IPlayer : IPlayer { override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) if (isPlaying) { - requestAutoFocus?.invoke() + event(RequestAudioFocusEvent()) onRenderFirst() } } @@ -1001,99 +1595,94 @@ class CS3IPlayer : IPlayer { Player.STATE_READY -> { } + 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), + 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 -> { // IDLE } + else -> Unit } } 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() { - updatedTime() super.onRenderedFirstFrame() onRenderFirst() + 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() { - if (!hasUsedFirstRender) { // this insures that we only call this once per player load - Log.i(TAG, "Rendered first frame") - 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) - hasUsedFirstRender = true - val format = exoPlayer?.videoFormat - val width = format?.width - val height = format?.height - if (height != null && width != null) { - playerDimensionsLoaded?.invoke(Pair(width, height)) - updatedTime() - exoPlayer?.apply { - requestedListeningPercentages?.forEach { percentage -> - createMessage { _, _ -> - updatedTime() - } - .setLooper(Looper.getMainLooper()) - .setPosition( /* positionMs= */contentDuration * percentage / 100) - // .setPayload(customPayloadData) - .setDeleteAfterDelivery(false) - .send() + if (hasUsedFirstRender) { // this insures that we only call this once per player load + return + } + Log.i(TAG, "Rendered first frame") + hasUsedFirstRender = true + + setPreferredSubtitles(currentSubtitles) + val format = exoPlayer?.videoFormat + val width = format?.width + val height = format?.height + if (height != null && width != null) { + event(ResizedEvent(width = width, height = height)) + updatedTime() + exoPlayer?.apply { + requestedListeningPercentages?.forEach { percentage -> + createMessage { _, _ -> + updatedTime() } + .setLooper(Looper.getMainLooper()) + .setPosition(contentDuration * percentage / 100) + // .setPayload(customPayloadData) + .setDeleteAfterDelivery(false) + .send() } } } @@ -1106,37 +1695,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) @@ -1145,45 +1733,166 @@ class CS3IPlayer : IPlayer { null } } + 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 @@ -1191,57 +1900,123 @@ class CS3IPlayer : IPlayer { HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) } - val mime = if (link.isM3u8) { - MimeTypes.APPLICATION_M3U8 - } 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, + 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 ) - subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) - - if (simpleCache == null) - simpleCache = getCache(context, simpleCacheSize) + // Create audio sources from ExtractorLink's audioTracks + val audioSources = getAudioSources( + audioTracks = link.audioTracks, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly + ) - val cacheFactory = CacheDataSource.Factory().apply { - simpleCache?.let { setCache(it) } - setUpstreamDataSourceFactory(onlineSourceFactory) - } + subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) - loadExo(context, mediaItems, subSources, cacheFactory) - } 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) } } -} \ No newline at end of file + + private val tracksAnalyticsListener = object : AnalyticsListener { + + override fun onVideoInputFormatChanged( + eventTime: AnalyticsListener.EventTime, + format: Format, + decoderReuseEvaluation: DecoderReuseEvaluation? + ) { + event(TracksChangedEvent()) + } + + override fun onAudioInputFormatChanged( + eventTime: AnalyticsListener.EventTime, + format: Format, + decoderReuseEvaluation: DecoderReuseEvaluation? + ) { + event(TracksChangedEvent()) + } + + override fun onVideoDisabled( + eventTime: AnalyticsListener.EventTime, + decoderCounters: DecoderCounters + ) { + event(TracksChangedEvent()) + } + + override fun onAudioDisabled( + eventTime: AnalyticsListener.EventTime, + decoderCounters: DecoderCounters + ) { + event(TracksChangedEvent()) + } + } + +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt new file mode 100644 index 00000000000..c26a4f2dfdb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/* +* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes. +*/ +package com.lagradost.cloudstream3.ui.player + +import android.text.Html +import android.text.Spanned +import android.text.TextUtils +import androidx.annotation.VisibleForTesting +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Format.CueReplacementBehavior +import androidx.media3.common.text.Cue +import androidx.media3.common.text.Cue.AnchorType +import androidx.media3.common.util.Consumer +import androidx.media3.common.util.Log +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.text.CuesWithTiming +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.text.SubtitleParser.OutputOptions +import com.google.common.base.Preconditions.checkNotNull +import com.google.common.collect.ImmutableList +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** A [SubtitleParser] for SubRip. */ +@UnstableApi +class CustomSubripParser : SubtitleParser { + private val textBuilder: StringBuilder = StringBuilder() + private val tags: ArrayList = ArrayList() + private val parsableByteArray: ParsableByteArray = ParsableByteArray() + + override fun getCueReplacementBehavior(): @CueReplacementBehavior Int { + return CUE_REPLACEMENT_BEHAVIOR + } + + override fun parse( + data: ByteArray, + offset: Int, + length: Int, + outputOptions: OutputOptions, + output: Consumer + ) { + parsableByteArray.reset(data, /* limit= */offset + length) + parsableByteArray.setPosition(offset) + val charset = detectUtfCharset(parsableByteArray) + + val cuesWithTimingBeforeRequestedStartTimeUs: MutableList? = + if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues) + ArrayList() + else + null + var currentLine: String? + while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) { + if (currentLine!!.isEmpty()) { + // Skip blank lines. + continue + } + + // Parse and check the index line. + try { + currentLine.toInt() + } catch (_: NumberFormatException) { + Log.w(TAG, "Skipping invalid index: $currentLine") + continue + } + + // Read and parse the timing line. + currentLine = parsableByteArray.readLine(charset) + if (currentLine == null) { + Log.w(TAG, "Unexpected end") + break + } + + val startTimeUs: Long + val endTimeUs: Long + val matcher = SUBRIP_TIMING_LINE.matcher(currentLine) + if (matcher.matches()) { + startTimeUs = parseTimecode(matcher, /* groupOffset= */1) + endTimeUs = parseTimecode(matcher, /* groupOffset= */6) + } else { + Log.w(TAG, "Skipping invalid timing: $currentLine") + continue + } + + // Read and parse the text and tags. + textBuilder.setLength(0) + tags.clear() + currentLine = parsableByteArray.readLine(charset) + while (!TextUtils.isEmpty(currentLine)) { + if (textBuilder.isNotEmpty()) { + textBuilder.append("
") + } + textBuilder.append(processLine(currentLine!!, tags)) + currentLine = parsableByteArray.readLine(charset) + } + + @Suppress("DEPRECATION") + val text = Html.fromHtml(textBuilder.toString()) + + var alignmentTag: String? = null + for (i in tags.indices) { + val tag = tags[i] + if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) { + alignmentTag = tag + // Subsequent alignment tags should be ignored. + break + } + } + if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) { + output.accept( + CuesWithTiming( + ImmutableList.of(buildCue(text, alignmentTag)), + startTimeUs, /* durationUs= */ + endTimeUs - startTimeUs + ) + ) + } else cuesWithTimingBeforeRequestedStartTimeUs?.add( + CuesWithTiming( + ImmutableList.of(buildCue(text, alignmentTag)), + startTimeUs, /* durationUs= */ + endTimeUs - startTimeUs + ) + ) + } + if (cuesWithTimingBeforeRequestedStartTimeUs != null) { + for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) { + output.accept(cuesWithTiming) + } + } + } + + /** + * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if + * no BOM is found. + */ + private fun detectUtfCharset(data: ParsableByteArray): Charset { + val charset = data.readUtfCharsetFromBom() + return charset ?: StandardCharsets.UTF_8 + } + + /** + * Trims and removes tags from the given line. The removed tags are added to `tags`. + * + * @param line The line to process. + * @param tags A list to which removed tags will be added. + * @return The processed line. + */ + private fun processLine(line: String, tags: ArrayList): String { + var line = line + line = line.trim { it <= ' ' } + + var removedCharacterCount = 0 + val processedLine = StringBuilder(line) + val matcher = SUBRIP_TAG_PATTERN.matcher(line) + while (matcher.find()) { + val tag = matcher.group() + tags.add(tag) + val start = matcher.start() - removedCharacterCount + val tagLength = tag.length + processedLine.replace(start, /* end= */start + tagLength, /* str= */"") + removedCharacterCount += tagLength + } + + return processedLine.toString() + } + + /** + * Build a [Cue] based on the given text and alignment tag. + * + * @param text The text. + * @param alignmentTag The alignment tag, or `null` if no alignment tag is available. + * @return Built cue + */ + private fun buildCue(text: Spanned, alignmentTag: String?): Cue { + val cue = Cue.Builder().setText(text) + if (alignmentTag == null) { + return cue.build() + } + + // Horizontal alignment. + when (alignmentTag) { + ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START) + ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END) + ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + } + + // Vertical alignment. + when (alignmentTag) { + ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END) + ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START) + ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) + else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) + } + + return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor())) + .setLine( + getFractionalPositionForAnchorType(cue.getLineAnchor()), + Cue.LINE_TYPE_FRACTION + ) + .build() + } + + companion object { + /** + * The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this + * implementation. + */ + const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int = + Format.CUE_REPLACEMENT_BEHAVIOR_MERGE + + // Fractional positions for use when alignment tags are present. + private const val START_FRACTION = 0.08f + private const val END_FRACTION = 1 - START_FRACTION + private const val MID_FRACTION = 0.5f + + private const val TAG = "SubripParser" + + // The google devs are useless, this entire class is just to override this + private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?" + private val SUBRIP_TIMING_LINE: Pattern = + Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*") + + // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183]. + private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}") + private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}" + + // Alignment tags for SSA V4+. + private const val ALIGN_BOTTOM_LEFT = "{\\an1}" + private const val ALIGN_BOTTOM_MID = "{\\an2}" + private const val ALIGN_BOTTOM_RIGHT = "{\\an3}" + private const val ALIGN_MID_LEFT = "{\\an4}" + private const val ALIGN_MID_MID = "{\\an5}" + private const val ALIGN_MID_RIGHT = "{\\an6}" + private const val ALIGN_TOP_LEFT = "{\\an7}" + private const val ALIGN_TOP_MID = "{\\an8}" + private const val ALIGN_TOP_RIGHT = "{\\an9}" + + private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long { + val hours = matcher.group(groupOffset + 1) + var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 + timestampMs += checkNotNull(matcher.group(groupOffset + 2)) + .toLong() * 60 * 1000 + timestampMs += checkNotNull(matcher.group(groupOffset + 3)) + .toLong() * 1000 + val millis = matcher.group(groupOffset + 4) + + timestampMs += when (millis?.length) { + null -> 0L + 1 -> millis.toLong() * 100L + 2 -> millis.toLong() * 10L + 3 -> millis.toLong() * 1L + else -> millis.substring(0, 3).toLong() + } + + return timestampMs * 1000 + } + + // TODO(b/289983417): Make package-private again, once it is no longer needed in + // DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed) + @VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE) + fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float { + return when (anchorType) { + Cue.ANCHOR_TYPE_START -> START_FRACTION + Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION + Cue.ANCHOR_TYPE_END -> END_FRACTION + Cue.TYPE_UNSET -> // Should never happen. + throw IllegalArgumentException() + + else -> + throw IllegalArgumentException() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 690d37064d2..61d6f556450 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -1,25 +1,42 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context +import android.text.Layout import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.text.Cue +import androidx.media3.common.util.Consumer +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.text.SubtitleDecoderFactory +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.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.google.android.exoplayer2.Format -import com.google.android.exoplayer2.text.SubtitleDecoder -import com.google.android.exoplayer2.text.SubtitleDecoderFactory -import com.google.android.exoplayer2.text.SubtitleInputBuffer -import com.google.android.exoplayer2.text.SubtitleOutputBuffer -import com.google.android.exoplayer2.text.ssa.SsaDecoder -import com.google.android.exoplayer2.text.subrip.SubripDecoder -import com.google.android.exoplayer2.text.ttml.TtmlDecoder -import com.google.android.exoplayer2.text.webvtt.WebvttDecoder -import com.google.android.exoplayer2.util.MimeTypes 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 -class CustomDecoder : SubtitleDecoder { +/** + * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not + * enough to identify the subtitle format. + */ +@OptIn(UnstableApi::class) +class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { companion object { fun updateForcedEncoding(context: Context) { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) @@ -34,12 +51,24 @@ class CustomDecoder : 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( @@ -59,7 +88,6 @@ class CustomDecoder : 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 @@ -69,19 +97,104 @@ class CustomDecoder : SubtitleDecoder { " " ) } - } - private var realDecoder: SubtitleDecoder? = null + 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 + } - override fun getName(): String { - return realDecoder?.name ?: this::javaClass.name - } + /** + * 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)) + } - override fun dequeueInputBuffer(): SubtitleInputBuffer { - Log.i(TAG, "dequeueInputBuffer") - return realDecoder?.dequeueInputBuffer() ?: SubtitleInputBuffer() + // toTextAlignment + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Layout.Alignment.ALIGN_NORMAL + SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Layout.Alignment.ALIGN_CENTER + SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE + else -> null + }?.let { anchor -> + setTextAlignment(anchor) + } + return this + } } + private var realDecoder: SubtitleParser? = null + private fun getStr(byteArray: ByteArray): Pair { val encoding = try { val encoding = overrideEncoding ?: run { @@ -114,160 +227,191 @@ class CustomDecoder : 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 + + // 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()) } + + //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 + } + } + + else -> null } + return subtitleParser } - private fun SubtitleInputBuffer.setSubtitleText(text: String) { -// println("Set subtitle text -----\n$text\n-----") - this.data = ByteBuffer.wrap(text.toByteArray(charset(UTF_8))) - } + 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 + ) - override fun queueInputBuffer(inputBuffer: SubtitleInputBuffer) { - Log.i(TAG, "queueInputBuffer") + output.accept(updatedCues) + } + Log.i(TAG, "Parse subtitle, current parser: $realDecoder") 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 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() - str.startsWith("1", ignoreCase = true) -> SubripDecoder() - else -> null - } + 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, - "Decoder selected: $realDecoder" + "Parser 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" - ) - - if (!inputString.isNullOrBlank()) { - var str: String = inputString - if (realDecoder !is SsaDecoder) { - if (regexSubtitlesToRemoveCaptions) - captionRegex.forEach { rgx -> - str = str.replace(rgx, "\n") - } - if (regexSubtitlesToRemoveBloat) + 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) } + private var latestDecoder: WeakReference? = null + + fun getSubtitleCues(): List? { + return latestDecoder?.get()?.currentSubtitleCues + } + + /** + * 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 { - return CustomDecoder() - //return when (val mimeType = format.sampleMimeType) { - // MimeTypes.TEXT_VTT -> WebvttDecoder() - // MimeTypes.TEXT_SSA -> SsaDecoder(format.initializationData) - // MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttDecoder() - // MimeTypes.APPLICATION_TTML -> TtmlDecoder() - // MimeTypes.APPLICATION_SUBRIP -> SubripDecoder() - // MimeTypes.APPLICATION_TX3G -> Tx3gDecoder(format.initializationData) - // MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> return Cea608Decoder( - // mimeType, - // format.accessibilityChannel, - // Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS - // ) - // MimeTypes.APPLICATION_CEA708 -> Cea708Decoder( - // format.accessibilityChannel, - // format.initializationData - // ) - // MimeTypes.APPLICATION_DVBSUBS -> DvbDecoder(format.initializationData) - // MimeTypes.APPLICATION_PGS -> PgsDecoder() - // MimeTypes.TEXT_EXOPLAYER_CUES -> ExoplayerCuesDecoder() - // // Default WebVttDecoder - // else -> WebvttDecoder() - //} + val parser = CustomDecoder(format) + // Allow garbage collection if player releases the decoder + latestDecoder = WeakReference(parser) + + return DelegatingSubtitleDecoder( + parser::class.simpleName + "Decoder", parser + ) } -} \ No newline at end of file +} + +/** 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 d3f4171a8b9..00000000000 --- 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 com.google.android.exoplayer2.text.SubtitleDecoderFactory -import com.google.android.exoplayer2.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 baf7ed52be7..35f8dcfd8ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt @@ -1,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 + 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) + } } - 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 (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 dc1bbba3cac..7a42cea93f7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt @@ -1,109 +1,97 @@ 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.hippo.unifile.UniFile import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.ExtractorUri -import com.lagradost.cloudstream3.utils.UIHelper.navigate - -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() - } - - private fun playLink(url: String) { - this.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - LinkGenerator( - listOf( - url - ) - ) - ) - ) + 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 playUri(uri: Uri) { - val name = UniFile.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) + attachBackPressedCallback("DownloadedPlayerActivity") { finish() } + } + + private fun handleIntent(intent: Intent) { val data = intent.data + if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) { + return + } - if (intent?.action == Intent.ACTION_SEND) { - 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 -> { finish(); return } } } else if (data?.scheme == "content") { - playUri(data) - } else { - finish() - return - } + playUri(this, data) + } else finish() + } + + override fun onResume() { + super.onResume() + CommonActivity.setActivityInstance(this) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt new file mode 100644 index 00000000000..85db33fc094 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt @@ -0,0 +1,27 @@ +package com.lagradost.cloudstream3.ui.player + +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +class ExtractorLinkGenerator( + private val links: List, + private val subtitles: List, +) : NoVideoGenerator(null) { + override suspend fun generateLinks( + clearCache: Boolean, + sourceTypes: Set, + callback: (Pair) -> Unit, + subtitleCallback: (SubtitleData) -> Unit, + offset: Int, + isCasting: Boolean + ): Boolean { + subtitles.forEach(subtitleCallback) + links.forEach { + if(sourceTypes.contains(it.type)) { + callback.invoke(it to null) + } + } + + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt new file mode 100644 index 00000000000..025267cc9ed --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.ui.player + +import android.content.Context +import android.os.Looper +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.text.TextOutput +import androidx.media3.exoplayer.text.TextRenderer +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory + +@UnstableApi +class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { + /** Somehow the nextlib authors decided that we need a text renderer that causes + * "ERROR_CODE_FAILED_RUNTIME_CHECK". + * + * Core issue: https://github.com/anilbeesetti/nextlib/pull/158 + * Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718 + * */ + override fun buildTextRenderers( + context: Context, + output: TextOutput, + outputLooper: Looper, + extensionRendererMode: Int, + out: ArrayList + ) { + out.add(TextRenderer(output, outputLooper)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index c79cdd763b4..26706699bcc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -2,120 +2,104 @@ 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.Resources +import android.content.res.Configuration import android.graphics.Color -import android.media.AudioManager import android.os.Build import android.os.Bundle -import android.provider.Settings import android.text.Editable -import android.util.DisplayMetrics import android.view.KeyEvent +import android.view.LayoutInflater import android.view.MotionEvent +import android.view.Surface import android.view.View +import android.view.ViewGroup import android.view.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.widget.EditText -import android.widget.ImageView -import android.widget.TextView +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.utils.Qualities -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +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 kotlinx.android.synthetic.main.player_custom_layout.* -import kotlinx.android.synthetic.main.player_custom_layout.bottom_player_bar -import kotlinx.android.synthetic.main.player_custom_layout.exo_ffwd -import kotlinx.android.synthetic.main.player_custom_layout.exo_ffwd_text -import kotlinx.android.synthetic.main.player_custom_layout.exo_progress -import kotlinx.android.synthetic.main.player_custom_layout.exo_rew -import kotlinx.android.synthetic.main.player_custom_layout.exo_rew_text -import kotlinx.android.synthetic.main.player_custom_layout.player_center_menu -import kotlinx.android.synthetic.main.player_custom_layout.player_ffwd_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_pause_play -import kotlinx.android.synthetic.main.player_custom_layout.player_pause_play_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_left -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_left_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_left_icon -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_right -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_right_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_progressbar_right_icon -import kotlinx.android.synthetic.main.player_custom_layout.player_rew_holder -import kotlinx.android.synthetic.main.player_custom_layout.player_time_text -import kotlinx.android.synthetic.main.player_custom_layout.player_video_bar -import kotlinx.android.synthetic.main.player_custom_layout.shadow_overlay -import kotlinx.android.synthetic.main.trailer_custom_layout.* -import kotlin.math.* - -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 +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt +import kotlin.math.roundToInt + +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 - - //private var episodes: List = listOf() - protected fun setEpisodes(ep: List) { - //hasEpisodes = ep.size > 1 // if has 2 episodes or more because you dont want to switch to your current episode - //(player_episode_list?.adapter as? PlayerEpisodeAdapter?)?.updateList(ep) - } - + protected var timestampShowState = false + private var metadataVisibilityToken = 0 protected var hasEpisodes = false private set - //protected val hasEpisodes - // get() = episodes.isNotEmpty() - - // options for player - protected var currentPrefQuality = - Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell - protected var fastForwardTime = 10000L - protected var swipeHorizontalEnabled = false - protected var swipeVerticalEnabled = false + + /** + * Default profile 1 + * Decides how links should be sorted based on a priority system. + * This will be set in runtime based on settings. + **/ + protected var currentQualityProfile = 1 + + protected var androidTVInterfaceOffSeekTime = 10000L + protected var androidTVInterfaceOnSeekTime = 30000L 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) @@ -129,48 +113,118 @@ 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 + + override fun fixLayout(view: View) = Unit + + /** + * Wet code but this can not be made into a function as it is a setter. + * + * The reason for this setter is to fix a bug with the titlecard popup, as we want it to autohide + * when pressing back. + * + * Note that we move the call to autoHide after field assignment with prevField to avoid inf recursion. */ + protected var selectSourceDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectTrackDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSpeedDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSubtitlesDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } - protected val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics + /** Checks if any top level dialog is open and showing */ + fun isDialogOpen() = + selectSourceDialog?.isShowing == true + || selectTrackDialog?.isShowing == true + || selectSpeedDialog?.isShowing == true + || selectSubtitlesDialog?.isShowing == true + || isShowingEpisodeOverlay + + private fun scheduleMetadataVisibility() { + val metadataScrim = playerBinding?.playerMetadataScrim ?: return + val ctx = metadataScrim.context ?: return + + if (!ctx.shouldShowPlayerMetadata()) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return + } - // screenWidth and screenHeight does always - // refer to the screen while in landscape mode - protected val screenWidth: Int - get() { - return max(displayMetrics.widthPixels, displayMetrics.heightPixels) + if (isLayout(PHONE)) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return } - protected val screenHeight: Int - get() { - return min(displayMetrics.widthPixels, displayMetrics.heightPixels) + + 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() + } } + } - private var statusBarHeight: Int? = null - private var navigationBarHeight: Int? = null - - private val brightnessIcons = listOf( - R.drawable.sun_1, - R.drawable.sun_2, - R.drawable.sun_3, - R.drawable.sun_4, - R.drawable.sun_5, - R.drawable.sun_6, - //R.drawable.sun_7, - // R.drawable.ic_baseline_brightness_1_24, - // R.drawable.ic_baseline_brightness_2_24, - // R.drawable.ic_baseline_brightness_3_24, - // R.drawable.ic_baseline_brightness_4_24, - // R.drawable.ic_baseline_brightness_5_24, - // R.drawable.ic_baseline_brightness_6_24, - // R.drawable.ic_baseline_brightness_7_24, - ) - - private val volumeIcons = listOf( - R.drawable.ic_baseline_volume_mute_24, - R.drawable.ic_baseline_volume_down_24, - R.drawable.ic_baseline_volume_up_24, - ) + override fun onDestroyView() { + playerHostView?.releaseOverlayLayoutListener() + playerBinding = null + super.onDestroyView() + } open fun showMirrorsDialogue() { throw NotImplementedError() @@ -182,152 +236,264 @@ 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 { - player_holder?.postDelayed({ updateUIVisibility() }, 200) + toggleEpisodesOverlay(false) + playerBinding?.playerHolder?.postDelayed({ updateUIVisibility() }, 200) } val titleMove = if (isShowing) 0f else -50.toPx.toFloat() - player_video_title?.let { + playerBinding?.playerVideoTitleHolder?.let { + ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { + duration = 200 + start() + } + } + playerBinding?.playerVideoTitleRez?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() } } - player_video_title_rez?.let { + 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() - bottom_player_bar?.let { + playerBinding?.bottomPlayerBar?.let { ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { duration = 200 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) -((bottom_player_bar?.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() - player_open_source?.let { - ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { - duration = 200 - start() + + playerBinding?.apply { + playerOpenSource.let { + ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { + duration = 200 + start() + } + } + + if (!isLocked) { + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) + shadowOverlay.isVisible = true + shadowOverlay.startAnimation(fadeAnimation) + downloadBothHeader.startAnimation(fadeAnimation) } + + bottomPlayerBar.startAnimation(fadeAnimation) + playerOpenSource.startAnimation(fadeAnimation) + playerTopHolder.startAnimation(fadeAnimation) + } + } + + 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 = + isBuiltinSubtitles || tracks.currentTextTracks.isEmpty() + } + private fun restoreOrientationWithSensor(activity: Activity) { + val currentOrientation = activity.resources.configuration.orientation + val orientation = when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - if (!isLocked) { - player_ffwd_holder?.alpha = 1f - player_rew_holder?.alpha = 1f - // player_pause_play_holder?.alpha = 1f - shadow_overlay?.isVisible = true - shadow_overlay?.startAnimation(fadeAnimation) - player_ffwd_holder?.startAnimation(fadeAnimation) - player_rew_holder?.startAnimation(fadeAnimation) - player_pause_play?.startAnimation(fadeAnimation) + Configuration.ORIENTATION_PORTRAIT -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - /*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) + else -> playerHostView?.dynamicOrientation() ?: return } + activity.requestedOrientation = orientation + } - bottom_player_bar?.startAnimation(fadeAnimation) - player_open_source?.startAnimation(fadeAnimation) - player_top_holder?.startAnimation(fadeAnimation) + 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 } - override fun subtitlesChanged() { - player_subtitle_offset_btt?.isGone = player.getCurrentPreferredSubtitle() == null + 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 } - 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 updateOrientation(ignoreDynamicOrientation: Boolean = false) { + activity?.apply { + if (lockRotation) { + if (isLocked) { + lockOrientation(this) + } else { + if (ignoreDynamicOrientation || rotatedManually) { + // Restore when lock is disabled. + restoreOrientationWithSensor(this) + } else { + this.requestedOrientation = + playerHostView?.dynamicOrientation() ?: return@apply + } + } } } - if (lockRotation) - activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } - 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 + 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 + } } - 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) - player_speed_btt?.text = + DataStoreHelper.playBackSpeed = speed + playerBinding?.playerSpeedBtt?.text = getString(R.string.player_speed_text_format).format(speed) .replace(".0x", "x") } catch (e: Exception) { @@ -343,200 +509,199 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } private fun showSubtitleOffsetDialog() { - context?.let { ctx -> - val builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - .setView(R.layout.subtitle_offset) - val dialog = builder.create() - dialog.show() - - val beforeOffset = subtitleDelay - - 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)!! - - input.doOnTextChanged { text, _, _, _ -> - text?.toString()?.toLongOrNull()?.let { - subtitleDelay = it - when { - it > 0L -> { - context?.getString(R.string.subtitle_offset_extra_hint_later_format) - ?.format(it) - } - it < 0L -> { - context?.getString(R.string.subtitle_offset_extra_hint_before_format) - ?.format(-it) + 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) + + // 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 isPortrait = + ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + fixSystemBarsPadding(binding.root, fixIme = isPortrait) + + var currentOffset = subtitleDelay + binding.apply { + subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> + text?.toString()?.toLongOrNull()?.let { time -> + currentOffset = time + val str = when { + time > 0L -> { + txt(R.string.subtitle_offset_extra_hint_later_format, time) } - it == 0L -> { - context?.getString(R.string.subtitle_offset_extra_hint_none_format) + + time < 0L -> { + txt(R.string.subtitle_offset_extra_hint_before_format, -time) } + else -> { - null + txt(R.string.subtitle_offset_extra_hint_none_format) } - }?.let { str -> - subTitle.text = str } + subtitleOffsetSubTitle.setText(str) } } - input.text = Editable.Factory.getInstance()?.newEditable(beforeOffset.toString()) + subtitleOffsetInput.text = + 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 fun changeBy(by: Long) { - val current = (input.text?.toString()?.toLongOrNull() ?: 0) + by - input.text = Editable.Factory.getInstance()?.newEditable(current.toString()) + val current = (subtitleOffsetInput.text?.toString()?.toLongOrNull() ?: 0) + by + subtitleOffsetInput.text = + Editable.Factory.getInstance()?.newEditable(current.toString()) } - add.setOnClickListener { + subtitleOffsetAdd.setOnClickListener { changeBy(buttonChange) } - addMore.setOnClickListener { + subtitleOffsetAddMore.setOnClickListener { changeBy(buttonChangeMore) } - sub.setOnClickListener { + subtitleOffsetSubtract.setOnClickListener { changeBy(-buttonChange) } - subMore.setOnClickListener { + subtitleOffsetSubtractMore.setOnClickListener { changeBy(-buttonChangeMore) } dialog.setOnDismissListener { - if (isFullScreenPlayer) - activity?.hideSystemUI() + selectSubtitlesDialog = null + activity?.hideSystemUI() } - applyButton.setOnClickListener { + applyBtt.setOnClickListener { + selectSubtitlesDialog = null + subtitleDelay = currentOffset dialog.dismissSafe(activity) player.seekTime(1L) } - cancelButton.setOnClickListener { - subtitleDelay = beforeOffset + resetBtt.setOnClickListener { + selectSubtitlesDialog = null + subtitleDelay = 0 dialog.dismissSafe(activity) + player.seekTime(1L) } - } - } - - private fun showSpeedDialog() { - val speedsText = - listOf( - "0.5x", - "0.75x", - "0.85x", - "1x", - "1.15x", - "1.25x", - "1.4x", - "1.5x", - "1.75x", - "2x" - ) - val speedsNumbers = - listOf(0.5f, 0.75f, 0.85f, 1f, 1.15f, 1.25f, 1.4f, 1.5f, 1.75f, 2f) - val speedIndex = speedsNumbers.indexOf(player.getPlaybackSpeed()) - - activity?.let { act -> - act.showDialog( - speedsText, - speedIndex, - act.getString(R.string.player_speed), - false, - { - if (isFullScreenPlayer) - activity?.hideSystemUI() - }) { index -> - if (isFullScreenPlayer) - activity?.hideSystemUI() - setPlayBackSpeed(speedsNumbers[index]) + cancelBtt.setOnClickListener { + selectSubtitlesDialog = null + dialog.dismissSafe(activity) } } } - fun resetRewindText() { - exo_rew_text?.text = - getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) - } - - fun resetFastForwardText() { - exo_ffwd_text?.text = - getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) + @SuppressLint("SetTextI18n") + fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { + val speed = player.getPlaybackSpeed() + binding.speedText.text = "%.2fx".format(speed).replace(".0x", "x") + // Android crashes if you don't round to an exact step size + binding.speedBar.value = + (speed.coerceIn(0.1f, 2.0f) / binding.speedBar.stepSize).roundToInt() + .toFloat() * binding.speedBar.stepSize } - private fun rewind() { - try { - player_center_menu?.isGone = false - player_rew_holder?.alpha = 1f - - val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) - exo_rew?.startAnimation(rotateLeft) - - val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) - goLeft.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} + private fun showSpeedDialog() { + val act = activity ?: return + val isPlaying = player.getIsPlaying() + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) - override fun onAnimationRepeat(animation: Animation?) {} + val binding: SpeedDialogBinding = SpeedDialogBinding.inflate( + LayoutInflater.from(act) + ) - override fun onAnimationEnd(animation: Animation?) { - exo_rew_text?.post { - resetRewindText() - player_center_menu?.isGone = !isShowing - player_rew_holder?.alpha = if (isShowing) 1f else 0f - } - } - }) - exo_rew_text?.startAnimation(goLeft) - exo_rew_text?.text = getString(R.string.rew_text_format).format(fastForwardTime / 1000) - player.seekTime(-fastForwardTime) - } catch (e: Exception) { - logError(e) + 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) + } } - } - private fun fastForward() { - try { - player_center_menu?.isGone = false - player_ffwd_holder?.alpha = 1f - - val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) - exo_ffwd?.startAnimation(rotateRight) + binding.speedMinus.setOnClickListener { + setPlayBackSpeed(maxOf((player.getPlaybackSpeed() - 0.1f), 0.1f)) + updateSpeedDialogBinding(binding) + } - val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) - goRight.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation?) {} + binding.speedPlus.setOnClickListener { + setPlayBackSpeed(minOf((player.getPlaybackSpeed() + 0.1f), 2.0f)) + updateSpeedDialogBinding(binding) + } - override fun onAnimationRepeat(animation: Animation?) {} + binding.speedBar.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + setPlayBackSpeed(value) + updateSpeedDialogBinding(binding) + } + } - override fun onAnimationEnd(animation: Animation?) { - exo_ffwd_text?.post { - resetFastForwardText() - player_center_menu?.isGone = !isShowing - player_ffwd_holder?.alpha = if (isShowing) 1f else 0f - } - } - }) - exo_ffwd_text?.startAnimation(goRight) - exo_ffwd_text?.text = getString(R.string.ffw_text_format).format(fastForwardTime / 1000) - player.seekTime(fastForwardTime) - } catch (e: Exception) { - logError(e) + val dismiss = DialogInterface.OnDismissListener { + activity?.hideSystemUI() + if (isPlaying) { + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + } + 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) { - player_intro_play?.isGone = true - autoHide() - } - if (isFullScreenPlayer) - activity?.hideSystemUI() + if (isShowing) autoHide() + activity?.hideSystemUI() animateLayoutChanges() - player_pause_play?.requestFocus() + if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() } private fun toggleLock() { @@ -545,8 +710,11 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } isLocked = !isLocked + playerHostView?.isLocked = isLocked + updateOrientation(true) // set true to ignore auto rotate to stay in current orientation + if (isLocked && isShowing) { - player_holder?.postDelayed({ + playerBinding?.playerHolder?.postDelayed({ if (isLocked && isShowing) { onClickChange() } @@ -554,40 +722,37 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } val fadeTo = if (isLocked) 0f else 1f + playerHostView?.gestureHelper?.animateCenterControls(fadeTo) + playerBinding?.apply { + val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { + duration = 100 + fillAfter = true + } - val fadeAnimation = AlphaAnimation(player_video_title.alpha, fadeTo).apply { - duration = 100 - fillAfter = true + updateUIVisibility() + downloadBothHeader.startAnimation(fadeAnimation) + + if (hasEpisodes) + playerEpisodesButton.startAnimation(fadeAnimation) + // player_media_route_button?.startAnimation(fadeAnimation) + // video_bar.startAnimation(fadeAnimation) + + // TITLE + playerVideoTitleRez.startAnimation(fadeAnimation) + playerVideoInfo.startAnimation(fadeAnimation) + playerEpisodeFiller.startAnimation(fadeAnimation) + playerVideoTitleHolder.startAnimation(fadeAnimation) + playerTopHolder.startAnimation(fadeAnimation) + // BOTTOM + playerLockHolder.startAnimation(fadeAnimation) + // player_go_back_holder?.startAnimation(fadeAnimation) + shadowOverlay.isVisible = true + shadowOverlay.startAnimation(fadeAnimation) } - - updateUIVisibility() - // MENUS - //centerMenu.startAnimation(fadeAnimation) - player_pause_play?.startAnimation(fadeAnimation) - player_ffwd_holder?.startAnimation(fadeAnimation) - player_rew_holder?.startAnimation(fadeAnimation) - - //if (hasEpisodes) - // player_episodes_button?.startAnimation(fadeAnimation) - //player_media_route_button?.startAnimation(fadeAnimation) - //video_bar.startAnimation(fadeAnimation) - - //TITLE - player_video_title_rez?.startAnimation(fadeAnimation) - player_episode_filler?.startAnimation(fadeAnimation) - player_video_title?.startAnimation(fadeAnimation) - player_top_holder?.startAnimation(fadeAnimation) - // BOTTOM - player_lock_holder?.startAnimation(fadeAnimation) - //player_go_back_holder?.startAnimation(fadeAnimation) - - shadow_overlay?.isVisible = true - shadow_overlay?.startAnimation(fadeAnimation) - updateLockUI() } - fun updateUIVisibility() { + private fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { @@ -597,770 +762,600 @@ open class FullScreenPlayer : AbstractPlayerFragment() { togglePlayerTitleGone = true } } - player_lock_holder?.isGone = isGone - player_video_bar?.isGone = isGone - player_pause_play_holder?.isGone = isGone - player_pause_play?.isGone = isGone - //player_buffering?.isGone = isGone - player_top_holder?.isGone = isGone - //player_episodes_button?.isVisible = !isGone && hasEpisodes - player_video_title?.isGone = togglePlayerTitleGone - player_video_title_rez?.isGone = isGone - player_episode_filler?.isGone = isGone - player_center_menu?.isGone = isGone - player_lock?.isGone = !isShowing - //player_media_route_button?.isClickable = !isGone - player_go_back_holder?.isGone = isGone - player_sources_btt?.isGone = isGone - player_skip_episode?.isClickable = !isGone + playerBinding?.apply { + playerLockHolder.isGone = isGone + playerVideoBar.isGone = isGone + + playerPausePlayHolderHolder.isGone = + isGone || currentPlayerStatus == CSPlayerLoading.IsBuffering + playerTopHolder.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 + playerGoBackHolder.isGone = isGone + playerSourcesBtt.isGone = isGone + shadowOverlay.isGone = isGone + playerSkipEpisode.isClickable = !isGone + } } private fun updateLockUI() { - player_lock?.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) - if (layout == R.layout.fragment_player) { + playerBinding?.apply { + playerLock.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) else Color.WHITE if (color != null) { - player_lock?.setTextColor(color) - player_lock?.iconTint = ColorStateList.valueOf(color) - player_lock?.rippleColor = + 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 - player_holder?.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 - player_holder?.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 - - // requested volume and brightness is used to make swiping smoother - // to make it not jump between values, - // this value is within the range [0,1] - private var currentRequestedVolume: Float = 0.0f - private var currentRequestedBrightness: Float = 1.0f - - enum class TouchAction { - Brightness, - Volume, - Time, + /** PlayerView.Callbacks touch overrides */ + + override fun isUIShowing(): Boolean = isShowing + + 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() - } + override fun onTouchDown() { + if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) + } + + @SuppressLint("SetTextI18n") + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text } + } - private fun convertTimeToString(sec: Long): String { - val rsec = sec % 60L - val min = ceil((sec - rsec) / 60.0).toInt() - val rmin = min % 60L - val h = ceil((min - rmin) / 60.0).toLong() - //int rh = h;// h % 24; - return (if (h > 0) forceLetters(h) + ":" else "") + (if (rmin >= 0 || h >= 0) forceLetters( - rmin - ) + ":" else "") + forceLetters( - rsec - ) + override fun onHidePlayerUI() { + hidePlayerUI() + } + + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + animateLayoutChanges() } + autoHide() } - private fun calculateNewTime( - startTime: Long?, - touchStart: Vector2?, - touchEnd: Vector2? - ): Long? { - if (touchStart == null || touchEnd == null || startTime == null) return null - val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidth.toFloat() - val duration = player.getDuration() ?: return null - return max( - min( - startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), - duration - ), 0 - ) + override fun playerStatusChanged() { + super.playerStatusChanged() + scheduleMetadataVisibility() } - private fun getBrightness(): Float? { - return if (useTrueSystemBrightness) { - try { - Settings.System.getInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS - ) / 255f - } catch (e: Exception) { - // because true system brightness requires - // permission, this is a lazy way to check - // as it will throw an error if we do not have it - useTrueSystemBrightness = false - return getBrightness() - } - } else { - try { - activity?.window?.attributes?.screenBrightness - } catch (e: Exception) { - logError(e) - null + // When the hold-speedup gesture fires, hide controls so the video is unobstructed. + // The speedup button show/hide and speed change are handled by PlayerView. + override fun onHoldSpeedUp(show: Boolean) { + if (show && isShowing) onClickChange() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + // If we rotate the device we need to recalculate the zoom + val gh = playerHostView?.gestureHelper ?: return + val matrix = gh.zoomMatrix + val animation = gh.matrixAnimation + if ((animation == null || !animation.isRunning) && matrix != null) { + // Ignore if we have no zoom or mid-animation + playerView?.post { + gh.applyZoomMatrix(matrix, true) + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() } } } - private fun setBrightness(brightness: Float) { - if (useTrueSystemBrightness) { - try { - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS_MODE, - Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL - ) + override fun resize(resize: PlayerResize, showToast: Boolean) { + super.resize(resize, showToast) + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + } - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS, (brightness * 255).toInt() - ) - } catch (e: Exception) { - useTrueSystemBrightness = false - setBrightness(brightness) + private fun handleKeyDownEvent(keyCode: Int): Boolean? { + // adb shell input keyevent [INT] + when (keyCode) { + KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + player.handleEvent(CSPlayerEvent.SeekForward) } - } else { - try { - val lp = activity?.window?.attributes - lp?.screenBrightness = brightness - activity?.window?.attributes = lp - } catch (e: Exception) { - logError(e) + + KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { + player.handleEvent(CSPlayerEvent.SeekBack) } - } - } - @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 - player_intro_play?.isGone = true - 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() - - getBrightness()?.let { - currentRequestedBrightness = it - } - (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> - val currentVolume = - audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolume = - audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { + player.handleEvent(CSPlayerEvent.NextEpisode) + } - currentRequestedVolume = currentVolume.toFloat() / maxVolume.toFloat() - } + 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) {} } } - 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) - } - } - } - } + + 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, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + + KeyEvent.KEYCODE_DPAD_CENTER -> { + if (isShowing) { + return null } + // If UI is not shown make click instantly skip to next chapter even if locked + if (timestampShowState) { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } else if (!isLocked) { + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + onClickChange() + } - // 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() - } + 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 { - currentClickCount = 0 + return null } + } - // call auto hide as it wont hide when you have your finger down - autoHide() + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { + player.seekTime(androidTVInterfaceOffSeekTime) + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(androidTVInterfaceOnSeekTime) + } else { + return null + } + } - // reset variables - isCurrentTouchValid = false - currentTouchStart = null - currentLastTouchAction = currentTouchAction - currentTouchAction = null - currentTouchStartPlayerTime = null - currentTouchLast = null - currentTouchStartTime = null - - // resets UI - player_time_text?.isVisible = false - player_progressbar_left_holder?.isVisible = false - player_progressbar_right_holder?.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 - } - } - } + KeyEvent.KEYCODE_VOLUME_DOWN, + KeyEvent.KEYCODE_VOLUME_UP -> { + // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR). + if (playerHostView?.handleVolumeKey(keyCode) != true) { + return null + } + } - // display action - val lastTouch = currentTouchLast - if (lastTouch != null) { - val diffFromLast = lastTouch - currentTouch - val verticalAddition = - diffFromLast.y * VERTICAL_MULTIPLIER / screenHeight.toFloat() - - // update UI - player_time_text?.isVisible = false - player_progressbar_left_holder?.isVisible = false - player_progressbar_right_holder?.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 - player_time_text?.text = - "${convertTimeToString(newMs / 1000)} [${ - (if (abs(skipMs) < 1000) "" else (if (skipMs > 0) "+" else "-")) - }${convertTimeToString(abs(skipMs / 1000))}]" - player_time_text?.isVisible = true - } - } - } - TouchAction.Brightness -> { - player_progressbar_right_holder?.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 - player_progressbar_right?.max = 100_000 - player_progressbar_right?.progress = - max(2_000, (currentRequestedBrightness * 100_000f).toInt()) - - player_progressbar_right_icon?.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 -> - player_progressbar_left_holder?.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 - player_progressbar_left?.max = 100_000 - player_progressbar_left?.progress = - max(2_000, (currentRequestedVolume * 100_000f).toInt()) - - player_progressbar_left_icon?.setImageResource( - volumeIcons[min( // clamp the value just in case - volumeIcons.size - 1, - max( - 0, - round(currentRequestedVolume * (volumeIcons.size - 1)).toInt() - ) - )] - ) - - // this is used instead of set volume because old devices does not support it - val desiredVolume = - round(currentRequestedVolume * maxVolume).toInt() - if (desiredVolume != currentVolume) { - val newVolumeAdjusted = - if (desiredVolume < currentVolume) AudioManager.ADJUST_LOWER else AudioManager.ADJUST_RAISE - - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - newVolumeAdjusted, - 0 - ) - } - } - } - else -> Unit - } - } + KeyEvent.KEYCODE_MENU, + KeyEvent.KEYCODE_SETTINGS -> { + if (isLocked || !isThereEpisodes()) { + return null } + toggleEpisodesOverlay(true) } + else -> return null // Avoid capturing all input } - currentTouchLast = currentTouch 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 - } - } - KeyEvent.KEYCODE_DPAD_UP -> { - if (!isShowing) { - onClickChange() - return true - } - } - KeyEvent.KEYCODE_DPAD_LEFT -> { - if (!isShowing && !isLocked) { - player.seekTime(-10000L) - return true - } else if (player_pause_play?.isFocused == true) { - player.seekTime(-30000L) - return true - } - } - KeyEvent.KEYCODE_DPAD_RIGHT -> { - if (!isShowing && !isLocked) { - player.seekTime(10000L) - return true - } else if (player_pause_play?.isFocused == true) { - player.seekTime(30000L) - return true - } - } - } - } - } + return false + } + val keyCode = event.keyCode - 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() - } - } + if (event.action == KeyEvent.ACTION_DOWN) { + val value = handleKeyDownEvent(keyCode) + if (value != null) { + return value + } + } - // netflix capture back and hide ~monke - KeyEvent.KEYCODE_BACK -> { - if (isShowing && isTv) { - onClickChange() - 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 + // 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 - player_skip_episode?.isVisible = false - player_tracks_btt?.isVisible = false - player_skip_op?.isVisible = false - shadow_overlay?.isVisible = false - + playerBinding?.apply { + playerSkipEpisode.isVisible = false + playerGoForwardRoot.isVisible = false + playerTracksBtt.isVisible = false + playerSkipOp.isVisible = false + shadowOverlay.isVisible = false + } updateLockUI() updateUIVisibility() animateLayoutChanges() - resetFastForwardText() - resetRewindText() + playerHostView?.gestureHelper?.resetFastForwardText() + playerHostView?.gestureHelper?.resetRewindText() } - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - // init variables - setPlayBackSpeed(getKey(PLAYBACK_SPEED_KEY) ?: 1.0f) + override fun onSaveInstanceState(outState: Bundle) { + // As this is video specific it is better to not do any setKey/getKey + outState.putLong(SUBTITLE_DELAY_BUNDLE_KEY, subtitleDelay) + super.onSaveInstanceState(outState) + } - // 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() - } - } - } + override fun onBindingCreated(binding: FragmentPlayerBinding, savedInstanceState: Bundle?) { + // Set up playerBinding before super initializes the player + // (brightness overlay is now injected by PlayerView.initialize()) + playerBinding = + PlayerCustomLayoutBinding.bind(binding.root.findViewById(R.id.player_holder)) - // handle tv controls 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 + 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(DataStoreHelper.playBackSpeed) + savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { + subtitleDelay = it } - //player_episodes_button?.setOnClickListener { - // player_episodes_button?.isGone = true - // player_episode_list?.isVisible = true - //} -// - //player_episode_list?.adapter = PlayerEpisodeAdapter { click -> -// - //} + // handle tv controls directly based on player state + setupKeyEventListener() try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - fastForwardTime = - settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10) + androidTVInterfaceOffSeekTime = + settingsManager.getInt( + ctx.getString(R.string.android_tv_interface_off_seek_key), + 10 + ) .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 + androidTVInterfaceOnSeekTime = + settingsManager.getInt( + ctx.getString(R.string.android_tv_interface_on_seek_key), + 10 ) + .toLong() * 1000L + 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 - ) + hideControlsNames = settingsManager.getBoolean( + ctx.getString(R.string.hide_player_control_names_key), + false + ) - doubleTapPauseEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_pause_enabled_key), - false - ) + val profiles = QualityDataHelper.getProfiles() + val type = if (ctx.isUsingMobileData()) + QualityDataHelper.QualityProfileType.Data + else QualityDataHelper.QualityProfileType.WiFi - currentPrefQuality = settingsManager.getInt( - ctx.getString(R.string.quality_pref_key), - currentPrefQuality - ) - // useSystemBrightness = - // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) + currentQualityProfile = + 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() + } } - - player_speed_btt?.isVisible = playBackSpeedEnabled - player_resize_btt?.isVisible = playerResizeEnabled } catch (e: Exception) { logError(e) } - player_pause_play?.setOnClickListener { - autoHide() - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } + playerBinding?.apply { + if (isLayout(TV or EMULATOR)) { + mapOf( + playerGoBack to playerGoBackText, + playerRestart to playerRestartText, + playerGoForward to playerGoForwardText, + downloadHeaderToggle to downloadHeaderToggleText, + playerEpisodesButton to playerEpisodesButtonText + ).forEach { (button, text) -> + button.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + 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 + } + } + } - skip_chapter_button?.setOnClickListener { - player.handleEvent(CSPlayerEvent.SkipCurrentChapter) - } + skipChapterButton.setOnClickListener { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } - // init clicks - player_resize_btt?.setOnClickListener { - autoHide() - nextResize() - } + playerRotateBtt.setOnClickListener { + autoHide() + toggleRotate() + } - player_speed_btt?.setOnClickListener { - autoHide() - showSpeedDialog() - } + // init clicks + playerResizeBtt.setOnClickListener { + autoHide() + nextResize() + } - player_skip_op?.setOnClickListener { - autoHide() - skipOp() - } + playerSpeedBtt.setOnClickListener { + autoHide() + showSpeedDialog() + } - player_skip_episode?.setOnClickListener { - autoHide() - player.handleEvent(CSPlayerEvent.NextEpisode) - } + playerSkipOp.setOnClickListener { + autoHide() + skipOp() + } - player_lock?.setOnClickListener { - autoHide() - toggleLock() - } + playerSkipEpisode.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.NextEpisode) + } - player_subtitle_offset_btt?.setOnClickListener { - showSubtitleOffsetDialog() - } + playerGoForward.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.NextEpisode) + } - exo_rew?.setOnClickListener { - autoHide() - rewind() - } + playerRestart.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.Restart) + } - exo_ffwd?.setOnClickListener { - autoHide() - fastForward() - } + playerLock.setOnClickListener { + autoHide() + toggleLock() + } - player_go_back?.setOnClickListener { - activity?.popCurrentPage() - } + playerSubtitleOffsetBtt.setOnClickListener { + showSubtitleOffsetDialog() + } - player_sources_btt?.setOnClickListener { - showMirrorsDialogue() - } + playerGoBack.setOnClickListener { + activity?.popCurrentPage("FullScreenPlayer") + } - player_tracks_btt?.setOnClickListener { - showTracksDialogue() - } + playerSourcesBtt.setOnClickListener { + showMirrorsDialogue() + } - // it is !not! a bug that you cant touch the right side, it does not register inputs on navbar or status bar - player_holder?.setOnTouchListener { callView, event -> - return@setOnTouchListener handleMotionEvent(callView, event) - } + playerTracksBtt.setOnClickListener { + showTracksDialogue() + } - exo_progress?.setOnTouchListener { _, event -> - // this makes the bar not disappear when sliding - when (event.action) { - MotionEvent.ACTION_DOWN -> { - currentTapIndex++ - } - MotionEvent.ACTION_MOVE -> { - currentTapIndex++ - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + playerControlsScroll.setOnScrollChangeListener { _, _, _, _, _ -> autoHide() } } - return@setOnTouchListener false + + exoProgress.registerPlayerView(playerView) + + @SuppressLint("ClickableViewAccessibility") + exoProgress.setOnTouchListener { _, event -> + // this makes the bar not disappear when sliding + when (event.action) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_MOVE -> { + playerHostView?.cancelAutoHide() + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { + autoHide() + } + } + 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 fa0a2a7fc33..2dfd5ef4df0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -3,112 +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.appcompat.app.AlertDialog +import androidx.annotation.MainThread +import androidx.annotation.OptIn import androidx.core.animation.addListener +import androidx.core.app.NotificationCompat +import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat +import androidx.core.content.edit +import androidx.core.text.toSpanned import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.media3.common.Format.NO_VALUE +import androidx.media3.common.MimeTypes +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerNotificationManager +import androidx.media3.ui.PlayerNotificationManager.EXTRA_INSTANCE_ID +import androidx.media3.ui.PlayerNotificationManager.MediaDescriptionAdapter import androidx.preference.PreferenceManager -import com.google.android.exoplayer2.Format.NO_VALUE -import com.google.android.exoplayer2.util.MimeTypes -import com.hippo.unifile.UniFile -import com.lagradost.cloudstream3.* +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.PlayerSelectSourceAndSubsBinding +import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding +import com.lagradost.cloudstream3.isAnimeOp +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.isLiveStream +import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall 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.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.setText -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.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 kotlinx.android.synthetic.main.dialog_online_subtitles.* -import kotlinx.android.synthetic.main.dialog_online_subtitles.apply_btt -import kotlinx.android.synthetic.main.dialog_online_subtitles.cancel_btt -import kotlinx.android.synthetic.main.fragment_player.* -import kotlinx.android.synthetic.main.player_custom_layout.* -import kotlinx.android.synthetic.main.player_select_source_and_subs.* -import kotlinx.android.synthetic.main.player_select_source_and_subs.subtitles_click_settings -import kotlinx.android.synthetic.main.player_select_tracks.* +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job - +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.Serializable +import java.util.Calendar +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +@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 fun startLoading() { - player.release() - currentSelectedSubtitles = null - isActive = false - overlay_loading_skip_button?.isVisible = false - player_loading_overlay?.isVisible = true - } + 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() + } + + 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,21 +218,29 @@ class GeneratorPlayer : FullScreenPlayer() { override fun onTracksInfoChanged() { val tracks = player.getVideoTracks() - player_tracks_btt?.isVisible = + 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 @@ -139,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() @@ -153,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 - player_loading_overlay?.isVisible = false + 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 + // setEpisodes(viewModel.getAllMeta() ?: emptyList()) setPlayerDimen(null) setTitle() if (!sameEpisode) @@ -173,6 +519,7 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link + val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -181,28 +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 sortLinks(useQualitySettings: Boolean = true): List> { - return currentLinks.sortedBy { - val (linkData, _) = it - var quality = linkData?.quality ?: Qualities.Unknown.value - - // we set all qualities above current max as reverse - if (useQualitySettings && quality > currentPrefQuality) { - quality = currentPrefQuality - quality - 1 - } - // negative because we want to sort highest quality first - -(quality) + if (!sameEpisode) { + player.addTimeStamps(emptyList()) // clear stamps + // Resets subtitle delay, as we watch some other content + player.setSubtitleOffset(0) } } @@ -210,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 { @@ -223,6 +561,7 @@ class GeneratorPlayer : FullScreenPlayer() { } meta.name = newMeta.headerName } + is ExtractorUri -> { if (newMeta.tvType?.isMovieType() == false) { meta.episode = newMeta.episode @@ -234,26 +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) - dialog.setContentView(R.layout.dialog_online_subtitles) + 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) { @@ -291,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) @@ -302,53 +645,104 @@ class GeneratorPlayer : FullScreenPlayer() { } dialog.show() - dialog.cancel_btt.setOnClickListener { + binding.cancelBtt.setOnClickListener { dialog.dismissSafe() } - dialog.subtitle_adapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE - dialog.subtitle_adapter.adapter = arrayAdapter - val adapter = - dialog.subtitle_adapter.adapter as? ArrayAdapter + binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE + binding.subtitleAdapter.adapter = arrayAdapter - dialog.subtitle_adapter.setOnItemClickListener { _, _, position, _ -> + 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)) - dialog.search_loading_bar.progressTintList = color - dialog.search_loading_bar.indeterminateTintList = color + val color = + ColorStateList.valueOf(context.colorFromAttribute(androidx.appcompat.R.attr.colorAccent)) + binding.searchLoadingBar.progressTintList = color + binding.searchLoadingBar.indeterminateTintList = color + + observeNullable(viewModel.currentSubtitleYear) { + // When year is changed search again + binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) + binding.yearBtt.text = it?.toString() ?: txt(R.string.none).asString(context) + } - dialog.subtitles_search.setOnQueryTextListener(object : + binding.yearBtt.setOnClickListener { + val none = txt(R.string.none).asString(context) + val currentYear = Calendar.getInstance().get(Calendar.YEAR) + val earliestYear = 1900 + + val years = (currentYear downTo earliestYear).toList() + val options = listOf(none) + years.map { + it.toString() + } + + val selectedIndex = viewModel.currentSubtitleYear.value + ?.let { + // + 1 since none also takes a space + years.indexOf(it) + 1 + } + ?.takeIf { it >= 0 } ?: 0 + + activity?.showDialog( + options, + selectedIndex, + txt(R.string.year).asString(context), + true, { + }, { index -> + viewModel.setSubtitleYear(years.getOrNull(index - 1)) + } + ) + } + + binding.subtitlesSearch.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { - dialog.search_loading_bar?.show() + binding.searchLoadingBar.show() ioSafe { val search = - AbstractSubtitleEntities.SubtitleSearch(query = query ?: return@ioSafe, - imdb = imdbId, + SubtitleSearch( + query = query ?: return@ioSafe, + 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.map { it.size }.maxOrNull() ?: return@ioSafe + } + val max = results.maxOfOrNull { it.size } ?: return@ioSafe // very ugly val items = ArrayList() @@ -362,7 +756,7 @@ class GeneratorPlayer : FullScreenPlayer() { // ugly ik activity?.runOnUiThread { setSubtitlesList(items) - dialog.search_loading_bar?.hide() + binding.searchLoadingBar.hide() } } @@ -374,33 +768,64 @@ class GeneratorPlayer : FullScreenPlayer() { } }) - dialog.search_filter.setOnClickListener { view -> - val lang639_1 = languages.map { it.ISO_639_1 } - activity?.showDialog(languages.map { it.languageName }, - lang639_1.indexOf(currentLanguageTwoLetters), + binding.searchFilter.setOnClickListener { view -> + val languagesTagName = + languages + .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + val (langTagsIETF, langNames) = languagesTagName.unzip() + + activity?.showDialog( + langNames, + langTagsIETF.indexOf(currentLanguageTagIETF), view?.context?.getString(R.string.subs_subtitle_languages) ?: return@setOnClickListener, true, { }) { index -> - currentLanguageTwoLetters = lang639_1[index] - dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true) + currentLanguageTagIETF = langTagsIETF[index] + binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) } } - dialog.apply_btt.setOnClickListener { + binding.applyBtt.setOnClickListener { 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 + } } } } @@ -413,7 +838,9 @@ class GeneratorPlayer : FullScreenPlayer() { } dialog.show() - dialog.subtitles_search.setQuery(currentTempMeta.name, true) + binding.subtitlesSearch.setQuery(currentTempMeta.name, true) + //TODO: Set year text from currently loaded movie on Player + //dialog.subtitles_search_year?.setText(currentTempMeta.year) } private fun openSubPicker() { @@ -436,26 +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( - activity, - 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 ) } @@ -463,35 +893,106 @@ 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 - val flags = + ctx.contentResolver.takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) - ctx.contentResolver.takePersistableUriPermission(uri, flags) - - val file = UniFile.fromUri(ctx, uri) - println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}") + val file = SafeFile.fromUri(ctx, uri) + val fileName = file?.name() + println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName") // DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES - val name = file.name ?: uri.toString() + val name = fileName ?: uri.toString() val subtitleData = SubtitleData( name, + "", uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), - emptyMap() + emptyMap(), + null ) addAndSelectSubtitles(subtitleData) } } - var selectSourceDialog: AlertDialog? = 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 { @@ -499,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 sourceBuilder = AlertDialog.Builder(ctx, R.style.AlertDialogCustomBlack) - .setView(R.layout.player_select_source_and_subs) - - val sourceDialog = sourceBuilder.create() + 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 = sourceDialog.sort_providers - val subtitleList = sourceDialog.sort_subtitles + 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 @@ -524,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) @@ -532,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 @@ -542,62 +1055,165 @@ 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 var startSource = 0 + var sortedUrls = emptyList>() - val sortedUrls = sortLinks(useQualitySettings = false) - if (sortedUrls.isEmpty()) { - sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true - } else { - startSource = sortedUrls.indexOf(currentSelectedLink) - sourceIndex = startSource + fun refreshLinks(qualityProfile: Int) { + sortedUrls = viewModel.state.sortLinks(qualityProfile) + if (sortedUrls.isEmpty()) { + sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = + true + } else { + startSource = sortedUrls.indexOf(currentSelectedLink) + sourceIndex = startSource + + val sourcesArrayAdapter = + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - val sourcesArrayAdapter = - ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + sourcesArrayAdapter.addAll(sortedUrls.map { (link, uri) -> + val name = link?.name ?: uri?.name ?: "NULL" + "$name ${Qualities.getStringByInt(link?.quality)}" + }) - sourcesArrayAdapter.addAll(sortedUrls.map { (link, uri) -> - val name = link?.name ?: uri?.name ?: "NULL" - "$name ${Qualities.getStringByInt(link?.quality)}" - }) + providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE + providerList.adapter = sourcesArrayAdapter + providerList.setSelection(sourceIndex) + providerList.setItemChecked(sourceIndex, true) - providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE - providerList.adapter = sourcesArrayAdapter - providerList.setSelection(sourceIndex) - providerList.setItemChecked(sourceIndex, true) + providerList.setOnItemClickListener { _, _, which, _ -> + sourceIndex = which + providerList.setItemChecked(which, true) + } - providerList.setOnItemClickListener { _, _, which, _ -> - sourceIndex = which - providerList.setItemChecked(which, true) + providerList.setOnItemLongClickListener { _, _, position, _ -> + sortedUrls.getOrNull(position)?.first?.url?.let { + clipboardHelper( + txt(R.string.video_source), + it + ) + } + true + } } } + refreshLinks(currentQualityProfile) + sourceDialog.setOnDismissListener { if (shouldDismiss) dismiss() 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. @@ -608,16 +1224,73 @@ 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() } } - sourceDialog.cancel_btt?.setOnClickListener { + 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) { + binding.sourceSettingsBtt.setText( + QualityDataHelper.getProfileName( + profile + ) + ) + } + setProfileName(currentQualityProfile) + + binding.profilesClickSettings.setOnClickListener { + val activity = activity ?: return@setOnClickListener + val dialog = QualityProfileDialog( + activity, + R.style.DialogFullscreenPlayer, + viewModel.state.links.mapNotNull { + it.first?.let { extractorLink -> + LinkSource( + extractorLink + ) + } + }, + currentQualityProfile + ) { profile -> + currentQualityProfile = profile.id + setProfileName(profile.id) + } + + dialog.setOnDismissListener { + viewModel.state.clearSortedLinksCache() + refreshLinks(currentQualityProfile) + } + + dialog.show() } - sourceDialog.subtitles_encoding_format?.apply { + binding.subtitlesEncodingFormat.apply { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) @@ -630,7 +1303,7 @@ class GeneratorPlayer : FullScreenPlayer() { text = prefNames[if (index == -1) 0 else index] } - sourceDialog.subtitles_click_settings?.setOnClickListener { + binding.subtitlesEncodingFormat.setOnClickListener { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) @@ -642,34 +1315,36 @@ 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 } } - sourceDialog.apply_btt?.setOnClickListener { - var init = false - if (sourceIndex != startSource) { - init = true - } - if (subtitleIndex != subtitleIndexStart) { - init = init || if (subtitleIndex <= 0) { + binding.applyBtt.setOnClickListener { + 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 } } @@ -679,6 +1354,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } sourceDialog.dismissSafe(activity) + selectSourceDialog = null } } } catch (e: Exception) { @@ -699,20 +1375,22 @@ class GeneratorPlayer : FullScreenPlayer() { it.height?.times(-1) } val currentAudioTracks = tracks.allAudioTracks + val binding: PlayerSelectTracksBinding = + PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) + val trackDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) + this.selectTrackDialog = trackDialog + trackDialog.setContentView(binding.root) + trackDialog.show() - val trackBuilder = AlertDialog.Builder(ctx, R.style.AlertDialogCustomBlack) - .setView(R.layout.player_select_tracks) - - val tracksDialog = trackBuilder.create() + fixSystemBarsPadding(binding.root) -// selectTracksDialog = tracksDialog + // selectTracksDialog = tracksDialog - tracksDialog.show() - val videosList = tracksDialog.video_tracks_list - val audioList = tracksDialog.auto_tracks_list + val videosList = binding.videoTracksList + val audioList = binding.autoTracksList - tracksDialog.video_tracks_holder.isVisible = currentVideoTracks.size > 1 - tracksDialog.audio_tracks_holder.isVisible = currentAudioTracks.size > 1 + binding.videoTracksHolder.isVisible = currentVideoTracks.size > 1 + binding.audioTracksHolder.isVisible = currentAudioTracks.size > 1 fun dismiss() { if (isPlaying) { @@ -747,24 +1425,58 @@ class GeneratorPlayer : FullScreenPlayer() { videosList.setItemChecked(which, true) } - tracksDialog.setOnDismissListener { + 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 @@ -777,14 +1489,17 @@ class GeneratorPlayer : FullScreenPlayer() { audioList.setItemChecked(which, true) } - tracksDialog.cancel_btt?.setOnClickListener { - tracksDialog.dismissSafe(activity) + binding.cancelBtt.setOnClickListener { + trackDialog.dismissSafe(activity) + this.selectTrackDialog = null } - tracksDialog.apply_btt?.setOnClickListener { + 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) @@ -793,8 +1508,8 @@ class GeneratorPlayer : FullScreenPlayer() { if (width != NO_VALUE && height != NO_VALUE) { player.setMaxVideoSize(width, height, currentVideo?.id) } - - tracksDialog.dismissSafe(activity) + trackDialog.dismissSafe(activity) + this.selectTrackDialog = null } } } catch (e: Exception) { @@ -802,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() { - showToast(activity, R.string.no_links_found_toast, Toast.LENGTH_SHORT) + 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() + 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() + val links = viewModel.state.sortLinks(currentQualityProfile) return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { - val links = sortLinks() + val links = viewModel.state.sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return @@ -865,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 @@ -887,47 +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) { @@ -941,15 +1699,28 @@ 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) } } if (meta.tvType.isAnimeOp()) isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE } } - player_skip_op?.isVisible = isOpVisible - player_skip_episode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true + + playerBinding?.playerSkipOp?.isVisible = isOpVisible + + 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() @@ -960,33 +1731,28 @@ class GeneratorPlayer : FullScreenPlayer() { subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null - val lang = fromTwoLettersToLanguage(langCode) ?: return null if (downloads) { - return subtitles.firstOrNull { sub -> - (sub.origin == SubtitleOrigin.DOWNLOADED_FILE && sub.name == context?.getString( - R.string.default_subtitles - )) + return sortSubs(subtitles).firstOrNull { + it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( + langCode + ) } } - sortSubs(subtitles).firstOrNull { sub -> - val t = sub.name.replace(Regex("[^A-Za-z]"), " ").trim() - (settings) && t == lang || t.startsWith(lang) || t == langCode - }?.let { sub -> - return sub - } + if (!settings) return null - return null + return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } } private fun autoSelectFromSettings(): Boolean { - // auto select subtitle based of settings + // auto select subtitle based on settings val langCode = preferredAutoSelectSubtitles val current = player.getCurrentPreferredSubtitle() Log.i(TAG, "autoSelectFromSettings = $current") context?.let { ctx -> - if (current != null) { - if (setSubtitles(current)) { + // Only use the player preferred subtitle if it matches the available language + if (current != null && (langCode == null || current.matchesLanguageCode(langCode))) { + if (setSubtitles(current, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -994,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) @@ -1008,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 @@ -1048,6 +1822,7 @@ class GeneratorPlayer : FullScreenPlayer() { season = meta.season tvType = meta.tvType } + is ExtractorUri -> { headerName = meta.headerName subName = meta.name @@ -1078,14 +1853,12 @@ class GeneratorPlayer : FullScreenPlayer() { return "" } - - @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() //Hide title, if set in setting if (limitTitle < 0) { - player_video_title?.visibility = View.GONE + playerBinding?.playerVideoTitle?.visibility = View.GONE } else { //Truncate video title if it exceeds limit val differenceInLength = playerVideoTitle.length - limitTitle @@ -1096,59 +1869,129 @@ class GeneratorPlayer : FullScreenPlayer() { } val isFiller: Boolean? = (currentMeta as? ResultEpisode)?.isFiller - player_episode_filler_holder?.isVisible = isFiller ?: false - player_video_title?.text = playerVideoTitle + 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" - player_video_title_rez?.text = 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 -> "" } } - override fun playerDimensionsLoaded(widthHeight: Pair) { - setPlayerDimen(widthHeight) + 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(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) - - return super.onCreateView(inflater, container, savedInstanceState) - } - - 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 @@ -1156,9 +1999,8 @@ class GeneratorPlayer : FullScreenPlayer() { private fun displayTimeStamp(show: Boolean) { if (timestampShowState == show) return skipIndex++ - println("displayTimeStamp = $show") timestampShowState = show - skip_chapter_button?.apply { + playerBinding?.skipChapterButton?.apply { val showWidth = 170.toPx val noShowWidth = 10.toPx //if((show && width == showWidth) || (!show && width == noShowWidth)) { @@ -1170,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 @@ -1178,7 +2026,13 @@ class GeneratorPlayer : FullScreenPlayer() { from, to ).apply { addListener(onEnd = { - if (!show) skip_chapter_button?.isVisible = false + if (!show) { + playerBinding?.skipChapterButton?.isVisible = false + if (!isShowing) { + // Automatically return focus to play pause + playerBinding?.playerPausePlay?.requestFocus() + } + } }) addUpdateListener { valueAnimator -> val value = valueAnimator.animatedValue as Int @@ -1192,16 +2046,16 @@ 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) { - skip_chapter_button.setText(timestamp.uiText) + playerBinding?.skipChapterButton?.setText(timestamp.uiText) displayTimeStamp(true) val currentIndex = skipIndex - skip_chapter_button?.handler?.postDelayed({ + playerBinding?.skipChapterButton?.handler?.postDelayed({ if (skipIndex == currentIndex) displayTimeStamp(false) }, 6000) @@ -1210,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() } } @@ -1238,30 +2210,62 @@ 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) } - overlay_loading_skip_button?.setOnClickListener { - startPlayer() + binding.overlayLoadingSkipButton.setOnClickListener { + // Mark as "success" early + viewModel.modifyState { + copy(loading = Resource.Success(Unit)) + } } - player_loading_go_back?.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 -> { // provider returned false //if (it.value != true) { @@ -1269,47 +2273,50 @@ class GeneratorPlayer : FullScreenPlayer() { //} startPlayer() } + is Resource.Failure -> { - showToast(activity, it.errorString, Toast.LENGTH_LONG) + showToast(loading.errorString, Toast.LENGTH_LONG) startPlayer() } } } - observe(viewModel.currentLinks) { - currentLinks = it - val turnVisible = it.isNotEmpty() - val wasGone = overlay_loading_skip_button?.isGone == true - overlay_loading_skip_button?.isVisible = turnVisible - if (turnVisible && wasGone) { - overlay_loading_skip_button?.requestFocus() + observe(viewModel.currentLinks) { (links, instance) -> + if (instance != viewModel.state.instance) return@observe // Outdated observe + + 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})" + } } - } - 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 + safe { + if (!isPlayerActive.get() && viewModel.state.links.any { link -> + getLinkPriority(currentQualityProfile, link.first) >= + QualityDataHelper.AUTO_SKIP_PRIORITY } + ) { + startPlayer() } - 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() + if (turnVisible && wasGone) { + binding.overlayLoadingSkipButton.requestFocus() } } } -} \ No newline at end of file +} + +@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 a1287e6aad9..3ab46ce215a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt @@ -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 ba5a4a85f23..0342372667f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -1,31 +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), @@ -38,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 { /** @@ -55,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 { @@ -104,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() @@ -135,7 +234,7 @@ interface IPlayer { fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() - fun addTimeStamps(timeStamps: List) + fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, @@ -145,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() @@ -167,9 +270,25 @@ interface IPlayer { fun getVideoTracks(): CurrentTracks + /** + * Original video aspect ratio used for PiP mode + * + * Set using: Width, Height. + * Example: Rational(16, 9) + * + * If null will default to set no aspect ratio. + * + * PiP functions calling this needs to coerce this value between 0.418410 and 2.390000 + * to prevent crashes. + */ + fun getAspectRatio(): Rational? + /** If no parameters are set it'll default to no set size, Specifying the id allows for track overrides to force the player to pick the quality. */ 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 1f2424819e6..db06e26e9a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt @@ -1,53 +1,57 @@ 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 -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 +data class ExtractorUri( + val uri: Uri, + val name: String, - override fun getCurrentId(): Int? { - return null - } + val basePath: String? = null, + val relativePath: String? = null, + val displayName: String? = null, - override fun hasNext(): Boolean { - return false - } + val id: Int? = null, + val parentId: Int? = null, + val episode: Int? = null, + val season: Int? = null, + val headerName: String? = null, + val tvType: TvType? = null, +) - 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() {} +/** + * Used to open the player more easily with the LinkGenerator + **/ +data class BasicLink( + val url: String, + val name: String? = null, +) +class LinkGenerator( + private val links: List, + private val extract: Boolean = true, + private val refererUrl: String? = null, + id: Int? +) : NoVideoGenerator(id) { 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, referer, { + if (!extract || !loadExtractor(link.url, refererUrl, { subtitleCallback(PlayerSubtitleHelper.getSubtitleData(it)) }) { callback(it to null) @@ -55,19 +59,43 @@ class LinkGenerator( // if don't extract or if no extractor found simply return the link callback( - ExtractorLink( + newExtractorLink( "", - link, - unshortenLinkSafe(link), // unshorten because it might be a raw link - referer ?: "", - Qualities.Unknown.value, isM3u8 ?: normalSafeApiCall { - URI(link).path?.substringAfterLast(".")?.contains("m3u") - } ?: false - ) to null + link.name ?: link.url, + unshortenLinkSafe(link.url), // unshorten because it might be a raw link + 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 3b47b27a6ba..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java +++ /dev/null @@ -1,456 +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 com.google.android.exoplayer2.text.Cue.DIMEN_UNSET; -import static com.google.android.exoplayer2.text.Cue.LINE_TYPE_NUMBER; -import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.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 com.google.android.exoplayer2.BaseRenderer; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.FormatHolder; -import com.google.android.exoplayer2.RendererCapabilities; -import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; -import com.google.android.exoplayer2.text.Cue; -import com.google.android.exoplayer2.text.CueGroup; -import com.google.android.exoplayer2.text.Subtitle; -import com.google.android.exoplayer2.text.SubtitleDecoder; -import com.google.android.exoplayer2.text.SubtitleDecoderException; -import com.google.android.exoplayer2.text.SubtitleDecoderFactory; -import com.google.android.exoplayer2.text.SubtitleInputBuffer; -import com.google.android.exoplayer2.text.SubtitleOutputBuffer; -import com.google.android.exoplayer2.text.TextOutput; -import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.MimeTypes; -import com.google.android.exoplayer2.util.Util; - -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. - @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 00000000000..ac25347b6bd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt @@ -0,0 +1,82 @@ +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 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 { + fun playLink(activity: Activity, url: String) { + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + LinkGenerator( + listOf( + BasicLink(url) + ), id = url.hashCode() + ), 0 + ) + ) + } + + // 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 + ) + ) + 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 + ) + ) + } +} \ 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 00000000000..f011ef37bfc --- /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/PlayerEpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerEpisodeAdapter.kt deleted file mode 100644 index cfe27a304aa..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerEpisodeAdapter.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.widget.ContentLoadingProgressBar -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.button.MaterialButton -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.ui.result.getDisplayPosition -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.player_episodes_large.view.episode_holder_large -import kotlinx.android.synthetic.main.player_episodes_large.view.episode_progress -import kotlinx.android.synthetic.main.player_episodes_small.view.episode_holder -import kotlinx.android.synthetic.main.result_episode_large.view.* - - -data class PlayerEpisodeClickEvent(val action: Int, val data: Any) - -class PlayerEpisodeAdapter( - private val items: MutableList = mutableListOf(), - private val clickCallback: (PlayerEpisodeClickEvent) -> Unit, -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return PlayerEpisodeCardViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.player_episodes, parent, false), - clickCallback, - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - println("HOLDER $holder $position") - - when (holder) { - is PlayerEpisodeCardViewHolder -> { - holder.bind(items[position]) - } - } - } - - override fun getItemCount(): Int { - return items.size - } - - fun updateList(newList: List) { - println("Updated list $newList") - val diffResult = DiffUtil.calculateDiff(EpisodeDiffCallback(this.items, newList)) - items.clear() - items.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - class PlayerEpisodeCardViewHolder - constructor( - itemView: View, - private val clickCallback: (PlayerEpisodeClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(itemView) { - @SuppressLint("SetTextI18n") - fun bind(card: Any) { - if (card is ResultEpisode) { - val (parentView, otherView) = if (card.poster == null) { - itemView.episode_holder to itemView.episode_holder_large - } else { - itemView.episode_holder_large to itemView.episode_holder - } - - val episodeText: TextView? = parentView.episode_text - val episodeFiller: MaterialButton? = parentView.episode_filler - val episodeRating: TextView? = parentView.episode_rating - val episodeDescript: TextView? = parentView.episode_descript - val episodeProgress: ContentLoadingProgressBar? = parentView.episode_progress - val episodePoster: ImageView? = parentView.episode_poster - - parentView.isVisible = true - otherView.isVisible = false - - - episodeText?.apply { - val name = - if (card.name == null) "${context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}" - - text = name - isSelected = true - } - - episodeFiller?.isVisible = card.isFiller == true - - 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(PlayerEpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) - //} - } - - parentView.setOnClickListener { - clickCallback.invoke(PlayerEpisodeClickEvent(0, card)) - } - - if (isTrueTvSettings()) { - parentView.isFocusable = true - parentView.isFocusableInTouchMode = true - parentView.touchscreenBlocksFocus = false - } - } - } - } -} - -class EpisodeDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - val a = oldList[oldItemPosition] - val b = newList[newItemPosition] - return if (a is ResultEpisode && b is ResultEpisode) { - a.id == b.id - } else { - a == b - } - } - - 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/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index dc33f67c093..e3c390d504c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -5,171 +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 + + @Volatile + var episodeIndex: Int = 0 + + /** + * The state of the video player, only modify it by modifyState to make sure observe is called, + * and avoid concurrency issues. + * + * This value can be used without Synchronized or locking when reading, as all fields are immutable. + * */ + @Volatile + var state = VideoState(instance = 0) + private set + + private val _currentLinks = + MutableLiveData>>>(null) + val currentLinks: LiveData>>> = _currentLinks - private val _currentLinks = MutableLiveData>>(setOf()) - val currentLinks: LiveData>> = _currentLinks + private val _currentSubtitles = MutableLiveData>>(null) + val currentSubtitles: LiveData>> = _currentSubtitles - private val _currentSubs = MutableLiveData>(setOf()) - val currentSubs: LiveData> = _currentSubs + private val _loadingLinks = MutableLiveData>>() + val loadingLinks: LiveData>> = _loadingLinks - private val _loadingLinks = MutableLiveData>() - val loadingLinks: LiveData> = _loadingLinks + private val _currentStamps = MutableLiveData>>(null) + val currentStamps: LiveData>> = _currentStamps - private val _currentStamps = MutableLiveData>(emptyList()) - 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 + } - fun getId(): Int? { - return generator?.getCurrentId() + /** + * 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)) } - fun loadLinks(episode: Int) { - generator?.goto(episode) - loadLinks() + 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 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 getAllMeta(): List? { - return normalSafeApiCall { generator?.getAll() } + fun loadThisEpisode(index: Int) { + episodeIndex = index + loadLinks() } - 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") - currentJob?.cancel() + var langFilterList = listOf() + var filterSubByLang = false - currentJob = viewModelScope.launchSafe { - val currentLinks = mutableSetOf>() - val currentSubs = mutableSetOf() + fun isValidSubtitle(subtitle: SubtitleData): Boolean { + if (langFilterList.isEmpty() || !filterSubByLang) { + return true + } - // clear old data - _currentSubs.postValue(currentSubs) - _currentLinks.postValue(currentLinks) + /** Only filter out subtitles fetched online */ + if (subtitle.origin != SubtitleOrigin.URL) { + return true + } - // load more data - _loadingLinks.postValue(Resource.Loading()) - val loadingState = safeApiCall { - generator?.generateLinks(clearCache = clearCache, isCasting = isCasting, { - currentLinks.add(it) - _currentLinks.postValue(currentLinks) - }, { - currentSubs.add(it) - // _currentSubs.postValue(currentSubs) // this causes ConcurrentModificationException, so fuck it - }) - } + return langFilterList.any { lang -> + subtitle.originalName.contains(lang, ignoreCase = true) + } + } - _loadingLinks.postValue(loadingState) - _currentLinks.postValue(currentLinks) - _currentSubs.postValue( - currentSubs.union(_currentSubs.value ?: emptySet()) + fun loadLinks(sourceTypes: Set = LOADTYPE_INAPP) { + Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex") + currentJob?.cancel() + val index = episodeIndex + + // Clear old data and reset the state + modifyState { + VideoState( + loading = Resource.Loading(), + generatorState = generator?.let { gen -> + GeneratorState( + meta = gen.videos.getOrNull(index), + nextMeta = gen.videos.getOrNull(index + 1), + id = gen.getId(index), + response = (gen as? RepoLinkGenerator)?.page, + index = index, + allMeta = gen.videos + ) + }, + instance = instance + 1 ) } + currentJob = viewModelScope.launchSafe { + // 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 00000000000..1c7086d1238 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -0,0 +1,1220 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Matrix +import android.media.AudioManager +import android.media.audiofx.LoudnessEnhancer +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation +import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight +import com.lagradost.cloudstream3.utils.Vector2 +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.roundToInt + +/** + * Handles all gesture, volume, brightness, speed-up, zoom, and hardware-key-event input for a + * [PlayerView]. Keeps these separate from the player-view setup and lifecycle + * code in [PlayerView] itself. + * + * Instantiated and owned by [PlayerView]; accessed from host fragments via the delegate + * properties [PlayerView] exposes. + */ +@OptIn(UnstableApi::class) +class PlayerGestureHelper(private val playerView: PlayerView) { + + companion object { + /** Swipe-seek constants */ + const val MINIMUM_SEEK_TIME = 7000L + const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height + const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height + const val VERTICAL_MULTIPLIER = 2.0f + const val HORIZONTAL_MULTIPLIER = 2.0f + + /** Double-tap constants */ + /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ + const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L + /** Time window (ms) between taps to count as a double-tap. + * Also determines how long a single-tap is delayed before firing. */ + const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L + /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ + const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 + + /** Zoom constants */ + /** Minimum zoom; allows zooming out past 100% but snaps back. */ + const val MINIMUM_ZOOM = 0.95f + /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ + const val ZOOM_SNAP_SENSITIVITY = 0.07f + /** Maximum zoom to prevent the user from getting lost. */ + const val MAXIMUM_ZOOM = 4.0f + + /** Extracts translation and uniform scale from a matrix with no rotation. */ + fun matrixToTranslationAndScale(matrix: Matrix): Triple { + val points = floatArrayOf(0f, 0f, 1f, 1f) + matrix.mapPoints(points) + val translationX = points[0] + val translationY = points[1] + val scale = points[2] - translationX + return Triple(translationX, translationY, scale) + } + } + + private val context: Context get() = playerView.context + + /** Set true by the host when the player occupies the full screen. + * Controls whether hardware volume-key overrides are active (phones/emulators only). */ + var isFullScreen: Boolean = false + + /** Volume state */ + var currentRequestedVolume: Float = 0.0f + var isVolumeLocked: Boolean = false + var hasShownVolumeToast: Boolean = false + private var loudnessEnhancer: LoudnessEnhancer? = null + private var progressBarLeftHideRunnable: Runnable? = null + + /** Brightness state */ + var currentRequestedBrightness: Float = 1.0f + var currentExtraBrightness: Float = 0.0f + var isBrightnessLocked: Boolean = false + var hasShownBrightnessToast: Boolean = false + /** When true, read/write system brightness via [Settings.System.SCREEN_BRIGHTNESS]. + * Automatically falls back to window-attribute brightness if the permission is missing. */ + var useTrueSystemBrightness: Boolean = true + /** White overlay inflated into exo_content_frame; alpha encodes extra brightness (0–1). */ + var brightnessOverlay: View? = null + private var progressBarRightHideRunnable: Runnable? = null + + /** Gesture settings (read from prefs in initialize) */ + var swipeVerticalEnabled: Boolean = true + var swipeHorizontalEnabled: Boolean = false + var extraBrightnessEnabled: Boolean = false + var speedupEnabled: Boolean = false + + /** Hold / speed-up */ + val holdHandler = Handler(Looper.getMainLooper()) + var hasTriggeredSpeedUp = false + val holdRunnable = Runnable { + playerView.player.setPlaybackSpeed(2.0f) + showOrHideSpeedUp(true) + playerView.callbacks?.onHoldSpeedUp(true) + hasTriggeredSpeedUp = true + } + + enum class TouchAction { Brightness, Volume, Time } + + /** Mirrors the host's lock state; suppresses gesture interactions when true. */ + var isLocked: Boolean = false + + /** Touch tracking */ + var isCurrentTouchValid = false + private set + private var currentTouchStart: Vector2? = null + private var currentTouchLast: Vector2? = null + /** Current in-progress swipe action, null when no swipe is active. */ + var currentTouchAction: TouchAction? = null + /** Action from the previous touch sequence; guards against mis-detected double-taps after swipes. */ + var currentLastTouchAction: TouchAction? = null + /** The time in the player when you first click. */ + private var currentTouchStartPlayerTime: Long? = null + /** The system time when you first click. */ + private var currentTouchStartTime: Long? = null + /** Whether the player UI was visible when the current swipe gesture began. */ + var uiShowingBeforeGesture: Boolean = false + + /** Icons */ + private val brightnessIcons = listOf( + R.drawable.sun_1, R.drawable.sun_2, R.drawable.sun_3, + R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, R.drawable.sun_7, + ) + private val volumeIcons = listOf( + R.drawable.ic_baseline_volume_mute_24, + R.drawable.ic_baseline_volume_down_24, + R.drawable.ic_baseline_volume_up_24, + ) + + /** Double-tap / tap state */ + + /** Whether double-tapping left/right seeks backward/forward. */ + var doubleTapEnabled: Boolean = false + + /** Whether double-tapping the center of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ + var doubleTapPauseEnabled: Boolean = false + + /** Seek distance (ms) for each double-tap seek. Read from prefs in [initialize]. */ + var fastForwardTime: Long = 10_000L + + /** Monotonically-incremented token; cancels any pending single-tap runnable when a double-tap arrives. */ + private var doubleTapToken = 0 + + /** Number of consecutive taps in the current double-tap window. */ + private var tapCount = 0 + + /** System time of the most-recent touch end. Updated by callers at the end of every ACTION_UP. */ + var lastTouchEndTime: Long = 0L + + /** Zoom state */ + + /** Optional view for showing the snap-hint outline during zoom (set by FullScreenPlayer). */ + var videoOutline: View? = null + + /** Current zoom+pan matrix, or null when no zoom is active. */ + var zoomMatrix: Matrix? = null + + /** The matrix the zoom will animate to after the user lifts fingers. */ + var desiredMatrix: Matrix? = null + + /** Running snap-back animation, or null. */ + var matrixAnimation: ValueAnimator? = null + + private var scaleGestureDetector: ScaleGestureDetector? = null + + /** Midpoint of the two-finger pan, null when no pan is active. */ + var lastPan: Vector2? = null + + private var overlayLayoutListener: View.OnLayoutChangeListener? = null + + /** Called from [PlayerView.initialize] after views are bound. */ + fun initialize() { + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) + swipeHorizontalEnabled = sm.getBoolean(context.getString(R.string.swipe_enabled_key), true) + extraBrightnessEnabled = sm.getBoolean(context.getString(R.string.extra_brightness_key), false) + speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) + doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) + doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) + fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L + } catch (_: Exception) { + } + + // Inject the brightness overlay into the ExoPlayer content frame so it sits + // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. + safe { + val pkg = context.packageName + @SuppressLint("DiscouragedApi") + val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) + val contentFrame = playerView.exoPlayerView?.findViewById(contentId) + if (contentFrame != null) { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + brightnessOverlay = LayoutInflater.from(context) + .inflate(R.layout.extra_brightness_overlay, contentFrame, false) + contentFrame.addView(brightnessOverlay) + } + } + + setupTouchGestures() + } + + /** Called from [PlayerView.release]. */ + fun release() { + safe { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + } + brightnessOverlay = null + loudnessEnhancer?.release() + loudnessEnhancer = null + holdHandler.removeCallbacksAndMessages(null) + clearZoomState() + releaseOverlayLayoutListener() + } + + /** Key-event listener */ + + /** + * Registers the basic volume-key listener on [keyEventListener]. + * Called from [PlayerView.initialize] and from the host fragment's onResume. + */ + fun setupKeyEventListener() { + keyEventListener = { (event, _) -> + if (event != null && event.action == KeyEvent.ACTION_DOWN) + handleVolumeKey(event.keyCode) + else false + } + } + + /** Nulls [keyEventListener]. Called from the host fragment's onPause. */ + fun releaseKeyEventListener() { + keyEventListener = null + } + + /** Speed-up */ + + fun showOrHideSpeedUp(show: Boolean) { + playerView.playerSpeedupButton?.let { btn -> + btn.clearAnimation() + btn.alpha = if (show) 0f else 1f + btn.isVisible = show + btn.animate() + .alpha(if (show) 1f else 0f) + .setDuration(200L) + .withEndAction { if (!show) btn.isVisible = false } + .start() + } + } + + /** Volume helpers */ + + /** + * Syncs [currentRequestedVolume] with the current system stream volume. + * + * This is here to make returning to the player less jarring, if we change the volume outside + * the app. Note that this will make it a bit wierd when using loudness in PiP, then returning + * however that is the cost of correctness. + */ + fun verifyVolume() { + ((context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { am -> + val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (cur < max || currentRequestedVolume <= 1.0f) { + currentRequestedVolume = cur.toFloat() / max.toFloat() + loudnessEnhancer?.release() + loudnessEnhancer = null + } + } + } + + /** + * Handles a hardware volume key press. + * Only active on phones/emulators when [isFullScreen] is true. + * + * @return true if the key was consumed (suppresses the system volume UI). + */ + fun handleVolumeKey(keyCode: Int): Boolean { + /** + * Some TVs do not support volume boosting, and overriding + * the volume buttons can be inconvenient for TV users. + * Since boosting volume is mainly useful on phones and emulators, + * we limit this feature to those devices. + */ + if (!isLayout(PHONE or EMULATOR) || !isFullScreen) return false + if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return false + verifyVolume() + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isVolumeLocked = currentRequestedVolume < 1.0f + // +- 5% + handleVolumeAdjustment(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 0.05f else -0.05f, fromButton = true) + return true + } + + fun handleVolumeAdjustment(delta: Float, fromButton: Boolean) { + val am = (context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return + val curStep = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxStep = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + + val cur = currentRequestedVolume + val locked = isVolumeLocked + val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) + val nextStep = (next * maxStep.toFloat()).roundToInt().coerceIn(0, maxStep) + + // Show toast + if (fromButton) { + // For button related request we only show a toast when we exceeded the volume. + if (cur <= 1.0f && next > 1.0f && !hasShownVolumeToast) { + showToast(R.string.volume_exceeded_100) + hasShownVolumeToast = true + } + } else { + val raw = cur + delta + // For swipes, we show toast that we need to swipe again. + if (raw > 1.0 && locked && !hasShownVolumeToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownVolumeToast = true + } + } + + // Set the current volume step. + if (nextStep != curStep) am.setStreamVolume(AudioManager.STREAM_MUSIC, nextStep, 0) + + var hasBoostError = false + // Apply loudness enhancer for volumes > 100%, removes it if less. + if (next > 1.0f) { + val boost = ((next - 1.0f) * 1000).toInt() + val existing = loudnessEnhancer + if (existing != null) { + existing.setTargetGain(boost) + } else { + val sessionId = (playerView.exoPlayerView?.player as? ExoPlayer)?.audioSessionId + if (sessionId != null && sessionId != AudioManager.ERROR) { + try { + loudnessEnhancer = LoudnessEnhancer(sessionId).apply { + setTargetGain(boost); enabled = true + } + } catch (t: Throwable) { logError(t); hasBoostError = true } + } + } + } else { + loudnessEnhancer?.release(); loudnessEnhancer = null + } + + currentRequestedVolume = next + + val leftHolder = playerView.playerProgressbarLeftHolder ?: return + val level1 = playerView.playerProgressbarLeftLevel1 ?: return + val level2 = playerView.playerProgressbarLeftLevel2 ?: return + val icon = playerView.playerProgressbarLeftIcon ?: return + + if (next > 1.0f) { + // Change color to show that LoudnessEnhancer broke + // this is not a real fix, but solves the crash issue. + level2.progressTintList = ColorStateList.valueOf( + ContextCompat.getColor(context, if (hasBoostError) R.color.colorPrimaryRed else R.color.colorPrimaryOrange) + ) + } + // Max is set high to make it smooth. + level1.max = 100_000 + level1.progress = (next * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.max = 100_000 + level2.progress = if (next > 1.0f) ((next - 1.0) * 100_000f).toInt().coerceIn(2_000, 100_000) else 0 + level2.isVisible = next > 1.0f + // Calculate the clamped index for the volume icon based on the requested volume. + val iconIdx = (next * volumeIcons.lastIndex).roundToInt().coerceIn(0, volumeIcons.lastIndex) + icon.setImageResource(volumeIcons[iconIdx]) + + if (!leftHolder.isVisible || leftHolder.alpha < 1f) { + leftHolder.animate().cancel(); leftHolder.alpha = 1f; leftHolder.isVisible = true + } + progressBarLeftHideRunnable?.let { leftHolder.removeCallbacks(it) } + progressBarLeftHideRunnable = Runnable { + leftHolder.animate().cancel() + leftHolder.animate().alpha(0f).setDuration(300).withEndAction { leftHolder.isVisible = false }.start() + } + // Show the progress bar for 1.5 seconds. + leftHolder.postDelayed(progressBarLeftHideRunnable, 1500) + } + + /** Brightness helpers */ + + /** + * Reads from [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun getBrightness(): Float? { + return if (useTrueSystemBrightness) { + try { + Settings.System.getInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) / 255f + } catch (_: Exception) { + // Because true system brightness requires + // permission, this is a lazy way to check + // as it will throw an error if we do not have it. + useTrueSystemBrightness = false + getBrightness() + } + } else { + try { + (context as? Activity)?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } + } catch (e: Exception) { + logError(e) + null + } + } + } + + /** + * Sets [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun setBrightness(brightness: Float) { + if (useTrueSystemBrightness) { + try { + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE, + Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL + ) + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) + ) + } catch (_: Exception) { + useTrueSystemBrightness = false + setBrightness(brightness) + } + } else { + try { + val lp = (context as? Activity)?.window?.attributes ?: return + // Use 0.004f instead of 0: on some devices a value too close to 0 causes the + // system to override with its own brightness, making fine-tuning impossible. + lp.screenBrightness = brightness.coerceIn(0.004f, 1.0f) + (context as? Activity)?.window?.attributes = lp + } catch (e: Exception) { + logError(e) + } + } + } + + fun handleBrightnessAdjustment(verticalAddition: Float) { + val lastBrightness = currentRequestedBrightness + val raw = currentRequestedBrightness + verticalAddition + val next = raw.coerceIn(0.0f, if (extraBrightnessEnabled && !isBrightnessLocked) 2.0f else 1.0f) + + if (extraBrightnessEnabled && isBrightnessLocked && raw > 1.0f && !hasShownBrightnessToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownBrightnessToast = true + } + + currentRequestedBrightness = next + if (lastBrightness != currentRequestedBrightness) setBrightness(currentRequestedBrightness) + + currentExtraBrightness = if (extraBrightnessEnabled && next > 1.0f) min(2.0f, next) - 1.0f else 0.0f + brightnessOverlay?.alpha = currentExtraBrightness + playerView.callbacks?.onBrightnessExtra(currentExtraBrightness) + + val rightHolder = playerView.playerProgressbarRightHolder ?: return + val level1 = playerView.playerProgressbarRightLevel1 ?: return + val level2 = playerView.playerProgressbarRightLevel2 ?: return + val icon = playerView.playerProgressbarRightIcon ?: return + + level1.max = 100_000 + level1.progress = max(2_000, (min(1.0f, next) * 100_000f).toInt()) + + if (extraBrightnessEnabled) { + level2.max = 100_000 + level2.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.isVisible = next > 1.0f + } + + icon.setImageResource( + // Clamp the value in case of extra brightness. + brightnessIcons[min(brightnessIcons.lastIndex, max(0, round(next * brightnessIcons.lastIndex).toInt()))] + ) + + if (!rightHolder.isVisible || rightHolder.alpha < 1f) { + rightHolder.animate().cancel(); rightHolder.alpha = 1f; rightHolder.isVisible = true + } + progressBarRightHideRunnable?.let { rightHolder.removeCallbacks(it) } + progressBarRightHideRunnable = Runnable { + rightHolder.animate().cancel() + rightHolder.animate().alpha(0f).setDuration(300).withEndAction { rightHolder.isVisible = false }.start() + } + rightHolder.postDelayed(progressBarRightHideRunnable, 1500) + } + + /** Zoom helpers */ + + /** + * Returns the current zoom matrix, accounting for RESIZE_MODE_ZOOM which already has + * an implicit zoom applied. + * + * This is different from `zoomMatrix ?: Matrix()` + * because it allows used to start zooming at different resizeModes. + * + * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM + * 100% will make the zoom snap to less zoomed in then you already are. + */ + fun currentZoomMatrix(): Matrix { + val current = zoomMatrix + if (current != null) return current + + val exoView = playerView.exoPlayerView + val videoView = exoView?.videoSurfaceView + + if (exoView == null || videoView == null || + exoView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { + return Matrix() + } + + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f) { + return Matrix() + } + + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = max(initAspect, 1f / initAspect) + return Matrix().apply { postScale(aspect, aspect) } + } + + /** + * Applies [newMatrix] (scale + translation only) to the video surface view. + * + * @param newMatrix The new zoom matrix + * @param animation If this zoom is part of an animation, as then it will not auto zoom after we are done. + */ + fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { + val exoView = playerView.exoPlayerView ?: return + if (!animation) { + matrixAnimation?.cancel() + matrixAnimation = null + } + val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) + + if (exoView.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { + exoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + + val videoView = exoView.videoSurfaceView ?: return + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + // Sanity check + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || scale <= 0.01f) return + + // Calculate the scaled aspect ratio as the view height is not real, check the debugger + // and you will see videoView.height > screen.height. + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = min(initAspect, 1f / initAspect) + val scaledAspect = scale * aspect + + // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight. + val maxTransX = max(0f, videoWidth * scaledAspect - playerWidth) * 0.5f + val maxTransY = max(0f, videoHeight * scaledAspect - playerHeight) * 0.5f + + // Correct the translation to clamp within the viewing area. + val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) + val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) + + // Set the transform to the correct x and y. + newMatrix.postTranslate( + expectedTranslationX - translationX, + expectedTranslationY - translationY + ) + zoomMatrix = newMatrix + + if (!animation) { + // If we are not in an animation, set up the values for the animation. + if ((scaledAspect - 1f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { + // We are within the correct scaling, so center and fit it. + videoOutline?.isVisible = true + val desired = Matrix() + desired.setScale(1f / aspect, 1f / aspect) + desiredMatrix = desired + } else if (scale < 1f) { + // We have zoomed too far, zoom to 100%. + videoOutline?.isVisible = false + desiredMatrix = Matrix() + } else { + // Keep the same scaling after zoom. + videoOutline?.isVisible = false + desiredMatrix = null + } + } + + // Finally set the actual scale + translation. + videoView.scaleX = scaledAspect + videoView.scaleY = scaledAspect + videoView.translationX = expectedTranslationX + videoView.translationY = expectedTranslationY + updateBrightnessOverlayBounds() + } + + /** + * Clears all zoom state and resets the video surface view to 1:1 scale. + * Does NOT change the ExoPlayer resize mode - call [PlayerView.resize] separately. + */ + fun clearZoomState() { + matrixAnimation?.cancel() + matrixAnimation = null + zoomMatrix = null + desiredMatrix = null + scaleGestureDetector = null + lastPan = null + playerView.exoPlayerView?.videoSurfaceView?.apply { + scaleX = 1f + scaleY = 1f + translationX = 0f + translationY = 0f + } + } + + /** + * Resets zoom to fit mode if any zoom is currently active. + * Calls [PlayerView.resize] to update the ExoPlayer resize mode. + */ + fun resetZoomToDefault() { + if (zoomMatrix != null) { + clearZoomState() + playerView.resize(PlayerResize.Fit, false) + } + } + + private fun createScaleGestureDetector(ctx: Context) { + scaleGestureDetector = ScaleGestureDetector( + ctx, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val matrix = currentZoomMatrix() + val (_, _, scale) = matrixToTranslationAndScale(matrix) + // Clamp scale of the zoom, do it here as it is easier than doing it within applyZoomMatrix. + val newScale = (scale * detector.scaleFactor).coerceIn(MINIMUM_ZOOM, MAXIMUM_ZOOM) + // This is how much we should scale it with to prevent infinite scaling. + val actualScaleFactor = newScale / scale + // Scale around the focus point, this is more natural than just zoom. + val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f + val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f + matrix.postScale(actualScaleFactor, actualScaleFactor, pivotX, pivotY) + applyZoomMatrix(matrix, false) + return true + } + } + ) + } + + /** + * Processes a two-finger zoom/pan gesture event. + * Handles scale detection, panning, and the snap-back animation after finger lift. + * + * @param event The motion event (should have pointerCount >= 2 or [lastPan] != null). + * @param ctx Context used to create the [ScaleGestureDetector] on first call. + * @param onFirstPointerDown Called on [MotionEvent.ACTION_POINTER_DOWN] (e.g. hide player UI). + * @param onGestureEnd Called when the gesture ends (e.g. reset caller touch state). + * @return Always true (event consumed). + */ + fun handleZoomPanGesture( + event: MotionEvent, + ctx: Context, + onFirstPointerDown: () -> Unit, + onGestureEnd: () -> Unit + ): Boolean { + if (scaleGestureDetector == null) createScaleGestureDetector(ctx) + scaleGestureDetector?.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + onFirstPointerDown() + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount >= 2) { + val newPan = Vector2( + (event.getX(0) + event.getX(1)) / 2f, + (event.getY(0) + event.getY(1)) / 2f + ) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan + } + } + + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> { + lastPan = null + videoOutline?.isVisible = false + matrixAnimation?.cancel() + matrixAnimation = null + + // Snap to desired matrix after zoom gesture ends + matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 0 + duration = 200 + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + addUpdateListener { anim -> + val v = anim.animatedValue as Float + val vInv = 1f - v + val m = Matrix() + m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) + m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) + applyZoomMatrix(m, true) + } + start() + } + + onGestureEnd() + } + } + return true + } + + /** + * Resizes and repositions [brightnessOverlay] to exactly match the visible video surface, + * accounting for zoom scale and translation. + */ + fun updateBrightnessOverlayBounds() { + val overlay = brightnessOverlay ?: return + val pv = playerView.exoPlayerView ?: return + val video = pv.videoSurfaceView ?: return + + // Compute accurate transformed bounding box of the video view after scale+translation. + val vw = video.width.toFloat() + val vh = video.height.toFloat() + val sx = video.scaleX + val sy = video.scaleY + if (vw <= 0f || vh <= 0f) return + + // Pivot defaults to center if not set. + val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f + val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f + // Use view position (includes translation) as base; avoid double-counting translation. + val tx = video.x + val ty = video.y + + // Transform function for a local point (lx,ly). + fun transform(lx: Float, ly: Float): Pair { + val gx = tx + pivotX + (lx - pivotX) * sx + val gy = ty + pivotY + (ly - pivotY) * sy + return Pair(gx, gy) + } + + val p0 = transform(0f, 0f); val p1 = transform(vw, 0f) + val p2 = transform(0f, vh); val p3 = transform(vw, vh) + + val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) + val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) + val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) + val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) + + val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) + val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) + + val lp = overlay.layoutParams + if (lp == null) { + overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) + } else if (lp.width != newW || lp.height != newH) { + lp.width = newW; lp.height = newH + overlay.layoutParams = lp + } + + overlay.scaleX = 1f; overlay.scaleY = 1f + overlay.x = minX; overlay.y = minY + } + + /** + * Attaches a persistent layout-change listener to the ExoPlayer view so + * [updateBrightnessOverlayBounds] is called on every layout pass (orientation change, + * aspect-ratio change, zoom, PiP transition, etc.). + */ + fun requestUpdateBrightnessOverlayOnNextLayout() { + val exoView = playerView.exoPlayerView ?: return + overlayLayoutListener?.let { exoView.removeOnLayoutChangeListener(it) } + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + safe { updateBrightnessOverlayBounds() } + } + overlayLayoutListener = listener + exoView.addOnLayoutChangeListener(listener) + } + + /** Removes the overlay layout listener registered by [requestUpdateBrightnessOverlayOnNextLayout]. */ + fun releaseOverlayLayoutListener() { + overlayLayoutListener?.let { playerView.exoPlayerView?.removeOnLayoutChangeListener(it) } + overlayLayoutListener = null + } + + /** Rewind / fast-forward animations */ + + /** Resets the rewind button label to the standard "–Xs" format. */ + fun resetRewindText() { + playerView.exoRewText?.text = context.getString(R.string.rew_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** Resets the fast-forward button label to the standard "+Xs" format. */ + fun resetFastForwardText() { + playerView.exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** + * Fades playerRewHolder, playerFfwdHolder, and playerPausePlay to [fadeTo] (0f or 1f). + * Always resets the holder alphas to 1f first so any stale fillAfter state is cleared. + * Called from host fragments' show/hide control animations so both GeneratorPlayer and trailer share + * the same fade logic. + */ + fun animateCenterControls(fadeTo: Float) { + val from = if (fadeTo > 0.5f) 0f else 1f + fun makeAnim() = AlphaAnimation(from, fadeTo).apply { duration = 100; fillAfter = true } + // Each view needs its own Animation instance; sharing one causes fillAfter to + // not hold reliably across all views once any of them restarts the animation. + playerView.playerRewHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerFfwdHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerPausePlay?.startAnimation(makeAnim()) + } + + /** Plays the rewind animation and seeks back by [fastForwardTime]. */ + fun rewind() { + try { + val rewHolder = playerView.playerRewHolder ?: return + val rew = playerView.playerRew + val rewText = playerView.exoRewText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + // Only expose the parent chain when controls are currently hidden. + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + rewHolder.alpha = 1f + + rew?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_left)) + val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) + goLeft.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + rewText?.post { + resetRewindText() + // Restore parent chain only if we changed it and controls are still hidden. + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + rewHolder.alpha = 0f + } + } + } + }) + rewText?.startAnimation(goLeft) + rewText?.text = context.getString(R.string.rew_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(-fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Plays the fast-forward animation and seeks forward by [fastForwardTime]. */ + fun fastForward() { + try { + val ffwdHolder = playerView.playerFfwdHolder ?: return + val ffwd = playerView.playerFfwd + val ffwdText = playerView.exoFfwdText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + ffwdHolder.alpha = 1f + + ffwd?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_right)) + val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) + goRight.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + ffwdText?.post { + resetFastForwardText() + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + ffwdHolder.alpha = 0f + } + } + } + }) + ffwdText?.startAnimation(goRight) + ffwdText?.text = context.getString(R.string.ffw_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Double-tap detection */ + + /** + * Call when a valid tap is detected (short hold, minimal movement, valid touch area). + * Routes to double-tap seeking/pausing or schedules a delayed single-tap callback. + * + * Updates [lastTouchEndTime] when a confirmed tap (single or double) is recorded. + * + * @param x X coordinate of the tap in the view's coordinate space. + * @param viewWidth Width of the view (used to compute left/center/right zones). + * @param isLocked Whether player controls are locked (suppresses double-tap seek). + * @param onSingleTap Invoked when it is determined to be a single tap; may be deferred. + * @return true if a double-tap action was performed. + */ + fun onTapDetected(x: Float, viewWidth: Int, isLocked: Boolean, onSingleTap: () -> Unit): Boolean { + val anyDoubleTap = doubleTapEnabled || doubleTapPauseEnabled + if (!anyDoubleTap) { + onSingleTap() + return false + } + + val timeSinceLast = System.currentTimeMillis() - lastTouchEndTime + return if (!isLocked && timeSinceLast < DOUBLE_TAP_MINIMUM_TIME_BETWEEN) { + /** Double-tap */ + tapCount++ + doubleTapToken++ // cancel any pending single-tap runnable + if (doubleTapPauseEnabled) { + when { + x < viewWidth / 2f - (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) rewind() + } + x > viewWidth / 2f + (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) fastForward() + } + else -> { + playerView.player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + } else if (doubleTapEnabled) { + if (x < viewWidth / 2f) rewind() else fastForward() + } + true + } else { + /** Single tap (first tap, or too slow for double-tap) */ + tapCount = 0 + val token = ++doubleTapToken + playerView.playerHolder?.postDelayed({ + if (token == doubleTapToken) { + onSingleTap() + } + }, DOUBLE_TAP_MINIMUM_TIME_BETWEEN) + false + } + } + + /** Seek time helpers */ + + private fun calculateNewTime(startTime: Long?, touchStart: Vector2?, touchEnd: Vector2?): Long? { + if (touchStart == null || touchEnd == null || startTime == null) return null + val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() + val duration = playerView.player.getDuration() ?: return null + return max(min(startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), duration), 0) + } + + private fun forceLetters(inp: Long, letters: Int = 2): String { + val added = letters - inp.toString().length + return if (added > 0) "0".repeat(added) + inp.toString() else inp.toString() + } + + private fun convertTimeToString(sec: Long): String { + val rsec = sec % 60L + val min = ceil((sec - rsec) / 60.0).toInt() + val rmin = min % 60L + val h = ceil((min - rmin) / 60.0).toLong() + // int rh = h;// h % 24; + return (if (h > 0) forceLetters(h) + ":" else "") + + (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + + forceLetters(rsec) + } + + /** Touch gestures */ + + fun setupTouchGestures() { + val holder = playerView.playerHolder ?: return + @SuppressLint("ClickableViewAccessibility") + holder.setOnTouchListener(::handleGesture) + } + + private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val holder = playerView.playerHolder ?: return true + val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom + val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right + return validHeight && validWidth + } + + return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation + } + + private fun handleGesture(view: View, event: MotionEvent): Boolean { + val currentTouch = Vector2(event.x, event.y) + val startTouch = currentTouchStart + + /** Two-finger zoom/pan (fullscreen, unlocked) */ + if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked + && !hasTriggeredSpeedUp && currentTouchAction == null) { + holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. + isCurrentTouchValid = false // Prevent other touches + return handleZoomPanGesture( + event = event, + ctx = view.context, + onFirstPointerDown = { + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + }, + onGestureEnd = { + currentTouchStart = null + currentLastTouchAction = null + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + } + ) + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + isCurrentTouchValid = isValidTouch(event.rawX, event.rawY) + if (isCurrentTouchValid) { + playerView.callbacks?.onTouchDown() + hasTriggeredSpeedUp = false + if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + holdHandler.postDelayed(holdRunnable, 500) + } + isVolumeLocked = currentRequestedVolume < 1.0f + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isBrightnessLocked = currentRequestedBrightness < 1.0f + if (currentRequestedBrightness <= 1.0f) hasShownBrightnessToast = false + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = playerView.player.getPosition() + getBrightness()?.let { currentRequestedBrightness = it + currentExtraBrightness } + verifyVolume() + } + return true + } + + MotionEvent.ACTION_MOVE -> { + if (hasTriggeredSpeedUp) return true + if (!isCurrentTouchValid) return true + + if (currentTouchAction == null && startTouch != null) { + val diffFromStart = startTouch - currentTouch + if (swipeVerticalEnabled) { + if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + currentTouchAction = if ((startTouch.x) >= view.width / 2f) + TouchAction.Volume else TouchAction.Brightness + } + } + if (swipeHorizontalEnabled && !isLocked) { + if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + currentTouchAction = TouchAction.Time + } + } + } + + val lastTouch = currentTouchLast + if (lastTouch != null) { + val diffFromLast = lastTouch - currentTouch + val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / view.height.toFloat() + when (currentTouchAction) { + TouchAction.Time -> { + // This simply updates UI as the seek logic happens on release + // startTime is rounded to make the UI sync in a nice way. + val startTime = currentTouchStartPlayerTime?.div(1000L)?.times(1000L) + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { newMs -> + val skipMs = newMs - startTime + playerView.callbacks?.onSeekPreviewText( + "${convertTimeToString(newMs / 1000)} [${ + if (abs(skipMs) < 1000) "" else if (skipMs > 0) "+" else "-" + }${convertTimeToString(abs(skipMs / 1000))}]" + ) + } + } + } + TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) + TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) + null -> Unit + } + if (currentTouchAction != TouchAction.Time) { + playerView.callbacks?.onSeekPreviewText(null) + } + } + currentTouchLast = currentTouch + return true + } + + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + holdHandler.removeCallbacks(holdRunnable) + if (hasTriggeredSpeedUp) { + playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) + showOrHideSpeedUp(false) + playerView.callbacks?.onHoldSpeedUp(false) + hasTriggeredSpeedUp = false + } + + if (isCurrentTouchValid) { + // Horizontal seek on release + if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time && !isLocked) { + val startTime = currentTouchStartPlayerTime + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> + if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { + playerView.player.seekTo(seekTo, PlayerEventSource.UI) + } + } + } + } + // Tap detection: only fire if the finger was held briefly (not a long-press). + val holdTime = currentTouchStartTime?.let { System.currentTimeMillis() - it } + if (currentTouchAction == null && currentLastTouchAction == null + && !hasTriggeredSpeedUp + && (holdTime == null || holdTime < DOUBLE_TAP_MAXIMUM_HOLD_TIME)) { + onTapDetected( + x = currentTouch.x, + viewWidth = view.width, + isLocked = isLocked, + onSingleTap = { playerView.callbacks?.onSingleTap() } + ) + } + } + + playerView.callbacks?.onSeekPreviewText(null) + val hadSwipe = currentTouchAction != null || currentLastTouchAction != null + playerView.callbacks?.onGestureEnd(hadSwipe, uiShowingBeforeGesture) + + // Reset touch + lastTouchEndTime = System.currentTimeMillis() + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + uiShowingBeforeGesture = false + return true + } + } + return false + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt index 0fbc22f692e..0db06499efe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt @@ -1,95 +1,205 @@ package com.lagradost.cloudstream3.ui.player import android.app.Activity +import android.app.AppOpsManager import android.app.PendingIntent import android.app.PictureInPictureParams import android.app.RemoteAction +import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.drawable.Icon import android.os.Build +import android.util.Rational import androidx.annotation.RequiresApi import androidx.annotation.StringRes +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import kotlin.math.roundToInt -class PlayerPipHelper { - companion object { - private fun getPen(activity: Activity, code: Int): PendingIntent { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.getBroadcast( - activity, - code, - Intent("media_control").putExtra("control_type", code), - PendingIntent.FLAG_IMMUTABLE - ) - } else { - PendingIntent.getBroadcast( - activity, - code, - Intent("media_control").putExtra("control_type", code), - 0 - ) - } +object PlayerPipHelper { + /** Is pip (Player in Player) supported, and enabled? */ + fun Context.isPIPPossible() : Boolean { + return try { + this.hasPIPEnabled() && this.hasPIPFeature() + } catch (t : Throwable) { + // While both hasPIPEnabled and hasPIPFeature should never throw, this catches it just in case + logError(t) + false } + } - @RequiresApi(Build.VERSION_CODES.O) - private fun getRemoteAction( - activity: Activity, - id: Int, - @StringRes title: Int, - event: CSPlayerEvent - ): RemoteAction { - val text = activity.getString(title) - return RemoteAction( - Icon.createWithResource(activity, id), - text, - text, - getPen(activity, event.value) - ) + /** Is pip enabled in app settings? */ + private fun Context.hasPIPEnabled(): Boolean { + return try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + settingsManager?.getBoolean( + getString(R.string.pip_enabled_key), + true + ) ?: true + } catch (e: Exception) { + logError(e) + false } + } + + + /** + * Is pip supported by the OS? + * + * Source: + * https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission + * https://developer.android.com/guide/topics/ui/picture-in-picture + * */ + private fun Context.hasPIPFeature(): Boolean = + // OS Support + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + // Might have the feature, but OS blocked due to power drain + this.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && + // Might have been disabled by the user + this.hasPIPPermission() - @RequiresApi(Build.VERSION_CODES.O) - fun updatePIPModeActions(activity: Activity, isPlaying: Boolean) { - val actions: ArrayList = ArrayList() + /** 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.go_back_30, - R.string.go_back_30, - CSPlayerEvent.SeekBack + R.drawable.netflix_pause, + R.string.pause, + CSPlayerEvent.Pause ) ) - - if (isPlaying) { - actions.add( - getRemoteAction( - activity, - R.drawable.netflix_pause, - R.string.pause, - CSPlayerEvent.Pause - ) - ) - } else { - actions.add( - getRemoteAction( - activity, - R.drawable.ic_baseline_play_arrow_24, - R.string.pause, - CSPlayerEvent.Play - ) - ) - } - + } else { actions.add( getRemoteAction( activity, - R.drawable.go_forward_30, - R.string.go_forward_30, - CSPlayerEvent.SeekForward + R.drawable.ic_baseline_play_arrow_24, + R.string.pause, + CSPlayerEvent.Play ) ) + } + + actions.add( + getRemoteAction( + activity, + R.drawable.go_forward_30, + R.string.go_forward_30, + CSPlayerEvent.SeekForward + ) + ) + + // 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().setActions(actions).build() + PictureInPictureParams.Builder() + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setSeamlessResizeEnabled(true) + setAutoEnterEnabled(isPipDesired && activity.isPIPPossible()) + } else { + // We enter pip manually in onUserLeaveHint as the smooth transition + // is not supported yet + CommonActivity.isPipDesired = isPipDesired + } + } + .setAspectRatio(fixedRational) + .setActions(actions) + .build() ) } } -} \ No newline at end of file + +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt index 8d85f176952..ee6170aa53f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt @@ -4,14 +4,14 @@ import android.util.Log import android.util.TypedValue import android.view.ViewGroup import android.widget.FrameLayout -import com.google.android.exoplayer2.ui.SubtitleView -import com.google.android.exoplayer2.util.MimeTypes +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 00000000000..0e6f1a3677d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -0,0 +1,842 @@ +package com.lagradost.cloudstream3.ui.player + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimatedVectorDrawable +import android.media.metrics.PlaybackErrorEvent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.text.format.DateUtils +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.RelativeLayout +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.annotation.OptIn +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar +import androidx.preference.PreferenceManager +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.github.rubensousa.previewseekbar.PreviewBar +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import com.lagradost.cloudstream3.CommonActivity.isInPIPMode +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI +import com.lagradost.cloudstream3.utils.UserPreferenceDelegate +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import java.net.SocketTimeoutException + +/** + * Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event + * dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper] + * ([PlayerGestureHelper]), which is exposed via delegate properties for easier access. + */ +@OptIn(UnstableApi::class) +class PlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + companion object { + private const val TAG = "PlayerView" + } + + /** All gesture, volume, brightness and key-event logic lives here. */ + val gestureHelper = PlayerGestureHelper(this) + + /** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */ + var isFullScreen: Boolean + get() = gestureHelper.isFullScreen + set(value) { gestureHelper.isFullScreen = value } + + var isLocked: Boolean + get() = gestureHelper.isLocked + set(value) { gestureHelper.isLocked = value } + + var videoOutline: View? + get() = gestureHelper.videoOutline + set(value) { gestureHelper.videoOutline = value } + + /** Delegate methods */ + fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode) + fun verifyVolume() = gestureHelper.verifyVolume() + fun setupKeyEventListener() = gestureHelper.setupKeyEventListener() + fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener() + fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout() + fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener() + + /** Callbacks */ + + /** Host-fragment-level callbacks invoked by [mainCallback]. */ + interface Callbacks { + fun nextEpisode() {} + fun prevEpisode() {} + fun playerPositionChanged(position: Long, duration: Long) {} + fun playerStatusChanged() {} + fun playerDimensionsLoaded(width: Int, height: Int) {} + fun subtitlesChanged() {} + fun embeddedSubtitlesFetched(subtitles: List) {} + fun onTracksInfoChanged() {} + fun onTimestamp(timestamp: VideoSkipStamp?) {} + fun onTimestampSkipped(timestamp: VideoSkipStamp) {} + fun exitedPipMode() {} + fun hasNextMirror(): Boolean = false + fun nextMirror() {} + fun onDownload(event: DownloadEvent) {} + fun playerError(exception: Throwable) {} + /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ + fun playerUpdated(player: Any?) {} + /** Called on a short single-tap on empty player area (no swipe, no double-tap). */ + fun onSingleTap() {} + /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ + fun onHoldSpeedUp(show: Boolean) {} + /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ + fun onBrightnessExtra(alpha: Float) {} + + /** Touch event callbacks */ + + /** Returns whether the player UI (controls overlay) is currently visible. */ + fun isUIShowing(): Boolean = false + /** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */ + fun onTouchDown() {} + /** Called with seek-preview text during a horizontal-swipe, or null to clear it. */ + fun onSeekPreviewText(text: String?) {} + /** Called when a swipe gesture begins; hide the player UI if desired. */ + fun onHidePlayerUI() {} + /** + * Called at the end of each touch sequence. + * @param hadSwipe true if a swipe (brightness/volume/time) was in progress. + * @param wasUiShowing true if the UI was visible when the swipe began. + */ + fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {} + /** + * Called when the auto-hide timer fires: UI is showing, no touch is active. + * Implement to hide the player controls. + */ + fun onAutoHideUI() {} + } + + var callbacks: Callbacks? = null + + /** Player state */ + + var player: IPlayer = CS3IPlayer() + var resizeMode: Int = 0 + var hasPipModeSupport: Boolean = true + var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering + var mMediaSession: MediaSession? = null + private var pipReceiver: BroadcastReceiver? = null + + /** Auto-hide */ + private var autoHideToken = 0 + private val autoHideHandler = Handler(Looper.getMainLooper()) + + /** View references (populated by bindViews) */ + + var subView: SubtitleView? = null + var playerPausePlayHolderHolder: FrameLayout? = null + var playerPausePlay: ImageView? = null + var playerBuffering: ProgressBar? = null + /** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */ + var exoPlayerView: androidx.media3.ui.PlayerView? = null + var piphide: FrameLayout? = null + var subtitleHolder: FrameLayout? = null + internal var playerRew: View? = null + internal var playerFfwd: View? = null + internal var exoRewText: TextView? = null + internal var exoFfwdText: TextView? = null + internal var playerCenterMenu: View? = null + internal var playerRewHolder: View? = null + internal var playerFfwdHolder: View? = null + internal var playerVideoHolder: View? = null + var playerProgressbarLeftHolder: RelativeLayout? = null + var playerProgressbarLeftIcon: ImageView? = null + var playerProgressbarLeftLevel1: ProgressBar? = null + var playerProgressbarLeftLevel2: ProgressBar? = null + var playerProgressbarRightHolder: RelativeLayout? = null + var playerProgressbarRightIcon: ImageView? = null + var playerProgressbarRightLevel1: ProgressBar? = null + var playerProgressbarRightLevel2: ProgressBar? = null + /** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */ + internal var playerSpeedupButton: View? = null + var playerHolder: FrameLayout? = null + private var exoDuration: TextView? = null + private var timeLeft: TextView? = null + private var exoPosition: TextView? = null + private var timeLive: View? = null + private var exoProgress: LivePreviewTimeBar? = null + + /** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */ + var seekTime: Long = 10_000L + + /** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */ + var isVerticalOrientation: Boolean = false + + /** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */ + var autoPlayerRotateEnabled: Boolean = false + + var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + + // Kept so SubtitlesFragment can unsubscribe the exact same reference. + private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged + + /** View discovery */ + + /** + * Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply + * remain null, all usage is null-safe. + */ + fun bindViews(root: View) { + exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) + exoFfwdText = root.findViewById(R.id.exo_ffwd_text) + exoPlayerView = root.findViewById(R.id.player_view) + exoPosition = root.findViewById(R.id.exo_position) + exoRewText = root.findViewById(R.id.exo_rew_text) + piphide = root.findViewById(R.id.piphide) + playerBuffering = root.findViewById(R.id.player_buffering) + playerCenterMenu = root.findViewById(R.id.player_center_menu) + playerFfwd = root.findViewById(R.id.player_ffwd) + playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) + playerHolder = root.findViewById(R.id.player_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) + playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) + playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) + playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) + playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder) + playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) + playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) + playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) + playerRew = root.findViewById(R.id.player_rew) + playerRewHolder = root.findViewById(R.id.player_rew_holder) + playerSpeedupButton = root.findViewById(R.id.player_speedup_button) + playerVideoHolder = root.findViewById(R.id.player_video_holder) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + timeLeft = root.findViewById(R.id.time_left) + timeLive = root.findViewById(R.id.time_live) + } + + /** + * Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener, + * player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper]. + */ + fun initialize() { + resizeMode = DataStoreHelper.resizeMode + resize(resizeMode, false) + + player.releaseCallbacks() + player.initCallbacks( + eventHandler = ::mainCallback, + requestedListeningPercentages = listOf( + SKIP_OP_VIDEO_PERCENTAGE, + PRELOAD_NEXT_EPISODE_PERCENTAGE, + NEXT_WATCH_EPISODE_PERCENTAGE, + UPDATE_SYNC_PROGRESS_PERCENTAGE, + ), + ) + + if (player is CS3IPlayer) { + // Preview bar + val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress) + exoProgress = progressBar as? LivePreviewTimeBar + val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView) + val previewFrameLayout: FrameLayout? = + exoPlayerView?.findViewById(R.id.previewFrameLayout) + + /** Hide the previewFrameLayout on TV to make the skip op button not float, + * as previewFrameLayout is normally invisible */ + if(isLayout(TV)) { + previewFrameLayout?.isVisible = false + } + + if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) { + var resume = false + progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { + override fun onScrubStart(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + val hasPreview = cs3.hasPreview() + progressBar.isPreviewEnabled = hasPreview + resume = cs3.getIsPlaying() + if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + // No clashing UI + if (hasPreview) subView?.isVisible = false + } + + override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {} + + override fun onScrubStop(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + // Delay to prevent the small flicker of subtitle before seeking. + subView?.postDelayed({ + // If we are not scrubbing then show subtitles again. + if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { + subView?.isVisible = true + } + }, 200) + } + }) + progressBar.attachPreviewView(previewFrameLayout) + progressBar.setPreviewLoader { currentPosition, max -> + val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader + val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat())) + previewImageView.isGone = bitmap == null + previewImageView.setImageBitmap(bitmap) + } + } + + subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) + (player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style) + (player as? CS3IPlayer)?.let { + (it.imageGenerator as? PreviewGenerator)?.params = + ImageParams.new16by9(screenWidth) + } + + /** + * This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player + * and once by the UI even if it should only be registered once by the UI. + */ + exoPlayerView?.findViewById(R.id.exo_progress) + ?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) return + val playerDuration = player.getDuration() ?: return + val playerPosition = player.getPosition() ?: return + mainCallback( + PositionEvent( + source = PlayerEventSource.UI, + durationMs = playerDuration, + fromMs = playerPosition, + toMs = position + ) + ) + } + }) + + // Read seek time and rotation settings. + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10) + .toLong() * 1000L + autoPlayerRotateEnabled = sm.getBoolean( + context.getString(R.string.auto_rotate_video_key), true + ) + } catch (_: Exception) { + } + + val seekSecs = (seekTime / 1000).toInt() + exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) + exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs) + + playerPausePlay?.setOnClickListener { + scheduleAutoHide() + if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI) + } else { + player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + playerRew?.setOnClickListener { + scheduleAutoHide() + gestureHelper.rewind() + } + playerFfwd?.setOnClickListener { + scheduleAutoHide() + gestureHelper.fastForward() + } + + SubtitlesFragment.applyStyleEvent += subStyleListener + + try { + val ctx = context + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val cs3 = player as? CS3IPlayer ?: return + cs3.cacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L + cs3.simpleCacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L + cs3.videoBufferMs = + settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L + } catch (e: Exception) { + logError(e) + } + + // Duration toggle click listeners + exoDuration?.setOnClickListener { setRemainingTimeCounter(true) } + timeLeft?.setOnClickListener { setRemainingTimeCounter(false) } + // Keep remaining-time text in sync with playback position + exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } + + // Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener) + gestureHelper.initialize() + setupKeyEventListener() + + // Apply duration-mode display (remaining time vs elapsed); TV always shows remaining + setRemainingTimeCounter(durationMode || isLayout(TV)) + } + } + + /** Lifecycle delegation */ + + var fullscreenNotch: Boolean = true // TODO SETTING + + fun enterFullscreen(updateOrientation: () -> Unit = {}) { + val activity = context as? Activity + if (isFullScreen) { + activity?.hideSystemUI() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { + val params = activity?.window?.attributes + params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + activity?.window?.attributes = params + } + } + updateOrientation() + } + + fun exitFullscreen() { + val activity = context as? Activity + gestureHelper.resetZoomToDefault() + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + // Simply resets brightness and notch settings that might have been overridden. + val lp = activity?.window?.attributes + lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity?.window?.attributes = lp + activity?.showSystemUI() + } + + fun onStop() { + player.onStop() + } + + fun onResume(ctx: Context) { + player.onResume(ctx) + } + + /** Releases all player resources. */ + fun release() { + player.release() + player.releaseCallbacks() + player = CS3IPlayer() + + // keyEventListener is deregistered in onPause so that the incoming player's + // onResume can register its own listener without racing against release(). + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + CSPlayerLoading.IsPaused, + false, + null + ) + + mMediaSession?.release() + mMediaSession = null + exoPlayerView?.player = null + + SubtitlesFragment.applyStyleEvent -= subStyleListener + + gestureHelper.release() + autoHideHandler.removeCallbacksAndMessages(null) + + keepScreenOn(false) + } + + fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + activity: Activity? + ) { + try { + isInPIPMode = isInPictureInPictureMode + if (isInPictureInPictureMode) { + // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. + piphide?.isVisible = false + pipReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (ACTION_MEDIA_CONTROL != intent.action) return + player.handleEvent( + CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)], + source = PlayerEventSource.UI + ) + } + } + val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + activity?.registerReceiver(pipReceiver, filter) + } + val isPlaying = player.getIsPlaying() + val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + updateIsPlaying(status, status) + } else { + // Restore the full-screen UI. + piphide?.isVisible = true + callbacks?.exitedPipMode() + pipReceiver?.let { + // Prevents java.lang.IllegalArgumentException: Receiver not registered + safe { activity?.unregisterReceiver(it) } + } + activity?.hideSystemUI() + hideKeyboard(this) + } + } catch (e: Exception) { + logError(e) + } + } + + /** Player UI helpers */ + + private fun keepScreenOn(on: Boolean) { + val window = (context as? Activity)?.window ?: return + if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) { + val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying + val isBuffering = CSPlayerLoading.IsBuffering == isPlaying + currentPlayerStatus = isPlaying + + keepScreenOn(isPlayingRightNow || isBuffering) + + if (isBuffering) { + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true + } else { + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false + + if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) + } else if (wasPlaying != isPlaying) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play + ) + val drawable = playerPausePlay?.drawable + var startedAnimation = false + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true } + } + if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true } + if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true } + // Somehow the phone is wacked + if (!startedAnimation) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } else { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + isPlaying, + hasPipModeSupport, + player.getAspectRatio() + ) + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + (context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + } + } + + private fun playerUpdated(player: Any?) { + if (player is ExoPlayer) { + mMediaSession?.release() + mMediaSession = MediaSession.Builder(context, player) + // Ensure unique ID for concurrent players. + .setId(System.currentTimeMillis().toString()) + .build() + + // Necessary for multiple combined videos. + @Suppress("DEPRECATION") + exoPlayerView?.setShowMultiWindowTimeBar(true) + exoPlayerView?.player = player + exoPlayerView?.performClick() + } + callbacks?.playerUpdated(player) + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed. + player.seekTime(-1) + } + + /** Error handling */ + + @MainThread + fun playerError(exception: Throwable) { + fun showErrorToast(message: String) { + if (callbacks?.hasNextMirror() == true) { + showToast(message, Toast.LENGTH_SHORT) + callbacks?.nextMirror() + } else { + showToast( + context.getString(R.string.no_links_found_toast) + "\n" + message, + Toast.LENGTH_LONG + ) + (context as? FragmentActivity)?.popCurrentPage() + } + } + + when (exception) { + is PlaybackException -> { + val msg = exception.message ?: "" + val errorName = exception.errorCodeName + when (val code = exception.errorCode) { + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> + showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_REMOTE_ERROR, + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_TIMEOUT, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> + showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg") + + PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, + PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> + showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> + showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> + showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg") + + else -> + showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg") + } + } + + is SocketTimeoutException -> + showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}") + + is ErrorLoadingException -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + + else -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + } + } + + /** Resize */ + + fun nextResize() { + resizeMode = (resizeMode + 1) % PlayerResize.entries.size + resize(resizeMode, true) + } + + fun resize(resize: Int, showToast: Boolean) { + // Clear all zoom state before applying the new resize mode + gestureHelper.clearZoomState() + resize(PlayerResize.entries[resize], showToast) + } + + fun resize(resize: PlayerResize, showToast: Boolean) { + DataStoreHelper.resizeMode = resize.ordinal + val type = when (resize) { + PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL + PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + exoPlayerView?.resizeMode = type + if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT) + } + + /** Orientation */ + + /** + * Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation] + * and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape. + * Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation. + */ + fun dynamicOrientation(): Int { + if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + return if (autoPlayerRotateEnabled && isVerticalOrientation) + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + + /** Event dispatch */ + + /** + * This receives the events from the player, if you want to append functionality + * you do it here, do note that this only receives events for UI changes, + * and returning early WON'T stop it from changing in e.g. the player time + * or pause status. + */ + @MainThread + fun mainCallback(event: PlayerEvent) { + // We don't want to spam DownloadEvent. + if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event") + when (event) { + is DownloadEvent -> callbacks?.onDownload(event) + is ResizedEvent -> { + // Skip 0x0 dimensions that the player emits when going to STATE_IDLE + // to avoid incorrectly resetting the auto-detected orientation. + if (event.width > 0 && event.height > 0) { + // TV never rotates; otherwise track whether the video is portrait. + isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width + } + callbacks?.playerDimensionsLoaded(event.width, event.height) + } + is PlayerAttachedEvent -> playerUpdated(event.player) + is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() + is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) + is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) + is TracksChangedEvent -> callbacks?.onTracksInfoChanged() + is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks) + is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error) + is RequestAudioFocusEvent -> requestAudioFocus() + is EpisodeSeekEvent -> when (event.offset) { + -1 -> callbacks?.prevEpisode() + 1 -> callbacks?.nextEpisode() + } + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + scheduleAutoHide() + callbacks?.playerStatusChanged() + } + is PositionEvent -> callbacks?.playerPositionChanged( + position = event.toMs, + duration = event.durationMs + ) + is VideoEndedEvent -> { + // Only play next episode if autoplay is on (default). + val ctx = context + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true + ) { + player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player) + } + } + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } + + /** Duration display */ + + fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + exoDuration?.isInvisible = showRemaining + timeLeft?.isVisible = showRemaining + if (showRemaining) updateRemainingTime() + } + + fun updateRemainingTime() { + val duration = player.getDuration() + val position = player.getPosition() + + if (exoProgress?.isAtLiveEdge() == true) { + timeLeft?.alpha = 0f + exoDuration?.alpha = 0f + timeLive?.isVisible = true + } else { + timeLeft?.alpha = 1f + exoDuration?.alpha = 1f + timeLive?.isVisible = false + } + + if (duration != null && duration > 1 && position != null) { + val remainingTimeSeconds = (duration - position + 500) / 1000 + @SuppressLint("SetTextI18n") + timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" + } + } + + /** Auto-hide */ + + /** + * Schedules a delayed auto-hide of the player UI after [delayMs] ms. + * Any previously pending hide is canceled first. + * The hide fires only when no touch is active and [Callbacks.isUIShowing] is true; + * the actual hide action is delegated to [Callbacks.onAutoHideUI]. + */ + fun scheduleAutoHide(delayMs: Long = 3000L) { + val token = ++autoHideToken + autoHideHandler.removeCallbacksAndMessages(null) + autoHideHandler.postDelayed({ + if (token != autoHideToken) return@postDelayed + if (gestureHelper.isCurrentTouchValid) return@postDelayed + if (callbacks?.isUIShowing() != true) return@postDelayed + callbacks?.onAutoHideUI() + }, delayMs) + } + + /** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */ + fun cancelAutoHide() { + autoHideToken++ + autoHideHandler.removeCallbacksAndMessages(null) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt new file mode 100644 index 00000000000..2893bcc47fd --- /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 2ce53ea5d98..0668a194bc3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt @@ -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 (currentLinkCache, currentSubsCache) = if (clearCache) { - Pair(mutableSetOf(), mutableSetOf()) - } else { - cache[Pair(current.apiName, current.id)] ?: Pair(mutableSetOf(), mutableSetOf()) + val current = videos.getOrNull(offset) ?: return false + + val currentCache = synchronized(cache) { + cache[current.apiName to current.id] ?: Cache( + mutableSetOf(), + mutableSetOf(), + unixTime, + false + ).also { + cache[current.apiName to current.id] = it + } } - //val currentLinkCache = if (clearCache) mutableSetOf() else linkCache[index].toMutableSet() - //val currentSubsCache = if (clearCache) mutableSetOf() else subsCache[index].toMutableSet() - - val currentLinks = mutableSetOf() // makes all urls unique - val currentSubsUrls = mutableSetOf() // makes all subs urls unique - val currentSubsNames = mutableSetOf() // makes all subs names unique + // These act as a general filter to prevent duplication of links or names + // Avoid any possible ConcurrentModificationException + val currentLinksUrls = ConcurrentHashMap.newKeySet() + val currentSubsUrls = ConcurrentHashMap.newKeySet() + // Use atomics as otherwise we get race conditions when incrementing, while rare it did actually happen! + val lastCountedSuffix = ConcurrentHashMap() + + synchronized(currentCache) { + val outdatedCache = + unixTime - currentCache.lastCachedTimestamp > 60 * 20 // 20 minutes + + 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" + ) + } - currentLinkCache.forEach { link -> - currentLinks.add(link.url) - callback(Pair(link, null)) - } + // call all callbacks + currentCache.linkCache.forEach { link -> + currentLinksUrls.add(link.url) + if (sourceTypes.contains(link.type)) { + callback(link to null) + } + } - currentSubsCache.forEach { sub -> - currentSubsUrls.add(sub.url) - currentSubsNames.add(sub.name) - subtitleCallback(sub) - } + 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 (currentLinkCache.size > 0) { - return true + // 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) - - // 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" - } + if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) { + return@loadLinks + } - currentSubsNames.add(name) - val updatedFile = correctFile.copy(name = name) + // 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() - if (!currentSubsCache.contains(updatedFile)) { + val updatedFile = + correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") + + 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 00000000000..824b5d1a2f3 --- /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 00000000000..fa65c322ec1 --- /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 00000000000..2e554f75eaf --- /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 00000000000..b3873bd32de --- /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 00000000000..5937b1973ed --- /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 00000000000..52cd4361bab --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import com.lagradost.cloudstream3.mvvm.debugWarning +import java.util.WeakHashMap + +object LiveHelper { + private val liveManagers = WeakHashMap>() + + @OptIn(UnstableApi::class) + fun registerPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper registerPlayer called with null player!" } + return + } + + // Prevent duplicates + if (liveManagers.contains(player)) { + return + } + + val liveManager = LiveManager(player) + val listener = object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + val window = Timeline.Window() + timeline.getWindow(player.currentMediaItemIndex, window) + if (window.isDynamic) { + liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs)) + } + super.onTimelineChanged(timeline, reason) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs) + + // Seek back to the optimal live spot + if (timeAheadOfLive > 100) { + player.seekTo(newPosition.positionMs - timeAheadOfLive) + } + } + } + + synchronized(liveManagers) { + player.addListener(listener) + liveManagers[player] = liveManager to listener + } + } + + fun unregisterPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper unregisterPlayer called with null player!" } + return + } + + // Prevent duplicates + if (!liveManagers.contains(player)) { + return + } + + synchronized(liveManagers) { + liveManagers[player]?.let { (_, listener) -> + player.removeListener(listener) + } + liveManagers.remove(player) + } + } + + fun getLiveManager(player: Player?) = liveManagers[player]?.first +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt new file mode 100644 index 00000000000..8d848d46aa9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.media3.common.C +import androidx.media3.common.Player +import java.lang.ref.WeakReference + +// How much margin from the live point is still considered "live" +const val LIVE_MARGIN = 6_000L + +// How many ms should we be behind the real live point? +// Too low, and we cannot pre-buffer +// Too high, and we are no longer live +const val PREFERRED_LIVE_OFFSET = 5_000L + +// An extra offset from the optimal calculated timestamp +// This is to account for chunk updates not always being the same size +const val CHUNK_VARIANCE = 3000L + +// A livestream chunk from the player, the time we get it and the duration can be used to calculate +// the expected live timestamp. +class LivestreamChunk( + durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis() +) { + // We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point. + // If we are ahead of the middle point we will reach the end before the new chunk is expected to be released. + val targetPosition = maxOf(0,minOf( + durationMs - PREFERRED_LIVE_OFFSET, + durationMs / 2 - CHUNK_VARIANCE + )) + + fun isPositionLive(position: Long): Boolean { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET + // println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive") + return withinLive + } + + fun getTimeAheadOfLive(position: Long): Long { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + // println("Ahead of live: ${position-livePosition}") + return position - livePosition + } +} + +// There are two types of livestreams we need to manage +// 1. A livestream with no history, a continually sliding window. +// This livestream has no currentLiveOffset, which means we need to calculate +// the real live point based on when we receive the latest update and the size of that update. +// 2. A livestream with history. +// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point. +// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations. +class LiveManager { + private var _currentPlayer: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayer?.get() + + constructor(player: Player?) { + _currentPlayer = WeakReference(player) + } + + private var lastLivestreamChunk: LivestreamChunk? = null + + fun submitLivestreamChunk(chunk: LivestreamChunk) { + lastLivestreamChunk = chunk + } + + /** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */ + fun getTimeAheadOfLive(position: Long): Long { + val player = currentPlayer ?: return 0 + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0 + + // If the currentLiveOffset is wrong we fall back to manual calculations + val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + val relativeOffset = player.currentLiveOffset - player.currentPosition + position + PREFERRED_LIVE_OFFSET - relativeOffset + } else { + lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0 + } + + // Ensure min of 0 + return maxOf(0, ahead) + } + + /** Check if the stream is currently at the expected live edge, with margins */ + fun isAtLiveEdge(): Boolean { + val player = currentPlayer ?: return false + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false + + // If the currentLiveOffset is wrong we fall back to manual calculations + return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET + } else { + lastLivestreamChunk?.isPositionLive(player.currentPosition) == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt new file mode 100644 index 00000000000..3001281fd45 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt @@ -0,0 +1,38 @@ +package com.lagradost.cloudstream3.ui.player.live + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView +import androidx.media3.ui.R +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import java.lang.ref.WeakReference + + +@OptIn(UnstableApi::class) +class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) { + + private var _currentPlayerView: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayerView?.get()?.player + + fun registerPlayerView(player: PlayerView?) { + _currentPlayerView = WeakReference(player) + val controller = + _currentPlayerView?.get()?.findViewById(R.id.exo_controller) + + controller?.setProgressUpdateListener { position, bufferedPosition -> + currentPlayer?.let { player -> + if (isAtLiveEdge()) { + setPosition(player.duration) + } + } + } + } + + fun isAtLiveEdge(): Boolean { + return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt new file mode 100644 index 00000000000..11dd39105a6 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt @@ -0,0 +1,52 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.lagradost.cloudstream3.databinding.PlayerPrioritizeItemBinding +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState + +data class SourcePriority( + val data: T, + val name: String, + var priority: Int +) + +class PriorityAdapter() : + NoStateAdapter>() { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + PlayerPrioritizeItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindContent( + holder: ViewHolderState, + item: SourcePriority, + position: Int + ) { + val binding = holder.view as? PlayerPrioritizeItemBinding ?: return + binding.priorityText.text = item.name + + fun updatePriority() { + binding.priorityNumber.text = item.priority.toString() + } + + updatePriority() + binding.addButton.setOnClickListener { + // If someone clicks til the integer limit then they deserve to crash. + item.priority++ + updatePriority() + } + + binding.subtractButton.setOnClickListener { + item.priority-- + updatePriority() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt new file mode 100644 index 00000000000..85c2a85df39 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt @@ -0,0 +1,137 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import android.content.res.ColorStateList +import android.graphics.Typeface +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.palette.graphics.Palette +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.drawableToBitmap +import com.lagradost.cloudstream3.utils.setText + +class ProfilesAdapter( + val usedProfile: Int?, + val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, +) : + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.id == b.id + })) { + + companion object { + private val art = arrayOf( + R.drawable.profile_bg_teal, + R.drawable.profile_bg_blue, + R.drawable.profile_bg_dark_blue, + R.drawable.profile_bg_purple, + R.drawable.profile_bg_pink, + R.drawable.profile_bg_red, + R.drawable.profile_bg_orange, + ) + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + PlayerQualityProfileItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + 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 new file mode 100644 index 00000000000..02470484ea1 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt @@ -0,0 +1,226 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import androidx.annotation.StringRes +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.Qualities +import kotlin.math.abs + +object QualityDataHelper { + private const val VIDEO_SOURCE_PRIORITY = "video_source_priority" + private const val VIDEO_PROFILE_NAME = "video_profile_name" + private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority" + + // Old key only supporting one type per profile + @Deprecated("Changed to support multiple types per profile") + private const val VIDEO_PROFILE_TYPE = "video_profile_type" + // New key supporting more than one type per profile + + private const val VIDEO_PROFILE_TYPES = "video_profile_types_2" + private const val DEFAULT_SOURCE_PRIORITY = 1 + + /** + * Automatically skip loading links once this priority is reached + **/ + const val AUTO_SKIP_PRIORITY = 10 + + /** + * Must be higher than amount of QualityProfileTypes + **/ + private const val PROFILE_COUNT = 7 + + /** + * Unique guarantees that there will always be one of this type in the profile list. + **/ + enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) { + None(R.string.none, false), + WiFi(R.string.wifi, true), + Data(R.string.mobile_data, true), + Download(R.string.download, true) + } + + data class QualityProfile( + val name: UiText, + val id: Int, + val types: Set + ) + + fun getSourcePriority(profile: Int, name: String?): Int { + if (name == null) return DEFAULT_SOURCE_PRIORITY + return getKey( + "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", + name, + DEFAULT_SOURCE_PRIORITY + ) ?: DEFAULT_SOURCE_PRIORITY + } + + fun getAllSourcePriorityNames(profile: Int): List { + val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" + return getKeys(folder)?.map { key -> + key.substringAfter("$folder/") + } ?: emptyList() + } + + fun setSourcePriority(profile: Int, name: String, priority: Int) { + val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" + // Prevent unnecessary keys + if (priority == DEFAULT_SOURCE_PRIORITY) { + removeKey(folder, name) + } else { + setKey(folder, name, priority) + } + } + + fun setProfileName(profile: Int, name: String?) { + val path = "$currentAccount/$VIDEO_PROFILE_NAME/$profile" + if (name == null) { + removeKey(path) + } else { + setKey(path, name.trim()) + } + } + + fun getProfileName(profile: Int): UiText { + return getKey("$currentAccount/$VIDEO_PROFILE_NAME/$profile")?.let { txt(it) } + ?: txt(R.string.profile_number, profile) + } + + fun getQualityPriority(profile: Int, quality: Qualities): Int { + return getKey( + "$currentAccount/$VIDEO_QUALITY_PRIORITY/$profile", + quality.value.toString(), + quality.defaultPriority + ) ?: quality.defaultPriority + } + + fun setQualityPriority(profile: Int, quality: Qualities, priority: Int) { + setKey( + "$currentAccount/$VIDEO_QUALITY_PRIORITY/$profile", + quality.value.toString(), + priority + ) + } + + + @Suppress("DEPRECATION") + fun getQualityProfileTypes(profile: Int): Set { + val newKey = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" + // Use arrays for to make with work with setKey properly (weird crashes otherwise) + val newProfiles = getKey>(newKey)?.toSet() + + // Migrate to new profile key + if (newProfiles == null) { + val oldProfile = + getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") + val newSet = oldProfile?.let { arrayOf(it) } ?: arrayOf() + setKey(newKey, newSet) + return newSet.toSet() + } else { + return newProfiles + } + } + + fun addQualityProfileType(profile: Int, type: QualityProfileType) { + val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" + val currentTypes = getQualityProfileTypes(profile) + + if (type != QualityProfileType.None) { + setKey(path, (currentTypes + type).toTypedArray()) + } + } + + fun removeQualityProfileType(profile: Int, type: QualityProfileType) { + val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" + val currentTypes = getQualityProfileTypes(profile) + + if (type != QualityProfileType.None) { + setKey(path, (currentTypes - type).toTypedArray()) + } + } + + /** + * Gets all quality profiles, always includes one profile with WiFi and Data + * Must under all circumstances at least return one profile + **/ + fun getProfiles(): List { + val availableTypes = QualityProfileType.entries.toMutableList() + val profiles = (1..PROFILE_COUNT).map { profileNumber -> + // Get the real type + val types = getQualityProfileTypes(profileNumber) + + val uniqueTypes = types.mapNotNull { type -> + // This makes it impossible to get more than one of each type + if (type.unique && !availableTypes.remove(type)) { + null + } else { + type + } + }.toSet() + + QualityProfile( + getProfileName(profileNumber), + profileNumber, + uniqueTypes + ) + }.toMutableList() + + /** + * If no profile of this type exists: insert it on the earliest profile + **/ + fun insertType( + list: MutableList, + type: QualityProfileType + ) { + if (list.any { it.types.contains(type) }) return + + synchronized(list) { + val firstItem = list.firstOrNull() ?: return + val fixedTypes = firstItem.types + type + val fixedItem = firstItem.copy(types = fixedTypes) + list.set(0, fixedItem) + } + } + + QualityProfileType.entries.forEach { + if (it.unique) insertType(profiles, it) + } + + debugAssert({ + !QualityProfileType.entries.all { type -> + !type.unique || profiles.any { it.types.contains(type) } + } + }, { "All unique quality types do not exist" }) + + debugAssert({ + profiles.isEmpty() + }, { "No profiles!" }) + + 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 new file mode 100644 index 00000000000..6a0f12e9a4e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt @@ -0,0 +1,151 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import android.app.Dialog +import androidx.annotation.StyleRes +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getAllSourcePriorityNames +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles +import com.lagradost.cloudstream3.utils.Coroutines.ioWork +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.setText + +/** Simplified ExtractorLink for the quality profile dialog */ +data class LinkSource( + val source: String +) { + constructor(extractorLink: ExtractorLink) : this(extractorLink.source) +} + + +class QualityProfileDialog private constructor( + val activity: FragmentActivity, + @StyleRes val themeRes: Int, + private val links: List, + private val usedProfile: Int?, + private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?, + private val useProfileSelection: Boolean +) : Dialog(activity, themeRes) { + constructor( + activity: FragmentActivity, + @StyleRes themeRes: Int, + links: List, + usedProfile: Int, + profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit), + ) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true) + + constructor( + activity: FragmentActivity, + @StyleRes themeRes: Int, + links: List + ) : this(activity, themeRes, links, null, null, false) + + companion object { + // Run on IO as this may be a heavy operation + suspend fun getAllDefaultSources(): List = ioWork { + getProfiles().flatMap { + getAllSourcePriorityNames(it.id) + }.distinct().map { LinkSource(it) } + } + } + + override fun show() { + val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false) + + setContentView(binding.root) + fixSystemBarsPadding(binding.root) + binding.apply { + fun getCurrentProfile(): QualityDataHelper.QualityProfile? { + return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile() + } + + fun refreshProfiles() { + if (usedProfile != null) { + currentlySelectedProfileText.setText(getProfileName(usedProfile)) + } + (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) + } + + profilesRecyclerview.adapter = ProfilesAdapter( + usedProfile, + ) { oldIndex: Int?, newIndex: Int -> + profilesRecyclerview.adapter?.notifyItemChanged(newIndex) + selectedItemHolder.alpha = 1f + if (oldIndex != null) { + profilesRecyclerview.adapter?.notifyItemChanged(oldIndex) + } + } + + refreshProfiles() + + editBtt.setOnClickListener { + getCurrentProfile()?.let { profile -> + SourcePriorityDialog(context, themeRes, links, profile) { + refreshProfiles() + }.show() + } + } + + + setDefaultBtt.setOnClickListener { + val currentProfile = getCurrentProfile() ?: return@setOnClickListener + val choices = + QualityDataHelper.QualityProfileType.entries.filter { it != QualityDataHelper.QualityProfileType.None } + val choiceNames = choices.map { txt(it.stringRes).asString(context) } + val selectedIndices = choices.mapIndexed { index, type -> index to type } + .filter { currentProfile.types.contains(it.second) }.map { it.first } + + activity.showMultiDialog( + choiceNames, + selectedIndices, + txt(R.string.set_default).asString(context), + {}, + { index -> + val pickedChoices = index.mapNotNull { choices.getOrNull(it) } + + pickedChoices.forEach { pickedChoice -> + // Remove previous picks + if (pickedChoice.unique) { + getProfiles().filter { it.types.contains(pickedChoice) }.forEach { + QualityDataHelper.removeQualityProfileType(it.id, pickedChoice) + } + } + + QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice) + } + + refreshProfiles() + }) + } + + cancelBtt.isVisible = useProfileSelection + useBtt.isVisible = useProfileSelection + applyBtt.isVisible = !useProfileSelection + + if (useProfileSelection) { + cancelBtt.setOnClickListener { + this@QualityProfileDialog.dismissSafe() + } + + useBtt.setOnClickListener { + getCurrentProfile()?.let { + profileSelectionCallback?.invoke(it) + this@QualityProfileDialog.dismissSafe() + } + } + } else { + applyBtt.setOnClickListener { + this@QualityProfileDialog.dismissSafe() + } + } + } + super.show() + } +} \ No newline at end of file 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 new file mode 100644 index 00000000000..c8ac96ebbf6 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt @@ -0,0 +1,103 @@ +package com.lagradost.cloudstream3.ui.player.source_priority + +import android.app.Dialog +import android.content.Context +import android.view.LayoutInflater +import androidx.annotation.StyleRes +import androidx.appcompat.app.AlertDialog +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.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, + private val profile: QualityDataHelper.QualityProfile, + /** + * Notify that the profile overview should be updated, for example if the name has been updated + * Should not be called excessively. + **/ + private val updatedCallback: () -> Unit +) : Dialog(ctx, themeRes) { + override fun show() { + val binding = + PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) + setContentView(binding.root) + fixSystemBarsPadding(binding.root) + val sourcesRecyclerView = binding.sortSources + val qualitiesRecyclerView = binding.sortQualities + val profileText = binding.profileTextEditable + val saveBtt = binding.saveBtt + val exitBtt = binding.closeBtt + val helpBtt = binding.helpBtt + + profileText.setText(QualityDataHelper.getProfileName(profile.id).asString(context)) + profileText.hint = txt(R.string.profile_number, profile.id).asString(context) + + sourcesRecyclerView.adapter = PriorityAdapter( + ).apply { + submitList(links.map { link -> + SourcePriority( + null, + link.source, + QualityDataHelper.getSourcePriority(profile.id, link.source) + ) + }.distinctBy { it.name }.sortedBy { -it.priority }) + } + + 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 }) + } + + @Suppress("UNCHECKED_CAST") // We know the types + saveBtt.setOnClickListener { + val qualityAdapter = qualitiesRecyclerView.adapter as? PriorityAdapter + val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter + + val qualities = qualityAdapter?.immutableCurrentList ?: emptyList() + val sources = sourcesAdapter?.immutableCurrentList ?: emptyList() + + qualities.forEach { + QualityDataHelper.setQualityPriority(profile.id, it.data, it.priority) + } + + sources.forEach { + QualityDataHelper.setSourcePriority(profile.id, it.name, it.priority) + } + + qualityAdapter?.submitList(qualities.sortedBy { -it.priority }) + sourcesAdapter?.submitList(sources.sortedBy { -it.priority }) + + val savedProfileName = profileText.text.toString() + if (savedProfileName.isBlank()) { + QualityDataHelper.setProfileName(profile.id, null) + } else { + QualityDataHelper.setProfileName(profile.id, savedProfileName) + } + updatedCallback.invoke() + } + + exitBtt.setOnClickListener { + this.dismissSafe() + } + + helpBtt.setOnClickListener { + AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { + setMessage(R.string.quality_profile_help) + }.show() + } + + super.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt index 62f967d230e..cf9bc9975c0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt @@ -2,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,37 +11,58 @@ 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 com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.R +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.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 kotlinx.android.synthetic.main.quick_search.* 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" + fun pushSearch( + autoSearch: String? = null, + providers: Array? = null + ) { + pushSearch(activity, autoSearch, providers) + } + fun pushSearch( activity: Activity?, autoSearch: String? = null, @@ -71,6 +91,17 @@ class QuickSearchFragment : Fragment() { private var providers: Set? = null private lateinit var searchViewModel: SearchViewModel + 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?, @@ -80,8 +111,8 @@ class QuickSearchFragment : Fragment() { WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] - - return inflater.inflate(R.layout.quick_search, container, false) + bottomSheetDialog?.ownShow() + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroy() { @@ -103,25 +134,7 @@ class QuickSearchFragment : Fragment() { return false } - private fun fixGrid() { - activity?.getSpanCount()?.let { - HomeFragment.currentSpan = it - } - quick_search_autofit_results.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) - context?.fixPaddingStatusbar(quick_search_root) - fixGrid() - + override fun onBindingCreated(binding: QuickSearchBinding) { arguments?.getStringArray(PROVIDER_KEY)?.let { providers = it.toSet() } @@ -131,52 +144,101 @@ class QuickSearchFragment : Fragment() { getApiFromNameNull(providers?.first())?.hasQuickSearch ?: false } else false - if (isSingleProvider) { - quick_search_autofit_results.adapter = activity?.let { - SearchAdapter( - ArrayList(), - quick_search_autofit_results, + val firstProvider = providers?.firstOrNull() + if (isSingleProvider && firstProvider != null) { + binding.quickSearchAutofitResults.apply { + setRecycledViewPool(SearchAdapter.sharedPool) + adapter = SearchAdapter( + this, ) { callback -> - SearchHelper.handleSearchClickCallback(activity, 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 { - quick_search?.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 { - quick_search_master_recycler?.adapter = - ParentItemAdapter(mutableListOf(), { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) - //when (callback.action) { - //SEARCH_ACTION_LOAD -> { - // clickCallback?.invoke(callback) - //} - // else -> SearchHelper.handleSearchClickCallback(activity, callback) - //} - }, { item -> - activity?.loadHomepageList(item) - }) - quick_search_master_recycler?.layoutManager = GridLayoutManager(context, 1) + 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) } - - quick_search_autofit_results?.isVisible = isSingleProvider - quick_search_master_recycler?.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() - (quick_search_master_recycler?.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) @@ -186,19 +248,12 @@ class QuickSearchFragment : Fragment() { } val searchExitIcon = - quick_search?.findViewById(androidx.appcompat.R.id.search_close_btn) - - //val searchMagIcon = - // quick_search?.findViewById(androidx.appcompat.R.id.search_mag_icon) - - //searchMagIcon?.scaleX = 0.65f - //searchMagIcon?.scaleY = 0.65f + binding.quickSearch.findViewById(androidx.appcompat.R.id.search_close_btn) - - quick_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding.quickSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { if (search(context, query, false)) - UIHelper.hideKeyboard(quick_search) + hideKeyboard(binding.quickSearch) return true } @@ -208,45 +263,50 @@ class QuickSearchFragment : Fragment() { return true } }) - - quick_search_loading_bar.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - (quick_search_autofit_results?.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 - quick_search_loading_bar?.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f } + is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f - quick_search_loading_bar?.alpha = 0f + binding.quickSearchLoadingBar.alpha = 0f } + is Resource.Loading -> { searchExitIcon?.alpha = 0f - quick_search_loading_bar?.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()) - // } - //} - - quick_search_back.setOnClickListener { - activity?.popCurrentPage() + if (isLayout(TV)) { + binding.quickSearch.requestFocus() } arguments?.getString(AUTOSEARCH_KEY)?.let { - quick_search?.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 92cecc377db..056588d0bb0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt @@ -1,144 +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 android.widget.ImageView -import android.widget.TextView 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.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.cast_item.view.* - -class ActorAdaptor() : RecyclerView.Adapter() { - data class ActorMetaData( - var isInverted: Boolean, - val actor: ActorData, - ) +import com.lagradost.cloudstream3.databinding.CastItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage + +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( - LayoutInflater.from(parent.context).inflate(R.layout.cast_item, parent, false), + 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 - } - - private fun updateActorList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - ActorDiffCallback(this.actors, newList) - ) - - actors.clear() - actors.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } + 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) - 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) - }) - } - } + val (mainImg, vaImage) = if (!isInverted || item.voiceActor?.image.isNullOrBlank()) { + Pair(item.actor.image, item.voiceActor?.image) + } else { + Pair(item.voiceActor?.image, item.actor.image) + } - private class CardViewHolder - constructor( - itemView: View, - ) : - RecyclerView.ViewHolder(itemView) { - private val actorImage: ImageView = itemView.actor_image - private val actorName: TextView = itemView.actor_name - private val actorExtra: TextView = itemView.actor_extra - private val voiceActorImage: ImageView = itemView.voice_actor_image - private val voiceActorImageHolder: View = itemView.voice_actor_image_holder - private val voiceActorName: TextView = itemView.voice_actor_name - - fun bind(actor: ActorData, isInverted: Boolean, position: Int, callback: (Int) -> Unit) { - val (mainImg, vaImage) = if (!isInverted || actor.voiceActor?.image.isNullOrBlank()) { - Pair(actor.actor.image, actor.voiceActor?.image) - } else { - Pair(actor.voiceActor?.image, actor.actor.image) - } + // Fix tv focus escaping the recyclerview + if (position == 0) { + itemView.nextFocusLeftId = R.id.result_cast_items + } else if ((position - 1) == itemCount) { + itemView.nextFocusRightId = R.id.result_cast_items + } + nextFocusUpId?.let { + itemView.nextFocusUpId = it + } - itemView.setOnClickListener { - callback(position) - } + itemView.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focusCallback(v) + } + } - actorImage.setImage(mainImg) + itemView.setOnClickListener { + inverted[item] = !isInverted + this.onUpdateContent(holder, getItem(position), position) + } - actorName.text = actor.actor.name - actor.role?.let { - actorExtra.context?.getString( - when (it) { - ActorRole.Main -> { - R.string.actor_main - } - ActorRole.Supporting -> { - R.string.actor_supporting - } - ActorRole.Background -> { - R.string.actor_background + 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 + true } - } ?: actor.roleString?.let { - actorExtra.isVisible = true - actorExtra.text = it - } ?: run { - actorExtra.isVisible = false - } - if (actor.voiceActor == null) { - voiceActorImageHolder.isVisible = false - voiceActorName.isVisible = false - } else { - voiceActorName.text = actor.voiceActor.name - 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 e5b839a8561..5e550416477 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -1,42 +1,49 @@ package com.lagradost.cloudstream3.ui.result -import android.annotation.SuppressLint import android.content.Context import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.core.widget.ContentLoadingProgressBar +import androidx.core.view.setPadding import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.button.MaterialButton +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.DownloadButtonViewHolder +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadClickEvent -import com.lagradost.cloudstream3.ui.download.EasyDownloadButton -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlinx.android.synthetic.main.result_episode.view.* -import kotlinx.android.synthetic.main.result_episode.view.episode_text -import kotlinx.android.synthetic.main.result_episode_large.view.* -import kotlinx.android.synthetic.main.result_episode_large.view.episode_filler -import kotlinx.android.synthetic.main.result_episode_large.view.episode_progress -import kotlinx.android.synthetic.main.result_episode_large.view.result_episode_download -import kotlinx.android.synthetic.main.result_episode_large.view.result_episode_progress_downloaded -import java.util.* - +import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.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 @@ -44,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 @@ -55,277 +61,421 @@ 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 -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), "") - var cardList: MutableList = mutableListOf() - - private val mBoundViewHolders: HashSet = HashSet() - private fun getAllBoundViewHolders(): Set? { - return Collections.unmodifiableSet(mBoundViewHolders) - } - - fun killAdapter() { - getAllBoundViewHolders()?.forEach { view -> - view?.downloadButton?.dispose() + return VideoClickActionHolder.uniqueIdToId(playerPref) ?: ACTION_PLAY_EPISODE_IN_PLAYER } + + val sharedPool = + newSharedPool { + setMaxRecycledViews(HAS_POSTER or CONTENT, 10) + setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) + } } - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + override fun onClearView(holder: ViewHolderState) { if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } - //(holder.itemView as? FrameLayout?)?.descendantFocusability = - // ViewGroup.FOCUS_BLOCK_DESCENDANTS - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() + when (val binding = holder.view) { + is ResultEpisodeLargeBinding -> { + clearImage(binding.episodePoster) + } } + super.onClearView(holder) } - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - holder.downloadButton.dispose() - mBoundViewHolders.remove(holder) - //(holder.itemView as? FrameLayout?)?.descendantFocusability = - // ViewGroup.FOCUS_BLOCK_DESCENDANTS - } - } + 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) { + HAS_NO_POSTER -> { + ViewHolderState( + ResultEpisodeBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } - override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { - if (holder is DownloadButtonViewHolder) { - //println("onViewAttachedToWindow = ${holder.absoluteAdapterPosition}") - //holder.itemView.post { - // if (holder.itemView.isAttachedToWindow) - // (holder.itemView as? FrameLayout?)?.descendantFocusability = - // ViewGroup.FOCUS_AFTER_DESCENDANTS - //} + HAS_POSTER -> { + ViewHolderState( + ResultEpisodeLargeBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } - holder.reattachDownloadButton() + else -> throw NotImplementedError() } } - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - ResultDiffCallback(this.cardList, newList) - ) - - cardList.clear() - cardList.addAll(newList) + 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 + + binding.apply { + episodeLinHolder.layoutParams.width = setWidth + episodeHolderLarge.layoutParams.width = setWidth + episodeHolder.layoutParams.width = setWidth + + if (isLayout(PHONE or EMULATOR) && CommonActivity.appliedTheme == R.style.AmoledMode) { + episodeHolderLarge.radius = 0.0f + episodeHolder.setPadding(0) + } - diffResult.dispatchUpdatesTo(this) - } + 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) + } + } + } - var layout = R.layout.result_episode_both + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) + + val name = + if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" + episodeFiller.isVisible = item.isFiller == true + episodeText.text = + name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name + episodeText.isSelected = true // is needed for text repeating + + if (item.videoWatchState == VideoWatchState.Watched) { + // This cannot be done in getDisplayPosition() as when you have not watched something + // the duration and position is 0 + //episodeProgress.max = 1 + //episodeProgress.progress = 1 + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + val displayPos = item.getDisplayPosition() + val durationSec = (item.duration / 1000).toInt() + val progressSec = (displayPos / 1000).toInt() + + if (displayPos >= item.duration && displayPos > 0) { + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + episodePlayIcon.setImageResource(R.drawable.netflix_play) + episodeProgress.apply { + max = durationSec + progress = progressSec + isVisible = displayPos > 0L + } + } + } - 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*/ + 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 - return EpisodeCardViewHolder( - LayoutInflater.from(parent.context) - .inflate(layout, parent, false), - hasDownloadSupport, - clickCallback, - downloadClickCallback - ) - } + 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 = "" + } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is EpisodeCardViewHolder -> { - holder.bind(cardList[position]) - mBoundViewHolders.add(holder) - } - } - } + 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 + } + } + } - override fun getItemCount(): Int { - return cardList.size - } + 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 + } - class EpisodeCardViewHolder - constructor( - itemView: View, - private val hasDownloadSupport: Boolean, - private val clickCallback: (EpisodeClickEvent) -> Unit, - private val downloadClickCallback: (DownloadClickEvent) -> Unit, - ) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder { - override var downloadButton = EasyDownloadButton() - - var episodeDownloadBar: ContentLoadingProgressBar? = null - var episodeDownloadImage: ImageView? = null - var localCard: ResultEpisode? = null - - @SuppressLint("SetTextI18n") - fun bind(card: ResultEpisode) { - localCard = card - - val isTrueTv = isTrueTvSettings() - - val (parentView, otherView) = if (card.poster == null) { - itemView.episode_holder to itemView.episode_holder_large - } else { - itemView.episode_holder_large to itemView.episode_holder - } - parentView.isVisible = true - otherView.isVisible = false - - val episodeText: TextView = parentView.episode_text - val episodeFiller: MaterialButton? = parentView.episode_filler - val episodeRating: TextView? = parentView.episode_rating - val episodeDescript: TextView? = parentView.episode_descript - val episodeProgress: ContentLoadingProgressBar? = parentView.episode_progress - val episodePoster: ImageView? = parentView.episode_poster - - episodeDownloadBar = - parentView.result_episode_progress_downloaded - episodeDownloadImage = parentView.result_episode_download - - 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 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 = "" - } + episodeRuntime.setText( + txt( + item.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } + ) + ) - episodeRating?.isGone = episodeRating?.text.isNullOrBlank() + 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 + } + } + } - episodeDescript?.apply { - text = card.description.html() - isGone = text.isNullOrBlank() - setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) + itemView.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) } - } - if (!isTrueTv) { - episodePoster?.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false } - episodePoster?.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) + itemView.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) return@setOnLongClickListener true } } - itemView.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) - } + is ResultEpisodeBinding -> { + binding.episodeHolder.layoutParams.apply { + width = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT + } - if (isTrueTv) { - itemView.isFocusable = true - itemView.isFocusableInTouchMode = true - //itemView.touchscreenBlocksFocus = false - } + 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) + } + } + } - itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) - return@setOnLongClickListener true - } + 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 + } + } + } - episodeDownloadImage?.isVisible = hasDownloadSupport - episodeDownloadBar?.isVisible = hasDownloadSupport - reattachDownloadButton() - } + itemView.setOnClickListener { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_CLICK_DEFAULT, + item + ) + ) + } - override fun reattachDownloadButton() { - downloadButton.dispose() - val card = localCard - if (hasDownloadSupport && card != null) { - if (episodeDownloadBar == null || - episodeDownloadImage == null - ) return - val downloadInfo = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( - itemView.context, - card.id - ) + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false + } - downloadButton.setUpButton( - downloadInfo?.fileLength, - downloadInfo?.totalBytes, - episodeDownloadBar ?: return, - episodeDownloadImage ?: return, - null, - VideoDownloadHelper.DownloadEpisodeCached( - card.name, - card.poster, - card.episode, - card.season, - card.id, - card.parentId, - card.rating, - card.description, - System.currentTimeMillis(), - ) - ) { - if (it.action == DOWNLOAD_ACTION_DOWNLOAD) { - clickCallback.invoke(EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, card)) - } else { - downloadClickCallback.invoke(it) + 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 ebd6a658a94..54657ed5730 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt @@ -1,116 +1,72 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.newSharedPool +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -/* -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 class ImageAdapter( - val layout: Int, val clickCallback: ((Int) -> Unit)? = null, val nextFocusUp: Int? = null, val nextFocusDown: Int? = null, ) : - RecyclerView.Adapter() { - private val images: MutableList = mutableListOf() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return ImageViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false) + NoStateAdapter( + diffCallback = BaseDiffCallback( + itemSame = Int::equals, + contentSame = Int::equals ) + ) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ImageViewHolder -> { - holder.bind(images[position], clickCallback, nextFocusUp, nextFocusDown) - } - } - } - - override fun getItemCount(): Int { - return images.size + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) } - override fun getItemId(position: Int): Long { - return images[position].toLong() + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? ResultMiniImageBinding ?: return + clearImage(binding.root) } - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - DiffCallback(this.images, newList) - ) - - images.clear() - images.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } + override fun onBindContent(holder: ViewHolderState, item: Int, position: Int) { + val binding = holder.view as? ResultMiniImageBinding ?: return - class ImageViewHolder - constructor(itemView: View) : - RecyclerView.ViewHolder(itemView) { - fun bind( - img: Int, - clickCallback: ((Int) -> Unit)?, - nextFocusUp: Int?, - nextFocusDown: Int?, - ) { - (itemView as? ImageView?)?.apply { - setImageResource(img) - if (nextFocusDown != null) { - this.nextFocusDownId = nextFocusDown + binding.root.apply { + loadImage(item) + if (nextFocusDown != null) { + this.nextFocusDownId = nextFocusDown + } + if (nextFocusUp != null) { + this.nextFocusUpId = nextFocusUp + } + if (clickCallback != null) { + if (isLayout(TV)) { + isClickable = true + isLongClickable = true + isFocusable = true + isFocusableInTouchMode = true } - if (nextFocusUp != null) { - this.nextFocusUpId = nextFocusUp + setOnClickListener { + clickCallback.invoke(IMAGE_CLICK) } - if (clickCallback != null) { - if (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 59a46264705..3a0edba2f8e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt @@ -4,18 +4,46 @@ import android.content.Context import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +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 -fun RecyclerView?.setLinearListLayout(isHorizontal: Boolean = true) { - if(this == null) return - this.layoutManager = - this.context?.let { LinearListLayout(it).apply { if (isHorizontal) setHorizontal() else setVertical() } } - ?: this.layoutManager +const val FOCUS_SELF = View.NO_ID - 1 +const val FOCUS_INHERIT = FOCUS_SELF - 1 + +fun RecyclerView?.setLinearListLayout( + isHorizontal: Boolean = true, + nextLeft: Int = FOCUS_INHERIT, + nextRight: Int = FOCUS_INHERIT, + nextUp: Int = FOCUS_INHERIT, + nextDown: Int = FOCUS_INHERIT +) { + if (this == null) return + val ctx = this.context ?: return + this.layoutManager = (this.layoutManager as? LinearListLayout ?: LinearListLayout(ctx)).apply { + if (isHorizontal) setHorizontal() else setVertical() + nextFocusLeft = + if (nextLeft == FOCUS_INHERIT) this@setLinearListLayout.nextFocusLeftId else nextLeft + nextFocusRight = + if (nextRight == FOCUS_INHERIT) this@setLinearListLayout.nextFocusRightId else nextRight + nextFocusUp = + if (nextUp == FOCUS_INHERIT) this@setLinearListLayout.nextFocusUpId else nextUp + nextFocusDown = + if (nextDown == FOCUS_INHERIT) this@setLinearListLayout.nextFocusDownId else nextDown + } } -class LinearListLayout(context: Context?) : +open class LinearListLayout(context: Context?) : LinearLayoutManager(context) { + var nextFocusLeft: Int = View.NO_ID + var nextFocusRight: Int = View.NO_ID + var nextFocusUp: Int = View.NO_ID + var nextFocusDown: Int = View.NO_ID + fun setHorizontal() { orientation = HORIZONTAL } @@ -24,7 +52,8 @@ class LinearListLayout(context: Context?) : orientation = VERTICAL } - private fun getCorrectParent(focused: View): View? { + private fun getCorrectParent(focused: View?): View? { + if (focused == null) return null var current: View? = focused val last: ArrayList = arrayListOf(focused) while (current != null && current !is RecyclerView) { @@ -55,27 +84,150 @@ class LinearListLayout(context: Context?) : startSmoothScroll(linearSmoothScroller) }*/ + /** from the current focus go to a direction */ + private fun getNextDirection(focused: View?, direction: FocusDirection): View? { + val id = when (direction) { + FocusDirection.Start -> if (isLayoutRTL) nextFocusRight else nextFocusLeft + FocusDirection.End -> if (isLayoutRTL) nextFocusLeft else nextFocusRight + FocusDirection.Up -> nextFocusUp + FocusDirection.Down -> nextFocusDown + } + + return when (id) { + View.NO_ID -> null + FOCUS_SELF -> focused + else -> CommonActivity.continueGetNextFocus( + activity ?: focused, + focused ?: return null, + direction, + id + ) + } + } + + 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 || direction == View.FOCUS_UP) return null - if (direction == View.FOCUS_RIGHT) 1 else -1 + 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 redirectRecycleToFirstItem(newFocus) + } + + if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) { + // This scrolls the recyclerview before doing focus search, which + // allows the focus search to work better. + + // Without this the recyclerview focus location on the screen + // would change when scrolling between recyclerviews. + (focused.parent as? RecyclerView)?.focusSearch(direction) + return null + } + var ret = if (direction == View.FOCUS_RIGHT) 1 else -1 + // only flip on horizontal layout + if (isLayoutRTL) { + ret = -ret + } + ret } else { - if (direction == View.FOCUS_RIGHT || direction == View.FOCUS_LEFT) return null + if (direction == View.FOCUS_RIGHT) getNextDirection( + focused, + FocusDirection.End + )?.let { newFocus -> + return newFocus + } + if (direction == View.FOCUS_LEFT) getNextDirection( + focused, + FocusDirection.Start + )?.let { newFocus -> + return newFocus + } + + if (direction == View.FOCUS_RIGHT || direction == View.FOCUS_LEFT) { + (focused.parent as? RecyclerView)?.focusSearch(direction) + return null + } + + //if (direction == View.FOCUS_RIGHT || direction == View.FOCUS_LEFT) return null if (direction == View.FOCUS_DOWN) 1 else -1 } - return try { - getPosition(getCorrectParent(focused))?.let { position -> - val lookfor = dir + position - //clamp(dir + position, 0, recyclerView.adapter?.itemCount ?: return null) - getViewFromPos(lookfor) ?: run { - scrollToPosition(lookfor) + try { + val position = getPosition(getCorrectParent(focused)) ?: return null + val lookFor = dir + position + + // if out of bounds then refocus as specified + return if (lookFor >= itemCount) { + getNextDirection( + focused, + if (orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down + ) + } else if (lookFor < 0) { + getNextDirection( + focused, + if (orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up + ) + } else { + getViewFromPos(lookFor) ?: run { + scrollToPosition(lookFor) null } } } catch (e: Exception) { logError(e) - null + return null + } + } + + 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 + ) } } 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 30ea889ec41..cbf94fd9796 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt @@ -1,109 +1,39 @@ package com.lagradost.cloudstream3.ui.result -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.content.Intent.* -import android.content.res.ColorStateList -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.widget.AbsListView -import android.widget.ArrayAdapter import android.widget.ImageView -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isGone +import android.widget.TextView import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.ViewModelProvider +import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager -import com.discord.panels.OverlappingPanelsLayout -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipDrawable -import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings -import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.APIHolder.updateHasTrailers +import coil3.dispose import com.lagradost.cloudstream3.DubStatus -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.SeasonData import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.mvvm.* -import com.lagradost.cloudstream3.syncproviders.providers.Kitsu -import com.lagradost.cloudstream3.ui.WatchType -import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD -import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick -import com.lagradost.cloudstream3.ui.download.EasyDownloadButton -import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.AppUtils.loadCache -import com.lagradost.cloudstream3.utils.AppUtils.openBrowser -import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe -import com.lagradost.cloudstream3.utils.Coroutines.main +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.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -import kotlinx.android.synthetic.main.fragment_result.* -import kotlinx.android.synthetic.main.fragment_result.result_cast_items -import kotlinx.android.synthetic.main.fragment_result.result_cast_text -import kotlinx.android.synthetic.main.fragment_result.result_coming_soon -import kotlinx.android.synthetic.main.fragment_result.result_data_holder -import kotlinx.android.synthetic.main.fragment_result.result_description -import kotlinx.android.synthetic.main.fragment_result.result_download_movie -import kotlinx.android.synthetic.main.fragment_result.result_episode_loading -import kotlinx.android.synthetic.main.fragment_result.result_episodes -import kotlinx.android.synthetic.main.fragment_result.result_error_text -import kotlinx.android.synthetic.main.fragment_result.result_finish_loading -import kotlinx.android.synthetic.main.fragment_result.result_info -import kotlinx.android.synthetic.main.fragment_result.result_loading -import kotlinx.android.synthetic.main.fragment_result.result_loading_error -import kotlinx.android.synthetic.main.fragment_result.result_meta_duration -import kotlinx.android.synthetic.main.fragment_result.result_meta_rating -import kotlinx.android.synthetic.main.fragment_result.result_meta_site -import kotlinx.android.synthetic.main.fragment_result.result_meta_type -import kotlinx.android.synthetic.main.fragment_result.result_meta_year -import kotlinx.android.synthetic.main.fragment_result.result_movie_download_icon -import kotlinx.android.synthetic.main.fragment_result.result_movie_download_text -import kotlinx.android.synthetic.main.fragment_result.result_movie_download_text_precentage -import kotlinx.android.synthetic.main.fragment_result.result_movie_progress_downloaded -import kotlinx.android.synthetic.main.fragment_result.result_movie_progress_downloaded_holder -import kotlinx.android.synthetic.main.fragment_result.result_next_airing -import kotlinx.android.synthetic.main.fragment_result.result_next_airing_time -import kotlinx.android.synthetic.main.fragment_result.result_no_episodes -import kotlinx.android.synthetic.main.fragment_result.result_play_movie -import kotlinx.android.synthetic.main.fragment_result.result_reload_connection_open_in_browser -import kotlinx.android.synthetic.main.fragment_result.result_reload_connectionerror -import kotlinx.android.synthetic.main.fragment_result.result_resume_parent -import kotlinx.android.synthetic.main.fragment_result.result_resume_progress_holder -import kotlinx.android.synthetic.main.fragment_result.result_resume_series_progress -import kotlinx.android.synthetic.main.fragment_result.result_resume_series_progress_text -import kotlinx.android.synthetic.main.fragment_result.result_resume_series_title -import kotlinx.android.synthetic.main.fragment_result.result_tag -import kotlinx.android.synthetic.main.fragment_result.result_tag_holder -import kotlinx.android.synthetic.main.fragment_result.result_title -import kotlinx.android.synthetic.main.fragment_result.result_vpn -import kotlinx.android.synthetic.main.fragment_result_swipe.* -import kotlinx.android.synthetic.main.fragment_result_tv.* -import kotlinx.android.synthetic.main.result_sync.* -import kotlinx.android.synthetic.main.trailer_custom_layout.* -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking +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 +/** + * Future proofed way to mark episodes as watched + **/ +enum class VideoWatchState { + /** Default value when no key is set */ + None, + Watched +} + data class ResultEpisode( val headerName: String, val name: String?, @@ -117,11 +47,20 @@ 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, val parentId: Int, + /** + * Conveys if the episode itself is marked as watched + **/ + val videoWatchState: VideoWatchState, + /** Sum of all previous season episode counts + episode */ + val totalEpisodeIndex: Int? = null, + val airDate: Long? = null, + val runTime: Int? = null, + val seasonData: SeasonData? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -151,31 +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, + 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 ) } @@ -184,287 +133,156 @@ fun ResultEpisode.getWatchProgress(): Float { return (getDisplayPosition() / 1000).toFloat() / (duration / 1000).toFloat() } -open class ResultFragment : ResultTrailerPlayer() { - companion object { - const val URL_BUNDLE = "url" - const val API_NAME_BUNDLE = "apiName" - const val SEASON_BUNDLE = "season" - const val EPISODE_BUNDLE = "episode" - const val START_ACTION_BUNDLE = "startAction" - const val START_VALUE_BUNDLE = "startValue" - const val RESTART_BUNDLE = "restart" - - fun newInstance( - card: SearchResponse, startAction: Int = 0, startValue: Int? = null - ): Bundle { - return Bundle().apply { - putString(URL_BUNDLE, card.url) - putString(API_NAME_BUNDLE, card.apiName) - if (card is DataStoreHelper.ResumeWatchingResult) { - if (card.season != null) - putInt(SEASON_BUNDLE, card.season) - if (card.episode != null) - putInt(EPISODE_BUNDLE, card.episode) - } - putInt(START_ACTION_BUNDLE, startAction) - if (startValue != null) - putInt(START_VALUE_BUNDLE, startValue) - - - putBoolean(RESTART_BUNDLE, true) +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" + private const val START_ACTION_BUNDLE = "startAction" + private const val START_VALUE_BUNDLE = "startValue" + private const val RESTART_BUNDLE = "restart" + + fun newInstance( + card: SearchResponse, startAction: Int = 0, startValue: Int? = null + ): Bundle { + 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) + if (card.episode != null) + putInt(EPISODE_BUNDLE, card.episode) } - } - - fun newInstance( - url: String, - apiName: String, - startAction: Int = 0, - startValue: Int = 0 - ): Bundle { - return Bundle().apply { - putString(URL_BUNDLE, url) - putString(API_NAME_BUNDLE, apiName) - putInt(START_ACTION_BUNDLE, startAction) + putInt(START_ACTION_BUNDLE, startAction) + if (startValue != null) putInt(START_VALUE_BUNDLE, startValue) - putBoolean(RESTART_BUNDLE, true) - } - } - fun updateUI() { - updateUIListener?.invoke() - } - - private var updateUIListener: (() -> Unit)? = null - } - open fun setTrailers(trailers: List?) {} - - protected lateinit var viewModel: ResultViewModel2 //by activityViewModels() - protected lateinit var syncModel: SyncViewModel - protected open val resultLayout = R.layout.fragment_result_swipe - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - viewModel = - ViewModelProvider(this)[ResultViewModel2::class.java] - syncModel = - ViewModelProvider(this)[SyncViewModel::class.java] - - return inflater.inflate(resultLayout, container, false) - } - - private var downloadButton: EasyDownloadButton? = null - override fun onDestroyView() { - updateUIListener = null - (result_episodes?.adapter as EpisodeAdapter?)?.killAdapter() - downloadButton?.dispose() - - super.onDestroyView() + putBoolean(RESTART_BUNDLE, true) + } } - override fun onResume() { - afterPluginsLoadedEvent += ::reloadViewModel - super.onResume() - activity?.let { - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) + 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) } } - override fun onDestroy() { - afterPluginsLoadedEvent -= ::reloadViewModel - super.onDestroy() + fun updateUI(id: Int? = null) { + // updateUIListener?.invoke() + updateUIEvent.invoke(id) } - /// 0 = LOADING, 1 = ERROR LOADING, 2 = LOADED - private fun updateVisStatus(state: Int) { - when (state) { - 0 -> { - result_bookmark_fab?.isGone = true - result_loading?.isVisible = true - result_finish_loading?.isVisible = false - result_loading_error?.isVisible = false - } - 1 -> { - result_bookmark_fab?.isGone = true - result_loading?.isVisible = false - result_finish_loading?.isVisible = false - result_loading_error?.isVisible = true - result_reload_connection_open_in_browser?.isVisible = true - } - 2 -> { - result_bookmark_fab?.isGone = isTrueTvSettings() - result_bookmark_fab?.extend() - //if (result_bookmark_button?.context?.isTrueTvSettings() == true) { - // when { - // result_play_movie?.isVisible == true -> { - // result_play_movie?.requestFocus() - // } - // result_resume_series_button?.isVisible == true -> { - // result_resume_series_button?.requestFocus() - // } - // else -> { - // result_bookmark_button?.requestFocus() - // } - // } - //} + val updateUIEvent = Event() - result_loading?.isVisible = false - result_finish_loading?.isVisible = true - result_loading_error?.isVisible = false - } - } - } + //private var updateUIListener: (() -> Unit)? = null - open fun setRecommendations(rec: List?, validApiName: String?) { - } - - private fun updateUI() { - syncModel.updateUserData() - viewModel.reloadEpisodes() - } - - open fun updateMovie(data: ResourceSome>) { - when (data) { - is ResourceSome.Success -> { - data.value.let { (text, ep) -> - result_play_movie.setText(text) - result_play_movie?.setOnClickListener { - viewModel.handleAction( - activity, - EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) - ) - } - result_play_movie?.setOnLongClickListener { - viewModel.handleAction( - activity, - EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) - ) - return@setOnLongClickListener true - } + //protected open val resultLayout = R.layout.fragment_result_swipe - main { - val file = - ioWorkSafe { - context?.let { - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( - it, - ep.id - ) - } - } + /* override var layout = R.layout.fragment_result_swipe - downloadButton?.dispose() - downloadButton = EasyDownloadButton() - downloadButton?.setUpMoreButton( - file?.fileLength, - file?.totalBytes, - result_movie_progress_downloaded ?: return@main, - result_movie_download_icon ?: return@main, - result_movie_download_text ?: return@main, - result_movie_download_text_precentage ?: return@main, - result_download_movie ?: return@main, - true, - VideoDownloadHelper.DownloadEpisodeCached( - ep.name, - ep.poster, - 0, - null, - ep.id, - ep.id, - null, - null, - System.currentTimeMillis(), - ) - ) { click -> - when (click.action) { - DOWNLOAD_ACTION_DOWNLOAD -> { - viewModel.handleAction( - activity, - EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) - ) - } - else -> handleDownloadClick(activity, click) - } - } - result_movie_progress_downloaded_holder?.isVisible = true - } - } - } - else -> { - result_movie_progress_downloaded_holder?.isVisible = false - result_play_movie?.isVisible = false - } - } - } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { - open fun updateEpisodes(episodes: ResourceSome>) { - when (episodes) { - is ResourceSome.None -> { - result_episode_loading?.isVisible = false - result_episodes?.isVisible = false - } - is ResourceSome.Loading -> { - result_episode_loading?.isVisible = true - result_episodes?.isVisible = false - } - is ResourceSome.Success -> { - result_episodes?.isVisible = true - result_episode_loading?.isVisible = false + return super.onCreateView(inflater, container, savedInstanceState) + //return inflater.inflate(resultLayout, container, false) + } - /* - * 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 - */ + override fun onDestroyView() { + updateUIListener = null + super.onDestroyView() + } - // Do not use this.isTv, that is the player - val isTv = isTvSettings() - val hasEpisodes = - !(result_episodes?.adapter as? EpisodeAdapter?)?.cardList.isNullOrEmpty() + override fun onResume() { + afterPluginsLoadedEvent += ::reloadViewModel + super.onResume() + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + } - if (isTv && hasEpisodes) { - // Make it impossible to focus anywhere else! - temporary_no_focus?.isFocusable = true - temporary_no_focus?.requestFocus() - } + override fun onDestroy() { + afterPluginsLoadedEvent -= ::reloadViewModel + super.onDestroy() + } - (result_episodes?.adapter as? EpisodeAdapter?)?.updateList(episodes.value) - if (isTv && hasEpisodes) main { - delay(500) - temporary_no_focus?.isFocusable = false - // This might make some people sad as it changes the focus when leaving an episode :( - result_episodes?.requestFocus() - } - } - } - } + private fun updateUI() { + syncModel.updateUserData() + viewModel.reloadEpisodes() + }*/ data class StoredData( - val url: String?, + val url: String, val apiName: String, + val name: String, val showFillers: Boolean, val dubStatus: DubStatus, val start: AutoResume?, - val playerAction: Int + val playerAction: Int, + val restart: Boolean, ) - private fun getStoredData(context: Context): StoredData? { + 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) + 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() @@ -474,6 +292,11 @@ open class ResultFragment : ResultTrailerPlayer() { val playerAction = getPlayerAction(context) + val restart = arguments?.getBoolean(RESTART_BUNDLE) ?: false + if (restart) { + arguments?.putBoolean(RESTART_BUNDLE, false) + } + val start = startAction?.let { action -> val startValue = arguments?.getInt(START_VALUE_BUNDLE) val resumeEpisode = arguments?.getInt(EPISODE_BUNDLE) @@ -488,10 +311,10 @@ open class ResultFragment : ResultTrailerPlayer() { season = resumeSeason ) } - return StoredData(url, apiName, showFillers, dubStatus, start, playerAction) + return StoredData(url, apiName, name, showFillers, dubStatus, start, playerAction, restart) } - private fun reloadViewModel(forceReload: Boolean) { + /*private fun reloadViewModel(forceReload: Boolean) { if (!viewModel.hasLoaded() || forceReload) { val storedData = getStoredData(activity ?: context ?: return) ?: return @@ -510,7 +333,6 @@ open class ResultFragment : ResultTrailerPlayer() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - result_cast_items?.adapter = ActorAdaptor() updateUIListener = ::updateUI @@ -524,9 +346,6 @@ open class ResultFragment : ResultTrailerPlayer() { context?.updateHasTrailers() activity?.loadCache() - activity?.fixPaddingStatusbar(result_top_bar) - //activity?.fixPaddingStatusbar(result_barstatus) - /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams backParameter.setMargins( backParameter.leftMargin, @@ -536,494 +355,16 @@ open class ResultFragment : ResultTrailerPlayer() { ) result_back.layoutParams = backParameter*/ - // activity?.fixPaddingStatusbar(result_toolbar) - val storedData = (activity ?: context)?.let { getStoredData(it) } - syncModel.addFromUrl(storedData?.url) - - val api = getApiFromNameNull(storedData?.apiName) - - result_episodes?.adapter = - EpisodeAdapter( - api?.hasDownloadSupport == true, - { episodeClick -> - viewModel.handleAction(activity, episodeClick) - }, - { downloadClickEvent -> - handleDownloadClick(activity, downloadClickEvent) - } - ) - - - observe(viewModel.watchStatus) { watchType -> - result_bookmark_button?.text = getString(watchType.stringRes) - result_bookmark_fab?.text = getString(watchType.stringRes) - - if (watchType == WatchType.NONE) { - result_bookmark_fab?.context?.colorFromAttribute(R.attr.white) - } else { - result_bookmark_fab?.context?.colorFromAttribute(R.attr.colorPrimary) - }?.let { - val colorState = ColorStateList.valueOf(it) - result_bookmark_fab?.iconTint = colorState - result_bookmark_fab?.setTextColor(colorState) - } - - result_bookmark_fab?.setOnClickListener { fab -> - activity?.showBottomDialog( - WatchType.values().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]) - } - } - - result_bookmark_button?.setOnClickListener { fab -> - activity?.showBottomDialog( - WatchType.values().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]) - } - } - } // This is to band-aid FireTV navigation val isTv = isTvSettings() result_season_button?.isFocusableInTouchMode = isTv result_episode_select?.isFocusableInTouchMode = isTv result_dub_select?.isFocusableInTouchMode = isTv - - context?.let { ctx -> - val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - /* - -1 -> None - 0 -> Watching - 1 -> Completed - 2 -> OnHold - 3 -> Dropped - 4 -> PlanToWatch - 5 -> ReWatching - */ - val items = listOf( - R.string.none, - R.string.type_watching, - R.string.type_completed, - R.string.type_on_hold, - R.string.type_dropped, - R.string.type_plan_to_watch, - R.string.type_re_watching - ).map { ctx.getString(it) } - arrayAdapter.addAll(items) - result_sync_check?.choiceMode = AbsListView.CHOICE_MODE_SINGLE - result_sync_check?.adapter = arrayAdapter - UIHelper.setListViewHeightBasedOnItems(result_sync_check) - - result_sync_check?.setOnItemClickListener { _, _, which, _ -> - syncModel.setStatus(which - 1) - } - - result_sync_rating?.addOnChangeListener { _, value, _ -> - syncModel.setScore(value.toInt()) - } - - result_sync_add_episode?.setOnClickListener { - syncModel.setEpisodesDelta(1) - } - - result_sync_sub_episode?.setOnClickListener { - syncModel.setEpisodesDelta(-1) - } - - result_sync_current_episodes?.doOnTextChanged { text, _, before, count -> - if (count == before) return@doOnTextChanged - text?.toString()?.toIntOrNull()?.let { ep -> - syncModel.setEpisodes(ep) - } - } - } - - observe(syncModel.synced) { list -> - result_sync_names?.text = - list.filter { it.isSynced && it.hasAccount }.joinToString { it.name } - - val newList = list.filter { it.isSynced && it.hasAccount } - - result_mini_sync?.isVisible = newList.isNotEmpty() - (result_mini_sync?.adapter as? ImageAdapter?)?.updateList(newList.mapNotNull { it.icon }) - } - - var currentSyncProgress = 0 - - fun setSyncMaxEpisodes(totalEpisodes: Int?) { - result_sync_episodes?.max = (totalEpisodes ?: 0) * 1000 - - normalSafeApiCall { - val ctx = result_sync_max_episodes?.context - result_sync_max_episodes?.text = - totalEpisodes?.let { episodes -> - ctx?.getString(R.string.sync_total_episodes_some)?.format(episodes) - } ?: run { - ctx?.getString(R.string.sync_total_episodes_none) - } - } - } - - observe(syncModel.metadata) { meta -> - when (meta) { - is Resource.Success -> { - val d = meta.value - result_sync_episodes?.progress = currentSyncProgress * 1000 - setSyncMaxEpisodes(d.totalEpisodes) - - viewModel.setMeta(d, syncModel.getSyncs()) - } - is Resource.Loading -> { - result_sync_max_episodes?.text = - result_sync_max_episodes?.context?.getString(R.string.sync_total_episodes_none) - } - else -> {} - } - } - - observe(syncModel.userData) { status -> - var closed = false - when (status) { - is Resource.Failure -> { - result_sync_loading_shimmer?.stopShimmer() - result_sync_loading_shimmer?.isVisible = false - result_sync_holder?.isVisible = false - closed = true - } - is Resource.Loading -> { - result_sync_loading_shimmer?.startShimmer() - result_sync_loading_shimmer?.isVisible = true - result_sync_holder?.isVisible = false - } - is Resource.Success -> { - result_sync_loading_shimmer?.stopShimmer() - result_sync_loading_shimmer?.isVisible = false - result_sync_holder?.isVisible = true - - val d = status.value - result_sync_rating?.value = d.score?.toFloat() ?: 0.0f - result_sync_check?.setItemChecked(d.status + 1, true) - val watchedEpisodes = d.watchedEpisodes ?: 0 - currentSyncProgress = watchedEpisodes - - d.maxEpisodes?.let { - // don't directly call it because we don't want to override metadata observe - setSyncMaxEpisodes(it) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - result_sync_episodes?.setProgress(watchedEpisodes * 1000, true) - } else { - result_sync_episodes?.progress = watchedEpisodes * 1000 - } - result_sync_current_episodes?.text = - Editable.Factory.getInstance()?.newEditable(watchedEpisodes.toString()) - normalSafeApiCall { // format might fail - context?.getString(R.string.sync_score_format)?.format(d.score ?: 0)?.let { - result_sync_score_text?.text = it - } - } - } - null -> { - closed = false - } - } - result_overlapping_panels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) - } - - observe(viewModel.resumeWatching) { resume -> - when (resume) { - is Some.Success -> { - result_resume_parent?.isVisible = true - val value = resume.value - value.progress?.let { progress -> - result_resume_series_title?.apply { - isVisible = !value.isMovie - text = - if (value.isMovie) null else activity?.getNameFull( - value.result.name, - value.result.episode, - value.result.season - ) - } - result_resume_series_progress_text.setText(progress.progressLeft) - result_resume_series_progress?.apply { - isVisible = true - this.max = progress.maxProgress - this.progress = progress.progress - } - result_resume_progress_holder?.isVisible = true - } ?: run { - result_resume_progress_holder?.isVisible = false - result_resume_series_progress?.isVisible = false - result_resume_series_title?.isVisible = false - result_resume_series_progress_text?.isVisible = false - } - - result_resume_series_button?.isVisible = !value.isMovie - result_resume_series_button_play?.isVisible = !value.isMovie - - val click = View.OnClickListener { - viewModel.handleAction( - activity, - EpisodeClickEvent( - storedData?.playerAction ?: ACTION_PLAY_EPISODE_IN_PLAYER, - value.result - ) - ) - } - - result_resume_series_button?.setOnClickListener(click) - result_resume_series_button_play?.setOnClickListener(click) - } - is Some.None -> { - result_resume_parent?.isVisible = false - } - } - } - - observe(viewModel.episodes) { episodes -> - updateEpisodes(episodes) - } - - result_cast_items?.setOnFocusChangeListener { _, hasFocus -> - // Always escape focus - if (hasFocus) result_bookmark_button?.requestFocus() - } - - result_sync_set_score?.setOnClickListener { - syncModel.publishUserData() - } - - observe(viewModel.trailers) { trailers -> - setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! - } - - observe(viewModel.recommendations) { recommendations -> - setRecommendations(recommendations, null) - } - - observe(viewModel.movie) { data -> - updateMovie(data) - } - - observe(viewModel.page) { data -> - when (data) { - is Resource.Success -> { - val d = data.value - - updateVisStatus(2) - - result_vpn.setText(d.vpnText) - result_info.setText(d.metaText) - result_no_episodes.setText(d.noEpisodesFoundText) - result_title.setText(d.titleText) - result_meta_site.setText(d.apiName) - result_meta_type.setText(d.typeText) - result_meta_year.setText(d.yearText) - result_meta_duration.setText(d.durationText) - result_meta_rating.setText(d.ratingText) - result_cast_text.setText(d.actorsText) - result_next_airing.setText(d.nextAiringEpisode) - result_next_airing_time.setText(d.nextAiringDate) - result_poster.setImage(d.posterImage) - result_poster_background.setImage(d.posterBackgroundImage) - //result_trailer_thumbnail.setImage(d.posterBackgroundImage, fadeIn = false) - - if (d.posterImage != null && !isTrueTvSettings()) - result_poster_holder?.setOnClickListener { - try { - context?.let { ctx -> - runBlocking { - val sourceBuilder = AlertDialog.Builder(ctx) - sourceBuilder.setView(R.layout.result_poster) - - val sourceDialog = sourceBuilder.create() - sourceDialog.show() - - sourceDialog.findViewById(R.id.imgPoster) - ?.apply { - setImage(d.posterImage) - setOnClickListener { - sourceDialog.dismissSafe() - } - } - } - } - } catch (e: Exception) { - logError(e) - } - } - - - result_cast_items?.isVisible = d.actors != null - (result_cast_items?.adapter as ActorAdaptor?)?.apply { - updateList(d.actors ?: emptyList()) - } - - result_open_in_browser?.isVisible = d.url.startsWith("http") - result_open_in_browser?.setOnClickListener { - val i = Intent(ACTION_VIEW) - i.data = Uri.parse(d.url) - try { - startActivity(i) - } catch (e: Exception) { - logError(e) - } - } - - result_search?.setOnClickListener { - QuickSearchFragment.pushSearch(activity, d.title) - } - - result_share?.setOnClickListener { - try { - val i = Intent(ACTION_SEND) - i.type = "text/plain" - i.putExtra(EXTRA_SUBJECT, d.title) - i.putExtra(EXTRA_TEXT, d.url) - startActivity(createChooser(i, d.title)) - } catch (e: Exception) { - logError(e) - } - } - - if (syncModel.addSyncs(d.syncData)) { - syncModel.updateMetaAndUser() - syncModel.updateSynced() - } else { - syncModel.addFromUrl(d.url) - } - - result_description.setTextHtml(d.plotText) - if (this !is ResultFragmentTv) // dont want this clickable on tv layout - result_description?.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() - } - } - - - result_tag?.removeAllViews() - - d.comingSoon.let { soon -> - result_coming_soon?.isVisible = soon - result_data_holder?.isGone = soon - } - - val tags = d.tags - result_tag_holder?.isVisible = tags.isNotEmpty() - result_tag?.apply { - tags.forEach { tag -> - val chip = Chip(context) - val chipDrawable = ChipDrawable.createFromAttributes( - context, - null, - 0, - R.style.ChipFilled - ) - chip.setChipDrawable(chipDrawable) - chip.text = tag - chip.isChecked = false - chip.isCheckable = false - chip.isFocusable = false - chip.isClickable = false - addView(chip) - } - } - // if (tags.isNotEmpty()) { - //result_tag_holder?.visibility = VISIBLE - //val isOnTv = isTrueTvSettings() - - - /*for ((index, tag) in tags.withIndex()) { - val viewBtt = layoutInflater.inflate(R.layout.result_tag, null) - val btt = viewBtt.findViewById(R.id.result_tag_card) - btt.text = tag - btt.isFocusable = !isOnTv - btt.isClickable = !isOnTv - result_tag?.addView(viewBtt, index) - }*/ - //} - } - is Resource.Failure -> { - result_error_text.text = storedData?.url?.plus("\n") + data.errorString - updateVisStatus(1) - } - is Resource.Loading -> { - updateVisStatus(0) - } - } - } - - context?.let { ctx -> - - //result_bookmark_button?.isVisible = ctx.isTvSettings() - - val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - - - Kitsu.isEnabled = - settingsManager.getBoolean(ctx.getString(R.string.show_kitsu_posters_key), true) - if (storedData?.url != null) { - result_reload_connectionerror.setOnClickListener { - viewModel.load( - activity, - storedData.url, - storedData.apiName, - storedData.showFillers, - storedData.dubStatus, - storedData.start - ) - } - - result_reload_connection_open_in_browser?.setOnClickListener { - val i = Intent(ACTION_VIEW) - i.data = Uri.parse(storedData.url) - try { - startActivity(i) - } catch (e: Exception) { - logError(e) - } - } - - result_open_in_browser?.isVisible = storedData.url.startsWith("http") - result_open_in_browser?.setOnClickListener { - val i = Intent(ACTION_VIEW) - i.data = Uri.parse(storedData.url) - try { - startActivity(i) - } catch (e: Exception) { - logError(e) - } - } - - // bloats the navigation on tv - if (!isTrueTvSettings()) { - result_meta_site?.setOnClickListener { - it.context?.openBrowser(storedData.url) - } - result_meta_site?.isFocusable = true - } else { - result_meta_site?.isFocusable = false - } - if (restart || !viewModel.hasLoaded()) { //viewModel.clear() viewModel.load( @@ -1036,6 +377,5 @@ open class ResultFragment : ResultTrailerPlayer() { ) } } - } - } + }*/ } 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 9bae87534f3..38b24b26517 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -1,57 +1,232 @@ package com.lagradost.cloudstream3.ui.result +import android.annotation.SuppressLint 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.View import android.view.ViewGroup import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.DecelerateInterpolator +import android.widget.AbsListView +import android.widget.ArrayAdapter import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import com.discord.panels.OverlappingPanelsLayout +import com.discord.panels.PanelState import com.discord.panels.PanelsChildGestureRegionObserver import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext -import com.google.android.gms.cast.framework.CastState import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.APIHolder.updateHasTrailers -import com.lagradost.cloudstream3.mvvm.Some +import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.base64Encode +import com.lagradost.cloudstream3.databinding.FragmentResultBinding +import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding +import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding +import com.lagradost.cloudstream3.databinding.ResultSyncBinding +import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.services.SubscriptionWorkManager +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup +import com.lagradost.cloudstream3.ui.player.CS3IPlayer import com.lagradost.cloudstream3.ui.player.CSPlayerEvent +import com.lagradost.cloudstream3.ui.player.IPlayer +import com.lagradost.cloudstream3.ui.player.PlayerView +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog +import com.lagradost.cloudstream3.ui.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.isCastApiAvailable -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.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 kotlinx.android.synthetic.main.fragment_result.* -import kotlinx.android.synthetic.main.fragment_result.result_cast_items -import kotlinx.android.synthetic.main.fragment_result.result_episodes_text -import kotlinx.android.synthetic.main.fragment_result.result_resume_parent -import kotlinx.android.synthetic.main.fragment_result.result_scroll -import kotlinx.android.synthetic.main.fragment_result.result_smallscreen_holder -import kotlinx.android.synthetic.main.fragment_result_swipe.* -import kotlinx.android.synthetic.main.fragment_result_swipe.result_back -import kotlinx.android.synthetic.main.fragment_result_tv.* -import kotlinx.android.synthetic.main.fragment_trailer.* -import kotlinx.android.synthetic.main.result_recommendations.* -import kotlinx.android.synthetic.main.result_recommendations.result_recommendations -import kotlinx.android.synthetic.main.trailer_custom_layout.* - - -class ResultFragmentPhone : ResultFragment() { - var currentTrailers: List = emptyList() +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) + } + } + + /** 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 resultBinding: FragmentResultBinding? = null + protected var recommendationBinding: ResultRecommendationsBinding? = null + protected var syncBinding: ResultSyncBinding? = null + + var player: IPlayer = CS3IPlayer() + protected open var hasPipModeSupport: Boolean = false + protected open var isFullScreenPlayer: Boolean = true + protected open var lockRotation: Boolean = true + protected var playerBinding: TrailerCustomLayoutBinding? = null + protected var isShowing: Boolean = false + + protected var playerHostView: PlayerView? = null + + open fun updateUIVisibility() {} + + protected fun uiReset() { + isShowing = false + updateUIVisibility() + } + + open fun showMirrorsDialogue() {} + open fun showTracksDialogue() {} + open fun openOnlineSubPicker( + context: android.content.Context, + loadResponse: LoadResponse?, + dismissCallback: () -> Unit + ) {} + + 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() { @@ -63,52 +238,75 @@ class ResultFragmentPhone : ResultFragment() { 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()) - result_trailer_loading?.isVisible = isSuccess + // result_trailer_loading?.isVisible = isSuccess val turnVis = !isSuccess && !isFullScreenPlayer - result_smallscreen_holder?.isVisible = turnVis - result_poster_background_holder?.apply { - val fadeIn: Animation = AlphaAnimation(alpha, if (turnVis) 1.0f else 0.0f).apply { - interpolator = DecelerateInterpolator() - duration = 200 - fillAfter = true + 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 { + interpolator = DecelerateInterpolator() + duration = 200 + fillAfter = true + } + clearAnimation() + startAnimation(fadeIn) } - clearAnimation() - startAnimation(fadeIn) - } + // We don't want the trailer to be focusable if it's not visible + resultSmallscreenHolder.descendantFocusability = if (isSuccess) { + ViewGroup.FOCUS_AFTER_DESCENDANTS + } else { + ViewGroup.FOCUS_BLOCK_DESCENDANTS + } + binding?.resultFullscreenHolder?.isVisible = !isSuccess && isFullScreenPlayer + } //player_view?.apply { //alpha = 0.0f //ObjectAnimator.ofFloat(player_view, "alpha", 1f).apply { @@ -122,34 +320,33 @@ class ResultFragmentPhone : ResultFragment() { // fillAfter = true //} //startAnimation(fadeIn) - // } - - // We don't want the trailer to be focusable if it's not visible - result_smallscreen_holder?.descendantFocusability = if (isSuccess) { - ViewGroup.FOCUS_AFTER_DESCENDANTS - } else { - ViewGroup.FOCUS_BLOCK_DESCENDANTS - } - result_fullscreen_holder?.isVisible = !isSuccess && isFullScreenPlayer + //} } - override 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 -> - result_cast_items?.let { + resultBinding?.resultCastItems?.let { obs.unregister(it) } - obs.removeGestureRegionsUpdateListener(this) + + obs.removeGestureRegionsUpdateListener(gestureRegionsListener) } + updateUIEvent -= ::updateUI + playerHostView?.release() + playerBinding = null + resultBinding?.resultScroll?.setOnClickListener(null) + resultBinding = null + syncBinding = null + recommendationBinding = null + activity?.detachBackPressedCallback(this@ResultFragmentPhone.toString()) super.onDestroyView() } @@ -167,39 +364,356 @@ class ResultFragmentPhone : ResultFragment() { } var selectSeason: String? = null + var selectEpisodeRange: String? = null - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - val apiName = arguments?.getString(API_NAME_BUNDLE) ?: return + private fun setUrl(url: String?) { + if (url == null) { + binding?.resultOpenInBrowser?.isVisible = false + return + } - super.onViewCreated(view, savedInstanceState) + val valid = url.startsWith("http") - player_open_source?.setOnClickListener { - currentTrailers.getOrNull(currentTrailerIndex)?.let { - context?.openBrowser(it.url) + binding?.resultOpenInBrowser?.apply { + isVisible = valid + setOnClickListener { + context?.openBrowser(url) } } - result_overlapping_panels?.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) - result_overlapping_panels?.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) - result_recommendations?.spanCount = 3 - result_recommendations?.adapter = - SearchAdapter( - ArrayList(), - result_recommendations, - ) { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) + resultBinding?.resultReloadConnectionOpenInBrowser?.setOnClickListener { + view?.context?.openBrowser(url) + } + + resultBinding?.resultMetaSite?.setOnClickListener { + view?.context?.openBrowser(url) + } + } + + private fun reloadViewModel(forceReload: Boolean) { + if (!viewModel.hasLoaded() || forceReload) { + val storedData = getStoredData() ?: return + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + } + } + + override fun onResume() { + afterPluginsLoadedEvent += ::reloadViewModel + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + context?.let { ctx -> + playerHostView?.onResume(ctx) + playerHostView?.setupKeyEventListener() + } + super.onResume() + PanelsChildGestureRegionObserver.Provider.get() + .addGestureRegionsUpdateListener(gestureRegionsListener) + } + + override fun onStop() { + afterPluginsLoadedEvent -= ::reloadViewModel + playerHostView?.onStop() + super.onStop() + } + + @Suppress("UNUSED_PARAMETER") + private fun updateUI(id: Int?) { + syncModel.updateUserData() + viewModel.reloadEpisodes() + } + + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + // Set up sub-binding references + viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] + syncModel = ViewModelProvider(this)[SyncViewModel::class.java] + updateUIEvent += ::updateUI + + resultBinding = binding.fragmentResult + recommendationBinding = binding.resultRecommendations + syncBinding = binding.resultSync + + // Set up trailer player + val ctx = context ?: return + playerHostView = PlayerView(ctx) + playerHostView?.player = player + playerHostView?.hasPipModeSupport = hasPipModeSupport + playerHostView?.callbacks = this + playerHostView?.bindViews(binding.root) + playerBinding = binding.root.findViewById(R.id.player_holder)?.let { + TrailerCustomLayoutBinding.bind(it) + } + playerHostView?.initialize() + + // ===== setup ===== + val storedData = getStoredData() ?: return + activity?.window?.decorView?.clearFocus() + activity?.loadCache() + context?.updateHasTrailers() + hideKeyboard(binding.root) + if (storedData.restart || !viewModel.hasLoaded()) + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + + setUrl(storedData.url) + syncModel.addFromUrl(storedData.url) + val api = APIHolder.getApiFromNameNull(storedData.apiName) + + // 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) } + } + } } - PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this) + ) + + // ===== ===== ===== - result_cast_items?.let { - PanelsChildGestureRegionObserver.Provider.get().register(it) + binding.resultSearch.isGone = storedData.name.isBlank() + binding.resultSearch.setOnClickListener { + QuickSearchFragment.pushSearch(activity, storedData.name) } + resultBinding?.apply { + resultReloadConnectionerror.setOnClickListener { + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + } - result_back?.setOnClickListener { - activity?.popCurrentPage() + resultCastItems.setLinearListLayout( + isHorizontal = true, + nextLeft = FOCUS_SELF, + nextRight = FOCUS_SELF + ) + /*resultCastItems.layoutManager = object : LinearListLayout(view.context) { + override fun onRequestChildFocus( + parent: RecyclerView, + state: RecyclerView.State, + child: View, + focused: View? + ): Boolean { + // Make the cast always focus the first visible item when focused + // from somewhere else. Otherwise, it jumps to the last item. + return if (parent.focusedChild == null) { + scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) + true + } else { + super.onRequestChildFocus(parent, state, child, focused) + } + } + }.apply { + this.orientation = RecyclerView.HORIZONTAL + }*/ + resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) + resultCastItems.adapter = ActorAdaptor() + resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) + resultEpisodes.adapter = + EpisodeAdapter( + api?.hasDownloadSupport == true, + { episodeClick -> + when (episodeClick.action) { + ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> { + requirePathForActions(listOf(episodeClick.action to episodeClick.data)) + } + + else -> viewModel.handleAction(episodeClick) + } + }, + { 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() + } else if (dy < -5) { + binding.resultBookmarkFab.extend() + } + if (!isFullScreenPlayer && player.getIsPlaying()) { + if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height + ?: scrollY) + ) { + player.handleEvent(CSPlayerEvent.Pause) + } + } + }) + } + + binding.apply { + resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) + resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) + resultBack.setOnClickListener { + activity?.popCurrentPage() + } + + activity?.attachBackPressedCallback(this@ResultFragmentPhone.toString()) { + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + runDefault() + } else resultOverlappingPanels.closePanels() + } + + resultMiniSync.setOnClickListener { + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + resultOverlappingPanels.openStartPanel() + } else resultOverlappingPanels.closePanels() + } + + /* + resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool) + resultMiniSync.adapter = ImageAdapter( + nextFocusDown = R.id.result_sync_set_score, + clickCallback = { action -> + if (action == IMAGE_CLICK || action == IMAGE_LONG_CLICK) { + if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + resultOverlappingPanels.openStartPanel() + } else resultOverlappingPanels.closePanels() + } + }) + */ + resultSubscribe.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 + ?: 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 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 { + showToast( + R.string.no_chromecast_support_toast, + Toast.LENGTH_LONG + ) + } + } + activity?.let { act -> + if (act.isCastApiAvailable()) { + try { + CastButtonFactory.setUpMediaRouteButton(act, this) + 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 + //} + } catch (e: Exception) { + logError(e) + } + } + } + } + } + + playerBinding?.apply { + playerOpenSource.setOnClickListener { + currentTrailers.getOrNull(currentTrailerIndex)?.let { (_, ogTrailerLink) -> + context?.openBrowser(ogTrailerLink) + } + } + } + + recommendationBinding?.apply { + resultRecommendationsList.apply { + spanCount = 3 + setRecycledViewPool(SearchAdapter.sharedPool) + adapter = + SearchAdapter( + this, + ) { callback -> + SearchHelper.handleSearchClickCallback(callback) + } + } } + /* result_bookmark_button?.setOnClickListener { it.popupMenuNoIcons( @@ -211,196 +725,674 @@ class ResultFragmentPhone : ResultFragment() { } }*/ - result_mini_sync?.adapter = ImageAdapter( - R.layout.result_mini_image, - nextFocusDown = R.id.result_sync_set_score, - clickCallback = { action -> - if (action == IMAGE_CLICK || action == IMAGE_LONG_CLICK) { - if (result_overlapping_panels?.getSelectedPanel()?.ordinal == 1) { - result_overlapping_panels?.openStartPanel() - } else { - result_overlapping_panels?.closePanels() + observeNullable(viewModel.resumeWatching) { resume -> + 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 = + if (resume.isMovie) null else context?.getNameFull( + resume.result.name, + resume.result.episode, + resume.result.season + ) + } + if (resume.isMovie) { + resultPlayParent.isGone = true + resultResumeSeriesProgressText.isVisible = true + resultResumeSeriesProgressText.setText(progress.progressLeft) } + resultResumeSeriesProgress.apply { + isVisible = true + this.max = progress.maxProgress + this.progress = progress.progress + } + 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.setOnClickListener { + resumeAction(storedData, resume) + } + resultNextSeriesButton.setOnClickListener { + resumeAction(storedData, resume) + } + } + } + + observeNullable(viewModel.subscribeStatus) { isSubscribed -> + binding.resultSubscribe.isVisible = isSubscribed != null + if (isSubscribed == null) return@observeNullable + + val drawable = if (isSubscribed) { + R.drawable.ic_baseline_notifications_active_24 + } else { + R.drawable.baseline_notifications_none_24 + } + + binding.resultSubscribe.setImageResource(drawable) + } + + observeNullable(viewModel.favoriteStatus) { isFavorite -> + binding.resultFavorite.isVisible = isFavorite != null + if (isFavorite == null) return@observeNullable - result_scroll?.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> - val dy = scrollY - oldScrollY - if (dy > 0) { //check for scroll down - result_bookmark_fab?.shrink() - } else if (dy < -5) { - result_bookmark_fab?.extend() + val drawable = if (isFavorite) { + R.drawable.ic_baseline_favorite_24 + } else { + R.drawable.ic_baseline_favorite_border_24 } - if (!isFullScreenPlayer && player.getIsPlaying()) { - if (scrollY > (player_background?.height ?: scrollY)) { - player.handleEvent(CSPlayerEvent.Pause) + + binding.resultFavorite.setImageResource(drawable) + } + + observeNullable(viewModel.episodes) { episodes -> + resultBinding?.apply { + // no failure? + resultEpisodeLoading.isVisible = episodes is Resource.Loading + resultEpisodes.isVisible = episodes is Resource.Success + resultBatchDownloadButton.isVisible = + episodes is Resource.Success && episodes.value.isNotEmpty() + + if (episodes is Resource.Success) { + (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) + + // Show quality dialog with all sources + resultBatchDownloadButton.setOnLongClickListener { + ioSafe { + val defaultSources = QualityProfileDialog.getAllDefaultSources() + val activity = activity ?: return@ioSafe + activity.runOnUiThread { + QualityProfileDialog( + activity, + R.style.DialogFullscreenPlayer, + defaultSources, + ).show() + } + } + + true + } + + resultBatchDownloadButton.setOnClickListener { view -> + val episodeStart = + episodes.value.firstOrNull()?.episode ?: return@setOnClickListener + val episodeEnd = + episodes.value.lastOrNull()?.episode ?: return@setOnClickListener + + val episodeRange = if (episodeStart == episodeEnd) { + episodeStart.toString() + } else { + txt( + R.string.episodes_range, + episodeStart, + episodeEnd + ).asString(view.context) + } + + val rangeMessage = txt( + R.string.download_episode_range, + episodeRange + ).asString(view.context) + + AlertDialog.Builder(view.context, R.style.AlertDialogCustom) + .setTitle(R.string.download_all) + .setMessage(rangeMessage) + .setPositiveButton(R.string.yes) { _, _ -> + requirePathForActions(episodes.value.map { ACTION_DOWNLOAD_EPISODE to it }) + } + .setNegativeButton(R.string.cancel) { _, _ -> }.show() + } } } - //result_poster_blur_holder?.translationY = -scrollY.toFloat() - }) - val api = APIHolder.getApiFromNameNull(apiName) + } - if (media_route_button != null) { - val chromecastSupport = api?.hasChromecastSupport == true - media_route_button?.alpha = if (chromecastSupport) 1f else 0.3f - if (!chromecastSupport) { - media_route_button?.setOnClickListener { - CommonActivity.showToast( - activity, - R.string.no_chromecast_support_toast, - Toast.LENGTH_LONG - ) + observeNullable(viewModel.movie) { data -> + resultBinding?.apply { + resultPlayMovie.isVisible = data is Resource.Success + downloadButton.isVisible = + data is Resource.Success && viewModel.currentRepo?.api?.hasDownloadSupport == true + + (data as? Resource.Success)?.value?.let { (text, ep) -> + resultPlayMovie.setText(text) + resultPlayMovie.setOnClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) + ) + } + resultPlayMovie.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) + ) + return@setOnLongClickListener true + } + resultResumeSeriesButton.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) + ) + return@setOnLongClickListener true + } + + val status = VideoDownloadManager.downloadStatus[ep.id] + downloadButton.setStatus(status) + downloadButton.setDefaultClickListener( + DownloadObjects.DownloadEpisodeCached( + 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 -> { + requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep)) + } + + DOWNLOAD_ACTION_LONG_CLICK -> { + requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep)) + } + + else -> DownloadButtonSetup.handleDownloadClick(click) + } + } } } - activity?.let { act -> - if (act.isCastApiAvailable()) { - try { - CastButtonFactory.setUpMediaRouteButton(act, media_route_button) - val castContext = CastContext.getSharedInstance(act.applicationContext) - media_route_button?.isGone = - castContext.castState == CastState.NO_DEVICES_AVAILABLE - // this shit leaks for some reason - //castContext.addCastStateListener { state -> - // media_route_button?.isGone = state == CastState.NO_DEVICES_AVAILABLE - //} - } catch (e: Exception) { - logError(e) + } + + 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) + resultNoEpisodes.setText(d.noEpisodesFoundText) + resultTitle.setText(d.titleText) + resultMetaSite.setText(d.apiName) + resultMetaType.setText(d.typeText) + 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.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 + } + } + + populateChips(resultTag, d.tags) + + resultComingSoon.isVisible = d.comingSoon + resultDataHolder.isGone = d.comingSoon + + val prefs = + androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) + val showCast = prefs.getBoolean( + root.context.getString(R.string.show_cast_in_details_key), + true + ) + + resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() + (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) + + 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() + syncModel.updateSynced() + } else { + syncModel.addFromUrl(d.url) + } + + binding.apply { + resultSearch.isGone = d.title.isBlank() + resultSearch.setOnClickListener { + QuickSearchFragment.pushSearch(activity, d.title) + } + + resultShare.setOnClickListener { + try { + val i = Intent(Intent.ACTION_SEND) + val nameBase64 = + base64Encode(d.apiName.toString().toByteArray(Charsets.UTF_8)) + val urlBase64 = base64Encode(d.url.toByteArray(Charsets.UTF_8)) + val encodedUri = URLEncoder.encode( + "$APP_STRING_SHARE:$nameBase64?$urlBase64", + "UTF-8" + ) + val redirectUrl = + "https://recloudstream.github.io/csredirect?redirectto=$encodedUri" + i.type = "text/plain" + i.putExtra(Intent.EXTRA_SUBJECT, d.title) + i.putExtra(Intent.EXTRA_TEXT, redirectUrl) + startActivity(Intent.createChooser(i, d.title)) + } catch (e: Exception) { + logError(e) + } + } + setUrl(d.url) + resultBookmarkFab.apply { + isVisible = true + extend() + } + } + } + + (data as? Resource.Failure)?.let { data -> + @SuppressLint("SetTextI18n") + resultErrorText.text = storedData.url.plus("\n") + data.errorString + } + + binding.resultBookmarkFab.isVisible = data is Resource.Success + resultFinishLoading.isVisible = data is Resource.Success + + resultLoading.isVisible = data is Resource.Loading + + 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 } } } - observe(viewModel.episodesCountText) { count -> - result_episodes_text.setText(count) + observeNullable(viewModel.episodesCountText) { count -> + resultBinding?.resultEpisodesText.setText(count) } - observe(viewModel.selectPopup) { popup -> - when (popup) { - is Some.Success -> { - popupDialog?.dismissSafe(activity) + observeNullable(viewModel.selectPopup) { popup -> + if (popup == null) { + popupDialog?.dismissSafe(activity) + popupDialog = null + return@observeNullable + } + popupDialog?.dismissSafe(activity) - popupDialog = activity?.let { act -> - val pop = popup.value - val options = pop.getOptions(act) - val title = pop.getTitle(act) + popupDialog = activity?.let { act -> + val options = popup.getOptions(act) + val title = popup.getTitle(act) - act.showBottomDialogInstant( - options, title, { - popupDialog = null - pop.callback(null) - }, { - popupDialog = null - pop.callback(it) - } - ) + act.showBottomDialogInstant( + options, title, { + popupDialog = null + popup.callback(null) + }, { + popupDialog = null + popup.callback(it) } + ) + } + } + + 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() + } + + + var currentSyncProgress = 0 + fun setSyncMaxEpisodes(totalEpisodes: Int?) { + syncBinding?.resultSyncEpisodes?.max = (totalEpisodes ?: 0) * 1000 + + safe { + val ctx = syncBinding?.resultSyncEpisodes?.context + syncBinding?.resultSyncMaxEpisodes?.text = + totalEpisodes?.let { episodes -> + ctx?.getString(R.string.sync_total_episodes_some)?.format(episodes) + } ?: run { + ctx?.getString(R.string.sync_total_episodes_none) + } + } + } + observe(syncModel.metadata) { meta -> + when (meta) { + is Resource.Success -> { + val d = meta.value + syncBinding?.resultSyncEpisodes?.progress = currentSyncProgress * 1000 + setSyncMaxEpisodes(d.totalEpisodes) + + viewModel.setMeta(d, syncModel.getSyncs()) } - is Some.None -> { - popupDialog?.dismissSafe(activity) - popupDialog = null + + is Resource.Loading -> { + syncBinding?.resultSyncMaxEpisodes?.text = + syncBinding?.resultSyncMaxEpisodes?.context?.getString(R.string.sync_total_episodes_none) } + + else -> {} } } - observe(viewModel.loadedLinks) { load -> - when (load) { - is Some.Success -> { - if (loadingDialog?.isShowing != true) { - loadingDialog?.dismissSafe(activity) - loadingDialog = null + + observe(syncModel.userData) { status -> + var closed = false + syncBinding?.apply { + when (status) { + is Resource.Failure -> { + resultSyncLoadingShimmer.stopShimmer() + resultSyncLoadingShimmer.isVisible = false + resultSyncHolder.isVisible = false + closed = true + } + + is Resource.Loading -> { + resultSyncLoadingShimmer.startShimmer() + resultSyncLoadingShimmer.isVisible = true + resultSyncHolder.isVisible = false } - loadingDialog = loadingDialog ?: context?.let { ctx -> - val builder = - BottomSheetDialog(ctx) - builder.setContentView(R.layout.bottom_loading) - builder.setOnDismissListener { - loadingDialog = null - viewModel.cancelLinks() + + is Resource.Success -> { + resultSyncLoadingShimmer.stopShimmer() + resultSyncLoadingShimmer.isVisible = false + resultSyncHolder.isVisible = true + + val d = status.value + val desiredScore = d.score?.toFloat(1) ?: 0.0f + val totalSteps = (resultSyncRating.valueTo / resultSyncRating.stepSize) + val desiredStep = (totalSteps * desiredScore).roundToInt() + resultSyncRating.value = desiredStep * resultSyncRating.stepSize + + resultSyncCheck.setItemChecked(d.status.internalId + 1, true) + val watchedEpisodes = d.watchedEpisodes ?: 0 + currentSyncProgress = watchedEpisodes + + d.maxEpisodes?.let { + // don't directly call it because we don't want to override metadata observe + setSyncMaxEpisodes(it) } - //builder.setOnCancelListener { - // it?.dismiss() - //} - builder.setCanceledOnTouchOutside(true) - builder.show() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + resultSyncEpisodes.setProgress(watchedEpisodes * 1000, true) + } else { + resultSyncEpisodes.progress = watchedEpisodes * 1000 + } + resultSyncCurrentEpisodes.text = + Editable.Factory.getInstance()?.newEditable(watchedEpisodes.toString()) + safe { // format might fail + val text = d.score?.toFloat(10)?.roundToInt()?.let { + context?.getString(R.string.sync_score_format)?.format(it) + } ?: "?" + resultSyncScoreText.text = text + } + } + + null -> { + closed = false + } + } + } + binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + } + observe(viewModel.recommendations) { recommendations -> + setRecommendations(recommendations, null) + } + context?.let { ctx -> + val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + /* + -1 -> None + 0 -> Watching + 1 -> Completed + 2 -> OnHold + 3 -> Dropped + 4 -> PlanToWatch + 5 -> ReWatching + */ + val items = listOf( + R.string.none, + R.string.type_watching, + R.string.type_completed, + R.string.type_on_hold, + R.string.type_dropped, + R.string.type_plan_to_watch, + R.string.type_re_watching + ).map { ctx.getString(it) } + arrayAdapter.addAll(items) + syncBinding?.apply { + resultSyncCheck.choiceMode = AbsListView.CHOICE_MODE_SINGLE + resultSyncCheck.adapter = arrayAdapter + setListViewHeightBasedOnItems(resultSyncCheck) + + resultSyncCheck.setOnItemClickListener { _, _, which, _ -> + syncModel.setStatus(which - 1) + } + + resultSyncRating.addOnChangeListener { it, value, fromUser -> + if (fromUser) syncModel.setScore(Score.from(value, it.valueTo.roundToInt())) + } + + resultSyncAddEpisode.setOnClickListener { + syncModel.setEpisodesDelta(1) + } + + resultSyncSubEpisode.setOnClickListener { + syncModel.setEpisodesDelta(-1) + } + + resultSyncCurrentEpisodes.doOnTextChanged { text, _, before, count -> + if (count == before) return@doOnTextChanged + text?.toString()?.toIntOrNull()?.let { ep -> + syncModel.setEpisodes(ep) + } + } + } + } + + syncBinding?.resultSyncSetScore?.setOnClickListener { + syncModel.publishUserData() + } + + observe(viewModel.watchStatus) { watchType -> + binding.resultBookmarkFab.apply { + setText(watchType.stringRes) + if (watchType == WatchType.NONE) { + context?.colorFromAttribute(R.attr.white) + } else { + context?.colorFromAttribute(R.attr.colorPrimary) + }?.let { + val colorState = ColorStateList.valueOf(it) + iconTint = colorState + setTextColor(colorState) + } - builder + setOnClickListener { fab -> + activity?.showBottomDialog( + 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.entries[it], context) } } - is Some.None -> { - loadingDialog?.dismissSafe(activity) + } + } + + + observeNullable(viewModel.loadedLinks) { load -> + if (load == null) { + loadingDialog?.dismissSafe(activity) + loadingDialog = null + return@observeNullable + } + if (loadingDialog?.isShowing != true) { + loadingDialog?.dismissSafe(activity) + loadingDialog = null + } + loadingDialog = loadingDialog ?: context?.let { ctx -> + val builder = BottomSheetDialog(ctx) + builder.setContentView(R.layout.bottom_loading) + builder.setOnDismissListener { loadingDialog = null + viewModel.cancelLinks() + } + 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})" } } } - observe(viewModel.selectedSeason) { text -> - result_season_button.setText(text) + observeNullable(viewModel.selectedSeason) { text -> + resultBinding?.apply { + resultSeasonButton.setText(text) - selectSeason = - (if (text is Some.Success) text.value else null)?.asStringNull(result_season_button?.context) - // If the season button is visible the result season button will be next focus down - if (result_season_button?.isVisible == true) - if (result_resume_parent?.isVisible == true) - setFocusUpAndDown(result_resume_series_button, result_season_button) - //else - // setFocusUpAndDown(result_bookmark_button, result_season_button) + selectSeason = + text?.asStringNull(resultSeasonButton.context) + // If the season button is visible the result season button will be next focus down + if (resultSeasonButton.isVisible && resultResumeParent.isVisible) { + setFocusUpAndDown(resultResumeSeriesButton, resultSeasonButton) + } + } } - observe(viewModel.selectedDubStatus) { status -> - result_dub_select?.setText(status) + observeNullable(viewModel.selectedDubStatus) { status -> + resultBinding?.apply { + resultDubSelect.setText(status) - if (result_dub_select?.isVisible == true) - if (result_season_button?.isVisible != true && result_episode_select?.isVisible != true) { - if (result_resume_parent?.isVisible == true) - setFocusUpAndDown(result_resume_series_button, result_dub_select) - //else - // setFocusUpAndDown(result_bookmark_button, result_dub_select) + if (resultDubSelect.isVisible && !resultSeasonButton.isVisible && !resultEpisodeSelect.isVisible && resultResumeParent.isVisible) { + setFocusUpAndDown(resultResumeSeriesButton, resultDubSelect) } + } } - observe(viewModel.selectedRange) { range -> - result_episode_select.setText(range) + observeNullable(viewModel.selectedRange) { range -> + resultBinding?.apply { + resultEpisodeSelect.setText(range) - // If Season button is invisible then the bookmark button next focus is episode select - if (result_episode_select?.isVisible == true) - if (result_season_button?.isVisible != true) { - if (result_resume_parent?.isVisible == true) - setFocusUpAndDown(result_resume_series_button, result_episode_select) - //else - // setFocusUpAndDown(result_bookmark_button, result_episode_select) + 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) } + } } // val preferDub = context?.getApiDubstatusSettings()?.all { it == DubStatus.Dubbed } == true observe(viewModel.dubSubSelections) { range -> - result_dub_select.setOnClickListener { view -> + 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]) } } } } observe(viewModel.rangeSelections) { range -> - result_episode_select?.setOnClickListener { view -> + resultBinding?.resultEpisodeSelect?.setOnClickListener { view -> view?.context?.let { ctx -> val names = range .mapNotNull { (text, r) -> 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) } } @@ -408,7 +1400,7 @@ class ResultFragmentPhone : ResultFragment() { } observe(viewModel.seasonSelections) { seasonList -> - result_season_button?.setOnClickListener { view -> + resultBinding?.resultSeasonButton?.setOnClickListener { view -> view?.context?.let { ctx -> val names = seasonList @@ -419,7 +1411,7 @@ class ResultFragmentPhone : ResultFragment() { activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectSeason }, - "", + ctx.getString(R.string.season), false, {}) { itemId -> viewModel.changeSeason(names[itemId].first) @@ -436,57 +1428,75 @@ class ResultFragmentPhone : ResultFragment() { } } - override fun onPause() { - super.onPause() - PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this) + private fun resumeAction( + storedData: ResultFragment.StoredData, + resume: ResumeWatchingStatus + ) { + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, + resume.result + ) + ) } - override fun onGestureRegionsUpdate(gestureRegions: List) { - result_overlapping_panels?.setChildGestureRegions(gestureRegions) + override fun onPause() { + playerHostView?.releaseKeyEventListener() + super.onPause() + PanelsChildGestureRegionObserver.Provider.get() + .addGestureRegionsUpdateListener(gestureRegionsListener) } - override fun setRecommendations(rec: List?, validApiName: String?) { + private fun setRecommendations(rec: List?, validApiName: String?) { val isInvalid = rec.isNullOrEmpty() - result_recommendations?.isGone = isInvalid - result_recommendations_btt?.isGone = isInvalid - result_recommendations_btt?.setOnClickListener { - val nextFocusDown = if (result_overlapping_panels?.getSelectedPanel()?.ordinal == 1) { - result_overlapping_panels?.openEndPanel() - R.id.result_recommendations - } else { - result_overlapping_panels?.closePanels() - R.id.result_description - } + val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - result_recommendations_btt?.nextFocusDownId = nextFocusDown - result_search?.nextFocusDownId = nextFocusDown - result_open_in_browser?.nextFocusDownId = nextFocusDown - result_share?.nextFocusDownId = nextFocusDown + recommendationBinding?.apply { + root.isGone = isInvalid + root.post { + rec?.let { list -> + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { it.apiName == matchAgainst }) + } + } } - result_overlapping_panels?.setEndPanelLockState(if (isInvalid) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) - val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - rec?.map { it.apiName }?.distinct()?.let { apiNames -> - // very dirty selection - result_recommendations_filter_button?.isVisible = apiNames.size > 1 - result_recommendations_filter_button?.text = matchAgainst - result_recommendations_filter_button?.setOnClickListener { _ -> - activity?.showBottomDialog( - apiNames, - apiNames.indexOf(matchAgainst), - getString(R.string.home_change_provider_img_des), false, {} - ) { - setRecommendations(rec, apiNames[it]) + binding?.apply { + resultRecommendationsBtt.isGone = isInvalid + resultRecommendationsBtt.setOnClickListener { + val nextFocusDown = if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { + resultOverlappingPanels.openEndPanel() + R.id.result_recommendations + } else { + resultOverlappingPanels.closePanels() + R.id.result_description + } + resultBinding?.apply { + resultRecommendationsBtt.nextFocusDownId = nextFocusDown + resultSearch.nextFocusDownId = nextFocusDown + resultOpenInBrowser.nextFocusDownId = nextFocusDown + resultShare.nextFocusDownId = nextFocusDown } } - } ?: run { - result_recommendations_filter_button?.isVisible = false - } + resultOverlappingPanels.setEndPanelLockState(if (isInvalid) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) - result_recommendations?.post { - rec?.let { list -> - (result_recommendations?.adapter as SearchAdapter?)?.updateList(list.filter { it.apiName == matchAgainst }) + rec?.map { it.apiName }?.distinct()?.let { apiNames -> + // very dirty selection + recommendationBinding?.resultRecommendationsFilterButton?.apply { + isVisible = apiNames.size > 1 + text = matchAgainst + setOnClickListener { _ -> + activity?.showBottomDialog( + apiNames, + apiNames.indexOf(matchAgainst), + getString(R.string.home_change_provider_img_des), false, {} + ) { + setRecommendations(rec, apiNames[it]) + } + } + } + } ?: run { + recommendationBinding?.resultRecommendationsFilterButton?.isVisible = false } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index 0e3ee53e562..cfbacc5d13f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -1,35 +1,100 @@ package com.lagradost.cloudstream3.ui.result +import android.animation.Animator +import android.annotation.SuppressLint import android.app.Dialog import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import android.widget.LinearLayout +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.lifecycle.ViewModelProvider import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.mvvm.ResourceSome -import com.lagradost.cloudstream3.mvvm.Some +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.setMaxViewPoolSize +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.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import kotlinx.android.synthetic.main.fragment_home.* -import kotlinx.android.synthetic.main.fragment_result.* -import kotlinx.android.synthetic.main.fragment_result_tv.* -import kotlinx.android.synthetic.main.fragment_result_tv.result_episodes -import kotlinx.android.synthetic.main.fragment_result_tv.result_episodes_text -import kotlinx.android.synthetic.main.fragment_result_tv.result_play_movie -import kotlinx.android.synthetic.main.fragment_result_tv.result_root - -class ResultFragmentTv : ResultFragment() { - override val resultLayout = R.layout.fragment_result_tv +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.UIHelper.populateChips +import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat +import com.lagradost.cloudstream3.utils.getImageFromDrawable +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.setTextHtml +import com.lagradost.cloudstream3.utils.txt + +class ResultFragmentTv : BaseFragment( + BindingCreator.Inflate(FragmentResultTvBinding::inflate) +) { + + private lateinit var viewModel: ResultViewModel2 + + override fun onDestroyView() { + updateUIEvent -= ::updateUI + activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) + super.onDestroyView() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + viewModel = + ViewModelProvider(this)[ResultViewModel2::class.java] + viewModel.EPISODE_RANGE_SIZE = 50 + updateUIEvent += ::updateUI + + return super.onCreateView(inflater, container, savedInstanceState) + } + + private fun updateUI(id: Int?) { + viewModel.reloadEpisodes() + } private var currentRecommendations: List = emptyList() @@ -38,12 +103,15 @@ class ResultFragmentTv : ResultFragment() { is EpisodeRange -> { viewModel.changeRange(data) } + is Int -> { viewModel.changeSeason(data) } + is DubStatus -> { viewModel.changeDubStatus(data) } + is String -> { setRecommendations(currentRecommendations, data) } @@ -55,7 +123,7 @@ class ResultFragmentTv : ResultFragment() { } private fun RecyclerView?.update(data: List) { - (this?.adapter as? SelectAdaptor?)?.updateSelectionList(data) + (this?.adapter as? SelectAdaptor?)?.submitList(data) this?.isVisible = data.size > 1 } @@ -65,160 +133,833 @@ class ResultFragmentTv : ResultFragment() { } } - private fun hasNoFocus(): Boolean { - val focus = activity?.currentFocus - if (focus == null || !focus.isVisible) return true - return focus == this.result_root +// 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() } - override fun updateEpisodes(episodes: ResourceSome>) { - super.updateEpisodes(episodes) - if (episodes is ResourceSome.Success && hasNoFocus()) { - result_episodes?.requestFocus() + private fun setRecommendations(rec: List?, validApiName: String?) { + currentRecommendations = rec ?: emptyList() + val isInvalid = rec.isNullOrEmpty() + binding?.apply { + resultRecommendationsList.isGone = isInvalid + resultRecommendationsHolder.isGone = isInvalid + val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName + (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.select(apiNames.indexOf(matchAgainst)) + } ?: run { + resultRecommendationsFilterSelection.isVisible = false + } } } - override fun updateMovie(data: ResourceSome>) { - super.updateMovie(data) - if (data is ResourceSome.Success && hasNoFocus()) { - result_play_movie?.requestFocus() + var loadingDialog: Dialog? = null + var popupDialog: Dialog? = null + + private fun reloadViewModel(forceReload: Boolean) { + if (!viewModel.hasLoaded() || forceReload) { + val storedData = getStoredData() ?: return + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) } } - override fun setRecommendations(rec: List?, validApiName: String?) { - currentRecommendations = rec ?: emptyList() - val isInvalid = rec.isNullOrEmpty() - result_recommendations?.isGone = isInvalid - result_recommendations_holder?.isGone = isInvalid - val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - (result_recommendations?.adapter as SearchAdapter?)?.updateList(rec?.filter { it.apiName == matchAgainst } - ?: emptyList()) + override fun onResume() { + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + afterPluginsLoadedEvent += ::reloadViewModel + super.onResume() + } + + override fun onStop() { + afterPluginsLoadedEvent -= ::reloadViewModel + super.onStop() + } - rec?.map { it.apiName }?.distinct()?.let { apiNames -> - // very dirty selection - result_recommendations_filter_selection?.isVisible = apiNames.size > 1 - result_recommendations_filter_selection?.update(apiNames.map { txt(it) to it }) - result_recommendations_filter_selection?.select(apiNames.indexOf(matchAgainst)) - } ?: run { - result_recommendations_filter_selection?.isVisible = false + private fun View.fade(turnVisible: Boolean) { + if (turnVisible) { + isVisible = true + } + + this.animate().alpha(if (turnVisible) 0.97f else 0.0f).apply { + duration = 200 + interpolator = DecelerateInterpolator() + setListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) { + } + + override fun onAnimationEnd(animation: Animator) { + this@fade.isVisible = turnVisible + } + + override fun onAnimationCancel(animation: Animator) { + } + + override fun onAnimationRepeat(animation: Animator) { + } + }) + } + this.animate().translationX(if (turnVisible) 0f else if (isRtl()) -100.0f else 100f).apply { + duration = 200 + interpolator = DecelerateInterpolator() } } - var loadingDialog: Dialog? = null - var popupDialog: Dialog? = null - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - result_episodes?.layoutManager = - //LinearListLayout(result_episodes ?: return, result_episodes?.context).apply { - LinearListLayout(result_episodes?.context).apply { - setHorizontal() - } - (result_episodes?.adapter as EpisodeAdapter?)?.apply { - layout = R.layout.result_episode_both_tv - } - //result_episodes?.setMaxViewPoolSize(0, Int.MAX_VALUE) - - result_season_selection.setAdapter() - result_range_selection.setAdapter() - result_dub_selection.setAdapter() - result_recommendations_filter_selection.setAdapter() - - observe(viewModel.selectPopup) { popup -> - when (popup) { - is Some.Success -> { - popupDialog?.dismissSafe(activity) - - popupDialog = activity?.let { act -> - val pop = popup.value - val options = pop.getOptions(act) - val title = pop.getTitle(act) - - act.showBottomDialogInstant( - options, title, { - popupDialog = null - pop.callback(null) - }, { - popupDialog = null - pop.callback(it) - } - ) + 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()) { + episodesShadowBackground.scaleX = -1f + } else { + episodesShadowBackground.scaleX = 1f + } + } + } + + 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() + activity?.loadCache() + hideKeyboard() + if (storedData.restart || !viewModel.hasLoaded()) + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + // ===== ===== ===== + var comingSoon = false + + binding.apply { + //episodesShadow.rotationX = 180.0f//if(episodesShadow.isRtl()) 180.0f else 0.0f + + // 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 { + val views = listOf( + resultPlayMovieButton, + resultPlaySeriesButton, + resultResumeSeriesButton, + resultPlayTrailerButton, + resultBookmarkButton, + resultFavoriteButton, + resultSubscribeButton, + resultSearchButton + ) + for (requestView in views) { + if (!requestView.isVisible) continue + if (requestView.requestFocus()) break } } - is Some.None -> { - popupDialog?.dismissSafe(activity) - popupDialog = null + } + + redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) return@setOnFocusChangeListener + toggleEpisodes(true) + binding.apply { + val views = listOf( + resultDubSelection, + resultSeasonSelection, + resultRangeSelection, + resultEpisodes, + resultPlayTrailerButton, + ) + for (requestView in views) { + if (!requestView.isShown) continue + if (requestView.requestFocus()) break // View.FOCUS_RIGHT + } + } + } + + 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, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + resultDubSelection.setLinearListLayout( + isHorizontal = false, + nextUp = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + resultRangeSelection.setLinearListLayout( + isHorizontal = false, + nextUp = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + resultSeasonSelection.setLinearListLayout( + isHorizontal = false, + nextUp = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + + /*.layoutManager = + LinearListLayout(resultEpisodes.context, resultEpisodes.isRtl()).apply { + setVertical() + }*/ + + resultReloadConnectionerror.setOnClickListener { + viewModel.load( + activity, + storedData.url, + storedData.apiName, + storedData.showFillers, + storedData.dubStatus, + storedData.start + ) + + } + + resultMetaSite.isFocusable = false + + resultSeasonSelection.setAdapter() + resultRangeSelection.setAdapter() + resultDubSelection.setAdapter() + resultRecommendationsFilterSelection.setAdapter() + + resultCastItems.setOnFocusChangeListener { _, hasFocus -> + // Always escape focus + if (hasFocus) binding.resultBookmarkButton.requestFocus() + } + //resultBack.setOnClickListener { + // activity?.popCurrentPage() + //} + + resultRecommendationsList.spanCount = 8 + resultRecommendationsList.setRecycledViewPool(SearchAdapter.sharedPool) + resultRecommendationsList.adapter = + SearchAdapter( + resultRecommendationsList, + ) { callback -> + if (callback.action == SEARCH_ACTION_FOCUSED) { + toggleEpisodes(false) + } else SearchHelper.handleSearchClickCallback(callback) + } + + resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) + resultEpisodes.adapter = + EpisodeAdapter( + false, + { episodeClick -> + viewModel.handleAction(episodeClick) + }, + { downloadClickEvent -> + DownloadButtonSetup.handleDownloadClick(downloadClickEvent) + } + ) + + resultCastItems.layoutManager = object : LinearListLayout(root.context) { + override fun onRequestChildFocus( + parent: RecyclerView, + state: RecyclerView.State, + child: View, + focused: View? + ): Boolean { + // Make the cast always focus the first visible item when focused + // from somewhere else. Otherwise it jumps to the last item. + return if (parent.focusedChild == null) { + scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) + true + } else { + super.onRequestChildFocus(parent, state, child, focused) + } + } + }.apply { setHorizontal() } + + val aboveCast = listOf( + binding.resultEpisodesShow, + binding.resultBookmark, + binding.resultFavorite, + binding.resultSubscribe, + ).firstOrNull { it.isVisible } + + resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) + resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { + toggleEpisodes(false) + } + + if (isLayout(EMULATOR)) { + episodesShadow.setOnClickListener { + toggleEpisodes(false) } } } - observe(viewModel.loadedLinks) { load -> - when (load) { - is Some.Success -> { - if (loadingDialog?.isShowing != true) { - loadingDialog?.dismissSafe(activity) - loadingDialog = null + observeNullable(viewModel.resumeWatching) { resume -> + binding.apply { + if (resume == null) { + return@observeNullable + } + + resultResumeSeries.isVisible = true + resultPlayMovie.isVisible = false + resultPlaySeries.isVisible = false + + // show progress no matter if series or movie + resume.progress?.let { progress -> + resultResumeSeriesTitle.apply { + isVisible = !resume.isMovie + text = + if (resume.isMovie) null else context?.getNameFull( + resume.result.name, + resume.result.episode, + resume.result.season + ) } - loadingDialog = loadingDialog ?: context?.let { ctx -> - val builder = - BottomSheetDialog(ctx) - builder.setContentView(R.layout.bottom_loading) - builder.setOnDismissListener { - loadingDialog = null - viewModel.cancelLinks() + resultResumeSeriesProgressText.setText(progress.progressLeft) + resultResumeSeriesProgress.apply { + isVisible = true + this.max = progress.maxProgress + this.progress = progress.progress + } + resultResumeProgressHolder.isVisible = true + } ?: run { + resultResumeProgressHolder.isVisible = false + } + + focusPlayButton() + // Stops last button right focus if it is a movie + if (resume.isMovie) + resultSearchButton.nextFocusRightId = R.id.result_search_Button + + 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}" + + else -> "${getString(R.string.episode)} ${resume.result.episode}" + } + + resultResumeSeriesButton.setOnClickListener { + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, + resume.result + ) + ) + } + + resultResumeSeriesButton.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, resume.result) + ) + return@setOnLongClickListener true + } + + } + } + + observe(viewModel.trailers) { trailersLinks -> + context?.updateHasTrailers() + if (!LoadResponse.isTrailersEnabled) return@observe + val extractedTrailerLinks = trailersLinks.flatMap { it.mirros } + .map { (extractedTrailerLink, _) -> extractedTrailerLink } + binding.apply { + resultPlayTrailer.isGone = extractedTrailerLinks.isEmpty() + resultPlayTrailerButton.setOnClickListener { + if (extractedTrailerLinks.isEmpty()) return@setOnClickListener + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + ExtractorLinkGenerator( + extractedTrailerLinks, + emptyList() + ), 0 + ) + ) + } + } + } + + observe(viewModel.watchStatus) { watchType -> + 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) } - //builder.setOnCancelListener { - // it?.dismiss() - //} - builder.setCanceledOnTouchOutside(true) + } + } + } + } + + observeNullable(viewModel.favoriteStatus) { isFavorite -> + binding.resultFavorite.isVisible = isFavorite != null + binding.resultFavoriteButton.apply { + if (isFavorite == null) return@observeNullable + + val drawable = if (isFavorite) { + R.drawable.ic_baseline_favorite_24 + } else R.drawable.ic_baseline_favorite_border_24 + 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 - builder.show() + val drawable = if (isSubscribed) { + R.drawable.ic_baseline_notifications_active_24 + } else R.drawable.baseline_notifications_none_24 + setIconResource(drawable) - builder + 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) + ) } + resultPlayMovieButton.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) + ) + return@setOnLongClickListener true + } + + resultPlayMovie.isVisible = !comingSoon && resultResumeSeries.isGone + if (comingSoon) { + resultBookmarkButton.requestFocus() + } else resultPlayMovieButton.requestFocus() + + // Stops last button right focus + resultSearchButton.nextFocusRightId = R.id.result_search_Button } - is Some.None -> { - loadingDialog?.dismissSafe(activity) + } + } + + observeNullable(viewModel.selectPopup) { popup -> + if (popup == null) { + popupDialog?.dismissSafe(activity) + popupDialog = null + return@observeNullable + } + + popupDialog?.dismissSafe(activity) + + popupDialog = activity?.let { act -> + val options = popup.getOptions(act) + val title = popup.getTitle(act) + + act.showBottomDialogInstant( + options, title, { + popupDialog = null + popup.callback(null) + }, { + popupDialog = null + popup.callback(it) + } + ) + } + } + + observeNullable(viewModel.loadedLinks) { load -> + if (load == null) { + loadingDialog?.dismissSafe(activity) + loadingDialog = null + return@observeNullable + } + if (loadingDialog?.isShowing != true) { + loadingDialog?.dismissSafe(activity) + loadingDialog = null + } + loadingDialog = loadingDialog ?: context?.let { ctx -> + val builder = BottomSheetDialog(ctx) + builder.setContentView(R.layout.bottom_loading) + builder.setOnDismissListener { loadingDialog = null + viewModel.cancelLinks() + } + 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})" } } } - observe(viewModel.episodesCountText) { count -> - result_episodes_text.setText(count) + observeNullable(viewModel.episodesCountText) { count -> + binding.resultEpisodesText.setText(count) } observe(viewModel.selectedRangeIndex) { selected -> - result_range_selection.select(selected) + binding.resultRangeSelection.select(selected) } observe(viewModel.selectedSeasonIndex) { selected -> - result_season_selection.select(selected) + binding.resultSeasonSelection.select(selected) } observe(viewModel.selectedDubStatusIndex) { selected -> - result_dub_selection.select(selected) + binding.resultDubSelection.select(selected) } observe(viewModel.rangeSelections) { - result_range_selection.update(it) + binding.resultRangeSelection.update(it) } observe(viewModel.dubSubSelections) { - result_dub_selection.update(it) + binding.resultDubSelection.update(it) } observe(viewModel.seasonSelections) { - result_season_selection.update(it) + binding.resultSeasonSelection.update(it) + } + observe(viewModel.recommendations) { recommendations -> + setRecommendations(recommendations, null) + } + + 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() + } + } } - result_back?.setOnClickListener { - activity?.popCurrentPage() + // Used to request focus the first time the episodes are loaded. + var hasLoadedEpisodesOnce = false + observeNullable(viewModel.episodes) { episodes -> + if (episodes == null) return@observeNullable + binding.apply { + if (comingSoon) resultBookmarkButton.requestFocus() + + // resultEpisodeLoading.isVisible = episodes is Resource.Loading + if (episodes is Resource.Success) { + val lastWatchedIndex = episodes.value.indexOfLast { ep -> + ep.getWatchProgress() >= NEXT_WATCH_EPISODE_PERCENTAGE.toFloat() / 100.0f || ep.videoWatchState == VideoWatchState.Watched + } + + val firstUnwatched = + episodes.value.getOrElse(lastWatchedIndex + 1) { episodes.value.firstOrNull() } + + 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, + firstUnwatched + ) + ) + } + resultPlaySeriesButton.setOnLongClickListener { + viewModel.handleAction( + EpisodeClickEvent(ACTION_SHOW_OPTIONS, firstUnwatched) + ) + return@setOnLongClickListener true + } + if (!hasLoadedEpisodesOnce) { + hasLoadedEpisodesOnce = true + resultPlaySeries.isVisible = resultResumeSeries.isGone && !comingSoon + resultEpisodesShow.isVisible = true && !comingSoon + resultPlaySeriesButton.requestFocus() + } + } + + + (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) + } + } } - result_recommendations?.spanCount = 8 - result_recommendations?.adapter = - SearchAdapter( - ArrayList(), - result_recommendations, - ) { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) + observeNullable(viewModel.page) { data -> + if (data == null) return@observeNullable + binding.apply { + when (data) { + is Resource.Success -> { + val d = data.value + resultVpn.setText(d.vpnText) + resultInfo.setText(d.metaText) + resultNoEpisodes.setText(d.noEpisodesFoundText) + resultTitle.setText(d.titleText) + resultMetaSite.setText(d.apiName) + resultMetaType.setText(d.typeText) + 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.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() + } + } + } + } + + val error = listOf( + R.drawable.profile_bg_dark_blue, + R.drawable.profile_bg_blue, + R.drawable.profile_bg_orange, + R.drawable.profile_bg_pink, + R.drawable.profile_bg_purple, + R.drawable.profile_bg_red, + R.drawable.profile_bg_teal + ).random() + + backgroundPoster.loadImage(d.posterBackgroundImage) { + error { getImageFromDrawable(context ?: return@error null, error) } + } + + bindLogo( + url = d.logoUrl, + headers = d.posterHeaders, + titleView = resultTitle, + logoView = backgroundPosterWatermarkBadgeHolder + ) + + 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.Failure -> { + resultErrorText.text = + storedData.url.plus("\n") + data.errorString + } + } + + resultFinishLoading.isVisible = data is Resource.Success + + resultLoading.isVisible = data is Resource.Loading + + resultLoadingError.isVisible = data is Resource.Failure + //resultReloadConnectionOpenInBrowser.isVisible = data is Resource.Failure } + } } -} \ 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 bf47209a475..3b1471e6ab2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -3,45 +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.discord.panels.PanelsChildGestureRegionObserver +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 kotlinx.android.synthetic.main.fragment_result.* -import kotlinx.android.synthetic.main.fragment_result.result_smallscreen_holder -import kotlinx.android.synthetic.main.fragment_result_swipe.* -import kotlinx.android.synthetic.main.fragment_result_tv.* -import kotlinx.android.synthetic.main.fragment_trailer.* -import kotlinx.android.synthetic.main.trailer_custom_layout.* +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding - -open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreenPlayer(), - PanelsChildGestureRegionObserver.GestureRegionsListener, 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 - override fun nextEpisode() {} + // Single-tap on empty player area: toggle controls. + override fun onSingleTap() { + if (introVisible) return + if (isShowing) uiReset() else showControls() + } - override fun prevEpisode() {} + private fun showControls() { + if (introVisible) return + isShowing = true + updateUIVisibility() + playerHostView?.scheduleAutoHide() + } + + override fun isUIShowing(): Boolean = isShowing - override fun playerPositionChanged(posDur: Pair) {} + override fun onAutoHideUI() { + if (player.getIsPlaying()) uiReset() + } + override fun onHidePlayerUI() = uiReset() + + // When the hold-speedup gesture fires, hide controls so the video is unobstructed. + // The speedup button show/hide and speed change are handled by PlayerView. + override fun onHoldSpeedUp(show: Boolean) { + if (show && isShowing) uiReset() + } + + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + showControls() + } else playerHostView?.scheduleAutoHide() + } + + override fun nextEpisode() {} + override fun prevEpisode() {} + override fun playerPositionChanged(position: Long, duration: Long) {} override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -51,51 +82,59 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen } 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 + 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 - result_smallscreen_holder?.isVisible = !isFullScreenPlayer - result_fullscreen_holder?.isVisible = isFullScreenPlayer + resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer + binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer val to = sw * h / w - player_background?.apply { + 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 + ) } - player_intro_play?.apply { - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - result_top_holder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT - ) + playerBinding?.playerIntroPlay?.apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT + ) } - if (player_intro_play?.isGone == true) { - result_top_holder?.apply { - + 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() @@ -104,9 +143,14 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen } } - 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() {} @@ -114,69 +158,100 @@ open class ResultTrailerPlayer : com.lagradost.cloudstream3.ui.player.FullScreen 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 onGestureRegionsUpdate(gestureRegions: List) {} + 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 - player_fullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) + playerHostView?.isFullScreen = fullscreen + + playerBinding?.playerFullscreen?.setImageResource( + if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24 + ) if (fullscreen) { - enterFullscreen() - result_top_bar?.isVisible = false - result_fullscreen_holder?.isVisible = true - result_main_holder?.isVisible = false - player_background?.let { view -> + playerHostView?.enterFullscreen() + binding?.apply { + resultTopBar.isVisible = false + resultFullscreenHolder.isVisible = true + resultMainHolder.isVisible = false + } + resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) - result_fullscreen_holder?.addView(view) + binding?.resultFullscreenHolder?.addView(view) } } else { - result_top_bar?.isVisible = true - result_fullscreen_holder?.isVisible = false - result_main_holder?.isVisible = true - player_background?.let { view -> - (view.parent as ViewGroup?)?.removeView(view) - result_smallscreen_holder?.addView(view) + binding?.apply { + resultTopBar.isVisible = true + resultFullscreenHolder.isVisible = false + resultMainHolder.isVisible = true + resultBinding?.fragmentTrailer?.playerBackground?.let { view -> + (view.parent as ViewGroup?)?.removeView(view) + 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) - player_fullscreen?.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 } - updateFullscreen(isFullScreenPlayer) - uiReset() + // Fade center controls in/out; also resets stale fillAfter alpha from seek animations. + playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) + } - player_intro_play?.setOnClickListener { - player_intro_play?.isGone = true - player.handleEvent(CSPlayerEvent.Play) - updateUIVisibility() - fixPlayerSize() + override fun playerStatusChanged() { + if (introVisible) { + playerBinding?.playerPausePlayHolderHolder?.isVisible = false } } - override fun onBackPressed(): Boolean { - return if (isFullScreenPlayer) { - updateFullscreen(false) - false - } else { - true + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + + playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } + updateFullscreen(isFullScreenPlayer) + uiReset() + + playerBinding?.playerIntroPlay?.setOnClickListener { + playerBinding?.playerIntroPlay?.isGone = true + introVisible = false + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + fixPlayerSize() + showControls() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index f5aae7fc203..7dfe3cf5988 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -1,64 +1,151 @@ package com.lagradost.cloudstream3.ui.result import android.app.Activity -import android.content.* -import android.net.Uri -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.getId +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.APIHolder.apis +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.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.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 java.lang.Math.abs +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 @@ -87,11 +174,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?, @@ -103,8 +192,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) { @@ -142,18 +253,25 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { minute ) } + hours > 0 -> txt( R.string.next_episode_time_hour_format, hours, minute ) + minute > 0 -> txt( R.string.next_episode_time_min_format, minute ) + 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) + } } } } @@ -168,12 +286,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(), @@ -183,10 +298,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) { @@ -202,12 +318,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 @@ -216,10 +339,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( @@ -231,12 +353,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, @@ -258,6 +397,7 @@ data class ResumeWatchingStatus( data class LinkLoadingResult( val links: List, val subs: List, + val syncData: HashMap ) sealed class SelectPopup { @@ -302,17 +442,23 @@ fun SelectPopup.getOptions(context: Context): List { is SelectPopup.SelectArray -> { this.options.map { it.first.asString(context) } } + is SelectPopup.SelectText -> options.map { it.asString(context) } } } data class ExtractedTrailerData( - var mirros: List, + var mirros: List>,//Pair of extracted trailer link and original trailer link var subtitles: List = emptyList(), ) class ResultViewModel2 : ViewModel() { private var currentResponse: LoadResponse? = null + var EPISODE_RANGE_SIZE: Int = 20 + fun clear() { + currentResponse = null + _page.postValue(null) + } data class EpisodeIndexer( val dubStatus: DubStatus, @@ -327,12 +473,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 - private var currentRepo: APIRepository? = null + 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 @@ -340,21 +487,21 @@ class ResultViewModel2 : ViewModel() { //private val currentHeaderName get() = currentResponse?.name - private val _page: MutableLiveData> = - MutableLiveData(Resource.Loading()) - val page: LiveData> = _page + private val _page: MutableLiveData?> = + MutableLiveData(null) + val page: LiveData?> = _page - private val _episodes: MutableLiveData>> = - MutableLiveData(ResourceSome.Loading()) - val episodes: LiveData>> = _episodes + private val _episodes: MutableLiveData>?> = + MutableLiveData(Resource.Loading()) + val episodes: LiveData>?> = _episodes - private val _movie: MutableLiveData>> = - MutableLiveData(ResourceSome.None) - val movie: LiveData>> = _movie + private val _movie: MutableLiveData>?> = + MutableLiveData(null) + val movie: LiveData>?> = _movie - private val _episodesCountText: MutableLiveData> = - MutableLiveData(Some.None) - val episodesCountText: LiveData> = _episodesCountText + private val _episodesCountText: MutableLiveData = + MutableLiveData(null) + val episodesCountText: LiveData = _episodesCountText private val _trailers: MutableLiveData> = MutableLiveData(mutableListOf()) @@ -376,16 +523,28 @@ class ResultViewModel2 : ViewModel() { MutableLiveData(emptyList()) val recommendations: LiveData> = _recommendations - private val _selectedRange: MutableLiveData> = - MutableLiveData(Some.None) - val selectedRange: LiveData> = _selectedRange + private val _selectedRange: MutableLiveData = + MutableLiveData(null) + val selectedRange: LiveData = _selectedRange - private val _selectedSeason: MutableLiveData> = - MutableLiveData(Some.None) - val selectedSeason: LiveData> = _selectedSeason + private val _selectedSorting: MutableLiveData = + MutableLiveData(null) + val selectedSorting: LiveData = _selectedSorting - private val _selectedDubStatus: MutableLiveData> = MutableLiveData(Some.None) - val selectedDubStatus: LiveData> = _selectedDubStatus + 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 + + private val _selectedDubStatus: MutableLiveData = MutableLiveData(null) + val selectedDubStatus: LiveData = _selectedDubStatus private val _selectedRangeIndex: MutableLiveData = MutableLiveData(-1) @@ -398,50 +557,58 @@ class ResultViewModel2 : ViewModel() { private val _selectedDubStatusIndex: MutableLiveData = MutableLiveData(-1) val selectedDubStatusIndex: LiveData = _selectedDubStatusIndex + private val _loadedLinks: MutableLiveData = MutableLiveData(null) + val loadedLinks: LiveData = _loadedLinks + + private val _resumeWatching: MutableLiveData = + MutableLiveData(null) + val resumeWatching: LiveData = _resumeWatching + + private val _episodeSynopsis: MutableLiveData = MutableLiveData(null) + val episodeSynopsis: LiveData = _episodeSynopsis - private val _loadedLinks: MutableLiveData> = MutableLiveData(Some.None) - val loadedLinks: LiveData> = _loadedLinks + private val _subscribeStatus: MutableLiveData = MutableLiveData(null) + val subscribeStatus: LiveData = _subscribeStatus - private val _resumeWatching: MutableLiveData> = - MutableLiveData(Some.None) - val resumeWatching: LiveData> = _resumeWatching + private val _favoriteStatus: MutableLiveData = MutableLiveData(null) + val favoriteStatus: LiveData = _favoriteStatus companion object { const val TAG = "RVM2" - private const val EPISODE_RANGE_SIZE = 20 - private const val EPISODE_RANGE_OVERLOAD = 30 + //private const val EPISODE_RANGE_SIZE = 20 + //private const val EPISODE_RANGE_OVERLOAD = 30 private fun List?.getSeason(season: Int?): SeasonData? { if (season == null) return null return this?.firstOrNull { it.season == season } } - fun updateWatchStatus(currentResponse: LoadResponse, status: WatchType) { - val currentId = currentResponse.getId() - val resultPage = currentResponse + fun seasonToTxt(seasonData: SeasonData?, season: Int?): UiText? { + if (season == 0) { + return txt(R.string.no_season) + } - DataStoreHelper.setResultWatchState(currentId, status.internalId) - val current = DataStoreHelper.getBookmarkedData(currentId) - val currentTime = System.currentTimeMillis() - DataStoreHelper.setBookmarkedData( - currentId, - DataStoreHelper.BookmarkedData( - currentId, - current?.bookmarkedTime ?: currentTime, - currentTime, - resultPage.name, - resultPage.url, - resultPage.apiName, - resultPage.type, - resultPage.posterUrl, - resultPage.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 ) - ) + } } + 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 } @@ -455,12 +622,16 @@ class ResultViewModel2 : ViewModel() { ) ) - private fun getRanges(allEpisodes: Map>): Map> { + private fun getRanges( + allEpisodes: Map>, + EPISODE_RANGE_SIZE: Int + ): Map> { return allEpisodes.keys.mapNotNull { index -> val episodes = allEpisodes[index] ?: return@mapNotNull null // this should never happened // fast case + val EPISODE_RANGE_OVERLOAD = EPISODE_RANGE_SIZE + 10 if (episodes.size <= EPISODE_RANGE_OVERLOAD) { return@mapNotNull index to listOf( EpisodeRange( @@ -491,7 +662,8 @@ class ResultViewModel2 : ViewModel() { val episodeNumber = episodes[currentIndex].episode if (episodeNumber < currentMin) { currentMin = episodeNumber - } else if (episodeNumber > currentMax) { + } + if (episodeNumber > currentMax) { currentMax = episodeNumber } ++currentIndex @@ -552,259 +724,399 @@ class ResultViewModel2 : ViewModel() { index to list }.toMap() } + } + + private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) + val watchStatus: LiveData get() = _watchStatus - 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 - ) { - // no notification + 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)) + + 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) } } - } - 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" - } - } + 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) - 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 + 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) } + } - 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 - ) + private fun startChromecast( + activity: Activity?, + result: ResultEpisode, + isVisible: Boolean = true + ) { + if (activity == null) return + loadLinks( + result, + isVisible = isVisible, + sourceTypes = LOADTYPE_CHROMECAST, + isCasting = true + ) { data -> + startChromecast(activity, result, data.links, data.subs, 0) + } + } - 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(), - ) - ) + /** + * 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 - 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(), - ) - ) + // This might be a bit confusing, but even if the loadresponse is not a EpisodeResponse + // _subscribeStatus might be true. - // DOWNLOAD VIDEO - VideoDownloadManager.downloadEpisodeUsingWorker( - context, - src,//url ?: return, - folder, - meta, - links - ) + if (isSubscribed) { + removeSubscribedData(currentId) + statusChangedCallback?.invoke(false) + _subscribeStatus.postValue(if (response is EpisodeResponse) false else null) + MainActivity.reloadLibraryEvent(true) + } else { + 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 + } - // 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 - ) - ) + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + removeSubscribedData(duplicateId) } - .map { ExtractorSubtitleLink(it.name, it.url, "") } - .forEach { link -> - val fileName = VideoDownloadManager.getFileName(context, meta) - downloadSubtitle(context, link, fileName, folder) - } } - } catch (e: Exception) { - logError(e) + + val current = getSubscribedData(currentId) + + setSubscribedData( + currentId, + 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) } } + } - 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) - }) + 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, + ) + } - if (currentLinks.isEmpty()) { - main { - showToast( - activity, - R.string.no_links_found_toast, - Toast.LENGTH_SHORT - ) - } - return@ioSafe - } else { - main { - showToast( - activity, - R.string.download_started, - Toast.LENGTH_SHORT - ) + + /** + * 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) } } - startDownload( - activity, - episode, - currentIsMovie, - currentHeaderName, - currentType, - currentPoster, - apiName, - parentId, - url, - sortUrls(currentLinks), - sortSubs(currentSubs), + 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, " ") + } - 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, + 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() } } - } - private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) - val watchStatus: LiveData get() = _watchStatus + if (duplicateEntries.isEmpty() || context == null) { + checkDuplicatesCallback.invoke(true, emptyList()) + return + } - private val _selectPopup: MutableLiveData> = MutableLiveData(Some.None) - val selectPopup: LiveData> get() = _selectPopup + 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 - fun updateWatchStatus(status: WatchType) { - updateWatchStatus(currentResponse ?: return, status) - _watchStatus.postValue(status) + 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 startChromecast( - activity: Activity?, - result: ResultEpisode, - isVisible: Boolean = true - ) { - if (activity == null) return - loadLinks(result, isVisible = isVisible, isCasting = true) { data -> - startChromecast(activity, result, data.links, data.subs, 0) + 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 } } @@ -837,23 +1149,22 @@ class ResultViewModel2 : ViewModel() { } fun cancelLinks() { - println("called::cancelLinks") currentLoadLinkJob?.cancel() currentLoadLinkJob = null - _loadedLinks.postValue(Some.None) + _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( - some(SelectPopup.SelectText( + SelectPopup.SelectText( text, options ) { value -> viewModelScope.launchSafe { - _selectPopup.postValue(Some.None) + _selectPopup.postValue(null) callback.invoke(value) } - }) + } ) } @@ -864,49 +1175,67 @@ class ResultViewModel2 : ViewModel() { callback: suspend (Int?) -> Unit ) { _selectPopup.postValue( - some(SelectPopup.SelectArray( + SelectPopup.SelectArray( text, options, ) { value -> viewModelScope.launchSafe { - _selectPopup.value = Some.None + _selectPopup.postValue(null) callback.invoke(value) } - }) + } ) } 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)) } } @@ -914,11 +1243,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) }) @@ -928,11 +1256,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)) @@ -940,183 +1274,90 @@ class ResultViewModel2 : ViewModel() { val subs: MutableSet = mutableSetOf() fun updatePage() { if (isVisible && isActive) { - _loadedLinks.postValue(some(LinkProgress(links.size, subs.size))) + _loadedLinks.postValue(LinkProgress(links.size, subs.size)) } } 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(Some.None) + _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(activity, txt(R.string.app_not_found_error), Toast.LENGTH_LONG) - } else { - showToast(activity, t.toString(), Toast.LENGTH_LONG) - } - } - } + fun handleAction(click: EpisodeClickEvent) = + viewModelScope.launchSafe { + handleEpisodeClickEvent(click) } + + fun releaseEpisodeSynopsis() { + _episodeSynopsis.postValue(null) } - 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) + private fun markEpisodes( + editor: Editor, + episodeIds: Array, + watchState: VideoWatchState + ) { + val watchStateString = DataStore.mapper.writeValueAsString(watchState) + episodeIds.forEach { + if (getVideoWatchState(it.toInt()) != watchState) { + editor.setKeyRaw( + getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), + watchStateString + ) } } - 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}" + 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() } } - outputFile.writeText(text) - - setDataAndType( - FileProvider.getUriForFile( - act, - act.applicationContext.packageName + ".provider", - outputFile - ), "video/*" - ) - } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, ids) -> ids.toTypedArray() } + .toMap(HashMap()) - val position = if (resume) { - getViewPos(id)?.position ?: 0L - } else { - 1L + if (season != 0) { + result.remove(0) } - - component = VLC_COMPONENT - - putExtra("from_start", !resume) - putExtra("position", position) + return result } - fun handleAction(activity: Activity?, click: EpisodeClickEvent) = - viewModelScope.launchSafe { - handleEpisodeClickEvent(activity, 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 - ) - ) - - private suspend fun handleEpisodeClickEvent(activity: Activity?, click: EpisodeClickEvent) { + private suspend fun handleEpisodeClickEvent(click: EpisodeClickEvent) { when (click.action) { ACTION_SHOW_OPTIONS -> { val options = mutableListOf>() + if (activity?.isConnectedToChromecast() == true) { options.addAll( listOf( @@ -1125,31 +1366,37 @@ 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.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) 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, txt(R.string.episode_action_reload_links) to ACTION_RELOAD_EPISODE, -// txt(R.string.action_mark_as_watched) to ACTION_MARK_AS_WATCHED, ) ) + 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 = + getVideoWatchState(click.data.id) == VideoWatchState.Watched + + val watchedText = if (isWatched) R.string.action_remove_from_watched + else R.string.action_mark_as_watched + + val markUpToText = + if (isWatched) R.string.action_remove_mark_watched_up_to_this_episode + else R.string.action_mark_watched_up_to_this_episode + + options.add(txt(watchedText) to ACTION_MARK_AS_WATCHED) + + options.add(txt(markUpToText) to ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE) + } postPopup( txt( activity?.getNameFull( @@ -1161,27 +1408,30 @@ class ResultViewModel2 : ViewModel() { options ) { result -> handleEpisodeClickEvent( - activity, click.copy(action = result ?: return@postPopup) ) } } + ACTION_CLICK_DEFAULT -> { activity?.let { ctx -> if (ctx.isConnectedToChromecast()) { handleEpisodeClickEvent( - activity, click.copy(action = ACTION_CHROME_CAST_EPISODE) ) } else { val action = getPlayerAction(ctx) handleEpisodeClickEvent( - activity, click.copy(action = action) ) } } } + + ACTION_SHOW_DESCRIPTION -> { + _episodeSynopsis.postValue(click.data.description) + } + /* not implemented, not used ACTION_DOWNLOAD_EPISODE_SUBTITLE -> { loadLinks(click.data, isVisible = false, isCasting = false) { links -> @@ -1193,7 +1443,6 @@ class ResultViewModel2 : ViewModel() { acquireSingleSubtitle( click.data, - false, txt(R.string.episode_action_download_subtitle) ) { (links, index) -> downloadSubtitle( @@ -1209,39 +1458,41 @@ class ResultViewModel2 : ViewModel() { ) ) showToast( - activity, R.string.download_started, Toast.LENGTH_SHORT ) } } + ACTION_SHOW_TOAST -> { - showToast(activity, R.string.play_episode_toast, Toast.LENGTH_SHORT) + showToast(R.string.play_episode_toast, Toast.LENGTH_SHORT) } + 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() ) } + ACTION_DOWNLOAD_MIRROR -> { 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, @@ -1252,134 +1503,161 @@ class ResultViewModel2 : ViewModel() { response.url, listOf(result.links[index]), result.subs, - ) - } + ).toWrapper() + ) showToast( - activity, R.string.download_started, Toast.LENGTH_SHORT ) } } + ACTION_RELOAD_EPISODE -> { ioSafe { 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(act, 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(activity, R.string.no_links_found_toast, Toast.LENGTH_SHORT) - return@loadLinks - } - playWithVlc( - activity, - links, - click.data.id + ACTION_PLAY_EPISODE_IN_PLAYER -> { + val list = HashMap(currentResponse?.syncData ?: emptyMap()) + val generator = generator ?: return + + // I know kinda shit to iterate all, but it is 100% sure to work + val index = generator.videos.indexOfFirst { value -> value.id == click.data.id } + + 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_MARK_AS_WATCHED -> { + val isWatched = + getVideoWatchState(click.data.id) == VideoWatchState.Watched + if (isWatched) { + setVideoWatchState(click.data.id, VideoWatchState.None) + } else { + setVideoWatchState(click.data.id, VideoWatchState.Watched) + } + // Kinda dirty to reload all episodes :( + reloadEpisodes() } - ACTION_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_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() + } } - ACTION_MARK_AS_WATCHED -> { - // TODO FIX -// DataStoreHelper.setViewPos(click.data.id, 1, 1) + + 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 + ) + } + } } } } @@ -1389,79 +1667,138 @@ class ResultViewModel2 : ViewModel() { meta: SyncAPI.SyncResult?, syncs: Map? = null ): Pair { - if (meta == null) return resp to false + //if (meta == null) return resp to false var updateEpisodes = false val out = resp.apply { Log.i(TAG, "applyMeta") - duration = duration ?: meta.duration - rating = rating ?: meta.publicScore - tags = tags ?: meta.genres - plot = if (plot.isNullOrBlank()) meta.synopsis else plot - posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl - actors = actors ?: meta.actors + if (meta != null) { + duration = duration ?: meta.duration + score = score ?: meta.publicScore + tags = tags ?: meta.genres + plot = if (plot.isNullOrBlank()) meta.synopsis else plot + posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl + actors = actors ?: meta.actors + + if (this is EpisodeResponse) { + nextAiring = nextAiring ?: meta.nextAiring + } - if (this is EpisodeResponse) { - nextAiring = nextAiring ?: meta.nextAiring + val realRecommendations = ArrayList() + val apiNames = synchronized(apis) { + apis.filter { + it.name.contains("gogoanime", true) || + it.name.contains("9anime", true) + }.map { + it.name + } + } + meta.recommendations?.forEach { rec -> + apiNames.forEach { name -> + realRecommendations.add(rec.copy(apiName = name)) + } + } + + recommendations = recommendations?.union(realRecommendations)?.toList() + ?: realRecommendations } for ((k, v) in syncs ?: emptyMap()) { syncData[k] = v } - val realRecommendations = ArrayList() - // TODO: fix - //val apiNames = listOf(GogoanimeProvider().name, NineAnimeProvider().name) - // meta.recommendations?.forEach { rec -> - // apiNames.forEach { name -> - // realRecommendations.add(rec.copy(apiName = name)) - // } - // } - - recommendations = recommendations?.union(realRecommendations)?.toList() - ?: realRecommendations - - argamap({ - addTrailer(meta.trailers) - }, { - if (this !is AnimeLoadResponse) return@argamap - val map = - Kitsu.getEpisodesDetails( - getMalId(), - getAniListId(), - isResponseRequired = false + runAllAsync( + { + if (this !is AnimeLoadResponse) return@runAllAsync + // already exist, no need to run getTracker + if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync + + val res = APIHolder.getTracker( + listOfNotNull( + this.engName, + this.name, + this.japName + ).filter { it.length > 2 } + .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 ) - if (map.isNullOrEmpty()) return@argamap - updateEpisodes = DubStatus.values().map { dubStatus -> - val current = - this.episodes[dubStatus]?.mapIndexed { index, episode -> - episode.apply { - this.episode = this.episode ?: (index + 1) - } - }?.sortedBy { it.episode ?: 0 }?.toMutableList() - if (current.isNullOrEmpty()) return@map false - val episodeNumbers = current.map { ep -> ep.episode!! } - var updateCount = 0 - map.forEach { (episode, node) -> - episodeNumbers.binarySearch(episode).let { index -> - current.getOrNull(index)?.let { currentEp -> - current[index] = currentEp.apply { - updateCount++ - val currentBack = this - this.description = this.description ?: node.description?.en - this.name = this.name ?: node.titles?.canonical - this.episode = - this.episode ?: node.num ?: episodeNumbers[index] - this.posterUrl = - this.posterUrl ?: node.thumbnail?.original?.url + + val kitsuId = AccountManager.kitsuApi.getAnimeIdByTitle(this.name) + + val ids = arrayOf( + AccountManager.malApi.idPrefix to res?.malId?.toString(), + AccountManager.aniListApi.idPrefix to res?.aniId, + AccountManager.kitsuApi.idPrefix to kitsuId + ) + + if (ids.any { (id, new) -> + val current = syncData[id] + new != null && current != null && current != new + } + ) { + // getTracker fucked up as it conflicts with current implementation + return@runAllAsync + } + + // set all the new data, prioritise old correct data + ids.forEach { (id, new) -> + new?.let { + syncData[id] = syncData[id] ?: it + } + } + + // set posters, might fuck up due to headers idk + posterUrl = posterUrl ?: res?.image + backgroundPosterUrl = backgroundPosterUrl ?: res?.cover + logoUrl = logoUrl + }, + { + if (meta == null) return@runAllAsync + addTrailer(meta.trailers) + }, { + if (this !is AnimeLoadResponse) return@runAllAsync + val map = + Kitsu.getEpisodesDetails( + getMalId(), + getAniListId(), + isResponseRequired = false + ) + if (map.isNullOrEmpty()) return@runAllAsync + updateEpisodes = DubStatus.entries.map { dubStatus -> + val current = + this.episodes[dubStatus]?.mapIndexed { index, episode -> + episode.apply { + this.episode = this.episode ?: (index + 1) + } + }?.sortedBy { it.episode ?: 0 }?.toMutableList() + if (current.isNullOrEmpty()) return@map false + val episodeNumbers = current.map { ep -> ep.episode!! } + var updateCount = 0 + map.forEach { (episode, node) -> + episodeNumbers.binarySearch(episode).let { index -> + current.getOrNull(index)?.let { currentEp -> + current[index] = currentEp.apply { + updateCount++ + this.description = this.description ?: node.description?.en + this.name = this.name ?: node.titles?.canonical + this.episode = + this.episode ?: node.num ?: episodeNumbers[index] + this.posterUrl = + this.posterUrl ?: node.thumbnail?.original?.url + } } } } - } - this.episodes[dubStatus] = current - updateCount > 0 - }.any { it } - }) + this.episodes[dubStatus] = current + updateCount > 0 + }.any { it } + }) } return out to updateEpisodes } @@ -1485,6 +1822,7 @@ class ResultViewModel2 : ViewModel() { postSuccessful( value ?: return@launchSafe, + currentId ?: return@launchSafe, currentRepo ?: return@launchSafe, updateEpisodes ?: return@launchSafe, false @@ -1493,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? { @@ -1519,49 +1870,70 @@ 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) - it.copy(position = posDur?.position ?: 0, duration = posDur?.duration ?: 0) - } + private fun getEpisodes( + indexer: EpisodeIndexer, + range: EpisodeRange, + ): List { + return currentEpisodes[indexer]?.let { list -> + val start = minOf(list.size, range.startIndex) + val end = minOf(list.size, start + range.length) + list.subList(start, end).map { + val posDur = getViewPos(it.id) + val watchState = getVideoWatchState(it.id) ?: VideoWatchState.None + it.copy( + position = posDur?.position ?: 0, + duration = posDur?.duration ?: 0, + videoWatchState = watchState + ) + } + } ?: emptyList() + } + + private fun 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 } - ?: emptyList() + + 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() { val response = currentResponse - _episodes.postValue(ResourceSome.None) + _episodes.postValue(null) if (response == null) { - _movie.postValue(ResourceSome.None) + _movie.postValue(null) return } 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 - else if (response.type.isMovieType()) // this wont break compatibility as you only need to override isMovieType + else if (response.isMovie()) // this wont break compatibility as you only need to override isMovieType R.string.play_movie_button else null } } ) val data = getMovie() - _episodes.postValue(ResourceSome.None) + _episodes.postValue(null) if (text == null || data == null) { - _movie.postValue(ResourceSome.None) + _movie.postValue(null) } else { - _movie.postValue(ResourceSome.Success(text to data)) + _movie.postValue(Resource.Success(text to data)) } } @@ -1570,20 +1942,57 @@ class ResultViewModel2 : ViewModel() { postMovie() } else { _episodes.postValue( - ResourceSome.Success( - getEpisodes( - currentIndex ?: return, - currentRange ?: return + Resource.Success( + getSortedEpisodes( + getEpisodes( + currentIndex ?: return, + currentRange ?: return, + ), currentSorting ?: return ) ) ) - _movie.postValue(ResourceSome.None) + _movie.postValue(null) } postResume() } - private fun postEpisodeRange(indexer: EpisodeIndexer?, range: EpisodeRange?) { - if (range == null || indexer == null) { + private fun postSubscription(loadResponse: LoadResponse) { + val id = loadResponse.getId() + val data = getSubscribedData(id) + if (loadResponse.isEpisodeBased()) { + 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?, + sorting: EpisodeSortType? + ) { + if (range == null || indexer == null || sorting == null) { return } @@ -1591,11 +2000,12 @@ 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 - ranges?.minByOrNull { abs(it.startEpisode - range.startEpisode) }?.let { r -> - postEpisodeRange(indexer, r) - return - } + // 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, sorting) + return + } } val isMovie = currentResponse?.isMovie() == true @@ -1609,14 +2019,14 @@ class ResultViewModel2 : ViewModel() { val size = currentEpisodes[indexer]?.size _episodesCountText.postValue( - some( - if (isMovie) null else - txt( - R.string.episode_format, - size, - txt(if (size == 1) R.string.episode else R.string.episodes), - ) - ) + + if (isMovie) null else + txt( + R.string.episode_format, + size, + txt(if (size == 1) R.string.episode else R.string.episodes), + ) + ) _selectedSeasonIndex.postValue( @@ -1624,29 +2034,8 @@ class ResultViewModel2 : ViewModel() { ) _selectedSeason.postValue( - some( - 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 - ) - } - } - } - ) + if (isMovie || currentSeasons.size <= 1) null else + (currentResponse as? EpisodeResponse)?.seasonNames.getSeasonTxt(indexer.season) ) _selectedRangeIndex.postValue( @@ -1654,13 +2043,13 @@ class ResultViewModel2 : ViewModel() { ) _selectedRange.postValue( - some( - if (isMovie) null else if ((currentRanges[indexer]?.size ?: 0) > 1) { - txt(R.string.episodes_range, range.startEpisode, range.endEpisode) - } else { - null - } - ) + + if (isMovie) null else if ((currentRanges[indexer]?.size ?: 0) > 1) { + txt(R.string.episodes_range, range.startEpisode, range.endEpisode) + } else { + null + } + ) _selectedDubStatusIndex.postValue( @@ -1668,10 +2057,10 @@ class ResultViewModel2 : ViewModel() { ) _selectedDubStatus.postValue( - some( - if (isMovie || currentDubStatus.size <= 1) null else - txt(indexer.dubStatus) - ) + + if (isMovie || currentDubStatus.size <= 1) null else + txt(indexer.dubStatus) + ) currentId?.let { id -> @@ -1696,41 +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(ResourceSome.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) + postFavorites(loadResponse) + _watchStatus.postValue(getResultWatchState(mainId)) + if (updateEpisodes) - postEpisodes(loadResponse, updateFillers) + postEpisodes(loadResponse, mainId, updateFillers) } - private suspend fun postEpisodes(loadResponse: LoadResponse, updateFillers: Boolean) { - _episodes.postValue(ResourceSome.Loading()) - - val mainId = loadResponse.getId() - currentId = mainId - - _watchStatus.postValue(getResultWatchState(mainId)) + private suspend fun postEpisodes( + loadResponse: LoadResponse, + mainId: Int, + updateFillers: Boolean + ) { + _episodes.postValue(Resource.Loading()) - if (updateFillers && loadResponse is AnimeLoadResponse) { - updateFillers(loadResponse.name) + if (updateFillers) { + updateFillers(loadResponse) } val allEpisodes = when (loadResponse) { @@ -1745,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) @@ -1754,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 @@ -1777,6 +2232,7 @@ class ResultViewModel2 : ViewModel() { } episodes } + is TvSeriesLoadResponse -> { val episodes: MutableMap> = mutableMapOf() @@ -1792,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 @@ -1821,6 +2289,7 @@ class ResultViewModel2 : ViewModel() { } episodes } + is MovieLoadResponse -> { singleMap( buildResultEpisode( @@ -1838,10 +2307,12 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null, ) ) } + is LiveStreamLoadResponse -> { singleMap( buildResultEpisode( @@ -1859,10 +2330,12 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null ) ) } + is TorrentLoadResponse -> { singleMap( buildResultEpisode( @@ -1880,10 +2353,12 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null ) ) } + else -> { mapOf() } @@ -1900,33 +2375,19 @@ 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 }) } currentEpisodes = allEpisodes - val ranges = getRanges(allEpisodes) + val ranges = getRanges(allEpisodes, EPISODE_RANGE_SIZE) currentRanges = ranges // this takes the indexer most preferable by the user given the current sorting val min = ranges.keys.minByOrNull { index -> kotlin.math.abs( - index.season - (preferStartSeason ?: 0) + index.season - (preferStartSeason ?: 1) ) + if (index.dubStatus == preferDubStatus) 0 else 100000 } @@ -1936,17 +2397,17 @@ class ResultViewModel2 : ViewModel() { it.startEpisode >= (preferStartEpisode ?: 0) } ?: ranger?.lastOrNull() - postEpisodeRange(min, range) + postEpisodeRange(min, range, DataStoreHelper.resultsSortingMode) postResume() } - fun postResume() { - _resumeWatching.postValue(some(resume())) + 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 @@ -1961,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" + ) + ) ) } @@ -1986,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 { @@ -2039,7 +2517,6 @@ class ResultViewModel2 : ViewModel() { for (ep in currentRange) { if (ep.getWatchProgress() > 0.9) continue handleAction( - activity, EpisodeClickEvent( getPlayerAction(activity), ep @@ -2049,6 +2526,7 @@ class ResultViewModel2 : ViewModel() { } } } + START_ACTION_LOAD_EP -> { val all = currentEpisodes.values.flatten() val episode = @@ -2059,7 +2537,6 @@ class ResultViewModel2 : ViewModel() { } ?: return@launchSafe handleAction( - activity, EpisodeClickEvent( getPlayerAction(activity), episode @@ -2069,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, @@ -2076,10 +2616,11 @@ class ResultViewModel2 : ViewModel() { showFillers: Boolean, dubStatus: DubStatus, autostart: AutoResume?, + loadTrailers: Boolean = true, ) = - viewModelScope.launchSafe { + ioSafe { _page.postValue(Resource.Loading(url)) - _episodes.postValue(ResourceSome.Loading()) + _episodes.postValue(Resource.Loading()) preferDubStatus = dubStatus currentShowFillers = showFillers @@ -2090,12 +2631,10 @@ class ResultViewModel2 : ViewModel() { _page.postValue( Resource.Failure( false, - null, - null, "This provider does not exist" ) ) - return@launchSafe + return@ioSafe } @@ -2103,24 +2642,18 @@ class ResultViewModel2 : ViewModel() { val validUrlResource = safeApiCall { SyncRedirector.redirect( url, - api.mainUrl + api ) } - // TODO: fix - // val validUrlResource = safeApiCall { - // SyncRedirector.redirect( - // url, - // api.mainUrl.replace(NineAnimeProvider().mainUrl, "9anime") - // .replace(GogoanimeProvider().mainUrl, "gogoanime") - // ) - // } + if (validUrlResource !is Resource.Success) { if (validUrlResource is Resource.Failure) { _page.postValue(validUrlResource) } - return@launchSafe + return@ioSafe } + val validUrl = validUrlResource.value val repo = APIRepository(api) currentRepo = repo @@ -2129,42 +2662,45 @@ class ResultViewModel2 : ViewModel() { is Resource.Failure -> { _page.postValue(data) } + is Resource.Success -> { - if (!isActive) return@launchSafe + if (!isActive) return@ioSafe val loadResponse = ioWork { applyMeta(data.value, currentMeta, currentSync).first } - if (!isActive) return@launchSafe + if (!isActive) return@ioSafe val mainId = loadResponse.getId() preferDubStatus = getDub(mainId) ?: preferDubStatus preferStartEpisode = getResultEpisode(mainId) - preferStartSeason = getResultSeason(mainId) + preferStartSeason = getResultSeason(mainId) ?: 1 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(), ) ) - - loadTrailers(data.value) + if (loadTrailers) + loadTrailers(data.value) postSuccessful( data.value, + mainId, updateEpisodes = true, updateFillers = showFillers, apiRepository = repo ) - if (!isActive) return@launchSafe + if (!isActive) return@ioSafe handleAutoStart(activity, autostart) } + is Resource.Loading -> { debugException { "Invalid load result" } } 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 2e7ec529f3d..4231819dd25 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt @@ -1,117 +1,69 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater -import android.view.View 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.R -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.databinding.ResultSelectionBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.setText typealias SelectData = Pair -class SelectAdaptor(val callback: (Any) -> Unit) : 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( - 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 - recyclerView.apply { - for (i in 0 until itemCount) { - val viewHolder = getChildViewHolder( getChildAt(i) ?: continue) ?: continue - val pos = viewHolder.absoluteAdapterPosition - if (viewHolder is SelectViewHolder) { - if (pos == oldIndex) { - viewHolder.update(false) - } else if (pos == newIndex) { - viewHolder.update(true) - } - } - } - } - } - - fun updateSelectionList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SelectDataCallback(this.selection, newList) - ) - - selection.clear() - selection.addAll(newList) - diffResult.dispatchUpdatesTo(this) - } - - - private class SelectViewHolder - constructor( - itemView: View, - ) : - RecyclerView.ViewHolder(itemView) { - private val item: MaterialButton = itemView as MaterialButton - - fun update(isSelected: Boolean) { - item.isSelected = isSelected - } - - 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) - } - } + notifyItemChanged(selectedIndex) + notifyItemChanged(oldIndex) } } - -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 91415d26d30..6c5c64ff8bc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt @@ -4,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,25 +34,25 @@ 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?> = + private val _userDataResponse: MutableLiveData?> = MutableLiveData(null) - val userData: LiveData?> get() = _userDataResponse + val userData: LiveData?> = _userDataResponse // prefix, id - private var syncs = mutableMapOf() + private val syncs = mutableMapOf() //private val _syncIds: MutableLiveData> = // MutableLiveData(mutableMapOf()) //val syncIds: LiveData> get() = _syncIds - fun getSyncs() : Map { + fun getSyncs(): Map { return syncs } @@ -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, ) } @@ -106,7 +111,7 @@ class SyncViewModel : ViewModel() { Log.i(TAG, "addFromUrl = $url") if (url == null || hasAddedFromUrl.contains(url)) return@ioSafe - if(!url.startsWith("http")) return@ioSafe + if (!url.startsWith("http")) return@ioSafe SyncUtil.getIdsFromUrl(url)?.let { (malId, aniListId) -> hasAddedFromUrl.add(url) @@ -150,15 +155,17 @@ class SyncViewModel : ViewModel() { val user = userData.value if (user is Resource.Success) { - _userDataResponse.postValue(Resource.Success(user.value.copy(watchedEpisodes = episodes))) + user.value.watchedEpisodes = episodes + _userDataResponse.postValue(Resource.Success(user.value)) } } - fun setScore(score: Int) { + fun setScore(score: Score?) { Log.i(TAG, "setScore = $score") val user = userData.value if (user is Resource.Success) { - _userDataResponse.postValue(Resource.Success(user.value.copy(score = score))) + user.value.score = score + _userDataResponse.postValue(Resource.Success(user.value)) } } @@ -167,7 +174,8 @@ class SyncViewModel : ViewModel() { if (which < -1 || which > 5) return // validate input val user = userData.value if (user is Resource.Success) { - _userDataResponse.postValue(Resource.Success(user.value.copy(status = which))) + user.value.status = SyncWatchType.fromInternalId(which) + _userDataResponse.postValue(Resource.Success(user.value)) } } @@ -176,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() @@ -185,31 +193,23 @@ class SyncViewModel : ViewModel() { fun modifyMaxEpisode(episodeNum: Int) { Log.i(TAG, "modifyMaxEpisode = $episodeNum") modifyData { status -> - status.copy( - watchedEpisodes = maxOf( - episodeNum, - status.watchedEpisodes ?: return@modifyData null - ) + status.watchedEpisodes = maxOf( + episodeNum, + status.watchedEpisodes ?: return@modifyData null ) + status } } /// modifies the current sync data, return null if you don't want to change it - private fun modifyData(update: ((SyncAPI.SyncStatus) -> (SyncAPI.SyncStatus?))) = + private fun modifyData(update: ((SyncAPI.AbstractSyncStatus) -> (SyncAPI.AbstractSyncStatus?))) = 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) } } } @@ -217,55 +217,55 @@ 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 if (syncs.containsKey(aniListApi.idPrefix)) { try { // swap can throw error - Collections.swap(current, current.indexOfFirst { it.first == aniListApi.idPrefix }, 0) - } catch (t : Throwable) { + Collections.swap( + current, + current.indexOfFirst { it.first == aniListApi.idPrefix }, + 0 + ) + } catch (t: Throwable) { logError(t) } } 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) } } } @@ -273,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 c2523931b21..7b63b6edec6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt @@ -4,99 +4,96 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.ImageView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.R 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 kotlinx.android.synthetic.main.search_result_compact.view.* +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 */ const val SEARCH_ACTION_LOAD = 0 + +/** Long press */ const val SEARCH_ACTION_SHOW_METADATA = 1 const val SEARCH_ACTION_PLAY_FILE = 2 const val SEARCH_ACTION_FOCUSED = 4 -class SearchClickCallback(val action: Int, val view: View, val position : Int, val card: SearchResponse) +class SearchClickCallback( + val action: Int, + val view: View, + val position: Int, + val card: SearchResponse +) class SearchAdapter( - private val cardList: MutableList, private val resView: AutofitRecyclerView, + private val isHorizontal:Boolean = false, private val clickCallback: (SearchClickCallback) -> Unit, -) : RecyclerView.Adapter() { - var hasNext : Boolean = false - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val layout = if(parent.context.IsBottomLayout()) R.layout.search_result_grid_expanded else R.layout.search_result_grid - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), - clickCallback, - resView - ) +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + if (a.id != null || b.id != null) { + a.id == b.id + } else { + a.name == b.name } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position], position) - } - } +})) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } } - override fun getItemCount(): Int { - return cardList.size - } + var hasNext: Boolean = false - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SearchResponseDiffCallback(this.cardList, newList) - ) + private val coverRatio = if(isHorizontal) 1.8 else 0.68 + + private val coverHeight: Int get() = (resView.itemWidth / coverRatio).roundToInt() - cardList.clear() - cardList.addAll(newList) + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) - diffResult.dispatchUpdatesTo(this) + val layout = + if (parent.context.isBottomLayout()) SearchResultGridExpandedBinding.inflate( + inflater, + parent, + false + ) else SearchResultGridBinding.inflate( + inflater, + parent, + false + ) + return ViewHolderState(layout) } - class CardViewHolder - constructor( - itemView: View, - private val clickCallback: (SearchClickCallback) -> Unit, - resView: AutofitRecyclerView - ) : - RecyclerView.ViewHolder(itemView) { - val cardView: ImageView = itemView.imageView + override fun onClearView(holder: ViewHolderState) { + clearImage( + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> binding.imageView + is SearchResultGridBinding -> binding.imageView + else -> null + } + ) + } - private val compactView = false//itemView.context.getGridIsCompact() - private val coverHeight: Int = if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() + override fun onBindContent(holder: ViewHolderState, item: 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 4e59e6a0831..5f5b064b543 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -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,55 +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.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality +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 kotlinx.android.synthetic.main.fragment_search.* -import kotlinx.android.synthetic.main.tvtypes_chips.* +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 -> @@ -78,12 +105,28 @@ class SearchFragment : Fragment() { fun newInstance(query: String): Bundle { return Bundle().apply { - putString(SEARCH_QUERY, query) + if (query.isNotBlank()) putString(SEARCH_QUERY, query) } } } private val searchViewModel: SearchViewModel by activityViewModels() + private var bottomSheetDialog: BottomSheetDialog? = null + + private val speechRecognizerLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val data: Intent? = result.data + val matches = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + if (!matches.isNullOrEmpty()) { + val recognizedText = matches[0] + binding?.mainSearch?.setQuery(recognizedText, true) + } + } + } + + override fun pickLayout(): Int? = + if (isLayout(TV or EMULATOR)) R.layout.fragment_search_tv else R.layout.fragment_search override fun onCreateView( inflater: LayoutInflater, @@ -93,25 +136,14 @@ class SearchFragment : Fragment() { activity?.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) - return inflater.inflate(R.layout.fragment_search, container, false) - } - - private fun fixGrid() { - activity?.getSpanCount()?.let { - currentSpan = it - } - search_autofit_results.spanCount = currentSpan - currentSpan = currentSpan - HomeFragment.configEvent.invoke(currentSpan) - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - fixGrid() + bottomSheetDialog?.ownShow() + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { hideKeyboard() + bottomSheetDialog?.ownHide() + activity?.detachBackPressedCallback("SearchFragment") super.onDestroyView() } @@ -135,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() @@ -170,56 +203,85 @@ class SearchFragment : Fragment() { searchViewModel.reloadRepos() context?.filterProviderByPreferredMedia()?.let { validAPIs -> bindChips( - home_select_group, + binding?.tvtypesChipsScroll?.tvtypesChips, selectedSearchTypes, 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(main_search?.query?.toString()) + search(binding?.mainSearch?.query?.toString()) } } } } + 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() + } - context?.fixPaddingStatusbar(searchRoot) - fixGrid() + override fun onBindingCreated( + binding: FragmentSearchBinding, + savedInstanceState: Bundle? + ) { reloadRepos() + binding.apply { + val adapter = + SearchAdapter( + searchAutofitResults, + ) { callback -> + SearchHelper.handleSearchClickCallback(callback) + } - val adapter: RecyclerView.Adapter? = activity?.let { - SearchAdapter( - ArrayList(), - search_autofit_results, - ) { callback -> - SearchHelper.handleSearchClickCallback(activity, 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 } - search_autofit_results.adapter = adapter - search_loading_bar.alpha = 0f + binding.voiceSearch.setOnClickListener { searchView -> + searchView?.context?.let { ctx -> + try { + if (!SpeechRecognizer.isRecognitionAvailable(ctx)) { + showToast(R.string.speech_recognition_unavailable) + } else { + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra( + RecognizerIntent.EXTRA_LANGUAGE_MODEL, + RecognizerIntent.LANGUAGE_MODEL_FREE_FORM + ) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) + putExtra( + RecognizerIntent.EXTRA_PROMPT, + ctx.getString(R.string.begin_speaking) + ) + } + speechRecognizerLauncher.launch(intent) + } + } catch (_: Throwable) { + // launch may throw + showToast(R.string.speech_recognition_unavailable) + } + } + } val searchExitIcon = - main_search.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 + binding.mainSearch.findViewById(androidx.appcompat.R.id.search_close_btn) - context?.let { ctx -> - val validAPIs = ctx.filterProviderByPreferredMedia() - selectedApis = ctx.getKey( - SEARCH_PREF_PROVIDERS, - defVal = validAPIs.map { it.name } - )!!.toMutableSet() - } + selectedApis = DataStoreHelper.searchPreferenceProviders.toMutableSet() - search_filter.setOnClickListener { searchView -> + binding.searchFilter.setOnClickListener { searchView -> searchView?.context?.let { ctx -> val validAPIs = ctx.filterProviderByPreferredMedia(hasHomePageIsRequired = false) var currentValidApis = listOf() @@ -230,9 +292,19 @@ class SearchFragment : Fragment() { BottomSheetDialog(ctx) builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED - builder.setContentView(R.layout.home_select_mainpage) + + 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) } @@ -259,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 -> @@ -284,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( - dialog.home_select_group, + 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() } @@ -315,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()) } @@ -325,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()) { - search_filter.isFocusable = true - search_filter.isFocusableInTouchMode = true + if (!isLayout(PHONE)) { + binding.searchFilter.isFocusable = true + binding.searchFilter.isFocusableInTouchMode = true } - main_search.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() - main_search?.let { + binding.mainSearch.let { hideKeyboard(it) } @@ -353,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 { + searchHistoryRecycler.isVisible = showHistory + searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch + searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch + // Hide suggestions when showing history or showing search results + searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled } - - search_history_holder?.isVisible = showHistory - - search_master_recycler?.isVisible = !showHistory && isAdvancedSearch - search_autofit_results?.isVisible = !showHistory && !isAdvancedSearch return true } }) - search_clear_call_history?.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() - } catch (e: Exception) { - logError(e) - // ye you somehow fucked up formatting did you? - } - } - - - } - - observe(searchViewModel.currentHistory) { list -> - search_clear_call_history?.isVisible = list.isNotEmpty() - (search_history_recycler.adapter as? SearchHistoryAdaptor?)?.updateList(list) - } - - searchViewModel.updateHistory() - observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> - if (data.isNotEmpty()) { - (search_autofit_results?.adapter as SearchAdapter?)?.updateList(data) + val list = data.list + if (list.isNotEmpty()) { + (binding.searchAutofitResults.adapter as? SearchAdapter)?.submitList( + list + ) } } - searchExitIcon.alpha = 1f - search_loading_bar.alpha = 0f + searchExitIcon?.alpha = 1f + binding.searchLoadingBar.alpha = 0f } + is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() - searchExitIcon.alpha = 1f - search_loading_bar.alpha = 0f + searchExitIcon?.alpha = 1f + binding.searchLoadingBar.alpha = 0f } + is Resource.Loading -> { - searchExitIcon.alpha = 0f - search_loading_bar.alpha = 1f + searchExitIcon?.alpha = 0f + binding.searchLoadingBar.alpha = 1f } } } @@ -432,20 +496,33 @@ class SearchFragment : Fragment() { try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() - (search_master_recycler?.adapter as ParentItemAdapter?)?.apply { - val newItems = list.map { ongoing -> - val dataList = - if (ongoing.data is Resource.Success) ongoing.data.value else ArrayList() + + val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() + + val sortedList = list.toList().sortedWith(compareBy { (providerName, _) -> + val index = pinnedOrder.indexOf(providerName) + if (index == -1) Int.MAX_VALUE else index + }) + + (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { + val newItems = sortedList.map { (providerName, providerData) -> + val dataList = providerData.list val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList - val ongoingList = HomePageList( - ongoing.apiName, + + val homePageList = HomePageList( + providerName, dataListFiltered ) - ongoingList + + HomeViewModel.ExpandableHomepageList( + homePageList, + providerData.currentPage, + providerData.hasNext + ) } - updateList(newItems) + submitList(newItems) //notifyDataSetChanged() } } catch (e: Exception) { @@ -464,57 +541,162 @@ class SearchFragment : Fragment() { }*/ //main_search.onActionViewExpanded()*/ - val masterAdapter: RecyclerView.Adapter = - ParentItemAdapter(mutableListOf(), { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) + val masterAdapter = + ParentItemAdapter(id = "masterAdapter".hashCode(), { callback -> + SearchHelper.handleSearchClickCallback(callback) }, { item -> - activity?.loadHomepageList(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(home_select_group, searchItem.type.toMutableList()) - main_search?.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??? } } } - search_history_recycler?.adapter = historyAdapter - search_history_recycler?.layoutManager = GridLayoutManager(context, 1) + 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()) { + sq = MainActivity.nextSearchQuery + } + + sq?.let { query -> + if (query.isBlank()) return@let - search_master_recycler?.adapter = masterAdapter - search_master_recycler?.layoutManager = GridLayoutManager(context, 1) + // 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) + MainActivity.nextSearchQuery = null + } + } - // Automatically search the specified query, this allows the app search to launch from intent - arguments?.getString(SEARCH_QUERY)?.let { query -> - if (query.isBlank()) return@let - main_search?.setQuery(query, true) - // Clear the query as to not make it request the same query every time the page is opened - arguments?.putString(SEARCH_QUERY, null) + 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") + } + } + } -} \ No newline at end of file + searchViewModel.updateHistory() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt index 1de89809629..449a04bf81f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt @@ -1,60 +1,66 @@ 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 +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R 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.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(activity: Activity?, callback: SearchClickCallback) { + fun handleSearchClickCallback(callback: SearchClickCallback) { val card = callback.card when (callback.action) { SEARCH_ACTION_LOAD -> { - activity.loadSearchResult(card) + loadSearchResult(card) } + SEARCH_ACTION_PLAY_FILE -> { if (card is DataStoreHelper.ResumeWatchingResult) { val id = card.id - if(id == null) { - showToast(activity, R.string.error_invalid_id, Toast.LENGTH_SHORT) + if (id == null) { + showToast(R.string.error_invalid_id, Toast.LENGTH_SHORT) } else { if (card.isFromDownload) { handleDownloadClick( - activity, DownloadClickEvent( + 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(), ) ) ) } else { - activity.loadSearchResult(card, START_ACTION_LOAD_EP, id) + loadSearchResult(card, START_ACTION_LOAD_EP, id) } } } else { handleSearchClickCallback( - activity, SearchClickCallback(SEARCH_ACTION_LOAD, callback.view, -1, callback.card) ) } } + SEARCH_ACTION_SHOW_METADATA -> { - showToast(activity, callback.card.name, Toast.LENGTH_SHORT) + (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 8132301b591..4868abb3d08 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt @@ -1,16 +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 kotlinx.android.synthetic.main.search_history_item.view.* +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, @@ -20,84 +22,73 @@ data class SearchHistoryItem( ) data class SearchHistoryCallback( - val item: SearchHistoryItem, + val item: SearchHistoryItem?, val clickAction: Int, ) const val SEARCH_HISTORY_OPEN = 0 const val SEARCH_HISTORY_REMOVE = 1 +const val SEARCH_HISTORY_CLEAR = 2 class SearchHistoryAdaptor( - private val cardList: MutableList, private val clickCallback: (SearchHistoryCallback) -> Unit, -) : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.search_history_item, parent, false), - clickCallback, +) : 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), ) } - 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( - itemView: View, - private val clickCallback: (SearchHistoryCallback) -> Unit, - ) : - RecyclerView.ViewHolder(itemView) { - 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) { - title.text = card.searchText - - removeButton.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_REMOVE)) + + 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 } - openButton.setOnClickListener { - clickCallback.invoke(SearchHistoryCallback(card, SEARCH_HISTORY_OPEN)) + 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 3afbb8c0431..fd99b8d4b06 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt @@ -1,21 +1,34 @@ 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 import android.widget.TextView import androidx.cardview.widget.CardView import androidx.core.view.isVisible +import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.getNameFull +import com.lagradost.cloudstream3.AnimeSearchResponse +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.LiveSearchResponse +import com.lagradost.cloudstream3.R +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.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 kotlinx.android.synthetic.main.home_result_grid.view.* +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.getImageFromDrawable object SearchResultBuilder { private val showCache: MutableMap = mutableMapOf() @@ -29,32 +42,32 @@ 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 ) { - val cardView: ImageView = itemView.imageView - val cardText: TextView? = itemView.imageText + val cardView: ImageView = itemView.findViewById(R.id.imageView) + val cardText: TextView? = itemView.findViewById(R.id.imageText) - val textIsDub: TextView? = itemView.text_is_dub - val textIsSub: TextView? = itemView.text_is_sub - val textFlag: TextView? = itemView.text_flag - val textQuality: TextView? = itemView.text_quality - val shadow: View? = itemView.title_shadow + val textIsDub: TextView? = itemView.findViewById(R.id.text_is_dub) + val textIsSub: TextView? = itemView.findViewById(R.id.text_is_sub) + val textFlag: TextView? = itemView.findViewById(R.id.text_flag) + val rating: TextView? = itemView.findViewById(R.id.text_rating) - val bg: CardView = itemView.background_card + val textQuality: TextView? = itemView.findViewById(R.id.text_quality) + val shadow: View? = itemView.findViewById(R.id.title_shadow) - val bar: ProgressBar? = itemView.watchProgress - val playImg: ImageView? = itemView.search_item_download_play + val bg: CardView = itemView.findViewById(R.id.background_card) + + 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 @@ -63,11 +76,31 @@ object SearchResultBuilder { textIsDub?.isVisible = false textIsSub?.isVisible = false textFlag?.isVisible = false + rating?.isVisible = false + episodeText?.isVisible = false val showSub = showCache[textIsDub?.context?.getString(R.string.show_sub_key)] ?: false val showDub = showCache[textIsDub?.context?.getString(R.string.show_dub_key)] ?: false val showTitle = showCache[cardText?.context?.getString(R.string.show_title_key)] ?: false + val showEpisodeText = showCache[cardText?.context?.getString(R.string.show_episode_text_key)] ?: false val showHd = showCache[textQuality?.context?.getString(R.string.show_hd_key)] ?: false + val showRatingView = + showCache[textQuality?.context?.getString(R.string.show_rating_key)] ?: false + if (card is SyncAPI.LibraryItem) { + val ratingText = card.personalRating?.toStringNull(0.1, 10, 1) + val showRating = !ratingText.isNullOrBlank() + rating?.isVisible = showRating + if (showRating) { + rating?.text = ratingText + } + } else if (showRatingView) { + val ratingText = card.score?.toStringNull(0.1, 10, 1) + val showRating = !ratingText.isNullOrBlank() + rating?.isVisible = showRating + if (showRating) { + rating?.text = ratingText + } + } shadow?.isVisible = showTitle @@ -99,10 +132,11 @@ object SearchResultBuilder { cardText?.text = card.name cardText?.isVisible = showTitle cardView.isVisible = true - - if (!cardView.setImage(card.posterUrl, card.posterHeaders)) { - 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( @@ -139,52 +173,69 @@ object SearchResultBuilder { } } - bg.setOnClickListener { - click(it) + bg.isFocusable = false + bg.isFocusableInTouchMode = false + if (!isLayout(TV)) { + bg.setOnClickListener { + click(it) + } + bg.setOnLongClickListener { + longClick(it) + return@setOnLongClickListener true + } } + // + // + // itemView.setOnClickListener { click(it) } - if (nextFocusUp != null) { - bg.nextFocusUpId = nextFocusUp + itemView.nextFocusUpId = nextFocusUp } if (nextFocusDown != null) { - bg.nextFocusDownId = nextFocusDown + itemView.nextFocusDownId = nextFocusDown } - when (nextFocusBehavior) { - true -> bg.nextFocusLeftId = bg.id - false -> bg.nextFocusRightId = bg.id + /*when (nextFocusBehavior) { + true -> itemView.nextFocusLeftId = bg.id + false -> itemView.nextFocusRightId = bg.id null -> { bg.nextFocusRightId = -1 bg.nextFocusLeftId = -1 } + }*/ + + /*if (nextFocusUp != null) { + bg.nextFocusUpId = nextFocusUp + } + + if (nextFocusDown != null) { + bg.nextFocusDownId = nextFocusDown } - 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 } - bg.setOnLongClickListener { - longClick(it) - return@setOnLongClickListener true - } + /**/ itemView.setOnLongClickListener { longClick(it) return@setOnLongClickListener true } - bg.setOnFocusChangeListener { view, b -> + /*bg.setOnFocusChangeListener { view, b -> focus(view, b) - } + }*/ itemView.setOnFocusChangeListener { view, b -> focus(view, b) @@ -199,6 +250,7 @@ object SearchResultBuilder { } } } + is DataStoreHelper.ResumeWatchingResult -> { val pos = card.watchPos?.fixVisual() if (pos != null) { @@ -206,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()) { @@ -249,5 +302,29 @@ object SearchResultBuilder { } } } + + // This is the logic for making the rounded corners more round on the top and bottom element + // a bit dirty to do memory allocation, but it makes it more extensible and is easier to reason about + // then a large if statement + + // Requires that the ordering here is the same as in the xml + val boxes = arrayListOf() + for (view in arrayOf(textIsDub, textIsSub, rating)) { + if (view?.isVisible == true) { + boxes.add(view) + } + } + if (boxes.size == 1) { + boxes[0].setBackgroundResource(R.drawable.bg_color_both) + } else if (boxes.size > 1) { + boxes[0].setBackgroundResource(R.drawable.bg_color_top) + for (i in 1 until boxes.size) { + boxes[i].setBackgroundResource(R.drawable.bg_color_center) + } + boxes[boxes.size - 1].setBackgroundResource(R.drawable.bg_color_bottom) + } + textIsDub?.apply { + backgroundTintList = ColorStateList.valueOf(context.colorFromAttribute(R.attr.textColor)) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt new file mode 100644 index 00000000000..74d5e7b08a3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt @@ -0,0 +1,85 @@ +package com.lagradost.cloudstream3.ui.search + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import com.lagradost.cloudstream3.databinding.SearchSuggestionFooterBinding +import com.lagradost.cloudstream3.databinding.SearchSuggestionItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout + +const val SEARCH_SUGGESTION_CLICK = 0 +const val SEARCH_SUGGESTION_FILL = 1 +const val SEARCH_SUGGESTION_CLEAR = 2 + +data class SearchSuggestionCallback( + val suggestion: String, + val clickAction: Int, +) + +class SearchSuggestionAdapter( + private val clickCallback: (SearchSuggestionCallback) -> Unit, +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a == b })) { + + // Add footer for all layouts + override val footers = 1 + + override fun submitList(list: Collection?, commitCallback: Runnable?) { + super.submitList(list, commitCallback) + // Notify footer to rebind when list changes to update visibility + if (footers > 0) { + notifyItemChanged(itemCount - 1) + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) + } + + override fun onBindContent( + holder: ViewHolderState, + item: String, + position: Int + ) { + val binding = holder.view as? SearchSuggestionItemBinding ?: return + binding.apply { + suggestionText.text = item + + // Click on the whole item to search + suggestionItem.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_CLICK)) + } + + // Click on the arrow to fill the search box without searching + suggestionFill.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_FILL)) + } + } + } + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + } + + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? SearchSuggestionFooterBinding ?: return + binding.clearSuggestionsButton.apply { + isGone = immutableCurrentList.isEmpty() + if (isLayout(TV or EMULATOR)) { + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback("", SEARCH_SUGGESTION_CLEAR)) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt new file mode 100644 index 00000000000..8dbd7817898 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt @@ -0,0 +1,74 @@ +package com.lagradost.cloudstream3.ui.search + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.nicehttp.NiceResponse + +/** + * API for fetching search suggestions from external sources. + * Uses TheMovieDB API to provide movie/show/anime related suggestions. + */ +object SearchSuggestionApi { + private const val TMDB_API_URL = "https://api.themoviedb.org/3/search/multi" + private const val TMDB_API_KEY = "e6333b32409e02a4a6eba6fb7ff866bb" + + data class TmdbSearchResult( + @JsonProperty("results") val results: List? + ) + + data class TmdbSearchItem( + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("original_title") val originalTitle: String?, + @JsonProperty("original_name") val originalName: String? + ) + + /** + * Fetches search suggestions from TheMovieDB multi search API. + * Returns suggestions for movies, TV series, and anime. + * + * @param query The search query to get suggestions for + * @return List of suggestion strings, empty list on failure + */ + suspend fun getSuggestions(query: String): List { + if (query.isBlank() || query.length < 2) return emptyList() + + return try { + val response = app.get( + TMDB_API_URL, + params = mapOf( + "api_key" to TMDB_API_KEY, + "query" to query, + "language" to "en-US" + ), + cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes) + ) + + parseSuggestions(response) + } catch (e: Exception) { + logError(e) + emptyList() + } + } + + /** + * Parses the TMDB search response and extracts movie/TV show titles. + * Filters to only include movies, TV shows, and anime. + */ + private fun parseSuggestions(response: NiceResponse): List { + return try { + val parsed = response.parsed() + parsed.results + ?.filter { it.mediaType == "movie" || it.mediaType == "tv" } + ?.mapNotNull { it.title ?: it.name } + ?.distinct() + ?.take(10) + ?: emptyList() + } catch (e: Exception) { + logError(e) + emptyList() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index aceda644c8d..27db8d1ae5e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -5,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 = apis.map { APIRepository(it) } + private val _searchSuggestions: MutableLiveData> = MutableLiveData() + val searchSuggestions: LiveData> get() = _searchSuggestions + + private var suggestionJob: Job? = null + + private var repos = synchronized(apis) { apis.map { APIRepository(it) } } fun clearSearch() { - _searchResponse.postValue(Resource.Success(ArrayList())) - _currentSearch.postValue(emptyList()) + _searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false))) + _currentSearch.postValue(emptyMap()) + expandableSearches.clear() } + var lastQuery: String? = null + + /** Save which providers can searched again and which search result page they are on. + * Maps provider name to search list. + * @see [HomeViewModel.expandable] */ + private val expandableSearches: MutableMap = mutableMapOf() + private var currentSearchIndex = 0 private var onGoingSearch: Job? = null fun reloadRepos() { - repos = apis.map { APIRepository(it) } + repos = synchronized(apis) { apis.map { APIRepository(it) } } } fun searchAndCancel( @@ -62,15 +82,119 @@ 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( query: String, providersActive: Set, @@ -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 9e03079f633..938b870bb89 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt @@ -1,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 e879f0dffdc..be8b4180c2b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt @@ -3,61 +3,54 @@ package com.lagradost.cloudstream3.ui.settings import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.utils.UIHelper.setImage +import com.lagradost.cloudstream3.databinding.AccountSingleBinding +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage -class AccountClickCallback(val action: Int, val view: View, val card: AuthAPI.LoginInfo) +class AccountClickCallback(val action: Int, val view: View, val card: AuthData) class AccountAdapter( - val cardList: List, - val layout: Int = R.layout.account_single, private val clickCallback: (AccountClickCallback) -> Unit ) : - RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), clickCallback + NoStateAdapter( + diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.user.id == b.user.id + }) + ) { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + AccountSingleBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position]) - } - } + override fun 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(itemView: View, private val clickCallback: (AccountClickCallback) -> Unit) : - RecyclerView.ViewHolder(itemView) { - 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 - accountName.text = card.name ?: "%s %d".format( - 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 ) - pfp.isVisible = pfp.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 00000000000..93e469a4d64 --- /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 00000000000..36599064683 --- /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 f9627e467a0..8d96a6b140e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -1,214 +1,432 @@ 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.AuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI -import com.lagradost.cloudstream3.syncproviders.OAuth2API +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthRepo +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import com.lagradost.cloudstream3.syncproviders.SubtitleRepo +import com.lagradost.cloudstream3.syncproviders.SyncRepo +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +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 kotlinx.android.synthetic.main.account_managment.* -import kotlinx.android.synthetic.main.account_switch.* -import kotlinx.android.synthetic.main.add_account_input.* +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 = + AccountManagmentBinding.inflate(activity.layoutInflater, null, false) val builder = - AlertDialog.Builder(activity ?: return, R.style.AlertDialogCustom) - .setView(R.layout.account_managment) + AlertDialog.Builder(activity, R.style.AlertDialogCustom) + .setView(binding.root) val dialog = builder.show() - dialog.account_main_profile_picture_holder?.isVisible = - dialog.account_main_profile_picture?.setImage(info.profilePicture) == true + binding.accountMainProfilePictureHolder.isVisible = + !info?.profilePicture.isNullOrEmpty() + binding.accountMainProfilePicture.loadImage(info?.profilePicture) - dialog.account_logout?.setOnClickListener { - api.logOut() + binding.accountLogout.isVisible = info != null + binding.accountLogout.setOnClickListener { + 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) } - dialog.account_site?.text = api.name - dialog.account_switch_account?.setOnClickListener { + binding.accountSite.text = api.name + binding.accountSwitchAccount.setOnClickListener { dialog.dismissSafe(activity) showAccountSwitch(activity, api) } - if (isTvSettings()) { - dialog.account_switch_account?.requestFocus() + if (isLayout(TV or EMULATOR)) { + binding.accountSwitchAccount.requestFocus() } } - 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) val builder = AlertDialog.Builder(activity, R.style.AlertDialogCustom) - .setView(R.layout.account_switch) + .setView(binding.root) val dialog = builder.show() - dialog.account_add?.setOnClickListener { + binding.accountAdd.setOnClickListener { addAccount(activity, api) dialog?.dismissSafe(activity) } - val ogIndex = api.accountIndex - - val items = ArrayList() - - for (index in accounts) { - api.accountIndex = index - val accountInfo = api.loginInfo() - if (accountInfo != null) { - items.add(accountInfo) - } + binding.accountNone.setOnClickListener { + api.accountId = -1 + dialog?.dismissSafe(activity) } - api.accountIndex = ogIndex - val adapter = AccountAdapter(items, R.layout.account_single) { + + val adapter = AccountAdapter { dialog?.dismissSafe(activity) - api.changeAccount(it.card.accountIndex) + api.accountId = it.card.user.id + }.apply { + submitList(accounts.toList()) } val list = dialog.findViewById(R.id.account_list) list?.adapter = adapter } + @UiThread - fun addAccount(activity: FragmentActivity?, api: AccountManager) { - try { - when (api) { - is OAuth2API -> { - api.authenticate(activity) + fun showPin(activity: FragmentActivity, api: AuthRepo) { + val binding: DeviceAuthBinding = + DeviceAuthBinding.inflate(activity.layoutInflater, null, false) + + val builder = + AlertDialog.Builder(activity) + .setView(binding.root) + + builder.apply { + setNegativeButton(R.string.cancel) { _, _ -> } + if (api.hasOAuth2) { + setPositiveButton(R.string.auth_locally) { _, _ -> + api.openOAuth2PageWithToast() } - is InAppAuthAPI -> { - val builder = - AlertDialog.Builder(activity ?: return, R.style.AlertDialogCustom) - .setView(R.layout.add_account_input) - val dialog = builder.show() - - val visibilityMap = mapOf( - dialog.login_email_input to api.requiresEmail, - dialog.login_password_input to api.requiresPassword, - dialog.login_server_input to api.requiresServer, - dialog.login_username_input to api.requiresUsername + } + } + + 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 + } - 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 - } - } + /*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() + }*/ - dialog.login_email_input?.isVisible = api.requiresEmail - dialog.login_password_input?.isVisible = api.requiresPassword - dialog.login_server_input?.isVisible = api.requiresServer - dialog.login_username_input?.isVisible = api.requiresUsername - dialog.create_account?.isGone = api.createAccountUrl.isNullOrBlank() - dialog.create_account?.setOnClickListener { - openBrowser( - api.createAccountUrl ?: return@setOnClickListener, - activity + 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 ) - dialog.dismissSafe() - } - dialog.text1?.text = api.name - - if (api.storesPasswordInPlainText) { - api.getLatestLoginData()?.let { data -> - dialog.login_email_input?.setText(data.email ?: "") - dialog.login_server_input?.setText(data.server ?: "") - dialog.login_username_input?.setText(data.username ?: "") - dialog.login_password_input?.setText(data.password ?: "") - } - } + ) + deviceAuthQrcode.loadImage(qrCodeImage) + } - dialog.apply_btt?.setOnClickListener { - val loginData = InAppAuthAPI.LoginData( - username = if (api.requiresUsername) dialog.login_username_input?.text?.toString() else null, - password = if (api.requiresPassword) dialog.login_password_input?.text?.toString() else null, - email = if (api.requiresEmail) dialog.login_email_input?.text?.toString() else null, - server = if (api.requiresServer) dialog.login_server_input?.text?.toString() else null, + 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 { - val isSuccessful = try { - api.login(loginData) - } catch (e: Exception) { - logError(e) - false - } - activity.runOnUiThread { - try { - showToast( - activity, - activity.getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail) - .format( - api.name - ) + if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.login( + pinCodeData + ) + ) { + showToast( + txt( + R.string.authenticated_user, + api.name ) - } catch (e: Exception) { - logError(e) // format might fail - } + ) + dialog.dismissSafe(activity) + cancel() } } - dialog.dismissSafe(activity) } - dialog.cancel_btt?.setOnClickListener { + + override fun onFinish() { + showToast(R.string.device_pin_expired_message) dialog.dismissSafe(activity) } + }.start() + } + } + } + + + fun showAppLogin(activity: FragmentActivity, api: AuthRepo) { + + val binding: AddAccountInputBinding = + AddAccountInputBinding.inflate(activity.layoutInflater, null, false) + val builder = + AlertDialog.Builder(activity, R.style.AlertDialogCustom) + .setView(binding.root) + val dialog = builder.show() + val req = + api.inAppLoginRequirement ?: throw ErrorLoadingException("Missing LoginRequirement") + val visibilityMap = listOf( + binding.loginEmailInput to req.email, + binding.loginPasswordInput to req.password, + binding.loginServerInput to req.server, + binding.loginUsernameInput to req.username + ) + + if (isLayout(TV or EMULATOR)) { + visibilityMap.forEach { (input, isVisible) -> + input.isVisible = isVisible + + // Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen + input.setOnEditorActionListener { textView, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_NEXT) { + val view = textView.focusSearch(FOCUS_DOWN) + return@setOnEditorActionListener view?.requestFocus( + FOCUS_DOWN + ) == true + } + return@setOnEditorActionListener true } - else -> { - throw NotImplementedError("You are trying to add an account that has an unknown login method") + } + } 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 + ) + ) } } - } catch (e: Exception) { - logError(e) } + binding.cancelBtt.setOnClickListener { + dialog.dismissSafe(activity) + } + } + + @UiThread + fun addAccount(activity: FragmentActivity, api: AuthRepo) { + try { + if (api.hasPin && !isLayout(PHONE)) { + showPin(activity, api) + } else if (api.hasOAuth2) { + api.openOAuth2PageWithToast() + } else if (api.hasInApp) { + showAppLogin(activity, api) + } else { + throw NotImplementedError("The api ${api.name} has no login") + } + } catch (t: Throwable) { + showToast(txt(R.string.authenticated_user_fail, api.name)) + logError(t) + } + } + } + + 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) } } @@ -216,27 +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.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 40c996cc33a..e41109b5982 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -1,41 +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.isVisible +import androidx.core.view.children +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 kotlinx.android.synthetic.main.main_settings.* -import kotlinx.android.synthetic.main.standard_toolbar.* +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) { @@ -44,37 +56,102 @@ 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 - settings_toolbar?.apply { + 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() + } } } - context.fixPaddingStatusbar(settings_toolbar) } fun Fragment?.setUpToolbar(@StringRes title: Int) { if (this == null) return - settings_toolbar?.apply { + 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) + children.firstOrNull { it is ImageView }?.tag = getString(R.string.tv_no_focus_tag) + setNavigationOnClickListener { + safe { activity?.onBackPressedDispatcher?.onBackPressed() } + } } } - context.fixPaddingStatusbar(settings_toolbar) + } + + fun Fragment.setSystemBarsPadding() { + view?.let { + fixSystemBarsPadding( + it, + padLeft = isLayout(TV or EMULATOR), + padBottom = isLandscape() + ) + } } fun getFolderSize(dir: File): Long { @@ -90,105 +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) - } + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } - private fun Context.isTvSettings(): Boolean { - var value = getLayoutInt() - if (value == -1) { - value = if (isAutoTv()) 1 else 0 - } - return value == 1 || value == 2 + override fun onBindingCreated(binding: MainSettingsBinding) { + fun navigate(id: Int) { + activity?.navigate(id, Bundle()) } - private fun Context.isTrueTvSettings(): Boolean { - var value = getLayoutInt() - if (value == -1) { - value = if (isAutoTv()) 1 else 0 - } - return value == 1 - } + /** used to debug leaks + showToast(activity,"${VideoDownloadManager.downloadStatusEvent.size} : + ${VideoDownloadManager.downloadProgressEvent.size}") **/ - fun Context.updateTv() { - isTrueTv = isTrueTvSettings() - isTv = isTvSettings() - } + fun hasProfilePictureFromAccountManagers(accountManagers: Array): Boolean { + for (syncApi in accountManagers) { + val login = syncApi.authUser() + val pic = login?.profilePicture ?: continue - fun isTrueTvSettings(): Boolean { - return isTrueTv + 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 } - fun isTvSettings(): Boolean { - return isTv - } + // 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) } - fun Context.isEmulatorSettings(): Boolean { - return getLayoutInt() == 2 - } + } catch (t: IllegalStateException) { + Log.e("AccountManager", "Activity not found", t) + null + } - 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") + binding.settingsProfilePic.loadImage(currentAccount?.image) + binding.settingsProfileText.text = currentAccount?.name } - } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.main_settings, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - fun navigate(id: Int) { - activity?.navigate(id, Bundle()) - } + binding.apply { + listOf( + settingsGeneral to R.id.action_navigation_global_to_navigation_settings_general, + settingsPlayer to R.id.action_navigation_global_to_navigation_settings_player, + 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 (isLayout(TV)) { + isFocusable = true + isFocusableInTouchMode = true + } + } + } - val isTrueTv = isTrueTvSettings() - - for (syncApi in accountManagers) { - val login = syncApi.loginInfo() - val pic = login?.profilePicture ?: continue - if (settings_profile_pic?.setImage( - pic, - errorImageDrawable = HomeFragment.errorProfilePic - ) == true - ) { - settings_profile_text?.text = login.name - settings_profile?.isVisible = true - break + // Default focus on TV + if (isLayout(TV)) { + settingsGeneral.requestFocus() } } - listOf( - Pair(settings_general, R.id.action_navigation_settings_to_navigation_settings_general), - Pair(settings_player, R.id.action_navigation_settings_to_navigation_settings_player), - Pair(settings_credits, R.id.action_navigation_settings_to_navigation_settings_account), - Pair(settings_ui, R.id.action_navigation_settings_to_navigation_settings_ui), - Pair(settings_providers, R.id.action_navigation_settings_to_navigation_settings_providers), - Pair(settings_updates, R.id.action_navigation_settings_to_navigation_settings_updates), - Pair( - settings_extensions, - R.id.action_navigation_settings_to_navigation_settings_extensions - ), - ).forEach { (view, navigationId) -> - view?.apply { - setOnClickListener { - navigate(navigationId) - } - if (isTrueTv) { - isFocusable = true - isFocusableInTouchMode = true - } - } + 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 68b65820a33..dbf2ff1dc53 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -1,94 +1,148 @@ package com.lagradost.cloudstream3.ui.settings import android.content.Context -import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Bundle -import android.os.Environment 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.hippo.unifile.UniFile 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 import com.lagradost.cloudstream3.R 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 kotlinx.android.synthetic.main.add_remove_sites.* -import kotlinx.android.synthetic.main.add_site_input.* -import java.io.File +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( - Triple("", "Spanish", "es"), - Triple("", "English", "en"), - Triple("", "Viet Nam", "vi"), - Triple("", "Dutch", "nl"), - Triple("", "French", "fr"), - Triple("", "Greek", "el"), - Triple("", "Swedish", "sv"), - Triple("", "Tagalog", "tl"), - Triple("", "Polish", "pl"), - Triple("", "Hindi", "hi"), - Triple("", "Malayalam", "ml"), - Triple("", "Norsk", "no"), - Triple("", "German", "de"), - Triple("", "Arabic", "ar"), - Triple("", "Turkish", "tr"), - Triple("", "Macedonian", "mk"), - Triple("\uD83C\uDDF5\uD83C\uDDF9", "Portuguese", "pt"), - Triple("\uD83C\uDDE7\uD83C\uDDF7", "Brazilian Portuguese", "bp"), - Triple("", "Romanian", "ro"), - Triple("", "Italian", "it"), - Triple("", "Chinese Simplified", "zh"), - Triple("\uD83C\uDDF9\uD83C\uDDFC", "Chinese Traditional", "zh_TW"), - Triple("\uD83C\uDDEE\uD83C\uDDE9", "Indonesian", "in"), - Triple("", "Czech", "cs"), - Triple("", "Croatian", "hr"), - Triple("", "Bulgarian", "bg"), - Triple("", "Bengali", "bn"), -).sortedBy { it.second } //ye, we go alphabetical, so ppl don't put their lang on top - -class SettingsGeneral : PreferenceFragmentCompat() { + /* begin language list */ + Pair("Afrikaans", "af"), + Pair("Azərbaycan dili", "az"), + Pair("Bahasa Indonesia", "in"), + Pair("Bahasa Melayu", "ms"), + Pair("Deutsch", "de"), + Pair("English", "en"), + Pair("Español", "es"), + Pair("Esperanto", "eo"), + Pair("Français", "fr"), + Pair("Galego", "gl"), + Pair("hrvatski", "hr"), + Pair("Italiano", "it"), + Pair("Latviešu valoda", "lv"), + Pair("Lietuvių kalba", "lt"), + Pair("Magyar", "hu"), + Pair("Malti", "mt"), + Pair("mmmm... monke", "qt"), + Pair("Nederlands", "nl"), + Pair("Norsk bokmål", "no"), + Pair("Norsk nynorsk", "nn"), + Pair("Polski", "pl"), + Pair("Português", "pt"), + Pair("Português (Brasil)", "pt-BR"), + Pair("Română", "ro"), + Pair("Slovenčina", "sk"), + Pair("Soomaaliga", "so"), + Pair("Svenska", "sv"), + Pair("Tagalog", "tl"), + Pair("Tiếng Việt", "vi"), + Pair("Türkçe", "tr"), + Pair("Wikang Filipino", "fil"), + Pair("Čeština", "cs"), + Pair("Ελληνικά", "el"), + Pair("български", "bg"), + Pair("македонски", "mk"), + Pair("русский", "ru"), + Pair("українська", "uk"), + Pair("עברית", "iw"), + Pair("اردو", "ur"), + Pair("العربية", "ar"), + Pair("اللهجة النجدية", "ars"), + Pair("عربي شامي", "apc"), + Pair("فارسی", "fa"), + Pair("کوردیی ناوەندی", "ckb"), + Pair("नेपाली", "ne"), + Pair("हिन्दी", "hi"), + Pair("অসমীয়া", "as"), + Pair("বাংলা", "bn"), + Pair("ଓଡ଼ିଆ", "or"), + Pair("தமிழ்", "ta"), + Pair("ಕನ್ನಡ", "kn"), + Pair("മലയാളം", "ml"), + Pair("ဗမာစာ", "my"), + Pair("ትግርኛ", "ti"), + Pair("አማርኛ", "am"), + Pair("中文", "zh"), + Pair("日本語 (にほんご)", "ja"), + Pair("正體中文(臺灣)", "zh-TW"), + Pair("한국어", "ko"), +/* end language list */ +).sortedBy { it.first.lowercase(Locale.ROOT) } // ye, we go alphabetical, so ppl don't put their lang on top + +fun Pair.nameNextToFlagEmoji(): String { + // fallback to [A][A] -> [?] question mak flag + val flag = SubtitleHelper.getFlagFromIso(this.second) ?: "\ud83c\udde6\ud83c\udde6" + + 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( @@ -102,37 +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 - - context.contentResolver.takePersistableUriPermission(uri, flags) - - val file = UniFile.fromUri(context, uri) - println("Selected URI path: $uri - Full path: ${file.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 - (file.filePath ?: uri.toString()).let { - PreferenceManager.getDefaultSharedPreferences(context) - .edit().putString(getString(R.string.download_path_pref), it).apply() + companion object { + fun Fragment.pickDownloadPath(uri: Uri?, path: String?) { + if (uri == null) return + + val context = context ?: CloudStreamApp.context ?: return + val visual = path ?: uri.toString() + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(getString(R.string.download_path_key), uri.toString()) + putString(context.getString(R.string.download_path_key_visual), visual) } } + } + + private val pathPicker = getChooseFolderLauncher { uri, path -> + pickDownloadPath(uri, path) + } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() - setPreferencesFromResource(R.xml.settins_general, rootKey) + setPreferencesFromResource(R.xml.settings_general, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) fun getCurrent(): MutableList { @@ -141,25 +184,20 @@ class SettingsGeneral : PreferenceFragmentCompat() { } getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> - val tempLangs = appLanguages.toMutableList() - //if (beneneCount > 100) { - // tempLangs.add(Triple("\uD83E\uDD8D", "mmmm... monke", "mo")) - //} 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) @@ -168,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 = allProviders.distinctBy { it.javaClass }.sortedBy { it.name } + val providers = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, @@ -179,21 +228,23 @@ class SettingsGeneral : PreferenceFragmentCompat() { {}) { selection -> val provider = providers.getOrNull(selection) ?: return@showDialog + val binding : AddSiteInputBinding = AddSiteInputBinding.inflate(layoutInflater,null,false) + val builder = AlertDialog.Builder(context ?: return@showDialog, R.style.AlertDialogCustom) - .setView(R.layout.add_site_input) + .setView(binding.root) val dialog = builder.create() dialog.show() - dialog.text2?.text = provider.name - dialog.apply_btt?.setOnClickListener { - val name = dialog.site_name_input?.text?.toString() - val url = dialog.site_url_input?.text?.toString() - val lang = dialog.site_lang_input?.text?.toString() + binding.text2.text = provider.name + binding.applyBtt.setOnClickListener { + val name = binding.siteNameInput.text?.toString() + 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) { - showToast(activity, R.string.error_invalid_data, Toast.LENGTH_SHORT) + if (url.isNullOrBlank() || name.isNullOrBlank()) { + showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } @@ -201,10 +252,12 @@ class SettingsGeneral : PreferenceFragmentCompat() { val newSite = CustomSite(provider.javaClass.simpleName, name, url, realLang) current.add(newSite) setKey(USER_PROVIDER_API, current.toTypedArray()) + // reload apis + MainActivity.afterPluginsLoadedEvent.invoke(false) dialog.dismissSafe(activity) } - dialog.cancel_btt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } @@ -224,18 +277,19 @@ class SettingsGeneral : PreferenceFragmentCompat() { } fun showAddOrDelete() { + val binding : AddRemoveSitesBinding = AddRemoveSitesBinding.inflate(layoutInflater,null,false) val builder = AlertDialog.Builder(context ?: return, R.style.AlertDialogCustom) - .setView(R.layout.add_remove_sites) + .setView(binding.root) val dialog = builder.create() dialog.show() - dialog.add_site?.setOnClickListener { + binding.addSite.setOnClickListener { showAdd() dialog.dismissSafe(activity) } - dialog.remove_site?.setOnClickListener { + binding.removeSite.setOnClickListener { showDelete() dialog.dismissSafe(activity) } @@ -274,42 +328,52 @@ 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 { - val defaultDir = VideoDownloadManager.getDownloadDir()?.filePath - - // app_name_download_path = Cloudstream and does not change depending on release. - // DOES NOT WORK ON SCOPED STORAGE. - val secondaryDir = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) null else Environment.getExternalStorageDirectory().absolutePath + - File.separator + resources.getString(R.string.app_name_download_path) - val first = listOf(defaultDir, secondaryDir) - (try { - val currentDir = context?.getBasePath()?.let { it.first?.filePath ?: it.second } - - (first + - requireContext().getExternalFilesDirs("").mapNotNull { it.path } + - currentDir) - } catch (e: Exception) { - first - }).filterNotNull().distinct() + return safe { + context?.let { ctx -> + val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.filePath() + + val first = listOf(defaultDir) + (try { + val currentDir = ctx.getBasePath().let { it.first?.filePath() ?: it.second } + + (first + + ctx.getExternalFilesDirs("").mapNotNull { it.path } + + currentDir) + } catch (e: Exception) { + first + }).filterNotNull().distinct() + } } ?: emptyList() } + 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) - ?: VideoDownloadManager.getDownloadDir().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, @@ -324,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), + beneneCount + ) } - settingsManager.edit().putInt( - getString(R.string.benene_count), - SettingsFragment.beneneCount - ) - .apply() - it.summary = - getString(R.string.benene_count_text).format(SettingsFragment.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 33d41934203..0a0fb33c8aa 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -3,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,25 +169,64 @@ 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.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.quality_pref_mobile_data_key)?.setOnPreferenceClickListener { + val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() + prefValues.remove(Qualities.Unknown.value) + + val prefNames = prefValues.map { Qualities.getStringByInt(it) } + + val currentQuality = + settingsManager.getInt( + getString(R.string.quality_pref_mobile_data_key), + Qualities.entries.last().value + ) + + activity?.showBottomDialog( + prefNames.toList(), + prefValues.indexOf(currentQuality), + getString(R.string.watch_quality_pref_data), + true, + {} + ) { + settingsManager.edit { + putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) + } + } + return@setOnPreferenceClickListener true + } + + 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 } @@ -139,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) @@ -151,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 } @@ -170,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 } @@ -181,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 3b01508d866..076f17a0aaf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -2,26 +2,30 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit +import androidx.navigation.fragment.findNavController +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 -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?) { @@ -31,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() @@ -43,19 +47,35 @@ 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() + ) + } } } return@setOnPreferenceClickListener true } + getPref(R.string.test_providers_key)?.setOnPreferenceClickListener { + // Somehow animations do not work without this. + val options = NavOptions.Builder() + .setEnterAnim(R.anim.enter_anim) + .setExitAnim(R.anim.exit_anim) + .setPopEnterAnim(R.anim.pop_enter) + .setPopExitAnim(R.anim.pop_exit) + .build() + + this@SettingsProviders.findNavController() + .navigate(R.id.navigation_test_providers, null, options) + true + } + getPref(R.string.prefer_media_type_key)?.setOnPreferenceClickListener { val names = enumValues().sorted().map { it.name } val default = @@ -74,48 +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 = APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName - - val currentList = current.map { - languages.indexOf(it) + activity?.getApiProviderLangSettings()?.let { currentLangTags -> + val languagesTagName = synchronized(APIHolder.apis) { + listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji } - val 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 e2fd24ca49f..f4c522bf981 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -3,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 65dd7885244..c04215594e1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -1,50 +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 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 kotlinx.android.synthetic.main.logcat.* -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 } @@ -57,93 +111,117 @@ class SettingsUpdates : PreferenceFragmentCompat() { activity?.restorePrompt() return@setOnPreferenceClickListener true } + getPref(R.string.backup_path_key)?.hideOn(EMULATOR)?.setOnPreferenceClickListener { + val dirs = getBackupDirsForDisplay() + val currentDir = + settingsManager.getString(getString(R.string.backup_dir_key), null) + ?: context?.let { ctx -> BackupUtils.getDefaultBackupDir(ctx)?.filePath() } + + activity?.showBottomDialog( + dirs + listOf(getString(R.string.custom)), + dirs.indexOf(currentDir), + getString(R.string.backup_path_title), + true, + {} + ) { + // Last = custom + if (it == dirs.size) { + try { + pathPicker.launch(Uri.EMPTY) + } catch (e: Exception) { + logError(e) + } + } else { + // Sets both visual and actual paths. + // path = used uri + // dir = dir path + settingsManager.edit { + putString(getString(R.string.backup_path_key), dirs[it]) + putString(getString(R.string.backup_dir_key), dirs[it]) + } + } + } + return@setOnPreferenceClickListener true + } + getPref(R.string.show_logcat_key)?.setOnPreferenceClickListener { pref -> - val builder = - AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) - .setView(R.layout.logcat) + 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 log = StringBuilder() + + val logList = mutableListOf() try { - //https://developer.android.com/studio/command-line/logcat + // https://developer.android.com/studio/command-line/logcat val process = Runtime.getRuntime().exec("logcat -d") - val bufferedReader = BufferedReader( - InputStreamReader(process.inputStream) - ) - - var line: String? - while (bufferedReader.readLine().also { line = it } != null) { - log.append("${line}\n") - } + val bufferedReader = BufferedReader(InputStreamReader(process.inputStream)) + bufferedReader.lineSequence().forEach { logList.add(it) } } catch (e: Exception) { logError(e) // kinda ironic } - val text = log.toString() - dialog.text1?.text = text + val adapter = LogcatAdapter().apply { submitList(logList) } + binding.logcatRecyclerView.layoutManager = LinearLayoutManager(pref.context) + binding.logcatRecyclerView.adapter = adapter - dialog.copy_btt?.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(activity, R.string.clipboard_too_large) - } + binding.copyBtt.setOnClickListener { + clipboardHelper(txt("Logcat"), logList.joinToString("\n")) + dialog.dismissSafe(activity) } - dialog.clear_btt?.setOnClickListener { + + binding.clearBtt.setOnClickListener { Runtime.getRuntime().exec("logcat -c") dialog.dismissSafe(activity) } - dialog.save_btt?.setOnClickListener { + + 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", - null, - "txt", - false - ).fileStream - fileStream?.writer()?.write(text) - } catch (e: Exception) { - logError(e) - } finally { - fileStream?.closeQuietly() + 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) } } - dialog.close_btt?.setOnClickListener { + + binding.closeBtt.setOnClickListener { dialog.dismissSafe(activity) } + return@setOnPreferenceClickListener true } getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) - val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) + // Use legacy installer as default until we make the new installer completely reliable val currentInstaller = - settingsManager.getInt(getString(R.string.apk_installer_key), 0) + settingsManager.getInt(getString(R.string.apk_installer_key), 1) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentInstaller), - getString(R.string.app_layout), + 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) } @@ -151,19 +229,72 @@ class SettingsUpdates : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.manual_check_update_key)?.setOnPreferenceClickListener { - ioSafe { - if (activity?.runAutoUpdate(false) == false) { - activity?.runOnUiThread { - showToast( - activity, - 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 + } + } + + getPref(R.string.auto_download_plugins_key)?.setOnPreferenceClickListener { + val prefNames = resources.getStringArray(R.array.auto_download_plugin) + val prefValues = + enumValues().sortedBy { x -> x.value }.map { x -> x.value } + + val current = settingsManager.getInt(getString(R.string.auto_download_plugins_key), 0) + + activity?.showBottomDialog( + prefNames.toList(), + prefValues.indexOf(current), + getString(R.string.automatic_plugin_download_mode_title), + true, + {} + ) { num -> + settingsManager.edit { + putInt(getString(R.string.auto_download_plugins_key), prefValues[num]) + } + (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } 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 fbf1049997b..af0d3dfe756 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt @@ -1,46 +1,48 @@ package com.lagradost.cloudstream3.ui.settings.extensions -import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.DialogInterface -import android.os.Bundle +import android.os.Build 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 import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.fragment.app.Fragment +import androidx.core.view.marginBottom +import androidx.core.view.marginTop import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.mvvm.Some +import com.lagradost.cloudstream3.databinding.AddRepoInputBinding +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.result.setText -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings +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.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.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.widget.LinearRecycleViewLayoutManager -import kotlinx.android.synthetic.main.add_repo_input.* -import kotlinx.android.synthetic.main.fragment_extensions.* - -class ExtensionsFragment : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.fragment_extensions, container, false) - } +import com.lagradost.cloudstream3.utils.setText + +class ExtensionsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentExtensionsBinding::inflate) +) { + + private val extensionViewModel: ExtensionsViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Int) { val param = LinearLayout.LayoutParams( @@ -51,8 +53,6 @@ class ExtensionsFragment : Fragment() { this.layoutParams = param } - private val extensionViewModel: ExtensionsViewModel by activityViewModels() - override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::reloadRepositories @@ -68,102 +68,113 @@ 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() - repo_recycler_view?.adapter = RepoAdapter(false, { - findNavController().navigate( - R.id.navigation_settings_extensions_to_navigation_settings_plugins, - PluginsFragment.newInstance( - it.name, - it.url, - false - ) + binding.repoRecyclerView.apply { + setLinearListLayout( + isHorizontal = false, + 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 ) - }, { repo -> - // Prompt user before deleting repo - main { - val builder = AlertDialog.Builder(context ?: view.context) - val dialogClickListener = - DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - ioSafe { - RepositoryManager.removeRepository(view.context, repo) - extensionViewModel.loadStats() - extensionViewModel.loadRepositories() - } - } - DialogInterface.BUTTON_NEGATIVE -> {} - } + + if (!isLayout(TV)) + binding.addRepoButton.let { button -> + button.post { + setPadding( + paddingLeft, + paddingTop, + paddingRight, + button.measuredHeight + button.marginTop + button.marginBottom + ) } + } - builder.setTitle(R.string.delete_repository) - .setMessage( - context?.getString(R.string.delete_repository_plugins) - ) - .setPositiveButton(R.string.delete, dialogClickListener) - .setNegativeButton(R.string.cancel, dialogClickListener) - .show() + 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 + } else if (dy < -5) { + binding.addRepoButton.extend() // show + } + } } - }) + adapter = RepoAdapter(false, { + findNavController().navigate( + R.id.navigation_settings_extensions_to_navigation_settings_plugins, + PluginsFragment.newInstance( + it.name, + it.url, + false + ) + ) + }, { repo -> + // Prompt user before deleting repo + main { + val uiContext = context ?: binding.root.context + val builder = AlertDialog.Builder(uiContext) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + ioSafe { + RepositoryManager.removeRepository(uiContext.applicationContext, repo) + extensionViewModel.loadStats() + extensionViewModel.loadRepositories() + } + } - observe(extensionViewModel.repositories) { - repo_recycler_view?.isVisible = it.isNotEmpty() - blank_repo_screen?.isVisible = it.isEmpty() - (repo_recycler_view?.adapter as? RepoAdapter)?.updateList(it) + DialogInterface.BUTTON_NEGATIVE -> {} + } + } + + builder.setTitle(R.string.delete_repository) + .setMessage(uiContext.getString(R.string.delete_repository_plugins)) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } + }) } - repo_recycler_view?.apply { - context?.let { ctx -> - layoutManager = LinearRecycleViewLayoutManager(ctx, nextFocusUpId, nextFocusDownId) - } + observe(extensionViewModel.repositories) { + binding.repoRecyclerView.isVisible = it.isNotEmpty() + binding.blankRepoScreen.isVisible = it.isEmpty() + (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) } -// 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) -// } -// } - - observe(extensionViewModel.pluginStats) { - when (it) { - is Some.Success -> { - val value = it.value - - plugin_storage_appbar?.isVisible = true - if (value.total == 0) { - plugin_download?.setLayoutWidth(1) - plugin_disabled?.setLayoutWidth(0) - plugin_not_downloaded?.setLayoutWidth(0) - } else { - plugin_download?.setLayoutWidth(value.downloaded) - plugin_disabled?.setLayoutWidth(value.disabled) - plugin_not_downloaded?.setLayoutWidth(value.notDownloaded) - } - plugin_not_downloaded_txt.setText(value.notDownloadedText) - plugin_disabled_txt.setText(value.disabledText) - plugin_download_txt.setText(value.downloadedText) + observeNullable(extensionViewModel.pluginStats) { value -> + binding.apply { + if (value == null) { + pluginStorageAppbar.isVisible = false + return@observeNullable } - is Some.None -> { - plugin_storage_appbar?.isVisible = false + + pluginStorageAppbar.isVisible = true + if (value.total == 0) { + pluginDownload.setLayoutWidth(1) + pluginDisabled.setLayoutWidth(0) + pluginNotDownloaded.setLayoutWidth(0) + } else { + pluginDownload.setLayoutWidth(value.downloaded) + pluginDisabled.setLayoutWidth(value.disabled) + pluginNotDownloaded.setLayoutWidth(value.notDownloaded) } + pluginNotDownloadedTxt.setText(value.notDownloadedText) + pluginDisabledTxt.setText(value.disabledText) + pluginDownloadTxt.setText(value.downloadedText) } } - plugin_storage_appbar?.setOnClickListener { + binding.pluginStorageAppbar.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( @@ -175,63 +186,82 @@ class ExtensionsFragment : Fragment() { } val addRepositoryClick = View.OnClickListener { + val ctx = context ?: return@OnClickListener + val binding = AddRepoInputBinding.inflate(LayoutInflater.from(ctx), null, false) val builder = - AlertDialog.Builder(context ?: return@OnClickListener, R.style.AlertDialogCustom) - .setView(R.layout.add_repo_input) + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + .setView(binding.root) 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 -> - dialog.repo_url_input?.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 - dialog.apply_btt?.setOnClickListener secondListener@{ - val name = dialog.repo_name_input?.text?.toString() + binding.applyBtt.setOnClickListener secondListener@{ + val name = binding.repoNameInput.text?.toString() + val urlInput = binding.repoUrlInput.text?.toString() ioSafe { - val url = dialog.repo_url_input?.text?.toString() - ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } + val url = urlInput?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } if (url.isNullOrBlank()) { main { - showToast(activity, R.string.error_invalid_data, Toast.LENGTH_SHORT) + showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) } } else { - val fixedName = if (!name.isNullOrBlank()) name - else RepositoryManager.parseRepository(url)?.name ?: "No name" + val repository = RepositoryManager.parseRepository(url) + + // Exit if wrong repository + if (repository == null) { + showToast(R.string.no_repository_found_error, Toast.LENGTH_LONG) + return@ioSafe + } - val newRepo = RepositoryData(fixedName, url) + val fixedName = if (!name.isNullOrBlank()) name + else repository.name + val newRepo = RepositoryData(repository.iconUrl,fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() - this@ExtensionsFragment.activity?.downloadAllPluginsDialog(url, fixedName) + + val plugins = RepositoryManager.getRepoPlugins(url) + if (plugins.isNullOrEmpty()) { + showToast(R.string.no_plugins_found_error, Toast.LENGTH_LONG) + } else { + this@ExtensionsFragment.activity?.addRepositoryDialog( + fixedName, + url, + ) + } } } dialog.dismissSafe(activity) } - dialog.cancel_btt?.setOnClickListener { + binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } - val isTv = isTrueTvSettings() - add_repo_button?.isGone = isTv - add_repo_button_imageview_holder?.isVisible = isTv + val isTv = isLayout(TV) + binding.apply { + addRepoButton.isGone = isTv + addRepoButtonImageviewHolder.isVisible = isTv - // Band-aid for Fire TV - plugin_storage_appbar?.isFocusableInTouchMode = isTv - add_repo_button_imageview?.isFocusableInTouchMode = isTv - - add_repo_button?.setOnClickListener(addRepositoryClick) - add_repo_button_imageview?.setOnClickListener(addRepositoryClick) + // Band-aid for Fire TV + pluginStorageAppbar.isFocusableInTouchMode = isTv + addRepoButtonImageview.isFocusableInTouchMode = isTv + addRepoButton.setOnClickListener(addRepositoryClick) + addRepoButtonImageview.setOnClickListener(addRepositoryClick) + } 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 63ed5357f2f..482251b7831 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -4,23 +4,25 @@ 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.Some import com.lagradost.cloudstream3.mvvm.debugAssert 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" @@ -40,8 +42,8 @@ class ExtensionsViewModel : ViewModel() { private val _repositories = MutableLiveData>() val repositories: LiveData> = _repositories - private val _pluginStats: MutableLiveData> = MutableLiveData(Some.None) - val pluginStats: LiveData> = _pluginStats + private val _pluginStats: MutableLiveData = MutableLiveData(null) + val pluginStats: LiveData = _pluginStats //TODO CACHE GET REQUESTS // DO not use viewModelScope.launchSafe, it will ANR on slow internet @@ -78,7 +80,7 @@ class ExtensionsViewModel : ViewModel() { debugAssert({ stats.downloaded + stats.notDownloaded + stats.disabled != stats.total }) { "downloaded(${stats.downloaded}) + notDownloaded(${stats.notDownloaded}) + disabled(${stats.disabled}) != total(${stats.total})" } - _pluginStats.postValue(Some.Success(stats)) + _pluginStats.postValue(stats) } private fun repos() = (getKey>(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 0c3d481b5e6..d0f9ff565da 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt @@ -1,108 +1,224 @@ 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 -import android.view.View 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 kotlinx.android.synthetic.main.repository_item.view.* -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 - return PluginViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false) +) : 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 RepositoryViewHolderState( + RepositoryItemBinding.bind(inflated) // may crash ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PluginViewHolder -> { - holder.bind(plugins[position]) + override fun onClearView(holder: ViewHolderState) { + if (holder is RepositoryViewHolderState) { + holder.recycleCount += 1 + } + when (val binding = holder.view) { + is RepositoryItemBinding -> { + clearImage(binding.entryIcon) } } } - override fun getItemCount(): Int { - return plugins.size - } + @SuppressLint("SetTextI18n") + override fun onBindContent(holder: ViewHolderState, item: PluginViewData, position: Int) { + val binding = holder.view as? RepositoryItemBinding ?: return + val itemView = holder.itemView + + val metadata = item.plugin.second + val disabled = metadata.status == PROVIDER_STATUS_DOWN + val name = metadata.name.removeSuffix("Provider") + val alpha = if (disabled) 0.6f else 1f + val isLocal = !item.plugin.second.url.startsWith("http") + binding.mainText.alpha = alpha + binding.subText.alpha = alpha + + val drawableInt = if (item.isDownloaded) + R.drawable.ic_baseline_delete_outline_24 + else R.drawable.netflix_download + + binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false + binding.actionButton.setImageResource(drawableInt) - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - PluginDiffCallback(this.plugins, newList) + 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) + if (url.isNullOrBlank()) { + binding.entryIcon.loadImage(R.drawable.ic_baseline_extension_24) + } else { + binding.entryIcon.loadImage( + url + ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } + } + + binding.extVersion.isVisible = true + binding.extVersion.text = "v${metadata.version}" - diffResult.dispatchUpdatesTo(this) - } + if (metadata.language.isNullOrBlank()) { + binding.langIcon.isVisible = false + } else { + binding.langIcon.isVisible = true + binding.langIcon.text = getNameNextToFlagEmoji(metadata.language) ?: metadata.language + } + + //val oldRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount - /* - private var storedPlugins: Array = reloadStoredPlugins() + binding.extVotes.isVisible = false - private fun reloadStoredPlugins(): Array { - return PluginManager.getPluginsOnline().also { storedPlugins = it } - }*/ + // Disable this for now as the vote api is down, this will also significantly improve the lag + // from doing all these network requests + /*if (!isLocal) { + ioSafe { + metadata.getVotes().main { votes -> + val currentRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount + + // Only set the text if the view is correctly rendered + if (currentRecycleCount == oldRecycleCount) { + binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(votes))) + binding.extVotes.isVisible = true + } + } + } + }*/ - // Clear glide image because setImageResource doesn't override - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - holder.itemView.entry_icon?.let { pluginIcon -> - GlideApp.with(pluginIcon).clear(pluginIcon) + if (metadata.fileSize != null) { + binding.extFilesize.isVisible = true + binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) + } else { + binding.extFilesize.isVisible = false } - super.onViewRecycled(holder) + + binding.mainText.setText( + if (disabled) txt( + R.string.single_plugin_disabled, + name + ) else txt(name) + ) + + binding.subText.isGone = metadata.description.isNullOrBlank() + binding.subText.text = metadata.description.html() } companion object { + // A high count as we can render in the entire list as the same time + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 15) } + private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max if (current >= target) return current 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 { @@ -112,146 +228,15 @@ class PluginAdapter( fun prettyCount(number: Number): String? { val suffix = charArrayOf(' ', 'k', 'M', 'B', 'T', 'P', 'E') val numValue = number.toLong() - val value = Math.floor(Math.log10(numValue.toDouble())).toInt() + val value = floor(log10(numValue.toDouble())).toInt() 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(itemView: View) : - RecyclerView.ViewHolder(itemView) { - - 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") - itemView.main_text?.alpha = alpha - itemView.sub_text?.alpha = alpha - - val drawableInt = if (data.isDownloaded) - R.drawable.ic_baseline_delete_outline_24 - else R.drawable.netflix_download - - itemView.nsfw_marker?.isVisible = metadata.tvTypes?.contains("NSFW") ?: false - itemView.action_button?.setImageResource(drawableInt) - - itemView.action_button?.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) { - itemView.action_settings?.isVisible = true - itemView.action_settings.setOnClickListener { - try { - plugin.openSettings!!.invoke(itemView.context) - } catch (e: Throwable) { - Log.e( - "PluginAdapter", - "Failed to open $name settings: ${ - Log.getStackTraceString(e) - }" - ) - } - } - } else { - itemView.action_settings?.isVisible = false - } - } else { - itemView.action_settings?.isVisible = false - } - - if (itemView.entry_icon?.setImage(//itemView.entry_icon?.height ?: - metadata.iconUrl?.replace( - "%size%", - "$iconSize" - )?.replace( - "%exact_size%", - "$iconSizeExact" - ), - null, - errorImageDrawable = R.drawable.ic_baseline_extension_24 - ) != true - ) { - itemView.entry_icon?.setImageResource(R.drawable.ic_baseline_extension_24) - } - - itemView.ext_version?.isVisible = true - itemView.ext_version?.text = "v${metadata.version}" - - if (metadata.language.isNullOrBlank()) { - itemView.lang_icon?.isVisible = false - } else { - itemView.lang_icon?.isVisible = true - itemView.lang_icon.text = "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" - } - - itemView.ext_votes?.isVisible = false - if (!isLocal) { - ioSafe { - metadata.getVotes().main { - itemView.ext_votes?.setText(txt(R.string.extension_rating, prettyCount(it))) - itemView.ext_votes?.isVisible = true - } - } - } - - - if (metadata.fileSize != null) { - itemView.ext_filesize?.isVisible = true - itemView.ext_filesize?.text = formatShortFileSize(itemView.context, metadata.fileSize) - } else { - itemView.ext_filesize?.isVisible = false - } - itemView.main_text.setText(if(disabled) txt(R.string.single_plugin_disabled, name) else txt(name)) - itemView.sub_text?.isGone = metadata.description.isNullOrBlank() - itemView.sub_text?.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 9729b4dea78..0dcbece6cc3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt @@ -1,34 +1,36 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.content.res.ColorStateList -import android.os.Bundle -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.fragment_plugin_details.* import android.text.format.Formatter.formatFileSize import android.util.Log +import android.view.View import androidx.core.view.isVisible -import com.lagradost.cloudstream3.plugins.VotingApi -import com.lagradost.cloudstream3.plugins.VotingApi.getVoteType +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.databinding.FragmentPluginDetailsBinding +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.plugins.VotingApi.canVote import com.lagradost.cloudstream3.plugins.VotingApi.getVotes +import com.lagradost.cloudstream3.plugins.VotingApi.hasVoted import com.lagradost.cloudstream3.plugins.VotingApi.vote +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.BaseBottomSheetDialogFragment +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.getImageFromDrawable +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.plugins.PluginManager -import com.lagradost.cloudstream3.plugins.VotingApi.canVote -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso -import kotlinx.android.synthetic.main.repository_item.view.* - +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 { @@ -43,116 +45,107 @@ class PluginDetailsFragment(val data: PluginViewData) : BottomSheetDialogFragmen } } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_plugin_details, container, false) - + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onBindingCreated(binding: FragmentPluginDetailsBinding) { val metadata = data.plugin.second - if (plugin_icon?.setImage(//plugin_icon?.height ?: - metadata.iconUrl?.replace( - "%size%", - "$iconSize" - )?.replace( - "%exact_size%", - "$iconSizeExact" - ), - null, - errorImageDrawable = R.drawable.ic_baseline_extension_24 - ) != true - ) { - plugin_icon?.setImageResource(R.drawable.ic_baseline_extension_24) - } - plugin_name?.text = metadata.name.removeSuffix("Provider") - plugin_version?.text = metadata.version.toString() - plugin_description?.text = metadata.description ?: getString(R.string.no_data) - plugin_size?.text = if (metadata.fileSize == null) getString(R.string.no_data) else formatFileSize(context, metadata.fileSize) - plugin_author?.text = if (metadata.authors.isEmpty()) getString(R.string.no_data) else metadata.authors.joinToString(", ") - plugin_status?.text = resources.getStringArray(R.array.extension_statuses)[metadata.status] - plugin_types?.text = if ((metadata.tvTypes == null) || metadata.tvTypes.isEmpty()) getString(R.string.no_data) else metadata.tvTypes.joinToString(", ") - plugin_lang?.text = if (metadata.language == null) - getString(R.string.no_data) - else - "${getFlagFromIso(metadata.language)} ${fromTwoLettersToLanguage(metadata.language)}" + 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() + pluginDescription.text = metadata.description ?: getString(R.string.no_data) + pluginSize.text = + if (metadata.fileSize == null) getString(R.string.no_data) else formatFileSize( + context, + metadata.fileSize + ) + pluginAuthor.text = + if (metadata.authors.isEmpty()) getString(R.string.no_data) else metadata.authors.joinToString( + ", " + ) + pluginStatus.text = + resources.getStringArray(R.array.extension_statuses)[metadata.status] + pluginTypes.text = + if (metadata.tvTypes.isNullOrEmpty()) getString(R.string.no_data) else metadata.tvTypes.joinToString( + ", " + ) + pluginLang.text = if (metadata.language == null) + getString(R.string.no_data) + else + getNameNextToFlagEmoji(metadata.language) ?: metadata.language - github_btn.setOnClickListener { - if (metadata.repositoryUrl != null) { - openBrowser(metadata.repositoryUrl) + githubBtn.setOnClickListener { + if (metadata.repositoryUrl != null) { + openBrowser(metadata.repositoryUrl) + } } - } - if (!metadata.canVote()) { - downvote.alpha = .6f - upvote.alpha = .6f - } + if (!metadata.canVote()) { + upvote.alpha = .6f + } - 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 && context != null) { - action_settings?.isVisible = true - action_settings.setOnClickListener { - try { - plugin.openSettings!!.invoke(requireContext()) - } catch (e: Throwable) { - Log.e( - "PluginAdapter", - "Failed to open ${metadata.name} settings: ${ - Log.getStackTraceString(e) - }" - ) + if (data.isDownloaded) { + // On local plugins page the filepath is provided instead of url. + val plugin = + (PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url]) as? com.lagradost.cloudstream3.plugins.Plugin + if (plugin?.openSettings != null && context != null) { + actionSettings.isVisible = true + actionSettings.setOnClickListener { + try { + plugin.openSettings!!.invoke(requireContext()) + } catch (e: Throwable) { + Log.e( + "PluginAdapter", + "Failed to open ${metadata.name} settings: ${ + Log.getStackTraceString(e) + }" + ) + } } + } else { + actionSettings.isVisible = false } } else { - action_settings?.isVisible = false + actionSettings.isVisible = false } - } else { - action_settings?.isVisible = false - } - upvote.setOnClickListener { - ioSafe { - metadata.vote(VotingApi.VoteType.UPVOTE).main { - updateVoting(it) + upvote.setOnClickListener { + ioSafe { + metadata.vote().main { + updateVoting(it) + } } } - } - downvote.setOnClickListener { + ioSafe { - metadata.vote(VotingApi.VoteType.DOWNVOTE).main { + metadata.getVotes().main { updateVoting(it) } - - } - } - - ioSafe { - metadata.getVotes().main { - updateVoting(it) } } } private fun updateVoting(value: Int) { val metadata = data.plugin.second - plugin_votes.text = value.toString() - when (metadata.getVoteType()) { - VotingApi.VoteType.UPVOTE -> { - upvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.colorPrimary) ?: R.color.colorPrimary) - downvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.white) ?: R.color.white) - } - VotingApi.VoteType.DOWNVOTE -> { - downvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.colorPrimary) ?: R.color.colorPrimary) - upvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.white) ?: R.color.white) - } - VotingApi.VoteType.NONE -> { - upvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.white) ?: R.color.white) - downvote.imageTintList = ColorStateList.valueOf(context?.colorFromAttribute(R.attr.white) ?: R.color.white) + binding?.apply { + pluginVotes.text = value.toString() + if (metadata.hasVoted()) { + upvote.imageTintList = ColorStateList.valueOf( + context?.colorFromAttribute(R.attr.colorPrimary) ?: R.color.colorPrimary + ) + } else { + upvote.imageTintList = ColorStateList.valueOf( + 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 bd44a058e5b..534ffa62a43 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -1,169 +1,207 @@ 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.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.mvvm.observe +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.ui.settings.appLanguages +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.fragment_plugins.* -import kotlinx.android.synthetic.main.tvtypes_chips.* -import kotlinx.android.synthetic.main.tvtypes_chips_scroll.* 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? { - return inflater.inflate(R.layout.fragment_plugins, container, false) - } +class PluginsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) +) { private val pluginViewModel: PluginsViewModel by activityViewModels() - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + override fun onDestroyView() { + pluginViewModel.clear() // clear for the next observe + super.onDestroyView() + } + 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 { + setOnMenuItemClickListener { menuItem -> + when (menuItem?.itemId) { + R.id.download_all -> { + PluginsViewModel.downloadAll(activity, url, pluginViewModel) + } - settings_toolbar?.setOnMenuItemClickListener { menuItem -> - when (menuItem?.itemId) { - R.id.download_all -> { - PluginsViewModel.downloadAll(activity, url, pluginViewModel) - } - 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" + R.id.lang_filter -> { + val languagesTagName = pluginViewModel.pluginLanguages + .map { langTag -> + Pair( + langTag, + getNameNextToFlagEmoji(langTag) ?: langTag + ) + } + .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( + languagesTagName.map { it.second }, + currentIndexList, + getString(R.string.provider_lang_settings), + {} + ) { selectedList -> + pluginViewModel.selectedLanguages = + selectedList.map { languagesTagName[it].first } + pluginViewModel.updateFilteredPlugins() } - val selectedList = - pluginViewModel.languages.map { it -> languageCodes.indexOf(it) } - - activity?.showMultiDialog( - languageNames, - selectedList, - getString(R.string.provider_lang_settings), - {}) { newList -> - pluginViewModel.languages = newList.map { it -> languageCodes[it] } - pluginViewModel.updateFilteredPlugins() } + + else -> {} } - else -> {} + return@setOnMenuItemClickListener true } - return@setOnMenuItemClickListener true - } - val searchView = - settings_toolbar?.menu?.findItem(R.id.search_button)?.actionView as? SearchView + val searchView = + menu?.findItem(R.id.search_button)?.actionView as? SearchView - // Don't go back if active query - settings_toolbar?.setNavigationOnClickListener { - if (searchView?.isIconified == false) { - searchView.isIconified = true - } else { - activity?.onBackPressed() + // Don't go back if active query + setNavigationOnClickListener { + if (searchView?.isIconified == false) { + searchView.isIconified = true + } else { + dispatchBackPressed() + } + } + searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> + if (!hasFocus) pluginViewModel.search(null) } - } + searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + pluginViewModel.search(query) + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + pluginViewModel.search(newText) + return true + } + }) + } // searchView?.onActionViewCollapsed = { // pluginViewModel.search(null) // } // Because onActionViewCollapsed doesn't wanna work we need this workaround :( - searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> - if (!hasFocus) pluginViewModel.search(null) - } - - searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - pluginViewModel.search(query) - return true - } - - override fun onQueryTextChange(newText: String?): Boolean { - pluginViewModel.search(newText) - return true - } - }) - - plugin_recycler_view?.adapter = - PluginAdapter { - pluginViewModel.handlePluginAction(activity, url, it, isLocal) - } + binding.pluginRecyclerView.apply { + setLinearListLayout( + isHorizontal = false, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + setRecycledViewPool(PluginAdapter.sharedPool) + adapter = + PluginAdapter { + pluginViewModel.handlePluginAction(activity, url, it, isLocal) + } + } - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { // Scrolling down does not reveal the whole RecyclerView on TV, add to bypass that. - plugin_recycler_view?.setPadding(0, 0, 0, 200.toPx) + binding.pluginRecyclerView.setPadding(0, 0, 0, 200.toPx) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> - (plugin_recycler_view?.adapter as? PluginAdapter?)?.updateList(list) - - if (scrollToTop) - plugin_recycler_view?.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 - settings_toolbar?.menu?.findItem(R.id.download_all)?.isVisible = false - settings_toolbar?.menu?.findItem(R.id.lang_filter)?.isVisible = false + downloadAllButton?.isVisible = false + binding.settingsToolbar.menu?.findItem(R.id.lang_filter)?.isVisible = false pluginViewModel.updatePluginListLocal() - tv_types_scroll_view?.isVisible = false + + binding.tvtypesChipsScroll.root.isVisible = false } else { pluginViewModel.updatePluginList(context, url) - tv_types_scroll_view?.isVisible = true - - bindChips(home_select_group, emptyList(), TvType.values().toList()) { list -> - pluginViewModel.tvTypes.clear() - pluginViewModel.tvTypes.addAll(list.map { it.name }) - pluginViewModel.updateFilteredPlugins() - } + binding.tvtypesChipsScroll.root.isVisible = true + // not needed for users but may be useful for devs + downloadAllButton?.isVisible = BuildConfig.DEBUG + + bindChips( + binding.tvtypesChipsScroll.tvtypesChips, + emptyList(), + TvType.entries.toList(), + callback = { list -> + pluginViewModel.tvTypes.clear() + pluginViewModel.tvTypes.addAll(list.map { it.name }) + pluginViewModel.updateFilteredPlugins() + }, + nextFocusDown = R.id.plugin_recycler_view, + nextFocusUp = null, + ) } } 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 894a9331e1c..dfc61eba54c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -8,21 +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 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. @@ -33,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 { @@ -85,14 +104,18 @@ class PluginsViewModel : ViewModel() { }.also { list -> main { showToast( - activity, - if (list.isEmpty()) { - txt( + when { + // No plugins at all + plugins.isEmpty() -> txt( + R.string.no_plugins_found_error, + ) + // All plugins downloaded + list.isEmpty() -> txt( R.string.batch_download_nothing_to_download_format, txt(R.string.plugin) ) - } else { - txt( + + else -> txt( R.string.batch_download_start_format, list.size, txt(if (list.size == 1) R.string.plugin_singular else R.string.plugin) @@ -102,16 +125,17 @@ class PluginsViewModel : ViewModel() { ) } }.amap { (repo, metadata) -> - PluginManager.downloadAndLoadPlugin( + PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, - repo + repo, + metadata.status != PROVIDER_STATUS_DOWN ) }.main { list -> if (list.any { it }) { showToast( - activity, txt( R.string.batch_download_finish_format, list.count { it }, @@ -121,7 +145,7 @@ class PluginsViewModel : ViewModel() { ) viewModel?.updatePluginListPrivate(activity, repositoryUrl) } else if (list.isNotEmpty()) { - showToast(activity, R.string.download_failed, Toast.LENGTH_SHORT) + showToast(R.string.download_failed, Toast.LENGTH_SHORT) } } } @@ -151,19 +175,23 @@ class PluginsViewModel : ViewModel() { val (success, message) = if (file.exists()) { PluginManager.deletePlugin(file) to R.string.plugin_deleted } else { - PluginManager.downloadAndLoadPlugin( + val isEnabled = plugin.second.status != PROVIDER_STATUS_DOWN + val message = if (isEnabled) R.string.plugin_loaded else R.string.plugin_downloaded + PluginManager.downloadPlugin( activity, metadata.url, - metadata.name, - repo - ) to R.string.plugin_loaded + metadata.fileHash, + metadata.internalName, + repo, + isEnabled + ) to message } runOnMainThread { if (success) - showToast(activity, message, Toast.LENGTH_SHORT) + showToast(message, Toast.LENGTH_SHORT) else - showToast(activity, R.string.error, Toast.LENGTH_SHORT) + showToast(R.string.error, Toast.LENGTH_SHORT) } if (success) @@ -174,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)) } @@ -190,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()) } } @@ -210,7 +245,12 @@ class PluginsViewModel : ViewModel() { // Return list to base state if no query this.sortedBy { it.plugin.second.name } } else { - this.sortedBy { -FuzzySearch.partialRatio(it.plugin.second.name.lowercase(), query.lowercase()) } + this.sortedBy { + -FuzzySearch.partialRatio( + it.plugin.second.name.lowercase(), + query.lowercase() + ) + } } } @@ -220,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") @@ -250,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 e90166a8908..0f9bf5f58c9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt @@ -1,14 +1,20 @@ package com.lagradost.cloudstream3.ui.settings.extensions import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView 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 kotlinx.android.synthetic.main.repository_item.view.* +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, @@ -16,87 +22,110 @@ 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() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val layout = if(isTrueTvSettings()) R.layout.repository_item_tv else R.layout.repository_item - return RepoViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false) + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.url == b.url + })) { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val layout = if (isLayout(TV)) RepositoryItemTvBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) else RepositoryItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false ) + 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 getItemCount(): Int { - return repositories.size - } - - fun updateList(newList: Array) { - val diffResult = DiffUtil.calculateDiff( - RepoDiffCallback(this.repositories, newList) - ) - - repositories.clear() - repositories.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - inner class RepoViewHolder(itemView: View) : - RecyclerView.ViewHolder(itemView) { - 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 - - // Only shows icon if on setup or if it isn't a prebuilt repo. - // No delete buttons on prebuilt repos. - if (!isPrebuilt || isSetup) { - itemView.action_button?.setImageResource(drawable) + override fun onBindContent(holder: ViewHolderState, item: RepositoryData, position: Int) { + val isPrebuilt = PREBUILT_REPOSITORIES.contains(item) + val drawable = + if (isSetup) R.drawable.netflix_download else R.drawable.ic_baseline_delete_outline_24 + when (val binding = holder.view) { + is RepositoryItemTvBinding -> { + binding.apply { + // Only shows icon if on setup or if it isn't a prebuilt repo. + // No delete buttons on prebuilt repos. + if (!isPrebuilt || isSetup) { + actionButton.setImageResource(drawable) + } + + actionButton.setOnClickListener { + imageClickCallback(item) + } + + 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 + ) + ) + } + } else { + entryIcon.loadImage(R.drawable.ic_github_logo) + } + } } - itemView.action_button?.setOnClickListener { - imageClickCallback(repositoryData) + is RepositoryItemBinding -> { + binding.apply { + // Only shows icon if on setup or if it isn't a prebuilt repo. + // No delete buttons on prebuilt repos. + if (!isPrebuilt || isSetup) { + actionButton.setImageResource(drawable) + } + + actionButton.setOnClickListener { + imageClickCallback(item) + } + + repositoryItemRoot.setOnClickListener { + clickCallback(item) + } + + repositoryItemRoot.setOnLongClickListener { + val shareableRepoData = + "${item.name}$SHAREABLE_REPO_SEPARATOR\n ${item.url}" + clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) + true + } + + mainText.text = item.name + subText.text = item.url + if (!item.iconUrl.isNullOrEmpty()) { + entryIcon.loadImage(item.iconUrl) { + error( + getImageFromDrawable( + binding.root.context, + R.drawable.ic_github_logo + ) + ) + } + } else { + entryIcon.loadImage(R.drawable.ic_github_logo) + } + } } - - itemView.repository_item_root?.setOnClickListener { - clickCallback(repositoryData) - } - itemView.main_text?.text = repositoryData.name - itemView.sub_text?.text = repositoryData.url } } -} -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 new file mode 100644 index 00000000000..4ec005a094d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt @@ -0,0 +1,93 @@ +package com.lagradost.cloudstream3.ui.settings.testing + +import android.view.View +import androidx.fragment.app.activityViewModels +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentTestingBinding +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar + +class TestFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentTestingBinding::inflate) +) { + + private val testViewModel: TestViewModel by activityViewModels() + + override fun fixLayout(view: View) { + setSystemBarsPadding() + } + + override fun onBindingCreated(binding: FragmentTestingBinding) { + setUpToolbar(R.string.category_provider_test) + setToolBarScrollFlags() + + binding.apply { + providerTestRecyclerView.adapter = TestResultAdapter() + + testViewModel.init() + if (testViewModel.isRunningTest) { + providerTest.setState(TestView.TestState.Running) + } + + observe(testViewModel.providerProgress) { (passed, failed, total) -> + providerTest.setProgress(passed, failed, total) + } + + observe(testViewModel.providerResults) { + safe { + val newItems = it.sortedBy { api -> api.first.name } + (providerTestRecyclerView.adapter as? TestResultAdapter)?.submitList( + newItems + ) + } + } + + providerTest.setOnPlayButtonListener { state -> + when (state) { + TestView.TestState.Stopped -> testViewModel.stopTest() + TestView.TestState.Running -> testViewModel.startTest() + TestView.TestState.None -> testViewModel.startTest() + } + } + + if (isLayout(TV)) { + providerTest.playPauseButton?.isFocusableInTouchMode = true + providerTest.playPauseButton?.requestFocus() + } + + providerTest.playPauseButton?.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + providerTestAppbar.setExpanded(true, true) + } + } + + fun focusRecyclerView() { + // Hack to make it possible to focus the recyclerview. + if (isLayout(TV)) { + providerTestRecyclerView.requestFocus() + providerTestAppbar.setExpanded(false, true) + } + } + + providerTest.setOnMainClick { + testViewModel.setFilterMethod(TestViewModel.ProviderFilter.All) + focusRecyclerView() + } + providerTest.setOnFailedClick { + testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Failed) + focusRecyclerView() + } + providerTest.setOnPassedClick { + testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Passed) + focusRecyclerView() + } + } + } +} \ 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 new file mode 100644 index 00000000000..c53ff1fcf8a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt @@ -0,0 +1,130 @@ +package com.lagradost.cloudstream3.ui.settings.testing + +import android.app.AlertDialog +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.ContextCompat +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.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() : + NoStateAdapter>( + diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.first.name == b.first.name && a.first.mainUrl == b.first.mainUrl + }, + contentSame = { a, b -> + a == b + }) + ) { + companion object { + private fun String.lastLine(): String? { + return this.lines().lastOrNull { it.isNotBlank() } + } + } + + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? ProviderTestItemBinding ?: return + clearImage(binding.actionButton) + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ProviderTestItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + 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 new file mode 100644 index 00000000000..65ed47a545a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt @@ -0,0 +1,119 @@ +package com.lagradost.cloudstream3.ui.settings.testing + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.TextView +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.AppContextUtils.animateProgressTo + +class TestView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : CardView(context, attrs) { + enum class TestState(@StringRes val stringRes: Int, @DrawableRes val icon: Int) { + None(R.string.start, R.drawable.ic_baseline_play_arrow_24), + + // Paused(R.string.resume, R.drawable.ic_baseline_play_arrow_24), + Stopped(R.string.restart, R.drawable.ic_baseline_play_arrow_24), + Running(R.string.stop, R.drawable.pause_to_play), + } + + var mainSection: View? = null + var testsPassedSection: View? = null + var testsFailedSection: View? = null + + var mainSectionText: TextView? = null + var mainSectionHeader: TextView? = null + var testsPassedSectionText: TextView? = null + var testsFailedSectionText: TextView? = null + var totalProgressBar: ContentLoadingProgressBar? = null + + var playPauseButton: MaterialButton? = null + var stateListener: (TestState) -> Unit = {} + + private var state = TestState.None + + init { + LayoutInflater.from(context).inflate(R.layout.view_test, this, true) + + mainSection = findViewById(R.id.main_test_section) + testsPassedSection = findViewById(R.id.passed_test_section) + testsFailedSection = findViewById(R.id.failed_test_section) + + mainSectionHeader = findViewById(R.id.main_test_header) + mainSectionText = findViewById(R.id.main_test_section_progress) + testsPassedSectionText = findViewById(R.id.passed_test_section_progress) + testsFailedSectionText = findViewById(R.id.failed_test_section_progress) + + totalProgressBar = findViewById(R.id.test_total_progress) + playPauseButton = findViewById(R.id.tests_play_pause) + + attrs?.let { + context.withStyledAttributes(it, R.styleable.TestView) { + mainSectionHeader?.text = getString(R.styleable.TestView_header_text) + } + } + + playPauseButton?.setOnClickListener { + val newState = when (state) { + TestState.None -> TestState.Running + TestState.Running -> TestState.Stopped + TestState.Stopped -> TestState.Running + } + setState(newState) + } + } + + fun setOnPlayButtonListener(listener: (TestState) -> Unit) { + stateListener = listener + } + + fun setState(newState: TestState) { + state = newState + stateListener.invoke(newState) + playPauseButton?.setText(newState.stringRes) + playPauseButton?.icon = ContextCompat.getDrawable(context, newState.icon) + } + + fun setProgress(passed: Int, failed: Int, total: Int?) { + val totalProgress = passed + failed + mainSectionText?.text = "$totalProgress / ${total?.toString() ?: "?"}" + testsPassedSectionText?.text = passed.toString() + testsFailedSectionText?.text = failed.toString() + + totalProgressBar?.max = (total ?: 0) * 1000 + totalProgressBar?.animateProgressTo(totalProgress * 1000) + + totalProgressBar?.isVisible = !(totalProgress == 0 || (total ?: 0) == 0) + if (totalProgress == total) { + setState(TestState.Stopped) + } + } + + fun setMainHeader(@StringRes header: Int) { + mainSectionHeader?.setText(header) + } + + fun setOnMainClick(listener: OnClickListener) { + mainSection?.setOnClickListener(listener) + } + + fun setOnPassedClick(listener: OnClickListener) { + testsPassedSection?.setOnClickListener(listener) + } + + fun setOnFailedClick(listener: OnClickListener) { + testsFailedSection?.setOnClickListener(listener) + } +} \ No newline at end of file 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 new file mode 100644 index 00000000000..818f1fd792f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt @@ -0,0 +1,107 @@ +package com.lagradost.cloudstream3.ui.settings.testing + +import androidx.lifecycle.LiveData +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.TestingUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel + +class TestViewModel : ViewModel() { + data class TestProgress( + val passed: Int, + val failed: Int, + val total: Int + ) + + enum class ProviderFilter { + All, + Passed, + Failed + } + + private val _providerProgress = MutableLiveData(null) + val providerProgress: LiveData = _providerProgress + + private val _providerResults = + MutableLiveData>>( + emptyList() + ) + + val providerResults: LiveData>> = + _providerResults + + private var scope: CoroutineScope? = null + val isRunningTest + get() = scope != null + + private var filter = ProviderFilter.All + private val providers = threadSafeListOf>() + private var passed = 0 + private var failed = 0 + private var total = 0 + + private fun updateProgress() { + _providerProgress.postValue(TestProgress(passed, failed, total)) + postProviders() + } + + private fun postProviders() { + synchronized(providers) { + val filtered = when (filter) { + ProviderFilter.All -> providers + ProviderFilter.Passed -> providers.filter { it.second.success } + ProviderFilter.Failed -> providers.filter { !it.second.success } + } + _providerResults.postValue(filtered) + } + } + + fun setFilterMethod(filter: ProviderFilter) { + if (this.filter == filter) return + this.filter = filter + postProviders() + } + + private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) { + synchronized(providers) { + val index = providers.indexOfFirst { it.first == api } + if (index == -1) { + providers.add(api to results) + if (results.success) passed++ else failed++ + } else { + providers[index] = api to results + } + updateProgress() + } + } + + fun init() { + total = synchronized(APIHolder.allProviders) { APIHolder.allProviders.size } + updateProgress() + } + + fun startTest() { + scope = CoroutineScope(Dispatchers.Default) + + val apis = synchronized(APIHolder.allProviders) { APIHolder.allProviders.toTypedArray() } + total = apis.size + failed = 0 + passed = 0 + providers.clear() + updateProgress() + + TestingUtils.getDeferredProviderTests(scope ?: return, apis) { api, result -> + addProvider(api, result) + } + } + + fun stopTest() { + scope?.cancel() + scope = null + } +} \ No newline at end of file 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 00000000000..dfc93117481 --- /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 b7d2fff6cd6..501ee0eef7b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt @@ -1,31 +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.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentSetupExtensionsBinding +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.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 kotlinx.android.synthetic.main.fragment_extensions.blank_repo_screen -import kotlinx.android.synthetic.main.fragment_extensions.repo_recycler_view -import kotlinx.android.synthetic.main.fragment_setup_media.next_btt -import kotlinx.android.synthetic.main.fragment_setup_media.prev_btt -import kotlinx.android.synthetic.main.fragment_setup_media.setup_root +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" @@ -39,13 +33,6 @@ class SetupFragmentExtensions : Fragment() { } } - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_setup_extensions, container, false) - } - override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::setRepositories @@ -56,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() - repo_recycler_view?.isVisible = hasRepos - blank_repo_screen?.isVisible = !hasRepos -// view_public_repositories_button?.isVisible = hasRepos + binding?.repoRecyclerView?.isVisible = hasRepos + binding?.blankRepoScreen?.isVisible = !hasRepos if (hasRepos) { - repo_recycler_view?.adapter = RepoAdapter(true, {}, { + binding?.repoRecyclerView?.adapter = RepoAdapter(true, {}, { PluginsViewModel.downloadAll(activity, it.url, null) - }).apply { updateList(repositories) } + }).apply { submitList(repositories.toList()) } } // else { // list_repositories?.setOnClickListener { @@ -78,44 +68,36 @@ class SetupFragmentExtensions : Fragment() { } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) + 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) -// } - - with(context) { - if (this == null) return + safe { setRepositories() - - if (!isSetup) { - next_btt.setText(R.string.setup_done) - } - prev_btt?.isVisible = isSetup - - next_btt?.setOnClickListener { - // Continue setup - if (isSetup) - if ( - // If any available languages - apis.distinctBy { it.lang }.size > 1 - ) { - findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) - } else { - findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_media) - } - else - findNavController().navigate(R.id.navigation_home) - } - - prev_btt?.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_language) + binding.apply { + if (!isSetup) { + nextBtt.setText(R.string.setup_done) + } + prevBtt.isVisible = isSetup + + nextBtt.setOnClickListener { + // Continue setup + if (isSetup) + if ( + // If any available languages + synchronized(apis) { apis.distinctBy { it.lang }.size > 1 } + ) { + findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) + } else { + findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_media) + } + else + findNavController().navigate(R.id.navigation_home) + } + + prevBtt.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_language) + } } } } - - -} \ 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 80db59eeaf6..e96a662c370 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt @@ -1,83 +1,74 @@ 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.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.getCurrentLocale -import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_language.* -import kotlinx.android.synthetic.main.fragment_setup_media.listview1 -import kotlinx.android.synthetic.main.fragment_setup_media.next_btt +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() { - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_setup_language, container, false) - } +class SetupFragmentLanguage : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupLanguageBinding::inflate) +) { - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) + } + override fun onBindingCreated(binding: FragmentSetupLanguageBinding) { // We don't want a crash for all users - normalSafeApiCall { - with(context) { - if (this == null) return@normalSafeApiCall - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + safe { + val ctx = context ?: return@safe + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - val arrayAdapter = - ArrayAdapter(this, R.layout.sort_bottom_single_choice) + val arrayAdapter = + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) + 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 } - app_icon_image?.setImageDrawable(ContextCompat.getDrawable(this, drawable)) + appIconImage.setImageDrawable(ContextCompat.getDrawable(ctx, drawable)) } - val current = getCurrentLocale(this) - 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 current = getCurrentLocale(ctx) + 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.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE + 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) + } } - next_btt?.setOnClickListener { + nextBtt.setOnClickListener { // If no plugins go to plugins page val nextDestination = if ( PluginManager.getPluginsOnline().isEmpty() @@ -92,12 +83,11 @@ class SetupFragmentLanguage : Fragment() { ) } - skip_btt?.setOnClickListener { + 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 bc9bfb1fbfc..4a8e784a145 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt @@ -1,39 +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.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.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_layout.* -import kotlinx.android.synthetic.main.fragment_setup_media.listview1 -import kotlinx.android.synthetic.main.fragment_setup_media.next_btt -import kotlinx.android.synthetic.main.fragment_setup_media.prev_btt -import kotlinx.android.synthetic.main.fragment_setup_media.setup_root -import org.acra.ACRA +import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +class SetupFragmentLayout : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupLayoutBinding::inflate) +) { -class SetupFragmentLayout : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_setup_layout, container, false) + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) + override fun onBindingCreated(binding: FragmentSetupLayoutBinding) { + safe { + val ctx = context ?: return@safe - with(context) { - if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = resources.getStringArray(R.array.app_layout) val prefValues = resources.getIntArray(R.array.app_layout_values) @@ -42,48 +34,32 @@ class SetupFragmentLayout : Fragment() { settingsManager.getInt(getString(R.string.app_layout_key), -1) val arrayAdapter = - ArrayAdapter(this, R.layout.sort_bottom_single_choice) + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(prefNames.toList()) - listview1?.adapter = arrayAdapter - listview1?.choiceMode = AbsListView.CHOICE_MODE_SINGLE - listview1?.setItemChecked( - prefValues.indexOf(currentLayout), true - ) - - listview1?.setOnItemClickListener { _, _, position, _ -> - settingsManager.edit() - .putInt(getString(R.string.app_layout_key), prefValues[position]) - .apply() - activity?.recreate() - } - - acra_switch?.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 - crash_reporting_text?.text = getText(text) - } - - val enableCrashReporting = !settingsManager.getBoolean(ACRA.PREF_DISABLE_ACRA, false) - acra_switch.isChecked = enableCrashReporting - crash_reporting_text.text = - getText( - if (enableCrashReporting) R.string.bug_report_settings_off else R.string.bug_report_settings_on + binding.apply { + listview1.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE + listview1.setItemChecked( + prefValues.indexOf(currentLayout), true ) - - next_btt?.setOnClickListener { - findNavController().navigate(R.id.navigation_home) - } - - prev_btt?.setOnClickListener { - findNavController().popBackStack() + listview1.setOnItemClickListener { _, _, position, _ -> + settingsManager.edit { + putInt(getString(R.string.app_layout_key), prefValues[position]) + } + activity?.recreate() + } + + nextBtt.setOnClickListener { + setKey(HAS_DONE_SETUP_KEY, true) + findNavController().navigate(R.id.navigation_home) + } + + prevBtt.setOnClickListener { + findNavController().popBackStack() + } } } } - - -} \ 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 257ce5c1f01..8da121daa98 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -1,81 +1,76 @@ package com.lagradost.cloudstream3.ui.setup -import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter +import androidx.core.content.edit import androidx.core.util.forEach -import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.utils.DataStore.removeKey -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_media.* +import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +class SetupFragmentMedia : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupMediaBinding::inflate) +) { -class SetupFragmentMedia : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_setup_media, container, false) + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) - - with(context) { - if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + override fun onBindingCreated(binding: FragmentSetupMediaBinding) { + safe { + val ctx = context ?: return@safe + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = - ArrayAdapter(this, R.layout.sort_bottom_single_choice) + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) val names = enumValues().sorted().map { it.name } val selected = mutableListOf() arrayAdapter.addAll(names) - listview1?.let { - it.adapter = arrayAdapter - it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + binding.apply { + listview1.let { + it.adapter = arrayAdapter + it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE - it.setOnItemClickListener { _, _, _, _ -> - it.checkedItemPositions?.forEach { key, value -> - if (value) { - selected.add(key) - } else { - selected.remove(key) + it.setOnItemClickListener { _, _, _, _ -> + it.checkedItemPositions?.forEach { key, value -> + if (value) { + selected.add(key) + } else { + selected.remove(key) + } + } + val prefValues = selected.mapNotNull { pos -> + val item = + it.getItemAtPosition(pos)?.toString() ?: return@mapNotNull null + val itemVal = TvType.valueOf(item) + itemVal.ordinal.toString() + }.toSet() + settingsManager.edit { + putStringSet(getString(R.string.prefer_media_type_key), prefValues) } - } - val prefValues = selected.mapNotNull { pos -> - val item = it.getItemAtPosition(pos)?.toString() ?: return@mapNotNull null - val itemVal = TvType.valueOf(item) - itemVal.ordinal.toString() - }.toSet() - settingsManager.edit() - .putStringSet(getString(R.string.prefer_media_type_key), prefValues) - .apply() - // Regenerate set homepage - removeKey(USER_SELECTED_HOMEPAGE_API) + // Regenerate set homepage + DataStoreHelper.currentHomePage = null + } } - } - next_btt?.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_media_to_navigation_setup_layout) - } + nextBtt.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_media_to_navigation_setup_layout) + } - prev_btt?.setOnClickListener { - findNavController().popBackStack() + prevBtt.setOnClickListener { + findNavController().popBackStack() + } } } } - - -} \ 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 51abee9051c..3c4a09adea8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt @@ -1,88 +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.APIHolder +import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBinding +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar -import kotlinx.android.synthetic.main.fragment_setup_media.* +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() { - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - // Inflate the layout for this fragment - return inflater.inflate(R.layout.fragment_setup_provider_languages, container, false) +class SetupFragmentProviderLanguage : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentSetupProviderLanguagesBinding::inflate) +) { + + override fun fixLayout(view: View) { + fixSystemBarsPadding(view) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) + override fun onBindingCreated(binding: FragmentSetupProviderLanguagesBinding) { + safe { + val ctx = context ?: return@safe - with(context) { - if (this == null) return - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = - ArrayAdapter(this, R.layout.sort_bottom_single_choice) - - val current = this.getApiProviderLangSettings() - val langs = APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - val currentList = - current.map { langs.indexOf(it) }.filter { it != -1 } // TODO LOOK INTO + val currentLangTags = ctx.getApiProviderLangSettings() - 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 languagesTagName = synchronized(APIHolder.apis) { + listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji } - arrayAdapter.addAll(languageNames) + val currentIndexList = currentLangTags.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } + }.filter { it > -1 } - listview1?.adapter = arrayAdapter - listview1?.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE - currentList.forEach { - listview1.setItemChecked(it, true) - } + 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 currentLanguages = mutableListOf() - listview1?.checkedItemPositions?.forEach { key, value -> - if (value) currentLanguages.add(langs[key]) + 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() + ) + } } - settingsManager.edit().putStringSet( - this.getString(R.string.provider_lang_key), - currentLanguages.toSet() - ).apply() - } - next_btt?.setOnClickListener { - findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) - } + nextBtt.setOnClickListener { + findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) + } - prev_btt?.setOnClickListener { - findNavController().popBackStack() + prevBtt.setOnClickListener { + findNavController().popBackStack() + } } } } - - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt index 83d134cb102..f9b1cb1fe88 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt @@ -7,31 +7,37 @@ import android.graphics.Color import android.os.Bundle import android.util.DisplayMetrics import android.util.TypedValue -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.TextView import android.widget.Toast -import androidx.fragment.app.Fragment +import androidx.annotation.OptIn +import androidx.media3.common.text.Cue +import androidx.media3.common.util.UnstableApi import com.fasterxml.jackson.annotation.JsonProperty -import com.google.android.exoplayer2.text.Cue -import com.google.android.gms.cast.TextTrackStyle -import com.google.android.gms.cast.TextTrackStyle.* +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.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.databinding.ChromecastSubtitleSettingsBinding +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import kotlinx.android.synthetic.main.subtitle_settings.* const val CHROME_SUBTITLE_KEY = "chome_subtitle_settings" @@ -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,18 +141,10 @@ class ChromecastSubtitlesFragment : Fragment() { updateState() } - private fun Context.updateState() { + private fun updateState() { //subtitle_text?.setStyle(fromSaveToStyle(state)) } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.chromecast_subtitle_settings, container, false) - } - private lateinit var state: SaveChromeCaptionStyle private var hide: Boolean = true @@ -153,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 - context?.fixPaddingStatusbar(subs_root) - 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() @@ -184,23 +187,25 @@ class ChromecastSubtitlesFragment : Fragment() { } this.setOnLongClickListener { - it.context.setColor(id, null) - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + setColor(id, null) + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } } - subs_text_color.setup(0) - subs_outline_color.setup(1) - subs_background_color.setup(2) + binding.apply { + subsTextColor.setup(0) + subsOutlineColor.setup(1) + subsBackgroundColor.setup(2) + } val dismissCallback = { if (hide) activity?.hideSystemUI() } - subs_edge_type.setFocusableInTv() - subs_edge_type.setOnClickListener { textView -> + binding.subsEdgeType.setFocusableInTv() + binding.subsEdgeType.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, @@ -233,19 +238,19 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.edgeType = edgeTypes.map { it.first }[index] - textView.context.updateState() + updateState() } } - subs_edge_type.setOnLongClickListener { + binding.subsEdgeType.setOnLongClickListener { state.edgeType = defaultState.edgeType - it.context.updateState() - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + updateState() + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - subs_font_size.setFocusableInTv() - subs_font_size.setOnClickListener { textView -> + binding.subsFontSize.setFocusableInTv() + binding.subsFontSize.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), @@ -278,24 +283,24 @@ class ChromecastSubtitlesFragment : Fragment() { } } - subs_font_size.setOnLongClickListener { _ -> + binding.subsFontSize.setOnLongClickListener { _ -> state.fontScale = defaultState.fontScale //textView.context.updateState() // font size not changed - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } - subs_font.setFocusableInTv() - subs_font.setOnClickListener { textView -> + binding.subsFont.setFocusableInTv() + binding.subsFont.setOnClickListener { textView -> val fontTypes = listOf( - Pair(null, textView.context.getString(R.string.normal)), - Pair("Droid Sans", "Droid Sans"), - Pair("Droid Sans Mono", "Droid Sans Mono"), - Pair("Droid Serif Regular", "Droid Serif Regular"), - Pair("Cutive Mono", "Cutive Mono"), - Pair("Short Stack", "Short Stack"), - Pair("Quintessential", "Quintessential"), - Pair("Alegreya Sans SC", "Alegreya Sans SC"), + null to textView.context.getString(R.string.normal), + "Droid Sans" to "Droid Sans", + "Droid Sans Mono" to "Droid Sans Mono", + "Droid Serif Regular" to "Droid Serif Regular", + "Cutive Mono" to "Cutive Mono", + "Short Stack" to "Short Stack", + "Quintessential" to "Quintessential", + "Alegreya Sans SC" to "Alegreya Sans SC", ) //showBottomDialog @@ -307,38 +312,44 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.fontFamily = fontTypes.map { it.first }[index] - textView.context.updateState() + updateState() } } - - subs_font.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 } - cancel_btt.setOnClickListener { + binding.cancelBtt.setOnClickListener { activity?.popCurrentPage() } - apply_btt.setOnClickListener { + binding.applyBtt.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) //it.context.fromSaveToStyle(state) activity?.popCurrentPage() } - subtitle_text.setCues( - listOf( - Cue.Builder() - .setTextSize( - getPixels(TypedValue.COMPLEX_UNIT_SP, 25.0f).toFloat(), - Cue.TEXT_SIZE_TYPE_ABSOLUTE - ) - .setText(subtitle_text.context.getString(R.string.subtitles_example_text)) - .build() + setSubtitleCues(binding) + } + + @OptIn(UnstableApi::class) + private fun setSubtitleCues(binding: ChromecastSubtitleSettingsBinding) { + binding.subtitleText.apply { + setCues( + listOf( + Cue.Builder() + .setTextSize( + getPixels(TypedValue.COMPLEX_UNIT_SP, 25.0f).toFloat(), + Cue.TEXT_SIZE_TYPE_ABSOLUTE + ) + .setText(context.getString(R.string.subtitles_example_text)) + .build() + ) ) - ) + } } } 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 ff0e0e828b9..5f716cca3f1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -6,39 +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 com.google.android.exoplayer2.text.Cue -import com.google.android.exoplayer2.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.ui.settings.SettingsFragment.Companion.isTrueTvSettings +import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding +import com.lagradost.cloudstream3.ui.BaseDialogFragment +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.player.CustomDecoder +import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.setSubtitleAlignment +import com.lagradost.cloudstream3.ui.player.OutlineSpan +import com.lagradost.cloudstream3.ui.player.RoundedBackgroundColorSpan +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog -import com.lagradost.cloudstream3.utils.SubtitleHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.SubtitleHelper.languages +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import kotlinx.android.synthetic.main.subtitle_settings.* -import kotlinx.android.synthetic.main.toast.view.* +import com.lagradost.cloudstream3.utils.UIHelper.toPx import java.io.File const val SUBTITLE_KEY = "subtitle_settings" @@ -49,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 @@ -58,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 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) + } - fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { + // 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, @@ -97,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) }) } @@ -110,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 { @@ -141,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" } } @@ -165,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() } @@ -184,17 +327,15 @@ class SubtitlesFragment : Fragment() { } private fun Context.updateState() { - subtitle_text?.setStyle(fromSaveToStyle(state)) - val text = subtitle_text.context.getString(R.string.subtitles_example_text) - val fixedText = if (state.upperCase) text.uppercase() else text - subtitle_text?.setCues( + val text = getString(R.string.subtitles_example_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() ) ) @@ -213,14 +354,6 @@ class SubtitlesFragment : Fragment() { return if (color == Color.TRANSPARENT) Color.BLACK else color } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.subtitle_settings, container, false) - } - private lateinit var state: SaveCaptionStyle private var hide: Boolean = true @@ -229,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 - subs_import_text?.text = getString(R.string.subs_import_text).format( + binding.subsImportText.text = getString(R.string.subs_import_text).format( context?.getExternalFilesDir(null)?.absolutePath.toString() + "/Fonts" ) - context?.fixPaddingStatusbar(subs_root) - state = getCurrentSavedStyle() context?.updateState() - val isTvTrueSettings = isTrueTvSettings() - + val isTvTrueSettings = isLayout(TV) fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvTrueSettings } @@ -264,317 +412,377 @@ class SubtitlesFragment : Fragment() { this.setOnLongClickListener { it.context.setColor(id, null) - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } } + binding.apply { + subsTextColor.setup(0) + subsOutlineColor.setup(1) + subsBackgroundColor.setup(2) + subsWindowColor.setup(3) - subs_text_color.setup(0) - subs_outline_color.setup(1) - subs_background_color.setup(2) - subs_window_color.setup(3) + val dismissCallback = { + if (hide) + activity?.hideSystemUI() + } - val dismissCallback = { - if (hide) - activity?.hideSystemUI() - } + subsSubtitleElevation.setFocusableInTv() + subsSubtitleElevation.setOnClickListener { textView -> + // tbh this should not be a dialog if it has so many values + val elevationTypes = listOf( + 0 to textView.context.getString(R.string.none) + ) + (1..40).map { x -> + val i = x * 10 + i to "${i}dp" + } - subs_subtitle_elevation.setFocusableInTv() - subs_subtitle_elevation.setOnClickListener { textView -> - val suffix = "dp" - 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"), - ) + //showBottomDialog + activity?.showDialog( + elevationTypes.map { it.second }, + elevationTypes.map { it.first }.indexOf(state.elevation), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.elevation = elevationTypes.map { it.first }[index] + textView.context.updateState() + if (hide) + activity?.hideSystemUI() + } + } - //showBottomDialog - activity?.showDialog( - elevationTypes.map { it.second }, - elevationTypes.map { it.first }.indexOf(state.elevation), - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - state.elevation = elevationTypes.map { it.first }[index] - textView.context.updateState() - if (hide) - activity?.hideSystemUI() + subsSubtitleElevation.setOnLongClickListener { + state.elevation = DEF_SUBS_ELEVATION + it.context.updateState() + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true } - } - subs_subtitle_elevation.setOnLongClickListener { - state.elevation = DEF_SUBS_ELEVATION - it.context.updateState() - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - 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" + } - subs_edge_type.setFocusableInTv() - subs_edge_type.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) - ), - ) + 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() + } + } - //showBottomDialog - activity?.showDialog( - edgeTypes.map { it.second }, - edgeTypes.map { it.first }.indexOf(state.edgeType), - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - state.edgeType = edgeTypes.map { it.first }[index] - textView.context.updateState() + subsBackgroundRadius.setOnLongClickListener { + state.backgroundRadius = null + it.context.updateState() + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true } - } - subs_edge_type.setOnLongClickListener { - state.edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE - it.context.updateState() - showToast(activity, 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() + } + } - subs_font_size.setFocusableInTv() - subs_font_size.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"), - ) + subsEdgeType.setFocusableInTv() + subsEdgeType.setOnClickListener { textView -> + val edgeTypes = listOf( + CaptionStyleCompat.EDGE_TYPE_NONE to + textView.context.getString(R.string.subtitles_none), + CaptionStyleCompat.EDGE_TYPE_OUTLINE to + textView.context.getString(R.string.subtitles_outline), + CaptionStyleCompat.EDGE_TYPE_DEPRESSED to + textView.context.getString(R.string.subtitles_depressed), + CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW to + textView.context.getString(R.string.subtitles_shadow), + CaptionStyleCompat.EDGE_TYPE_RAISED to + textView.context.getString(R.string.subtitles_raised), + ) + + //showBottomDialog + activity?.showDialog( + edgeTypes.map { it.second }, + edgeTypes.map { it.first }.indexOf(state.edgeType), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.edgeType = edgeTypes.map { it.first }[index] + textView.context.updateState() + } + } - //showBottomDialog - activity?.showDialog( - fontSizes.map { it.second }, - fontSizes.map { it.first }.indexOf(state.fixedTextSize), - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - state.fixedTextSize = fontSizes.map { it.first }[index] - //textView.context.updateState() // font size not changed + subsEdgeType.setOnLongClickListener { + state.edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE + it.context.updateState() + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true } - } - subtitles_remove_bloat?.isChecked = state.removeBloat - subtitles_remove_bloat?.setOnCheckedChangeListener { _, b -> - state.removeBloat = b - } - subtitles_uppercase?.isChecked = state.upperCase - subtitles_uppercase?.setOnCheckedChangeListener { _, b -> - state.upperCase = b - context?.updateState() - } + subsFontSize.setFocusableInTv() + subsFontSize.setOnClickListener { textView -> + val fontSizes = listOf( + null to textView.context.getString(R.string.normal), + ) + (6..60).map { i -> i.toFloat() to "${i}sp" } + + //showBottomDialog + activity?.showDialog( + fontSizes.map { it.second }, + fontSizes.map { it.first }.indexOf(state.fixedTextSize), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.fixedTextSize = fontSizes.map { it.first }[index] + textView.context.updateState() + } + } - subtitles_remove_captions?.isChecked = state.removeCaptions - subtitles_remove_captions?.setOnCheckedChangeListener { _, b -> - state.removeCaptions = b - } + 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() + } + } - subs_font_size.setOnLongClickListener { _ -> - state.fixedTextSize = null - //textView.context.updateState() // font size not changed - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } + subtitlesRemoveBloat.isChecked = state.removeBloat + subtitlesRemoveBloat.setOnCheckedChangeListener { _, b -> + state.removeBloat = b + } + subtitlesUppercase.isChecked = state.upperCase + subtitlesUppercase.setOnCheckedChangeListener { _, b -> + state.upperCase = b + context?.updateState() + } - //Fetch current value from preference - context?.let { ctx -> - subtitles_filter_sub_lang?.isChecked = - PreferenceManager.getDefaultSharedPreferences(ctx) - .getBoolean(getString(R.string.filter_sub_lang_key), false) - } + subtitlesRemoveCaptions.isChecked = state.removeCaptions + subtitlesRemoveCaptions.setOnCheckedChangeListener { _, b -> + 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() + } - subtitles_filter_sub_lang?.setOnCheckedChangeListener { _, b -> + subsFontSize.setOnLongClickListener { _ -> + state.fixedTextSize = null + context?.updateState() + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + subsEdgeSize.setOnLongClickListener { _ -> + state.edgeSize = null + context?.updateState() + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + //Fetch current value from preference context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx) - .edit() - .putBoolean(getString(R.string.filter_sub_lang_key), b) - .apply() + subtitlesFilterSubLang.isChecked = + PreferenceManager.getDefaultSharedPreferences(ctx) + .getBoolean(getString(R.string.filter_sub_lang_key), false) } - } - subs_font.setFocusableInTv() - subs_font.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"), - ) - val savedFontTypes = textView.context.getSavedFonts() - - val currentIndex = - savedFontTypes.indexOfFirst { it.absolutePath == state.typefaceFilePath } - .let { index -> - if (index == -1) - fontTypes.indexOfFirst { it.first == state.typeface } - else index + fontTypes.size + subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> + context?.let { ctx -> + PreferenceManager.getDefaultSharedPreferences(ctx).edit { + putBoolean(getString(R.string.filter_sub_lang_key), b) } + } + } - //showBottomDialog - activity?.showDialog( - fontTypes.map { it.second } + savedFontTypes.map { it.name }, - currentIndex, - (textView as TextView).text.toString(), - false, - dismissCallback - ) { index -> - if (index < fontTypes.size) { - state.typeface = fontTypes[index].first - state.typefaceFilePath = null - } else { - state.typefaceFilePath = savedFontTypes[index - fontTypes.size].absolutePath - state.typeface = null + subsFont.setFocusableInTv() + subsFont.setOnClickListener { textView -> + val fontTypes = listOf( + null to textView.context.getString(R.string.normal), + R.font.trebuchet_ms to "Trebuchet MS", + R.font.netflix_sans to "Netflix Sans", + R.font.google_sans to "Google Sans", + R.font.open_sans to "Open Sans", + R.font.futura to "Futura", + R.font.consola to "Consola", + R.font.gotham to "Gotham", + R.font.lucida_grande to "Lucida Grande", + R.font.stix_general to "STIX General", + R.font.times_new_roman to "Times New Roman", + R.font.verdana to "Verdana", + R.font.ubuntu_regular to "Ubuntu", + R.font.comic_sans to "Comic Sans", + R.font.poppins_regular to "Poppins", + ) + val savedFontTypes = textView.context.getSavedFonts() + + val currentIndex = + savedFontTypes.indexOfFirst { it.absolutePath == state.typefaceFilePath } + .let { index -> + if (index == -1) + fontTypes.indexOfFirst { it.first == state.typeface } + else index + fontTypes.size + } + + //showBottomDialog + activity?.showDialog( + fontTypes.map { it.second } + savedFontTypes.map { it.name }, + currentIndex, + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + if (index < fontTypes.size) { + state.typeface = fontTypes[index].first + state.typefaceFilePath = null + } else { + state.typefaceFilePath = savedFontTypes[index - fontTypes.size].absolutePath + state.typeface = null + } + textView.context.updateState() } - textView.context.updateState() } - } - subs_font.setOnLongClickListener { textView -> - state.typeface = null - state.typefaceFilePath = null - textView.context.updateState() - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } + subsFont.setOnLongClickListener { textView -> + state.typeface = null + state.typefaceFilePath = null + textView.context.updateState() + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } - subs_auto_select_language.setFocusableInTv() - subs_auto_select_language.setOnClickListener { textView -> - val langMap = arrayListOf( - SubtitleHelper.Language639( - textView.context.getString(R.string.none), - textView.context.getString(R.string.none), - "", - "", - "", - "", - "" - ), - ) - langMap.addAll(SubtitleHelper.languages) - - val lang639_1 = langMap.map { it.ISO_639_1 } - activity?.showDialog( - langMap.map { it.languageName }, - lang639_1.indexOf(getAutoSelectLanguageISO639_1()), - (textView as TextView).text.toString(), - true, - dismissCallback - ) { index -> - setKey(SUBTITLE_AUTO_SELECT_KEY, lang639_1[index]) + subsAutoSelectLanguage.setFocusableInTv() + subsAutoSelectLanguage.setOnClickListener { textView -> + val languagesTagName = + listOf( + Pair( + textView.context.getString(R.string.none), + textView.context.getString(R.string.none) + ) + ) + + languages + .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + + val (langTagsIETF, langNames) = languagesTagName.unzip() + + activity?.showDialog( + langNames, + langTagsIETF.indexOf(getAutoSelectLanguageTagIETF()), + (textView as TextView).text.toString(), + true, + dismissCallback + ) { index -> + setKey(SUBTITLE_AUTO_SELECT_KEY, langTagsIETF[index]) + } } - } - subs_auto_select_language.setOnLongClickListener { - setKey(SUBTITLE_AUTO_SELECT_KEY, "en") - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } + subsAutoSelectLanguage.setOnLongClickListener { + setKey(SUBTITLE_AUTO_SELECT_KEY, "en") + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } - subs_download_languages.setFocusableInTv() - subs_download_languages.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 } - - activity?.showMultiDialog( - langMap.map { it.languageName }, - keyMap, - (textView as TextView).text.toString(), - dismissCallback - ) { indexList -> - setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { lang639_1[it] }.toList()) + subsDownloadLanguages.setFocusableInTv() + subsDownloadLanguages.setOnClickListener { textView -> + val languagesTagName = + languages + .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } + .sortedBy { + it.second.substringAfter("\u00a0").lowercase() + } // name ignoring flag emoji + + val (langTagsIETF, langNames) = languagesTagName.unzip() + + val selectedLanguages = getDownloadSubsLanguageTagIETF() + .map { langTagsIETF.indexOf(it) } + .filter { it >= 0 } + + activity?.showMultiDialog( + langNames, + selectedLanguages, + (textView as TextView).text.toString(), + dismissCallback + ) { indexList -> + setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { langTagsIETF[it] }.toList()) + } } - } - subs_download_languages.setOnLongClickListener { - setKey(SUBTITLE_DOWNLOAD_KEY, listOf("en")) + subsDownloadLanguages.setOnLongClickListener { + setKey(SUBTITLE_DOWNLOAD_KEY, listOf("en")) - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } - cancel_btt.setOnClickListener { - activity?.popCurrentPage() - } + cancelBtt.setOnClickListener { + if (popFragment) { + activity?.popCurrentPage() + } else { + dismiss() + } + } - apply_btt.setOnClickListener { - it.context.saveStyle(state) - applyStyleEvent.invoke(state) - it.context.fromSaveToStyle(state) - activity?.popCurrentPage() + applyBtt.setOnClickListener { + it.context.saveStyle(state) + applyStyleEvent.invoke(state) + 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 e9b69c5b013..00000000000 --- 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 53% 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 847b3328bb4..1377ccd08ad 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -1,9 +1,14 @@ package com.lagradost.cloudstream3.utils +import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Activity.RESULT_CANCELED -import android.content.* +import android.app.NotificationChannel +import android.app.NotificationManager +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 @@ -11,46 +16,67 @@ 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.Build -import android.os.Environment -import android.os.ParcelFileDescriptor -import android.provider.MediaStore +import android.os.Handler +import android.os.Looper import android.text.Spanned import android.util.Log +import android.view.View +import android.view.View.LAYOUT_DIRECTION_LTR +import android.view.View.LAYOUT_DIRECTION_RTL +import android.view.animation.DecelerateInterpolator import android.widget.Toast 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 import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager 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 com.fasterxml.jackson.module.kotlin.readValue +import androidx.viewpager2.widget.ViewPager2 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.lagradost.cloudstream3.* +import com.google.android.material.bottomsheet.BottomSheetDialog +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.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 @@ -59,19 +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 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? @@ -79,11 +104,27 @@ object AppUtils { return if (layoutManager == null || adapter == null) false else layoutManager.findLastCompletelyVisibleItemPosition() < adapter.itemCount - 7 // bit more than 1 to make it more seamless } + fun View.isLtr() = this.layoutDirection == LAYOUT_DIRECTION_LTR + fun View.isRtl() = this.layoutDirection == LAYOUT_DIRECTION_RTL + + fun BottomSheetDialog?.ownHide() { + this?.hide() + } + + fun BottomSheetDialog?.ownShow() { + // the reason for this is because show has a shitty animation we don't want + this?.window?.setWindowAnimations(-1) + this?.show() + Handler(Looper.getMainLooper()).postDelayed({ + 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), @@ -106,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) { @@ -129,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() @@ -151,6 +192,52 @@ object AppUtils { return builder.build() } + // https://stackoverflow.com/a/67441735/13746422 + fun ViewPager2.reduceDragSensitivity(f: Int = 4) { + val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView") + recyclerViewField.isAccessible = true + val recyclerView = recyclerViewField.get(this) as RecyclerView + + val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop") + touchSlopField.isAccessible = true + val touchSlop = touchSlopField.get(recyclerView) as Int + touchSlopField.set(recyclerView, touchSlop * f) // "8" was obtained experimentally + } + + fun ContentLoadingProgressBar?.animateProgressTo(to: Int) { + if (this == null) return + val animation: ObjectAnimator = ObjectAnimator.ofInt( + this, + "progress", + this.progress, + to + ) + animation.duration = 500 + animation.setAutoCancel(true) + animation.interpolator = DecelerateInterpolator() + animation.start() + } + + fun Context.createNotificationChannel( + channelId: String, + channelName: String, + description: String + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = + NotificationChannel(channelId, channelName, importance).apply { + this.description = description + } + + // Register the channel with the system. + val notificationManager: NotificationManager = + this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(channel) + } + } + @SuppressLint("RestrictedApi") fun getAllWatchNextPrograms(context: Context): Set { val COLUMN_WATCH_NEXT_ID_INDEX = 0 @@ -225,13 +312,14 @@ object AppUtils { // https://github.com/googlearchive/leanback-homescreen-channels/blob/master/app/src/main/java/com/google/android/tvhomescreenchannels/SampleTvProvider.java @SuppressLint("RestrictedApi") + @Throws @WorkerThread suspend fun Context.addProgramsToContinueWatching(data: List) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return 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 @@ -276,23 +364,174 @@ 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 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() ) - 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 + + 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 = synchronized(apis) { + 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) { @@ -300,40 +539,52 @@ object AppUtils { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe RepositoryManager.addRepository( RepositoryData( + repo.iconUrl ?: "", repo.name, url ) ) main { showToast( - this@loadRepository, getString(R.string.player_loaded_subtitles, repo.name), Toast.LENGTH_LONG ) } afterRepositoryLoadedEvent.invoke(true) - downloadAllPluginsDialog(url, repo.name) + addRepositoryDialog(repo.name, url) } } - fun Activity.downloadAllPluginsDialog(repositoryUrl: String, repositoryName: String) { + fun Activity.addRepositoryDialog( + repositoryName: String, + repositoryURL: String, + ) { + val repos = RepositoryManager.getRepositories() + + // 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 { - 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) + AlertDialog.Builder(this).apply { + setTitle(repositoryName) + setMessage(R.string.download_all_plugins_from_repo) + setPositiveButton(R.string.open_downloaded_repo) { _, _ -> + openAddedRepo() } - - setNegativeButton(R.string.no) { _, _ -> } + setNegativeButton(R.string.dismiss, null) + show().setDefaultFocus() } - builder.show() } } @@ -343,7 +594,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)) @@ -357,10 +608,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 @@ -376,10 +627,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) { @@ -388,6 +636,20 @@ object AppUtils { } } + fun Context.isNetworkAvailable(): Boolean { + 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 { val queryPairs: MutableMap = LinkedHashMap() val query: String = url.query @@ -400,24 +662,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 @@ -451,6 +695,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()) @@ -461,28 +717,55 @@ 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 } } + fun loadResult( + url: String, + apiName: String, + name : String, + startAction: Int = 0, + startValue: Int = 0 + ) { + (activity as FragmentActivity?)?.loadResult(url, apiName, name, startAction, startValue) + } + fun FragmentActivity.loadResult( url: String, apiName: String, + name : String, startAction: Int = 0, startValue: Int = 0 ) { + try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + Kitsu.isEnabled = + settingsManager.getBoolean(this.getString(R.string.show_kitsu_posters_key), true) + } catch (t: Throwable) { + logError(t) + } + this.runOnUiThread { // viewModelStore.clear() this.navigate( getResultsId(), - ResultFragment.newInstance(url, apiName, startAction, startValue) + ResultFragment.newInstance(url, apiName, name, startAction, startValue) ) } } + fun loadSearchResult( + card: SearchResponse, + startAction: Int = 0, + startValue: Int? = null, + ) { + activity?.loadSearchResult(card, startAction, startValue) + } + fun Activity?.loadSearchResult( card: SearchResponse, startAction: Int = 0, @@ -499,12 +782,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, @@ -536,129 +825,57 @@ 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 } } return false } - // 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 + /** + * Sets the focus to the negative button when in TV and Emulator layout. + **/ + fun AlertDialog.setDefaultFocus(buttonFocus: Int = DialogInterface.BUTTON_NEGATIVE) { + if (!Globals.isLayout(Globals.TV or Globals.EMULATOR)) return + this.getButton(buttonFocus).run { + isFocusableInTouchMode = true + requestFocus() } - 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 + 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 } } + private fun Activity?.cacheClass(clazz: String?) { clazz?.let { c -> this?.cacheDir?.let { @@ -696,9 +913,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 00000000000..10736e13e5f --- /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 338b1ed2788..88cb7481c9a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,46 +1,49 @@ package com.lagradost.cloudstream3.utils -import android.content.ContentValues import android.content.Context import android.net.Uri -import android.os.Build -import android.provider.MediaStore 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_SHOULD_UPDATE_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_SHOULD_UPDATE_LIST -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.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.getBasePath -import com.lagradost.cloudstream3.utils.VideoDownloadManager.isDownloadDir +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 { @@ -48,42 +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_SHOULD_UPDATE_LIST, - ANILIST_UNIXTIME_KEY, - ANILIST_USER_KEY, - MAL_TOKEN_KEY, - MAL_REFRESH_TOKEN_KEY, - MAL_SHOULD_UPDATE_LIST, 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) } } - var restoreFileSelector: ActivityResultLauncher>? = null + 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( @@ -91,9 +132,12 @@ object BackupUtils { @JsonProperty("settings") val settings: BackupVars ) - fun Context.getBackup(): BackupFile { - val allData = getSharedPrefs().all.filter { it.key.isTransferable() } - val allSettings = getDefaultSharedPrefs().all.filter { it.key.isTransferable() } + @Suppress("UNCHECKED_CAST") + private fun getBackup(context: Context?): BackupFile? { + if (context == null) return null + + val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } + val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } val allDataSorted = BackupVars( allData.filter { it.value is Boolean } as? Map, @@ -119,131 +163,118 @@ object BackupUtils { ) } - fun Context.restore( + @WorkerThread + 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 } } - fun FragmentActivity.backup() { + fun backup(context: Context?) = ioSafe { + if (context == null) return@ioSafe + + var fileStream: OutputStream? = null + var printStream: PrintWriter? = null try { - if (checkWrite()) { - val subDir = getBasePath().first - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) - val ext = "json" - val displayName = "CS3_Backup_${date}" - val backupFile = getBackup() - - 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) - } + if (!context.checkWrite()) { + showToast(R.string.backup_failed, Toast.LENGTH_LONG) + context.getActivity()?.requestRW() + return@ioSafe + } - 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 date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) + val displayName = "CS3_Backup_${date}" + val backupFile = getBackup(context) + val stream = setupBackupStream(context, displayName) - val printStream = PrintWriter(steam) - printStream.print(mapper.writeValueAsString(backupFile)) - printStream.close() + fileStream = stream.openNew() + printStream = PrintWriter(fileStream) + printStream.print(mapper.writeValueAsString(backupFile)) - showToast( - this, - R.string.backup_success, - Toast.LENGTH_LONG - ) - } else { - showToast(this, getString(R.string.backup_failed), Toast.LENGTH_LONG) - requestRW() - return - } + showToast( + R.string.backup_success, + Toast.LENGTH_LONG + ) } catch (e: Exception) { logError(e) try { showToast( - this, - 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) { logError(e) } + } finally { + printStream?.closeQuietly() + fileStream?.closeQuietly() } } + @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 = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - this.let { activity -> - uri?.let { - try { - val input = - activity.contentResolver.openInputStream(uri) - ?: return@registerForActivityResult - - val restoredValue = - mapper.readValue(input) - activity.restore( - restoredValue, - restoreSettings = true, - restoreDataStore = true + if (uri == null) return@registerForActivityResult + val activity = this + ioSafe { + try { + val input = activity.contentResolver.openInputStream(uri) + ?: return@ioSafe + + val restoredValue = + mapper.readValue(input) + + restore( + activity, + restoredValue, + restoreSettings = true, + restoreDataStore = true + ) + activity.runOnUiThread { activity.recreate() } + } catch (e: Exception) { + logError(e) + main { // smth can fail in .format + showToast( + getString(R.string.restore_failed_format).format(e.toString()) ) - activity.recreate() - } catch (e: Exception) { - logError(e) - try { // smth can fail in .format - showToast( - activity, - getString(R.string.restore_failed_format).format(e.toString()) - ) - } catch (e: Exception) { - logError(e) - } } } } @@ -264,10 +295,11 @@ object BackupUtils { "application/json", "unknown/unknown", "content/unknown", + "application/octet-stream", ) ) } catch (e: Exception) { - showToast(this, e.message) + showToast(e.message) logError(e) } } @@ -277,8 +309,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 00000000000..bce8f09dced --- /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 9e8cc1d4b3b..b48c8d40a69 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt @@ -1,7 +1,7 @@ package com.lagradost.cloudstream3.utils -import android.net.Uri -import com.google.android.exoplayer2.util.MimeTypes +import androidx.core.net.toUri +import androidx.media3.common.MimeTypes import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.media.RemoteMediaClient @@ -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 00000000000..def41d7a07a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt @@ -0,0 +1,47 @@ +package com.lagradost.cloudstream3.utils + +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import com.lagradost.cloudstream3.mvvm.Resource + +/** + * This is an atomic LiveData where you can do .value instantly after doing .postValue. + * + * The default behavior is a footgun that will cause race conditions, + * as we do not really care if it is posted as we only want the latest data (even in the binding). + * + * Fuck all that is LiveData, because we want this value to be accessible everywhere instantly. + * */ +open class ConsistentLiveData(initValue : T? = null) : LiveData(initValue) { + @Volatile private var internalValue : T? = initValue + + override fun getValue(): T? { + return internalValue + } + + /** If someone want the old behavior then good for them */ + val postedValue : T? get() = super.getValue() + + public override fun postValue(value : T?) { + super.postValue(value) + internalValue = value + } + + @MainThread + public override fun setValue(value: T?) { + super.setValue(value) + internalValue = value + } +} + +/** Atomic resource livedata, to make it easier to work with resources without local copies */ +class ResourceLiveData(initValue : Resource? = null) : ConsistentLiveData>(initValue) { + var success + get() = when(val output = this.value) { + is Resource.Success -> { + output.value + } + else -> null + } + set(value) = this.postValue(value?.let { Resource.Success(it) } ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index e1cedd3989d..0a1db85fadb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -5,21 +5,90 @@ import android.content.SharedPreferences 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.fasterxml.jackson.module.kotlin.kotlinModule +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError +import kotlin.reflect.KClass +import kotlin.reflect.KProperty +import androidx.core.content.edit +/** Used to display metadata about downloads and resume watching */ const val DOWNLOAD_HEADER_CACHE = "download_header_cache" +const val DOWNLOAD_HEADER_CACHE_BACKUP = "BACKUP_download_header_cache" //const val WATCH_HEADER_CACHE = "watch_header_cache" const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" +const val DOWNLOAD_EPISODE_CACHE_BACKUP = "BACKUP_download_episode_cache" const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" - const val PREFERENCES_NAME = "rebuild_preference" +// TODO degelgate by value for get & set + +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 + + operator fun getValue(self: Any?, property: KProperty<*>) = + cache ?: getKeyClass(key, klass.java).also { newCache -> cache = newCache } ?: default + + operator fun setValue( + self: Any?, + property: KProperty<*>, + t: T? + ) { + cache = t + if (t == null) { + removeKey(key) + } else { + setKeyClass(key, t) + } + } +} + +/** 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()) + val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() private fun getPreferences(context: Context): SharedPreferences { @@ -30,26 +99,16 @@ object DataStore { return getPreferences(this) } + fun getFolderName(folder: String, path: String): String { 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 { @@ -57,7 +116,9 @@ object DataStore { } fun Context.getKeys(folder: String): List { - return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } + // Ensure that the folder ends with "/" to prevent matching with other folders + val fixedFolder = folder.trimEnd('/') + "/" + return this.getSharedPrefs().all.keys.filter { it.startsWith(fixedFolder) } } fun Context.removeKey(folder: String, path: String) { @@ -77,9 +138,9 @@ object DataStore { try { val prefs = getSharedPrefs() if (prefs.contains(path)) { - val editor: SharedPreferences.Editor = prefs.edit() - editor.remove(path) - editor.apply() + prefs.edit { + remove(path) + } } } catch (e: Exception) { logError(e) @@ -87,23 +148,39 @@ object DataStore { } fun Context.removeKeys(folder: String): Int { - val keys = getKeys(folder) - keys.forEach { value -> - removeKey(value) + val keys = getKeys("$folder/") + try { + getSharedPrefs().edit { + keys.forEach { value -> + remove(value) + } + } + return keys.size + } catch (e: Exception) { + logError(e) + return 0 } - return keys.size } fun Context.setKey(path: String, value: T) { try { - val editor: SharedPreferences.Editor = getSharedPrefs().edit() - editor.putString(path, mapper.writeValueAsString(value)) - editor.apply() + getSharedPrefs().edit { + putString(path, mapper.writeValueAsString(value)) + } } catch (e: Exception) { logError(e) } } + fun Context.getKey(path: String, valueType: Class): T? { + try { + val json: String = getSharedPrefs().getString(path, null) ?: return null + return json.toKotlinObject(valueType) + } catch (e: Exception) { + return null + } + } + fun Context.setKey(folder: String, path: String, value: T) { setKey(getFolderName(folder, path), value) } @@ -112,6 +189,10 @@ object DataStore { return mapper.readValue(this, T::class.java) } + fun String.toKotlinObject(valueType: Class): T { + return mapper.readValue(this, valueType) + } + // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR inline fun Context.getKey(path: String, defVal: T?): T? { try { 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 46c29e3f31a..19caead21ee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,28 +1,237 @@ package com.lagradost.cloudstream3.utils +import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty -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.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.syncproviders.AccountManager +import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.ui.player.ExtractorUri +import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE +import com.lagradost.cloudstream3.ui.result.EpisodeSortType +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.VideoWatchState +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +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 + val profileImages = arrayOf( + R.drawable.profile_bg_dark_blue, + R.drawable.profile_bg_blue, + R.drawable.profile_bg_orange, + R.drawable.profile_bg_pink, + R.drawable.profile_bg_purple, + R.drawable.profile_bg_red, + 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, + @JsonProperty("name") + val name: String, + @JsonProperty("customImage") + val customImage: String? = null, + @JsonProperty("defaultImageIndex") + val defaultImageIndex: Int, + @JsonProperty("lockPin") + val lockPin: String? = null, + ) { + val image + get() = customImage?.let { UiImage.Image(it) } ?: profileImages.getOrNull( + defaultImageIndex + )?.let { UiImage.Drawable(it) } ?: UiImage.Drawable(profileImages.first()) + } + + const val TAG = "data_store_helper" + var accounts by PreferenceDelegate("$TAG/account", arrayOf()) + var selectedKeyIndex by PreferenceDelegate("$TAG/account_key_index", 0) + val currentAccount: String get() = selectedKeyIndex.toString() + + /** + * 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 { + setKey(key, value) + } + } + + fun setAccount(account: Account) { + val homepage = currentHomePage + + selectedKeyIndex = account.keyIndex + AccountManager.updateAccountIds() + showToast(context?.getString(R.string.logged_account, account.name) ?: account.name) + MainActivity.bookmarksUpdatedEvent(true) + MainActivity.reloadLibraryEvent(true) + 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) + } + } + + fun getDefaultAccount(context: Context): Account { + return accounts.let { currentAccounts -> + currentAccounts.getOrNull(currentAccounts.indexOfFirst { it.keyIndex == 0 }) ?: Account( + keyIndex = 0, + name = context.getString(R.string.default_account), + defaultImageIndex = 0 + ) + } + } + + fun getAccounts(context: Context): List { + return accounts.toMutableList().apply { + val item = getDefaultAccount(context) + remove(item) + add(0, item) + } + } + + /** 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, @JsonProperty("duration") val duration: Long @@ -37,19 +246,204 @@ object DataStoreHelper { return this } - data class BookmarkedData( + fun Int.toYear(): Date = + GregorianCalendar.getInstance().also { it.set(Calendar.YEAR, this) }.time + + /** + * Used to display notifications on new episodes and posters in library. + **/ + abstract class LibrarySearchResponse( @JsonProperty("id") override var id: Int?, - @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, - @JsonProperty("latestUpdatedTime") val latestUpdatedTime: Long, + @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, - ) : SearchResponse + @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, + 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 + ) + } + } + + data class BookmarkedData( + @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, + override var id: Int?, + override val latestUpdatedTime: Long, + override val name: String, + override val url: String, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override val year: Int?, + override val syncData: Map? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override val plot: String? = null, + override 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, + url, + id, + null, + null, + null, + latestUpdatedTime, + 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 + ) + } + } data class ResumeWatchingResult( @JsonProperty("name") override val name: String, @@ -57,9 +451,7 @@ object DataStoreHelper { @JsonProperty("apiName") override val apiName: String, @JsonProperty("type") override var type: TvType? = null, @JsonProperty("posterUrl") override var posterUrl: String?, - @JsonProperty("watchPos") val watchPos: PosDur?, - @JsonProperty("id") override var id: Int?, @JsonProperty("parentId") val parentId: Int?, @JsonProperty("episode") val episode: Int?, @@ -67,9 +459,12 @@ 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 - private var currentAccount: String = "0" //TODO ACCOUNT IMPLEMENTATION + /** + * A datastore wide account for future implementations of a multiple account system + **/ fun getAllWatchStateIds(): List? { val folder = "$currentAccount/$RESULT_WATCH_STATE" @@ -83,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? { @@ -135,7 +530,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - VideoDownloadHelper.ResumeWatching( + DownloadObjects.ResumeWatching( parentId, episodeId, episode, @@ -156,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", @@ -164,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", @@ -175,6 +570,7 @@ object DataStoreHelper { fun setBookmarkedData(id: Int?, data: BookmarkedData) { if (id == null) return setKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString(), data) + AccountManager.localListApi.requireLibraryRefresh = true } fun getBookmarkedData(id: Int?): BookmarkedData? { @@ -182,19 +578,155 @@ 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) + } ?: emptyList() + } + + fun removeSubscribedData(id: Int?) { + if (id == null) return + AccountManager.localListApi.requireLibraryRefresh = true + removeKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) + } + + /** + * Set new seen episodes and update time + **/ + fun updateSubscribedData(id: Int?, data: SubscribedData?, episodeResponse: EpisodeResponse?) { + if (id == null || data == null || episodeResponse == null) return + val newData = data.copy( + latestUpdatedTime = unixTimeMS, + lastSeenEpisodeCount = episodeResponse.getLatestEpisodes() + ) + setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), newData) + } + + fun setSubscribedData(id: Int?, data: SubscribedData) { + if (id == null) return + setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), data) + AccountManager.localListApi.requireLibraryRefresh = true + } + + fun getSubscribedData(id: Int?): SubscribedData? { + if (id == null) return null + 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) } + fun getVideoWatchState(id: Int?): VideoWatchState? { + if (id == null) return null + return getKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString(), null) + } + + fun setVideoWatchState(id: Int?, watchState: VideoWatchState) { + if (id == null) return + + // None == No key + if (watchState == VideoWatchState.None) { + removeKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString()) + } else { + setKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString(), watchState) + } + } + fun getDub(id: Int): DubStatus? { - return DubStatus.values() + return DubStatus.entries .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) } @@ -204,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) } } @@ -248,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 c1eb649b678..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ /dev/null @@ -1,93 +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.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 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 { - println("KEY $key") - if (key == DOWNLOAD_CHECK) { - downloadCheck(applicationContext, ::handleNotification)?.let { - awaitDownload(it) - } - } else if (key != null) { - val info = applicationContext.getKey(WORK_KEY_INFO, key) - val pkg = - applicationContext.getKey(WORK_KEY_PACKAGE, key) - if (info != null) { - downloadEpisode( - applicationContext, - info.source, - info.folder, - info.ep, - info.links, - ::handleNotification - ) - awaitDownload(info.ep.id) - } else if (pkg != null) { - downloadFromResume(applicationContext, pkg, ::handleNotification) - awaitDownload(pkg.item.ep.id) - } - 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 26f83d1e1e2..f66da4e5ff3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -3,16 +3,49 @@ package com.lagradost.cloudstream3.utils class Event { private val observers = mutableSetOf<(T) -> Unit>() + 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) + } + } +} + +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() + } } -} \ No newline at end of file +} 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 24708e9912e..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ /dev/null @@ -1,480 +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( - 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, -) : VideoDownloadManager.IDownloadableMinimum { - 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) { - Unknown(400), - P144(144), // 144p - P240(240), // 240p - P360(360), // 360p - P480(480), // 480p - P720(720), // 720p - P1080(1080), // 1080p - P1440(1440), // 1440p - P2160(2160); // 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 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(), - StreamSB1(), - StreamSB2(), - StreamSB3(), - StreamSB4(), - StreamSB5(), - StreamSB6(), - StreamSB7(), - StreamSB8(), - StreamSB9(), - StreamSB10(), - SBfull(), - // Streamhub(), cause Streamhub2() works - Streamhub2(), - Ssbstream(), - Sbthe(), - Vidgomunime(), - Sbflix(), - Streamsss(), - Sbspeed(), - - Fastream(), - - FEmbed(), - FeHD(), - Fplayer(), - DBfilm(), - Luxubu(), - LayarKaca(), - Rasacintaku(), - FEnet(), - Kotakajair(), - Cdnplayer(), - // WatchSB(), 'cause StreamSB.kt works - Uqload(), - Uqload1(), - Evoload(), - Evoload1(), - VoeExtractor(), - UpstreamExtractor(), - - Tomatomatela(), - Cinestart(), - OkRu(), - OkRuHttps(), - - // dood extractors - DoodCxExtractor(), - DoodPmExtractor(), - DoodToExtractor(), - DoodSoExtractor(), - DoodLaExtractor(), - DoodWsExtractor(), - DoodShExtractor(), - DoodWatchExtractor(), - DoodWfExtractor(), - - AsianLoad(), - - // GenericM3U8(), - Jawcloud(), - Zplayer(), - ZplayerV2(), - Upstream(), - - Maxstream(), - Tantifilm(), - Userload(), - Supervideo(), - GuardareStream(), - CineGrabber(), - - // StreamSB.kt works - // SBPlay(), - // SBPlay1(), - // SBPlay2(), - - PlayerVoxzer(), - - BullStream(), - GMPlayer(), - - Blogger(), - Solidfiles(), - YourUpload(), - - Hxfile(), - KotakAnimeid(), - Neonime8n(), - Neonime7n(), - Yufiles(), - Aico(), - - JWPlayer(), - Meownime(), - DesuArcg(), - DesuOdchan(), - DesuOdvip(), - DesuDrive(), - - Filesim(), - Linkbox(), - Acefile(), - SpeedoStream(), - SpeedoStream1(), - Zorofile(), - Embedgram(), - Mvidoo(), - Streamplay(), - Vidmoly(), - Vidmolyme(), - Voe(), - Moviehab(), - MoviehabNet(), - Jeniusplay(), - - Gdriveplayerapi(), - Gdriveplayerapp(), - Gdriveplayerfun(), - Gdriveplayerio(), - Gdriveplayerme(), - Gdriveplayerbiz(), - Gdriveplayerorg(), - Gdriveplayerus(), - Gdriveplayerco(), - Gdriveplayer(), - DatabaseGdrive(), - DatabaseGdrive2(), - - YoutubeExtractor(), - YoutubeShortLinkExtractor(), - YoutubeMobileExtractor(), - YoutubeNoCookieExtractor(), - Streamlare(), - VidSrcExtractor(), - VidSrcExtractor2(), - PlayLtXyz(), - AStreamHub(), -) - - -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 - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt index 14d1b055654..8456094d1e9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt @@ -1,112 +1,166 @@ package com.lagradost.cloudstream3.utils -import com.lagradost.cloudstream3.app +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.utils.Coroutines.main -import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import java.io.InputStream +import kotlin.let object FillerEpisodeCheck { - private const val MAIN_URL = "https://www.animefillerlist.com" + fun String?.toClassDir(): String { + val q = this ?: "null" + val z = (6..10).random().calc() + return q + "cache" + z + } - var list: HashMap? = null - var cache: HashMap> = hashMapOf() + data class Show( + @JsonProperty("slug") + val slug: String, + @JsonProperty("title") + val title: String, + @JsonProperty("filler") + val filler: ArrayList, + @JsonProperty("mixedCanon") + val mixedCanon: ArrayList, + @JsonProperty("mangaCanon") + val mangaCanon: ArrayList, + @JsonProperty("animeCanon") + val animeCanon: ArrayList, + ) - private fun fixName(name: String): String { - return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ") - .replace("[^a-zA-Z0-9 ]".toRegex(), "") - } + data class MappingRoot( + @JsonProperty("type") + val type: String?, + @JsonProperty("anidb_id") + val anidbId: Long?, + @JsonProperty("anilist_id") + val anilistId: Long?, + @JsonProperty("animecountdown_id") + val animecountdownId: Long?, + @JsonProperty("animenewsnetwork_id") + val animenewsnetworkId: Long?, + @JsonProperty("anime-planet_id") + val animePlanetId: String?, + @JsonProperty("anisearch_id") + val anisearchId: Long?, + @JsonProperty("imdb_id") + val imdbId: String?, + @JsonProperty("kitsu_id") + val kitsuId: Long?, + @JsonProperty("livechart_id") + val livechartId: Long?, + @JsonProperty("mal_id") + val malId: Long?, + @JsonProperty("simkl_id") + val simklId: Long?, + @JsonProperty("themoviedb_id") + val themoviedbId: Long?, + @JsonProperty("tvdb_id") + val tvdbId: Long?, + @JsonProperty("season") + val season: Season?, + ) - private suspend fun getFillerList(): Boolean { - if (list != null) return true - try { - val result = app.get("$MAIN_URL/shows").text - val documented = Jsoup.parse(result) - val localHTMLList = documented.select("div#ShowList > div.Group > ul > li > a") - val localList = HashMap() - for (i in localHTMLList) { - val name = i.text() - - if (name.lowercase(Locale.ROOT).contains("manga only")) continue - - val href = i.attr("href") - if (name.isNullOrEmpty() || href.isNullOrEmpty()) { - continue - } + data class Season( + @JsonProperty("tvdb") + val tvdb: Long?, + @JsonProperty("tmdb") + val tmdb: Long?, + ) - val values = "(.*) \\((.*)\\)".toRegex().matchEntire(name)?.groups - if (values != null) { - for (index in 1 until values.size) { - val localName = values[index]?.value ?: continue - localList[fixName(localName)] = href - } - } else { - localList[fixName(name)] = href - } - } - if (localList.size > 0) { - list = localList - return true - } - } catch (e: Exception) { - e.printStackTrace() + data class CombinedMedia( + @JsonProperty("mapping") + val mapping: MappingRoot?, + @JsonProperty("show") + val show: Show + ) + + data class Database( + val mal: HashMap = hashMapOf(), + val anilist: HashMap = hashMapOf(), + val kitsu: HashMap = hashMapOf(), + val tmdb: HashMap = hashMapOf(), + val imdb: HashMap = hashMapOf(), + val name: HashMap = hashMapOf(), + ) + + private var database: Database? = null + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String): String = + name.replace(strip, "").lowercase() + + + @Synchronized + @Throws + @WorkerThread + fun loadJson(): Database { + database?.let { + return it } - return false - } + + /** The entire "database" is stored as a json file we can parse */ + val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!! + val text = stream.reader().readText() - fun String?.toClassDir(): String { - val q = this ?: "null" - val z = (6..10).random().calc() - return q + "cache" + z + val allMedia = parseJson>(text) + val pending = Database() + for (media in allMedia) { + val lowercase = stripName(media.show.title) + pending.name[lowercase] = media + val map = media.mapping ?: continue + + map.imdbId?.let { id -> pending.imdb[id] = media } + map.malId?.let { id -> pending.mal[id] = media } + map.anilistId?.let { id -> pending.anilist[id] = media } + map.kitsuId?.let { id -> pending.kitsu[id] = media } + map.season?.tmdb?.let { id -> pending.tmdb[id] = media } + } + database = pending + return pending } - suspend fun getFillerEpisodes(query: String): HashMap? { - try { - cache[query]?.let { - return it - } - if (!getFillerList()) return null - val localList = list ?: return null - - // Strips these from the name - val blackList = listOf( - "TV Dubbed", - "(Dub)", - "Subbed", - "(TV)", - "(Uncensored)", - "(Censored)", - "(\\d+)" // year - ) - val blackListRegex = - Regex( - """ (${ - blackList.joinToString(separator = "|").replace("(", "\\(") - .replace(")", "\\)") - })""" - ) - - val realQuery = - fixName(query.replace(blackListRegex, "")).replace("shippuuden", "shippuden") - if (!localList.containsKey(realQuery)) return null - val href = localList[realQuery]?.replace(MAIN_URL, "") ?: return null // JUST IN CASE - val result = app.get("$MAIN_URL$href").text - val documented = Jsoup.parse(result) ?: return null - val hashMap = HashMap() - documented.select("table.EpisodeList > tbody > tr").forEach { - val type = it.selectFirst("td.Type > span")?.text() == "Filler" - val episodeNumber = it.selectFirst("td.Number")?.text()?.toIntOrNull() - if (episodeNumber != null) { - hashMap[episodeNumber] = type - } - } - cache[query] = hashMap - return hashMap - } catch (e: Exception) { - e.printStackTrace() + val loadCache: HashMap?> = hashMapOf() + + @Synchronized + @Throws + @WorkerThread + fun getFillerEpisodes(data: LoadResponse): HashSet? { + /** Only for anime */ + if (data.type != TvType.Anime) { return null } + /** Try to hit the cache for this entry, to avoid recreating the hashset */ + loadCache[data.getId()]?.let { cachedResponse -> + return cachedResponse + } + val db = loadJson() + + val media = + db.mal[data.getMalId()?.toLongOrNull()] + ?: db.anilist[data.getAniListId()?.toLongOrNull()] + ?: db.kitsu[data.getKitsuId()?.toLongOrNull()] + ?: db.imdb[data.getImdbId()] + ?: db.tmdb[data.getTMDbId()?.toLongOrNull()] + ?: db.name[stripName(data.name)] + + return media?.show?.filler?.toHashSet().also { response -> + loadCache[data.getId()] = response + } } private fun Int.calc(): Int { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt new file mode 100644 index 00000000000..58ff44bb257 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt @@ -0,0 +1,20 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context + +/** + * Simple helper to get the short commit hash from assets. + * The hash is generated at build and stored as an asset + * that can be accessed at runtime for Gradle + * configuration cache support. + */ +object GitInfo { + fun Context.currentCommitHash(): String = try { + assets.open("git-hash.txt") + .bufferedReader() + .readText() + .trim() + } catch (_: Exception) { + "" + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt deleted file mode 100644 index 4b0ee8903eb..00000000000 --- 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 b4922945b0d..00000000000 --- 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 00000000000..9d5c75289c8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt @@ -0,0 +1,176 @@ +package com.lagradost.cloudstream3.utils + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +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.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.crossfade +import coil3.util.DebugLogger +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.network.buildDefaultClient +import okhttp3.HttpUrl +import okio.Path.Companion.toOkioPath +import java.io.File +import java.nio.ByteBuffer + +object ImageLoader { + + private const val TAG = "CoilImgLoader" + + internal fun buildImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) + .crossfade(200) + .allowHardware(SDK_INT >= 28) // SDK_INT >= 28, cant use hardware bitmaps for Palette Builder + .diskCachePolicy(CachePolicy.ENABLED) + .networkCachePolicy(CachePolicy.ENABLED) + .memoryCache { + MemoryCache.Builder().maxSizePercent(context, 0.1) // Use 10 % of the app's available memory for caching + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) // 512 MB + .maxSizePercent(0.04) // Use 4 % of the device's storage space for disk caching + .build() + } + /** Pass interceptors with care, unnecessary passing tokens to servers + or image hosting services causes unauthorized exceptions **/ + .components { add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) } + .also { + it.setupCoilLogger() + Log.d(TAG, "buildImageLoader: Setting COIL Image Loader.") + } + .build() + + /** Use DebugLogger on debug builds which won't slow down release builds & use EventListener for + Errors on release builds. **/ + internal fun ImageLoader.Builder.setupCoilLogger() { + if (BuildConfig.DEBUG) { + logger(DebugLogger()) + Log.d(TAG, "setupCoilLogger: Activated DEBUG_LOGGER FOR COIL") + } else { + eventListener(object : EventListener() { + override fun onError(request: ImageRequest, result: ErrorResult) { + super.onError(request, result) + Log.e(TAG, "Error loading image: ${result.throwable}") + } + }) + Log.d(TAG, "setupCoilLogger: Activated EVENT_LISTENER FOR COIL") + } + } + + /** we use coil's built in loader with our global synchronized instance, this way we achieve + latest and complete functionality as well as stability **/ + private fun ImageView.loadImageInternal( + imageData: Any?, + headers: Map? = null, + builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations + ) { + // clear image to avoid loading & flickering issue at fast scrolling (e.g, an image recycler) + this.dispose() + + if(imageData == null) return // Just in case + + // setImageResource is better than coil3 on resources due to attr + if(imageData is Int) { + this.setImageResource(imageData) + return + } + + // Use Coil's built-in load method but with our custom module & a decent USER-AGENT always + // which can be overridden by extensions. + this.load(imageData, SingletonImageLoader.get(context)) { + this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder -> + headerBuilder["User-Agent"] = USER_AGENT + headers?.forEach { (key, value) -> + headerBuilder[key] = value + } + }.build()) + + builder() // if passed + } + } + + /** 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: HttpUrl?, + 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) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt new file mode 100644 index 00000000000..6ed4d4afaff --- /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 1a671499824..8bcd1b88e70 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -3,349 +3,365 @@ package com.lagradost.cloudstream3.utils import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.PackageManager.NameNotFoundException import android.net.Uri import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.content.edit import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.services.PackageInstallerService +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink import okio.buffer import okio.sink +import java.io.BufferedReader import java.io.File +import java.io.IOException +import java.io.InputStreamReader + +object InAppUpdater { + private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_REPO = "cloudstream" + + private const val PRERELEASE_PACKAGE_NAME = "com.lagradost.cloudstream3.prerelease" + private const val LOG_TAG = "InAppUpdater" + + private data class GithubAsset( + @JsonProperty("name") val name: String, + @JsonProperty("size") val size: Int, // Size in bytes + @JsonProperty("browser_download_url") val browserDownloadUrl: String, + @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive + ) + + private data class GithubRelease( + @JsonProperty("tag_name") val tagName: String, // Version code + @JsonProperty("body") val body: String, // Description + @JsonProperty("assets") val assets: List, + @JsonProperty("target_commitish") val targetCommitish: String, // Branch + @JsonProperty("prerelease") val prerelease: Boolean, + @JsonProperty("node_id") val nodeId: String, + ) + + private data class GithubObject( + @JsonProperty("sha") val sha: String, // SHA-256 hash + @JsonProperty("type") val type: String, + @JsonProperty("url") val url: String, + ) + + private data class GithubTag( + @JsonProperty("object") val githubObject: GithubObject, + ) + + private data class Update( + @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, + @JsonProperty("updateURL") val updateURL: String?, + @JsonProperty("updateVersion") val updateVersion: String?, + @JsonProperty("changelog") val changelog: String?, + @JsonProperty("updateNodeId") val updateNodeId: String?, + ) + + private suspend fun Activity.getAppUpdate(installPrerelease: Boolean): Update { + return try { + when { + // No updates on debug version + BuildConfig.DEBUG -> Update(false, null, null, null, null) + BuildConfig.FLAVOR == "prerelease" || installPrerelease -> getPreReleaseUpdate() + else -> getReleaseUpdate() + } + } catch (e: Exception) { + Log.e(LOG_TAG, Log.getStackTraceString(e)) + Update(false, null, null, null, null) + } + } + private suspend fun Activity.getReleaseUpdate(): Update { + val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = parseJson>( + app.get(url, headers = headers).text + ) -class InAppUpdater { - companion object { - const val GITHUB_USER_NAME = "recloudstream" - const val GITHUB_REPO = "cloudstream" + val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") + val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") + val foundList = response.filter { rel -> + !rel.prerelease + }.sortedWith(compareBy { release -> + release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> + versionRegex.find( + it1 + )?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + } + } + }).toList() - const val LOG_TAG = "InAppUpdater" + val found = foundList.lastOrNull() + val foundAsset = found?.assets?.getOrNull(0) + val foundVersion = foundAsset?.name?.let { versionRegex.find(it) } - // === IN APP UPDATER === - data class GithubAsset( - @JsonProperty("name") val name: String, - @JsonProperty("size") val size: Int, // Size bytes - @JsonProperty("browser_download_url") val browser_download_url: String, // download link - @JsonProperty("content_type") val content_type: String, // application/vnd.android.package-archive - ) + if (foundVersion == null) { + return Update(false, null, null, null, null) + } - 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 - ) + val currentVersion = packageName?.let { + packageManager.getPackageInfo(it, 0) + } - data class GithubObject( - @JsonProperty("sha") val sha: String, // sha 256 hash - @JsonProperty("type") val type: String, // object type - @JsonProperty("url") val url: String, - ) + val shouldUpdate = if (foundAsset.browserDownloadUrl.isBlank()) { + false + } else { + currentVersion?.versionName?.let { versionName -> + versionRegexLocal.find(versionName)?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + } + }?.compareTo( + foundVersion.groupValues.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + })!! < 0 + } - data class GithubTag( - @JsonProperty("object") val github_object: GithubObject, + return Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + foundVersion.groupValues[2], + found.body, + found.nodeId ) + } - data class Update( - @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, - @JsonProperty("updateURL") val updateURL: String?, - @JsonProperty("updateVersion") val updateVersion: String?, - @JsonProperty("changelog") val changelog: String?, - @JsonProperty("updateNodeId") val updateNodeId: String? + private suspend fun Activity.getPreReleaseUpdate(): Update { + val tagUrl = + "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" + val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = parseJson>( + app.get(releaseUrl, headers = headers).text ) - private suspend fun Activity.getAppUpdate(): Update { - return try { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - if (settingsManager.getBoolean( - getString(R.string.prerelease_update_key), - resources.getBoolean(R.bool.is_prerelease) - ) - ) { - getPreReleaseUpdate() - } else { - getReleaseUpdate() - } - } catch (e: Exception) { - Log.e(LOG_TAG, Log.getStackTraceString(e)) - Update(false, null, null, null, null) - } + val found = response.lastOrNull { rel -> + rel.prerelease || rel.tagName == "pre-release" } - private suspend fun Activity.getReleaseUpdate(): Update { - val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = - parseJson>( - app.get( - url, - headers = headers - ).text - ) - - val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") - val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") - /* - val releases = response.map { it.assets }.flatten() - .filter { it.content_type == "application/vnd.android.package-archive" } - val found = - releases.sortedWith(compareBy { - versionRegex.find(it.name)?.groupValues?.get(2) - }).toList().lastOrNull()*/ - val 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?.filter { it -> + it.contentType == "application/vnd.android.package-archive" + }?.getOrNull(0) - 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) - } - } + if (foundAsset == null) { return Update(false, null, null, null, null) } - private suspend fun Activity.getPreReleaseUpdate(): Update { - val tagUrl = - "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" - val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = - parseJson>(app.get(releaseUrl, headers = headers).text) - - val found = - response.lastOrNull { rel -> - rel.prerelease || rel.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 tagResponse = parseJson(app.get(tagUrl, headers = headers).text) + val updateCommitHash = tagResponse.githubObject.sha.trim().take(7) + Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash") + + return Update( + currentCommitHash() != updateCommitHash, + foundAsset.browserDownloadUrl, + updateCommitHash, + found.body, + found.nodeId + ) + } + + private val updateLock = Mutex() + + private suspend fun Activity.downloadUpdate(url: String): Boolean { + try { + Log.d(LOG_TAG, "Downloading update: $url") + val appUpdateName = "CloudStream" + val appUpdateSuffix = "apk" + + // Delete all old updates + this.cacheDir.listFiles()?.filter { + it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix + }?.forEach { deleteFileOnExit(it) } + + val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") + val sink: BufferedSink = downloadedFile.sink().buffer() + + updateLock.withLock { + sink.writeAll(app.get(url).body.source()) + sink.close() + openApk(this, Uri.fromFile(downloadedFile)) } + + return true + } catch (e: Exception) { + logError(e) + return false } + } + private fun openApk(context: Context, uri: Uri) = safe { + val path = uri.path ?: return@safe + val contentUri = FileProvider.getUriForFile( + context, BuildConfig.APPLICATION_ID + ".provider", File(path) + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + data = contentUri + } + context.startActivity(installIntent) + } - private val updateLock = Mutex() + fun Activity.installPreReleaseIfNeeded() = ioSafe { + val isInstalled = try { + packageManager.getPackageInfo(PRERELEASE_PACKAGE_NAME, 0) + true + } catch (_: NameNotFoundException) { + false + } - private suspend fun Activity.downloadUpdate(url: String): Boolean { - try { - Log.d(LOG_TAG, "Downloading update: $url") - val appUpdateName = "CloudStream" - val appUpdateSuffix = "apk" + if (isInstalled) { + showToast(R.string.prerelease_already_installed) + } else if (!runAutoUpdate(checkAutoUpdate = false, installPrerelease = true)) { + showToast(R.string.prerelease_install_failed) + } + } - // Delete all old updates - this.cacheDir.listFiles()?.filter { - it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix - }?.forEach { - it.deleteOnExit() - } - val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") - val sink: BufferedSink = downloadedFile.sink().buffer() + /** + * @param checkAutoUpdate if the update check was launched automatically + * @param installPrerelease if we want to install the pre-release version + */ + suspend fun Activity.runAutoUpdate( + checkAutoUpdate: Boolean = true, installPrerelease: Boolean = false + ): Boolean { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val autoUpdateEnabled = + settingsManager.getBoolean(getString(R.string.auto_update_key), true) + if (checkAutoUpdate && !autoUpdateEnabled) { + return false + } - updateLock.withLock { - sink.writeAll(app.get(url).body.source()) - sink.close() - openApk(this, Uri.fromFile(downloadedFile)) - } - return true - } catch (e: Exception) { - return false - } + val update = getAppUpdate(installPrerelease) + if (!update.shouldUpdate || update.updateURL == null) { + return false } - private fun openApk(context: Context, uri: Uri) { - try { - uri.path?.let { - val contentUri = FileProvider.getUriForFile( - context, - BuildConfig.APPLICATION_ID + ".provider", - File(it) - ) - val installIntent = Intent(Intent.ACTION_VIEW).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) - data = contentUri - } - context.startActivity(installIntent) - } - } catch (e: Exception) { - logError(e) - } + // Check if update should be skipped + val updateNodeId = settingsManager.getString( + getString(R.string.skip_update_key), "" + ) + + // Skips the update if its an automatic update and the update is skipped + // This allows updating manually + if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { + return false } - /** - * @param checkAutoUpdate if the update check was launched automatically - **/ - suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + runOnUiThread { + safe { + val currentVersion = packageName?.let { + packageManager.getPackageInfo(it, 0) + } - if (!checkAutoUpdate || settingsManager.getBoolean( - getString(R.string.auto_update_key), - true + val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) + builder.setTitle( + getString(R.string.new_update_format).format( + currentVersion?.versionName, update.updateVersion + ) ) - ) { - val update = getAppUpdate() - if ( - update.shouldUpdate && - update.updateURL != null) { - - // Check if update should be skipped - val updateNodeId = - settingsManager.getString(getString(R.string.skip_update_key), "") - - // Skips the update if its an automatic update and the update is skipped - // This allows updating manually - if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { - return false - } - runOnUiThread { - try { - val currentVersion = packageName?.let { - packageManager.getPackageInfo( - it, - 0 - ) + val logRegex = Regex("\\[(.*?)]\\((.*?)\\)") + val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> + matchResult.groupValues[1] + } // Sanitized because it looks cluttered + + builder.setMessage(sanitizedChangelog) + builder.apply { + setPositiveButton(R.string.update) { _, _ -> + // Forcefully start any delayed installations + if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton + + showToast(R.string.download_started, Toast.LENGTH_LONG) + + // Check if the setting hasn't been changed + if (settingsManager.getInt( + getString(R.string.apk_installer_key), -1 + ) == -1 + ) { + // Set to legacy installer if using MIUI + if (isMiUi()) { + settingsManager.edit { + putInt(getString(R.string.apk_installer_key), 1) + } } + } - val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setTitle( - getString(R.string.new_update_format).format( - currentVersion?.versionName, - update.updateVersion + val 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 ) - ) - builder.setMessage("${update.changelog}") - - val context = this - builder.apply { - setPositiveButton(R.string.update) { _, _ -> - showToast(context, R.string.download_started, Toast.LENGTH_LONG) - - val currentInstaller = - settingsManager.getInt( - getString(R.string.apk_installer_key), - 0 - ) - - when (currentInstaller) { - // New method - 0 -> { - val intent = PackageInstallerService.getIntent( - context, - update.updateURL + } + // Legacy + 1 -> { + ioSafe { + if (!downloadUpdate(update.updateURL)) { + runOnUiThread { + showToast( + R.string.download_failed, Toast.LENGTH_LONG ) - ContextCompat.startForegroundService(context, intent) - } - // Legacy - 1 -> { - ioSafe { - if (!downloadUpdate(update.updateURL)) - runOnUiThread { - showToast( - context, - R.string.download_failed, - Toast.LENGTH_LONG - ) - } - } } } } + } + } + } - setNegativeButton(R.string.cancel) { _, _ -> } + setNegativeButton(R.string.cancel) { _, _ -> } - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit().putString( - getString(R.string.skip_update_key), - update.updateNodeId ?: "" - ).apply() - } - } + if (checkAutoUpdate) { + setNeutralButton(R.string.skip_update) { _, _ -> + settingsManager.edit { + putString( + getString(R.string.skip_update_key), update.updateNodeId ?: "" + ) } - builder.show() - } catch (e: Exception) { - logError(e) } } - return true } - return false + builder.show().setDefaultFocus() } - return false } + 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 00000000000..d37d8aad49d --- /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 6c5117b4e16..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt +++ /dev/null @@ -1,272 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import kotlinx.coroutines.runBlocking -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec -import kotlin.math.pow - - -class M3u8Helper { - companion object { - private val generator = M3u8Helper() - suspend fun generateM3u8( - source: String, - streamUrl: String, - referer: String, - quality: Int? = null, - headers: Map = mapOf(), - name: String = source - ): List { - return generator.m3u8Generation( - 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("""(.*\.ts.*|.*\.jpg.*)""") //.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 val defaultIvGen = sequence { - var initial = 1 - - while (true) { - yield(toBytes16Big(initial)) - ++initial - } - }.iterator() - - private fun getDecrypter( - secretKey: ByteArray, - data: ByteArray, - iv: ByteArray = "".toByteArray() - ): ByteArray { - val ivKey = if (iv.isEmpty()) defaultIvGen.next() 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") - } - - data class M3u8Stream( - val streamUrl: String, - val quality: Int? = null, - val headers: Map = mapOf() - ) - - private fun selectBest(qualities: List): 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.contains("https://") && !url.contains("http://") - } - - suspend fun m3u8Generation(m3u8: M3u8Stream, returnThis: Boolean? = true): List { -// return listOf(m3u8) - val list = mutableListOf() - - val m3u8Parent = getParentLink(m3u8.streamUrl) - val response = app.get(m3u8.streamUrl, headers = m3u8.headers, verify = false).text - -// var hasAnyContent = false - for (match in QUALITY_REGEX.findAll(response)) { -// hasAnyContent = true - 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( - M3u8Stream( - m3u8Link, - quality.toIntOrNull(), - m3u8.headers - ), false - ) - } - list += M3u8Stream( - m3u8Link, - quality.toIntOrNull(), - m3u8.headers - ) - } - if (returnThis != false) { - list += M3u8Stream( - m3u8.streamUrl, - Qualities.Unknown.value, - m3u8.headers - ) - } - - return list - } - - - data class HlsDownloadData( - val bytes: ByteArray, - val currentIndex: Int, - val totalTs: Int, - val errored: Boolean = false - ) - - suspend fun hlsYield( - qualities: List, - startIndex: Int = 0 - ): Iterator { - if (qualities.isEmpty()) return listOf( - HlsDownloadData( - byteArrayOf(), - 1, - 1, - true - ) - ).iterator() - - var selected = selectBest(qualities) - if (selected == null) { - selected = qualities[0] - } - val headers = selected.headers - - val streams = qualities.map { m3u8Generation(it, false) }.flatten() - //val sslVerification = if (headers.containsKey("ssl_verification")) headers["ssl_verification"].toBoolean() else true - - val secondSelection = selectBest(streams.ifEmpty { listOf(selected) }) - if (secondSelection != null) { - val m3u8Response = - runBlocking { - app.get( - secondSelection.streamUrl, - headers = headers, - verify = false - ).text - } - - var encryptionUri: String? - var encryptionIv = byteArrayOf() - var encryptionData = byteArrayOf() - - val encryptionState = isEncrypted(m3u8Response) - - if (encryptionState) { - val match = - ENCRYPTION_URL_IV_REGEX.find(m3u8Response)!!.destructured // its safe to assume that its not going to be null - encryptionUri = match.component2() - - if (isNotCompleteUrl(encryptionUri)) { - encryptionUri = "${getParentLink(secondSelection.streamUrl)}/$encryptionUri" - } - - encryptionIv = match.component3().toByteArray() - val encryptionKeyResponse = - runBlocking { app.get(encryptionUri, headers = headers, verify = false) } - encryptionData = encryptionKeyResponse.body?.bytes() ?: byteArrayOf() - } - - val allTs = TS_EXTENSION_REGEX.findAll(m3u8Response) - val allTsList = allTs.toList() - val totalTs = allTsList.size - if (totalTs == 0) { - return listOf(HlsDownloadData(byteArrayOf(), 1, 1, true)).iterator() - } - var lastYield = 0 - - val relativeUrl = getParentLink(secondSelection.streamUrl) - var retries = 0 - val tsByteGen = sequence { - loop@ for ((index, ts) in allTs.withIndex()) { - val url = if ( - isNotCompleteUrl(ts.destructured.component1()) - ) "$relativeUrl/${ts.destructured.component1()}" else ts.destructured.component1() - val c = index + 1 + startIndex - - while (lastYield != c) { - try { - val tsResponse = - runBlocking { app.get(url, headers = headers, verify = false) } - var tsData = tsResponse.body?.bytes() ?: byteArrayOf() - - if (encryptionState) { - tsData = getDecrypter(encryptionData, tsData, encryptionIv) - yield(HlsDownloadData(tsData, c, totalTs)) - lastYield = c - break - } - yield(HlsDownloadData(tsData, c, totalTs)) - lastYield = c - } catch (e: Exception) { - logError(e) - if (retries == 3) { - yield(HlsDownloadData(byteArrayOf(), c, totalTs, true)) - break@loop - } - ++retries - Thread.sleep(2_000) - } - } - } - } - return tsByteGen.iterator() - } - return listOf(HlsDownloadData(byteArrayOf(), 1, 1, true)).iterator() - } -} 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 5cf6c359c40..67851f629cc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -1,19 +1,51 @@ package com.lagradost.cloudstream3.utils +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.IntentSender import android.content.pm.PackageInstaller import android.os.Build +import android.util.Log +import android.widget.Toast +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.PackageInstallerService +import com.lagradost.cloudstream3.utils.Coroutines.main import java.io.InputStream const val INSTALL_ACTION = "ApkInstaller.INSTALL_ACTION" - class ApkInstaller(private val service: PackageInstallerService) { + + companion object { + /** + * Used for postponed installations + **/ + var delayedInstaller: DelayedInstaller? = null + private var isReceiverRegistered = false + private const val TAG = "ApkInstaller" + } + + inner class DelayedInstaller( + private val session: PackageInstaller.Session, + private val intent: IntentSender + ) { + fun startInstallation(): Boolean { + return try { + session.commit(intent) + true + } catch (e: Exception) { + logError(e) + false + }.also { delayedInstaller = null } + } + } + private val packageInstaller = service.packageManager.packageInstaller enum class InstallProgressStatus { @@ -24,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) } @@ -64,7 +97,7 @@ class ApkInstaller(private val service: PackageInstallerService) { session.openWrite(context.packageName, 0, size) .use { outputStream -> - val buffer = ByteArray(1024) + val buffer = ByteArray(4 * 1024) var bytesRead = inputStream.read(buffer) while (bytesRead >= 0) { @@ -73,19 +106,42 @@ class ApkInstaller(private val service: PackageInstallerService) { installProgress.invoke(bytesRead) } + session.fsync(outputStream) inputStream.close() } - installProgressStatus.invoke(InstallProgressStatus.Installing) + // 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 - session.commit(intentSender) + // Use delayed installations on android 13 and only if "allow from unknown sources" is enabled + // if the app lacks installation permission it cannot ask for the permission when it's closed. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + context.packageManager.canRequestPackageInstalls() + ) { + // Save for later installation since it's more jarring to have the app exit abruptly + delayedInstaller = DelayedInstaller(session, intentSender) + main { + // Use real toast since it should show even if app is exited + Toast.makeText(context, R.string.delayed_update_notice, Toast.LENGTH_LONG) + .show() + } + } else { + installProgressStatus.invoke(InstallProgressStatus.Installing) + session.commit(intentSender) + } } catch (e: Exception) { logError(e) @@ -99,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 00000000000..6580182bba8 --- /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 00000000000..e3c7d68dffd --- /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 b3bce446a7a..26c710103fa 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -2,19 +2,32 @@ 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.* +import android.widget.AbsListView +import android.widget.ArrayAdapter +import android.widget.LinearLayout +import android.widget.TextView import androidx.appcompat.app.AlertDialog -import androidx.core.view.* +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.view.marginLeft +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.ui.settings.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding +import com.lagradost.cloudstream3.databinding.BottomSelectionDialogBinding +import com.lagradost.cloudstream3.databinding.BottomTextDialogBinding +import com.lagradost.cloudstream3.databinding.OptionsPopupTvBinding +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.add_account_input.* -import kotlinx.android.synthetic.main.add_account_input.text1 -import kotlinx.android.synthetic.main.bottom_selection_dialog_direct.* object SingleSelectionHelper { fun Activity?.showOptionSelectStringRes( @@ -44,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 { @@ -65,23 +79,24 @@ 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( + binding: BottomSelectionDialogBinding, dialog: Dialog, items: List, selectedIndex: List, @@ -95,38 +110,43 @@ object SingleSelectionHelper { if (this == null) return val realShowApply = showApply || isMultiSelect - val listView = dialog.listview1//.findViewById(R.id.listview1)!! - val textView = dialog.text1//.findViewById(R.id.text1)!! - val applyButton = dialog.apply_btt//.findViewById(R.id.apply_btt) - val cancelButton = dialog.cancel_btt//findViewById(R.id.cancel_btt) - val applyHolder = dialog.apply_btt_holder//.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 + applyHolder.isVisible = realShowApply if (!realShowApply) { val params = listView.layoutParams as LinearLayout.LayoutParams params.setMargins(listView.marginLeft, listView.marginTop, listView.marginRight, 0) listView.layoutParams = params } - textView?.text = name - textView?.isGone = name.isBlank() + textView.text = name + textView.isGone = name.isBlank() val arrayAdapter = ArrayAdapter(this, itemLayout) arrayAdapter.addAll(items) - listView?.adapter = arrayAdapter + listView.adapter = arrayAdapter if (isMultiSelect) { - listView?.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + listView.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE } else { - listView?.choiceMode = AbsListView.CHOICE_MODE_SINGLE + listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE } for (select in selectedIndex) { - listView?.setItemChecked(select, true) + listView.setItemChecked(select, true) } selectedIndex.minOrNull()?.let { - listView?.setSelection(it) + listView.setSelection(it) } // var lastSelectedIndex = if(selectedIndex.isNotEmpty()) selectedIndex.first() else -1 @@ -135,7 +155,7 @@ object SingleSelectionHelper { dismissCallback.invoke() } - listView?.setOnItemClickListener { _, _, which, _ -> + listView.setOnItemClickListener { _, _, which, _ -> // lastSelectedIndex = which if (realShowApply) { if (!isMultiSelect) { @@ -147,7 +167,7 @@ object SingleSelectionHelper { } } if (realShowApply) { - applyButton?.setOnClickListener { + applyButton.setOnClickListener { val list = ArrayList() for (index in 0 until listView.count) { if (listView.checkedItemPositions[index]) @@ -156,14 +176,14 @@ object SingleSelectionHelper { callback.invoke(list) dialog.dismissSafe(this) } - cancelButton?.setOnClickListener { + cancelButton.setOnClickListener { dialog.dismissSafe(this) } } } - private fun Activity?.showInputDialog( + binding: BottomInputDialogBinding, dialog: Dialog, value: String, name: String, @@ -173,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 @@ -212,13 +232,26 @@ object SingleSelectionHelper { ) { if (this == null) return + val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( + LayoutInflater.from(this) + ) val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - .setView(R.layout.bottom_selection_dialog) + .setView(binding.root) val dialog = builder.create() dialog.show() - showDialog(dialog, items, selectedIndex, name, true, true, callback, dismissCallback) + showDialog( + binding, + dialog, + items, + selectedIndex, + name, + showApply = true, + isMultiSelect = true, + callback, + dismissCallback + ) } fun Activity?.showDialog( @@ -231,13 +264,19 @@ object SingleSelectionHelper { ) { if (this == null) return + val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( + LayoutInflater.from(this) + ) val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) - .setView(R.layout.bottom_selection_dialog) + .setView(binding.root) val dialog = builder.create() dialog.show() + + showDialog( + binding, dialog, items, listOf(selectedIndex), @@ -259,12 +298,18 @@ object SingleSelectionHelper { callback: (Int) -> Unit, ) { if (this == null) return + + val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( + LayoutInflater.from(this) + ) + val builder = BottomSheetDialog(this) - builder.setContentView(R.layout.bottom_selection_dialog) + builder.setContentView(binding.root) builder.show() showDialog( + binding, builder, items, listOf(selectedIndex), @@ -284,13 +329,19 @@ object SingleSelectionHelper { ): BottomSheetDialog { val builder = BottomSheetDialog(this) - builder.setContentView(R.layout.bottom_selection_dialog_direct) + val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( + LayoutInflater.from(this) + ) + + //builder.setContentView(R.layout.bottom_selection_dialog_direct) + builder.setContentView(binding.root) builder.show() showDialog( + binding, builder, items, - listOf(), + emptyList(), name, showApply = false, isMultiSelect = false, @@ -308,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, @@ -321,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 00000000000..b43b51c7425 --- /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 33f1b6ffda6..00000000000 --- 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 00000000000..c0068f91a83 --- /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 7dda3e18c10..351e77c8d72 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -4,6 +4,7 @@ package com.lagradost.cloudstream3.utils import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.apis //import com.lagradost.cloudstream3.animeproviders.AniflixProvider import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError @@ -12,7 +13,7 @@ import java.util.concurrent.TimeUnit object SyncUtil { private val regexs = listOf( - Regex("""(9anime)\.(?:to|center|id)/watch/(?:.*?)\.([^/?]*)"""), + Regex("""(9anime)\.(?:to|center|id)/watch/.*?\.([^/?]*)"""), Regex("""(gogoanime|gogoanimes)\..*?/category/([^/?]*)"""), Regex("""(twist\.moe)/a/([^/?]*)"""), ) @@ -43,6 +44,13 @@ object SyncUtil { matchList[site]?.let { realSite -> getIdsFromSlug(slug, realSite)?.let { return it + } ?: kotlin.run { + if (slug.endsWith("-dub")) { + println("testing non -dub slug $slug") + getIdsFromSlug(slug.removeSuffix("-dub"), realSite)?.let { + return it + } + } } } } @@ -65,8 +73,8 @@ object SyncUtil { val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text val mapped = parseJson(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() @@ -78,17 +86,23 @@ object SyncUtil { return null } - suspend fun getUrlsFromId(id: String, type: String = "anilist") : List { - return arrayListOf() - // val url = - // "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/$type/anime/$id.json" - // val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).parsed() - // val pages = response.pages ?: return emptyList() - // val current = pages.gogoanime.values.union(pages.nineanime.values).union(pages.twistmoe.values).mapNotNull { it.url }.toMutableList() - // if(type == "anilist") { // TODO MAKE BETTER - // current.add("${AniflixProvider().mainUrl}/anime/$id") - // } - // return current + suspend fun getUrlsFromId(id: String, type: String = "anilist"): List { + val url = + "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/$type/anime/$id.json" + val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).parsed() + val pages = response.pages ?: return emptyList() + val current = + pages.gogoanime.values.union(pages.nineanime.values).union(pages.twistmoe.values) + .mapNotNull { it.url }.toMutableList() + + if (type == "anilist") { // TODO MAKE BETTER + synchronized(apis) { + apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { + current.add("${it.mainUrl}/anime/$id") + } + } + } + return current } data class SyncPage( @@ -121,8 +135,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? ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt new file mode 100644 index 00000000000..91c8a2fc1fb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -0,0 +1,324 @@ +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) + val Fail = TestResult(false) + } + } + + class Logger { + enum class LogLevel { + Normal, + Warning, + Error; + } + + 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)) + } + } + + 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: Logger + ): TestResult { + if (api.hasMainPage) { + try { + val f = api.mainPage.first() + val homepage = + api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages)) + when { + homepage == null -> { + logger.error("Provider ${api.name} did not correctly load homepage!") + } + + homepage.items.isEmpty() -> { + logger.warn("Provider ${api.name} does not contain any homepage rows!") + } + + homepage.items.any { it.list.isEmpty() } -> { + 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) { + when (e) { + is NotImplementedError -> { + Assert.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\"") } + } + } + } + } + return TestResult.Pass + } + + @Throws(AssertionError::class, CancellationException::class) + private suspend fun testSearch( + api: MainAPI, + testQueries: List, + logger: Logger, + ): TestResult { + val searchResults = testQueries.firstNotNullOfOrNull { query -> + try { + 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()") + } else if (e is CancellationException) { + throw e + } + logError(e) + null + } + } + + return if (searchResults.isNullOrEmpty()) { + Assert.fail("Api ${api.name} did not return any search responses") + TestResult.Fail // Should not be reached + } else { + TestResultList(searchResults) + } + } + + + @Throws(AssertionError::class, CancellationException::class) + private suspend fun testLoad( + api: MainAPI, + result: SearchResponse, + logger: Logger + ): TestResult { + try { + 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.error("Returned null loadResponse on ${result.url} on ${api.name}") + return TestResult.Fail + } + + 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 -> { + val gotNoEpisodes = + loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() } + + if (gotNoEpisodes) { + 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.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.error("Api ${api.name} got no episodes on ${loadResponse.url}") + return TestResult.Fail + } + loadResponse.episodes.firstOrNull()?.data + } + + is LiveStreamLoadResponse -> { + loadResponse.dataUrl + } + + else -> { + logger.error("Unknown load response: ${loadResponse.javaClass.name}") + return TestResult.Fail + } + } ?: return TestResult.Fail + + return TestResultLoad(url, loadResponse.type != TvType.CustomMedia) + +// val loadTest = testLoadResponse(api, load, logger) +// if (loadTest is TestResultLoad) { +// testLinkLoading(api, loadTest.extractorData, logger).success +// } else { +// false +// } +// if (!validResults) { +// logger("Api ${api.name} did not load on the first search results: ${smallSearchResults.map { it.name }}") +// } + +// return TestResult(validResults) + } catch (e: Throwable) { + if (e is NotImplementedError) { + Assert.fail("Provider has not implemented load()") + } + throw e + } + } + + @Throws(AssertionError::class, CancellationException::class) + private suspend fun testLinkLoading( + api: MainAPI, + url: String?, + logger: Logger + ): TestResult { + Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) + if (url == null) return TestResult.Fail // Should never trigger + + var linksLoaded = 0 + try { + val success = api.loadLinks(url, false, {}) { link -> + logger.log("Video loaded: ${link.name}") + Assert.assertTrue( + "Api ${api.name} returns link with invalid url ${link.url}", + link.url.length > 4 + ) + linksLoaded++ + } + if (success) { + logger.log("Links loaded: $linksLoaded") + return TestResult(linksLoaded > 0) + } else { + Assert.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()") + } + + else -> { + logger.error("Failed link loading on ${api.name} using data: $url") + throw e + } + } + } + return TestResult.Pass + } + + fun getDeferredProviderTests( + scope: CoroutineScope, + providers: Array, + callback: (MainAPI, TestResultProvider) -> Unit + ) { + providers.forEach { api -> + scope.launch { + val logger = Logger() + + val result = try { + logger.log("Trying ${api.name}") + + // Test Homepage + val homepage = testHomepage(api, logger) + Assert.assertTrue("Homepage failed to load", homepage.success) + val homePageList = (homepage as? TestResultList)?.results ?: emptyList() + + // Test Search Results + 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) + Assert.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 -> + logger.log("Testing search result: ${searchResponse.url}") + val loadResponse = testLoad(api, searchResponse, logger) + if (loadResponse !is TestResultLoad) { + false + } else { + if (loadResponse.shouldLoadLinks) { + testLinkLoading(api, loadResponse.extractorData, logger).success + } else { + logger.log("Skipping link loading test") + true + } + } + } + + if (success) { + logger.log("Success ${api.name}") + TestResultProvider(true, logger.getRawLog(), null) + } else { + logger.error("Link loading failed") + TestResultProvider(false, logger.getRawLog(), null) + } + } catch (e: Throwable) { + 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 59% 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 81ef8d57b83..4f3a747374b 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,17 +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.Some 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 { @@ -20,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( @@ -28,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? { @@ -60,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 - setImageResource(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) } @@ -162,11 +122,3 @@ fun TextView?.setTextHtml(text: UiText?) { this.text = str.html() } } - -fun TextView?.setTextHtml(text: Some?) { - setTextHtml(if (text is Some.Success) text.value else null) -} - -fun TextView?.setText(text: Some?) { - setText(if (text is Some.Success) text.value else null) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt new file mode 100644 index 00000000000..feecbe312df --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt @@ -0,0 +1,164 @@ +package com.lagradost.cloudstream3.utils + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.net.toUri +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.PreviewProgram +import androidx.tvprovider.media.tv.TvContractCompat +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.base64Encode +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import java.net.URLEncoder + +const val PROGRAM_ID_LIST_KEY = "persistent_program_ids" + +object TvChannelUtils { + fun Context.saveProgramId(programId: Long) { + val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + val updated = (existing + programId).distinct() + setKey(PROGRAM_ID_LIST_KEY, updated) + } + fun Context.getStoredProgramIds(): List { + return getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + } + fun Context.removeProgramId(programId: Long) { + val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + val updated = existing.filter { it != programId } + setKey(PROGRAM_ID_LIST_KEY, updated) + } + + + fun getChannelId(context: Context, channelName: String): Long? { + return try { + context.contentResolver.query( + TvContractCompat.Channels.CONTENT_URI, + arrayOf( + TvContractCompat.Channels._ID, + TvContractCompat.Channels.COLUMN_DISPLAY_NAME + ), + null, + null, + null + )?.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong( + cursor.getColumnIndexOrThrow(TvContractCompat.Channels._ID) + ) + val name = cursor.getString( + cursor.getColumnIndexOrThrow(TvContractCompat.Channels.COLUMN_DISPLAY_NAME) + ) + if (name == channelName) return id + } + null + } + } catch (e: Exception) { + Log.e("TvChannelUtils", "Query failed: ${e.message}", e) + null + } + } + + /** Insert programs into a channel */ + @SuppressLint("RestrictedApi") + fun addPrograms(context: Context, channelId: Long, items: List) { + for (item in items) { + try { + val nameBase64 = base64Encode(item.apiName.toByteArray(Charsets.UTF_8)) + val urlBase64 = base64Encode(item.url.toByteArray(Charsets.UTF_8)) + val csshareUri = "$APP_STRING_SHARE:$nameBase64?$urlBase64" + val poster=item.posterUrl + val builder = PreviewProgram.Builder() + .setChannelId(channelId) + .setTitle(item.name) + .apply { + val scoreText = item.score?.toStringNull(0.1, 10, 1)?.let { + " - " + txt(R.string.rating_format, it).asString(context) + } ?: "" + setDescription("${item.apiName}$scoreText") + } + .setContentId(item.url) + .setType(TvContractCompat.PreviewPrograms.TYPE_MOVIE) + .setIntentUri(csshareUri.toUri()) + .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_2_3) + + // Validate poster URL before setting + if (!poster.isNullOrBlank() && poster.startsWith("http")) { + builder.setPosterArtUri(poster.toUri()) + + } + val program = builder.build() + + val uri = context.contentResolver.insert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + program.toContentValues() + ) + + if (uri != null) { + val programId = ContentUris.parseId(uri) + context.saveProgramId(programId) + Log.d("TvChannelUtils", "Inserted program ${item.name}, ID=$programId") + } else { + Log.e("TvChannelUtils", "Insert failed for ${item.name}") + } + + } catch (error: Exception) { + Log.e("TvChannelUtils", "Error inserting ${item.name}: $error") + } + } + } + + fun deleteStoredPrograms(context: Context) { + val programIds = context.getStoredProgramIds() + + for (id in programIds) { + val uri = ContentUris.withAppendedId(TvContractCompat.PreviewPrograms.CONTENT_URI, id) + try { + val rowsDeleted = context.contentResolver.delete(uri, null, null) + if (rowsDeleted > 0) { + context.removeProgramId(id) // Remove from persistent list + } else { + Log.w("ProgramDelete", "No program found for ID: $id") + } + } catch (e: Exception) { + Log.e("ProgramDelete", "Failed to delete program ID: $id", e) + } + } + + Log.d("ProgramDelete", "Finished deleting stored programs") + } + + fun createTvChannel(context: Context) { + val componentName = ComponentName(context, MainActivity::class.java) + val iconUri = "android.resource://${context.packageName}/mipmap/ic_launcher".toUri() + val inputId = TvContractCompat.buildInputId(componentName) + val channel = Channel.Builder() + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setAppLinkIconUri(iconUri) + .setDisplayName(context.getString(R.string.app_name)) + .setAppLinkIntent(Intent(Intent.ACTION_VIEW).apply { + data = "cloudstreamapp://open".toUri() + }) + .setInputId(inputId) + .build() + + val channelUri = context.contentResolver.insert( + TvContractCompat.Channels.CONTENT_URI, + channel.toContentValues() + ) + + channelUri?.let { + val channelId = ContentUris.parseId(it) + TvContractCompat.requestChannelBrowsable(context, channelId) + Log.d("TvChannelUtils", "Channel Created: $channelId") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index 553860bacf5..c12674816dc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -5,46 +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.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.engine.DiskCacheStrategy -import com.bumptech.glide.load.model.GlideUrl -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions +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.settings.SettingsFragment.Companion.isEmulatorSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.GlideOptions.bitmapTransform -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() @@ -52,12 +90,47 @@ 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 ) - == PackageManager.PERMISSION_GRANTED) + == PackageManager.PERMISSION_GRANTED + // Since Android 13, we can't request external storage permission, + // so don't check it. + || Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + } + + fun populateChips( + view: ChipGroup?, + tags: List, + @StyleRes style: Int = R.style.ChipFilled, + @AttrRes textColor: Int? = R.attr.white, + ) { + if (view == null) return + view.removeAllViews() + val context = view.context ?: return + val maxTags = tags.take(10) // Limited because they are too much + + maxTags.forEach { tag -> + val chip = Chip(context) + val chipDrawable = ChipDrawable.createFromAttributes( + context, + null, + 0, + style + ) + chip.setChipDrawable(chipDrawable) + chip.text = tag + chip.isChecked = false + chip.isCheckable = false + chip.isFocusable = false + chip.isClickable = false + textColor?.let { + chip.setTextColor(context.colorFromAttribute(it)) + } + view.addView(chip) + } } fun Activity.requestRW() { @@ -72,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. @@ -102,17 +204,15 @@ object UIHelper { listView.requestLayout() } - fun Activity?.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() { @@ -122,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 { @@ -129,106 +237,122 @@ 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) } - } catch (t: Throwable) { - logError(t) } } - @ColorInt - fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { - val typedArray = obtainStyledAttributes(intArrayOf(resource)) - val color = typedArray.getColor(0, 0) - typedArray.recycle() + // 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 (alphaFactor < 1f) { - val alpha = (color.alpha * alphaFactor).roundToInt() - return Color.argb(alpha, color.red, color.green, color.blue) + if (args != null) { + intent.putExtras(args) + } + Log.i(tag, "Navigating to Activity: ${activity.simpleName}") + startActivity(intent) + } catch (t: Throwable) { + logError(t) } - - return color } - fun ImageView?.setImage( - url: String?, - headers: Map? = null, - @DrawableRes - errorImageDrawable: Int? = null, - fadeIn: Boolean = true - ): Boolean { - if (this == null || url.isNullOrBlank()) return false - - return try { - val builder = GlideApp.with(this) - .load(GlideUrl(url) { headers ?: emptyMap() }) - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.ALL).let { req -> - if (fadeIn) - req.transition(DrawableTransitionOptions.withCrossFade()) - else req + /** 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() } - - val res = if (errorImageDrawable != null) - builder.error(errorImageDrawable).into(this) - else - builder.into(this) - res.clearOnDetach() - - true - } catch (e: Exception) { - logError(e) - false + } + if (fromBackPressedCallback != null) { + enableBackPressedCallback(fromBackPressedCallback) + } } } - 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) + @ColorInt + fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { + val color = colorFromAttribute(resource) + return if (alphaFactor < 1f) adjustAlpha(color, alphaFactor) else color + } + + @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(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) + val alpha = (color.alpha * factor).roundToInt() + return Color.argb(alpha, color.red, color.green, color.blue) } - fun Context.colorFromAttribute(attribute: Int): Int { - val attributes = obtainStyledAttributes(intArrayOf(attribute)) - val color = attributes.getColor(0, 0) - attributes.recycle() - return color + var createPaletteAsyncCache: HashMap = hashMapOf() + fun createPaletteAsync(url: String, bitmap: Bitmap, callback: (Palette) -> Unit) { + createPaletteAsyncCache[url]?.let { palette -> + callback.invoke(palette) + return + } + Palette.from(bitmap).generate { paletteNull -> + paletteNull?.let { palette -> + createPaletteAsyncCache[url] = palette + callback(palette) + } + } } 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 @@ -239,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 - } - }*/ - - // 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() - } - } - }*/ + fun Activity.setNavigationBarColorCompat(@AttrRes resourceId: Int) { + // edge-to-edge handles this + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return + + @Suppress("DEPRECATION") + window?.navigationBarColor = colorFromAttribute(resourceId) + } fun Context.getStatusBarHeight(): Int { - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { return 0 } @@ -318,24 +393,108 @@ object UIHelper { return result } - fun Context?.fixPaddingStatusbar(v: View?) { - if (v == null || this == null) return - v.setPadding( - v.paddingLeft, - v.paddingTop + getStatusBarHeight(), - v.paddingRight, - v.paddingBottom - ) + fun fixPaddingStatusbarMargin(v: View?) { + if (v == null) return + val ctx = v.context ?: return + + v.layoutParams = v.layoutParams.apply { + if (this is MarginLayoutParams) { + setMargins( + v.marginLeft, + v.marginTop + ctx.getStatusBarHeight(), + v.marginRight, + v.marginBottom + ) + } + } } - fun Context.fixPaddingStatusbarView(v: View?) { + fun fixPaddingStatusbarView(v: View?) { if (v == null) return - + val ctx = v.context ?: return val params = v.layoutParams - params.height = getStatusBarHeight() + params.height = ctx.getStatusBarHeight() 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") @@ -345,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?) { @@ -425,7 +578,7 @@ object UIHelper { } fun Dialog?.dismissSafe() { - if (this?.isShowing == true) { + if (this?.isShowing == true && activity?.isFinishing != true) { this.dismiss() } } @@ -437,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) @@ -461,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) @@ -477,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 a76cc1155fd..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.ui.download.EasyDownloadButton - -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") override val id: Int, - @JsonProperty("parentId") val parentId: Int, - @JsonProperty("rating") val rating: Int?, - @JsonProperty("description") val description: String?, - @JsonProperty("cacheTime") val cacheTime: Long, - ) : EasyDownloadButton.IMinimumData - - 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/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt deleted file mode 100644 index a629dad94d4..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ /dev/null @@ -1,1705 +0,0 @@ -package com.lagradost.cloudstream3.utils - -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.os.Build -import android.os.Environment -import android.provider.MediaStore -import androidx.annotation.DrawableRes -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -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.fasterxml.jackson.annotation.JsonProperty -import com.hippo.unifile.UniFile -import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey -import com.lagradost.cloudstream3.MainActivity -import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.services.VideoDownloadService -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.removeKey -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import okhttp3.internal.closeQuietly -import java.io.BufferedInputStream -import java.io.File -import java.io.InputStream -import java.io.OutputStream -import java.lang.Thread.sleep -import java.net.URI -import java.net.URL -import java.net.URLConnection -import java.util.* -import kotlin.math.roundToInt - -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() - - private const val USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" - - @DrawableRes - const val imgDone = R.drawable.rddone - - @DrawableRes - const val imgDownloading = R.drawable.rdload - - @DrawableRes - const val imgPaused = R.drawable.rdpause - - @DrawableRes - const val imgStopped = R.drawable.rderror - - @DrawableRes - const val imgError = R.drawable.rderror - - @DrawableRes - const val pressToPauseIcon = R.drawable.ic_baseline_pause_24 - - @DrawableRes - const val pressToResumeIcon = R.drawable.ic_baseline_play_arrow_24 - - @DrawableRes - const val pressToStopIcon = R.drawable.exo_icon_stop - - enum class DownloadType { - IsPaused, - IsDownloading, - IsDone, - IsFailed, - IsStopped, - } - - enum class DownloadActionType { - Pause, - Resume, - 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, - ) - - private const val SUCCESS_DOWNLOAD_DONE = 1 - private const val SUCCESS_STREAM = 3 - private const val SUCCESS_STOPPED = 2 - - // will not download the next one, but is still classified as an error - private const val ERROR_DELETING_FILE = 3 - private const val ERROR_CREATE_FILE = -2 - private const val ERROR_UNKNOWN = -10 - - //private const val ERROR_OPEN_FILE = -3 - private const val ERROR_TOO_SMALL_CONNECTION = -4 - - //private const val ERROR_WRONG_CONTENT = -5 - private const val ERROR_CONNECTION_ERROR = -6 - - //private const val ERROR_MEDIA_STORE_URI_CANT_BE_CREATED = -7 - //private const val ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM = -8 - private const val ERROR_CONTENT_RESOLVER_NOT_FOUND = -9 - - private const val KEY_RESUME_PACKAGES = "download_resume" - const val KEY_DOWNLOAD_INFO = "download_info" - private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" - - val downloadStatus = HashMap() - val downloadStatusEvent = Event>() - val downloadDeleteEvent = Event() - val downloadEvent = Event>() - val downloadProgressEvent = Event>() - val downloadQueue = LinkedList() - - private var 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 - } - // 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() - private fun Context.getImageBitmapFromUrl(url: String): Bitmap? { - try { - if (cachedBitmaps.containsKey(url)) { - return cachedBitmaps[url] - } - - val bitmap = GlideApp.with(this) - .asBitmap() - .load(url).into(720, 720) - .get() - if (bitmap != null) { - cachedBitmaps[url] = bitmap - } - return null - } 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( - context: Context, - source: String?, - linkName: String?, - ep: DownloadEpisodeMetadata, - state: DownloadType, - progress: Long, - total: Long, - notificationCallback: (Int, Notification) -> Unit, - hlsProgress: Long? = null, - hlsTotal: Long? = null, - - ): Notification? { - 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) - .setOnlyAlertOnce(true) - .setShowWhen(false) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setColor(context.colorFromAttribute(R.attr.colorPrimary)) - .setContentTitle(ep.mainName) - .setSmallIcon( - when (state) { - DownloadType.IsDone -> imgDone - DownloadType.IsDownloading -> imgDownloading - DownloadType.IsPaused -> imgPaused - DownloadType.IsFailed -> imgError - DownloadType.IsStopped -> imgStopped - } - ) - - if (ep.sourceApiName != null) { - builder.setSubText(ep.sourceApiName) - } - - if (source != null) { - val intent = Intent(context, MainActivity::class.java).apply { - 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) - } - builder.setContentIntent(pendingIntent) - } - - if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { - builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false) - } - - val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else "" - val rowTwo = if (ep.season != null && ep.episode != null) { - "${context.getString(R.string.season_short)}${ep.season}:${context.getString(R.string.episode_short)}${ep.episode}" + rowTwoExtra - } else if (ep.episode != null) { - "${context.getString(R.string.episode)} ${ep.episode}" + rowTwoExtra - } else { - (ep.name ?: "") + "" - } - val downloadFormat = context.getString(R.string.download_format) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (ep.poster != null) { - val poster = withContext(Dispatchers.IO) { - context.getImageBitmapFromUrl(ep.poster) - } - if (poster != null) - builder.setLargeIcon(poster) - } - - val progressPercentage: Long - val progressMbString: String - val totalMbString: String - val suffix: String - - if (hlsProgress != null && hlsTotal != null) { - progressPercentage = hlsProgress.toLong() * 100 / hlsTotal - progressMbString = hlsProgress.toString() - totalMbString = hlsTotal.toString() - suffix = " - %.1f MB".format(progress / 1000000f) - } else { - progressPercentage = progress * 100 / total - progressMbString = "%.1f MB".format(progress / 1000000f) - totalMbString = "%.1f MB".format(total / 1000000f) - suffix = "" - } - - val bigText = - if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { - (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix" - } else if (state == DownloadType.IsFailed) { - downloadFormat.format(context.getString(R.string.download_failed), rowTwo) - } else if (state == DownloadType.IsDone) { - downloadFormat.format(context.getString(R.string.download_done), rowTwo) - } else { - downloadFormat.format(context.getString(R.string.download_canceled), rowTwo) - } - - val bodyStyle = NotificationCompat.BigTextStyle() - bodyStyle.bigText(bigText) - builder.setStyle(bodyStyle) - } else { - val txt = - if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { - rowTwo - } else if (state == DownloadType.IsFailed) { - downloadFormat.format(context.getString(R.string.download_failed), rowTwo) - } else if (state == DownloadType.IsDone) { - downloadFormat.format(context.getString(R.string.download_done), rowTwo) - } else { - downloadFormat.format(context.getString(R.string.download_canceled), rowTwo) - } - - builder.setContentText(txt) - } - - if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val actionTypes: MutableList = ArrayList() - // INIT - if (state == DownloadType.IsDownloading) { - actionTypes.add(DownloadActionType.Pause) - actionTypes.add(DownloadActionType.Stop) - } - - if (state == DownloadType.IsPaused) { - actionTypes.add(DownloadActionType.Resume) - actionTypes.add(DownloadActionType.Stop) - } - - // ADD ACTIONS - for ((index, i) in actionTypes.withIndex()) { - val actionResultIntent = Intent(context, VideoDownloadService::class.java) - - actionResultIntent.putExtra( - "type", when (i) { - DownloadActionType.Resume -> "resume" - DownloadActionType.Pause -> "pause" - DownloadActionType.Stop -> "stop" - } - ) - - actionResultIntent.putExtra("id", ep.id) - - val pending: PendingIntent = PendingIntent.getService( - // BECAUSE episodes lying near will have the same id +1, index will give the same requested as the previous episode, *100000 fixes this - context, (4337 + index * 1000000 + ep.id), - actionResultIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - builder.addAction( - NotificationCompat.Action( - when (i) { - DownloadActionType.Resume -> pressToResumeIcon - DownloadActionType.Pause -> pressToPauseIcon - DownloadActionType.Stop -> pressToStopIcon - }, when (i) { - DownloadActionType.Resume -> context.getString(R.string.resume) - DownloadActionType.Pause -> context.getString(R.string.pause) - DownloadActionType.Stop -> context.getString(R.string.cancel) - }, pending - ) - ) - } - } - - if (!hasCreatedNotChanel) { - context.createNotificationChannel() - } - - val notification = builder.build() - notificationCallback(ep.id, notification) - with(NotificationManagerCompat.from(context)) { - // notificationId is a unique int for each notification that you must define - notify(ep.id, notification) - } - return notification - } catch (e: Exception) { - logError(e) - return null - } - } - - 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(' ') - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun ContentResolver.getExistingFolderStartName(relativePath: String): List>? { - try { - val projection = arrayOf( - MediaStore.MediaColumns._ID, - MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only) - //MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only) - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath'" - - val result = this.query( - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), - projection, selection, null, null - ) - val list = ArrayList>() - - result.use { c -> - if (c != null && c.count >= 1) { - c.moveToFirst() - while (true) { - val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - val name = - c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val uri = ContentUris.withAppendedId( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, id - ) - list.add(Pair(name, uri)) - if (c.isLast) { - break - } - c.moveToNext() - } - - /* - val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/ - - } - } - return list - } catch (e: Exception) { - logError(e) - return null - } - } - - /** - * 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?.gotoDir(relativePath, false) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) { - return context.contentResolver?.getExistingFolderStartName(relativePath) - } else { -// val normalPath = -// "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace( -// '/', -// File.separatorChar -// ) -// val folder = File(normalPath) - if (folder?.isDirectory == true) { - return folder.listFiles()?.map { Pair(it.name ?: "", it.uri) } - } - } - return null -// } - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun ContentResolver.getExistingDownloadUriOrNullQ( - relativePath: String, - displayName: String - ): Uri? { - try { - val projection = arrayOf( - MediaStore.MediaColumns._ID, - //MediaStore.MediaColumns.DISPLAY_NAME, // unused (for verification use only) - //MediaStore.MediaColumns.RELATIVE_PATH, // unused (for verification use only) - ) - - val selection = - "${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath' AND " + "${MediaStore.MediaColumns.DISPLAY_NAME}='$displayName'" - - val result = this.query( - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), - projection, selection, null, null - ) - - result.use { c -> - if (c != null && c.count >= 1) { - c.moveToFirst().let { - val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) - /* - val cDisplayName = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)) - val cRelativePath = c.getString(c.getColumnIndexOrThrow(MediaStore.MediaColumns.RELATIVE_PATH))*/ - - return ContentUris.withAppendedId( - MediaStore.Downloads.EXTERNAL_CONTENT_URI, id - ) - } - } - } - return null - } catch (e: Exception) { - logError(e) - return null - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - fun ContentResolver.getFileLength(fileUri: Uri): Long? { - return try { - this.openFileDescriptor(fileUri, "r") - .use { it?.statSize ?: 0 } - } catch (e: Exception) { - logError(e) - null - } - } - - data class CreateNotificationMetadata( - val type: DownloadType, - val bytesDownloaded: Long, - val bytesTotal: Long, - val hlsProgress: Long? = null, - val hlsTotal: Long? = null, - ) - - data class StreamData( - val errorCode: Int, - val resume: Boolean? = null, - val fileLength: Long? = null, - val fileStream: OutputStream? = null, - ) - - /** - * Sets up the appropriate file and creates a data stream from the file. - * Used for initializing downloads. - * */ - fun setupStream( - context: Context, - name: String, - folder: String?, - extension: String, - tryResume: Boolean, - ): StreamData { - val displayName = getDisplayName(name, extension) - val fileStream: OutputStream - val fileLength: Long - var resume = tryResume - val baseFile = context.getBasePath() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.first?.isDownloadDir() == true) { - val cr = context.contentResolver ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) - - val currentExistingFile = - cr.getExistingDownloadUriOrNullQ( - folder ?: "", - displayName - ) // CURRENT FILE WITH THE SAME PATH - - fileLength = - if (currentExistingFile == null || !resume) 0 else (cr.getFileLength( - currentExistingFile - ) - ?: 0)// IF NOT RESUME THEN 0, OTHERWISE THE CURRENT FILE SIZE - - if (!resume && currentExistingFile != null) { // DELETE FILE IF FILE EXITS AND NOT RESUME - val rowsDeleted = context.contentResolver.delete(currentExistingFile, null, null) - if (rowsDeleted < 1) { - println("ERROR DELETING FILE!!!") - } - } - - var appendFile = false - val newFileUri = if (resume && currentExistingFile != null) { - appendFile = true - currentExistingFile - } else { - val contentUri = - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI - //val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - val currentMimeType = when (extension) { - - // 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 - } - val newFile = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.TITLE, name) - if (currentMimeType != null) - put(MediaStore.MediaColumns.MIME_TYPE, currentMimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, folder) - } - - cr.insert( - contentUri, - newFile - ) ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) - } - - fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else "")) - ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) - } else { - val subDir = baseFile.first?.gotoDir(folder) - val rFile = subDir?.findFile(displayName) - if (rFile?.exists() != true) { - fileLength = 0 - if (subDir?.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE) - } else { - if (resume) { - fileLength = rFile.size() - } else { - fileLength = 0 - if (!rFile.delete()) return StreamData(ERROR_DELETING_FILE) - if (subDir.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE) - } - } - fileStream = (subDir.findFile(displayName) - ?: subDir.createFile(displayName))!!.openOutputStream() -// fileStream = FileOutputStream(rFile, false) - if (fileLength == 0L) resume = false - } - return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream) - } - - fun downloadThing( - context: Context, - link: IDownloadableMinimum, - name: String, - folder: String?, - extension: String, - tryResume: Boolean, - parentId: Int?, - createNotificationCallback: (CreateNotificationMetadata) -> Unit, - ): Int { - if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) { - return ERROR_UNKNOWN - } - - val basePath = context.getBasePath() - - val displayName = getDisplayName(name, extension) - val relativePath = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.first.isDownloadDir()) getRelativePath( - folder - ) else folder - - fun deleteFile(): Int { - return delete(context, name, relativePath, extension, parentId, basePath.first) - } - - val stream = setupStream(context, name, relativePath, extension, tryResume) - if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode - - val resume = stream.resume!! - val fileStream = stream.fileStream!! - val fileLength = stream.fileLength!! - - // CONNECT - val connection: URLConnection = - URL(link.url.replace(" ", "%20")).openConnection() // IDK OLD PHONES BE WACK - - // SET CONNECTION SETTINGS - connection.connectTimeout = 10000 - connection.setRequestProperty("Accept-Encoding", "identity") - connection.setRequestProperty("user-agent", USER_AGENT) - if (link.referer.isNotEmpty()) connection.setRequestProperty("referer", link.referer) - - // extra stuff - connection.setRequestProperty( - "sec-ch-ua", - "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"" - ) - - connection.setRequestProperty("sec-ch-ua-mobile", "?0") - connection.setRequestProperty("accept", "*/*") - // dataSource.setRequestProperty("Sec-Fetch-Site", "none") //same-site - connection.setRequestProperty("sec-fetch-user", "?1") - connection.setRequestProperty("sec-fetch-mode", "navigate") - connection.setRequestProperty("sec-fetch-dest", "video") - link.headers.entries.forEach { - connection.setRequestProperty(it.key, it.value) - } - - if (resume) - connection.setRequestProperty("Range", "bytes=${fileLength}-") - val resumeLength = (if (resume) fileLength else 0) - - // ON CONNECTION - connection.connect() - - val contentLength = try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // fuck android - connection.contentLengthLong - } else { - connection.getHeaderField("content-length").toLongOrNull() - ?: connection.contentLength.toLong() - } - } catch (e: Exception) { - logError(e) - 0L - } - val bytesTotal = contentLength + resumeLength - - if (extension == "mp4" && bytesTotal < 5000000) return ERROR_TOO_SMALL_CONNECTION // DATA IS LESS THAN 5MB, SOMETHING IS WRONG - - parentId?.let { - setKey( - KEY_DOWNLOAD_INFO, - it.toString(), - DownloadedFileInfo( - bytesTotal, - relativePath ?: "", - displayName, - basePath = basePath.second - ) - ) - } - - // Could use connection.contentType for mime types when creating the file, - // however file is already created and players don't go of file type - - // https://stackoverflow.com/questions/23714383/what-are-all-the-possible-values-for-http-content-type-header - // might receive application/octet-stream - /*if (!connection.contentType.isNullOrEmpty() && !connection.contentType.startsWith("video")) { - return ERROR_WRONG_CONTENT // CONTENT IS NOT VIDEO, SHOULD NEVER HAPPENED, BUT JUST IN CASE - }*/ - - // READ DATA FROM CONNECTION - val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream) - val buffer = ByteArray(1024) - var count: Int - var bytesDownloaded = resumeLength - - var isPaused = false - var isStopped = false - var isDone = false - var isFailed = false - - // TO NOT REUSE CODE - fun updateNotification() { - val type = when { - isDone -> DownloadType.IsDone - isStopped -> DownloadType.IsStopped - isFailed -> DownloadType.IsFailed - isPaused -> DownloadType.IsPaused - else -> DownloadType.IsDownloading - } - - parentId?.let { id -> - try { - downloadStatus[id] = type - downloadStatusEvent.invoke(Pair(id, type)) - downloadProgressEvent.invoke(Triple(id, bytesDownloaded, bytesTotal)) - } catch (e: Exception) { - // IDK MIGHT ERROR - } - } - - createNotificationCallback.invoke( - CreateNotificationMetadata( - type, - bytesDownloaded, - bytesTotal - ) - ) - /*createNotification( - context, - source, - link.name, - ep, - type, - bytesDownloaded, - bytesTotal - )*/ - } - - val downloadEventListener = { event: Pair -> - if (event.first == parentId) { - when (event.second) { - DownloadActionType.Pause -> { - isPaused = true; updateNotification() - } - DownloadActionType.Stop -> { - isStopped = true; updateNotification() - removeKey(KEY_RESUME_PACKAGES, event.first.toString()) - saveQueue() - } - DownloadActionType.Resume -> { - isPaused = false; updateNotification() - } - } - } - } - - if (parentId != null) - downloadEvent += downloadEventListener - - // UPDATE DOWNLOAD NOTIFICATION - val notificationCoroutine = main { - while (true) { - if (!isPaused) { - updateNotification() - } - for (i in 1..10) { - delay(100) - } - } - } - - // THE REAL READ - try { - while (true) { - count = connectionInputStream.read(buffer) - if (count < 0) break - bytesDownloaded += count - // downloadProgressEvent.invoke(Pair(id, bytesDownloaded)) // Updates too much for any UI to keep up with - while (isPaused) { - sleep(100) - if (isStopped) { - break - } - } - if (isStopped) { - break - } - fileStream.write(buffer, 0, count) - } - } catch (e: Exception) { - logError(e) - isFailed = true - updateNotification() - } - - // REMOVE AND EXIT ALL - fileStream.close() - connectionInputStream.close() - notificationCoroutine.cancel() - - try { - if (parentId != null) - downloadEvent -= downloadEventListener - } catch (e: Exception) { - logError(e) - } - - try { - parentId?.let { - downloadStatus.remove(it) - } - } catch (e: Exception) { - // IDK MIGHT ERROR - } - - // RETURN MESSAGE - return when { - isFailed -> { - parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) } - ERROR_CONNECTION_ERROR - } - isStopped -> { - parentId?.let { id -> downloadProgressEvent.invoke(Triple(id, 0, 0)) } - deleteFile() - } - else -> { - parentId?.let { id -> - downloadProgressEvent.invoke( - Triple( - id, - bytesDownloaded, - bytesTotal - ) - ) - } - isDone = true - updateNotification() - SUCCESS_DOWNLOAD_DONE - } - } - } - - - /** - * Guarantees a directory is present with the dir name (if createMissingDirectories is true). - * Works recursively when '/' is present. - * Will remove any file with the dir name if present and add directory. - * Will not work if the parent directory does not exist. - * - * @param directoryName if null will use the current path. - * @return UniFile / null if createMissingDirectories = false and folder is not found. - * */ - private fun UniFile.gotoDir( - directoryName: String?, - createMissingDirectories: Boolean = true - ): UniFile? { - - // May give this error on scoped storage. - // W/DocumentsContract: Failed to create document - // java.lang.IllegalArgumentException: Parent document isn't a directory - - // Not present in latest testing. - -// println("Going to dir $directoryName from ${this.uri} ---- ${this.filePath}") - - try { - // Creates itself from parent if doesn't exist. - if (!this.exists() && createMissingDirectories && !this.name.isNullOrBlank()) { - if (this.parentFile != null) { - this.parentFile?.createDirectory(this.name) - } else if (this.filePath != null) { - UniFile.fromFile(File(this.filePath!!).parentFile)?.createDirectory(this.name) - } - } - - val allDirectories = directoryName?.split("/") - return if (allDirectories?.size == 1 || allDirectories == null) { - val found = this.findFile(directoryName) - when { - directoryName.isNullOrBlank() -> this - found?.isDirectory == true -> found - - !createMissingDirectories -> null - // Below creates directories - found?.isFile == true -> { - found.delete() - this.createDirectory(directoryName) - } - this.isDirectory -> this.createDirectory(directoryName) - else -> this.parentFile?.createDirectory(directoryName) - } - } else { - var currentDirectory = this - allDirectories.forEach { - // If the next directory is not found it returns the deepest directory possible. - val nextDir = currentDirectory.gotoDir(it, createMissingDirectories) - currentDirectory = nextDir ?: return null - } - currentDirectory - } - } catch (e: Exception) { - logError(e) - return null - } - } - - private fun getDisplayName(name: String, extension: String): String { - 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 getDownloadDir(): UniFile? { - // See https://www.py4u.net/discuss/614761 - return UniFile.fromFile( - File( - Environment.getExternalStorageDirectory().absolutePath + File.separatorChar + - Environment.DIRECTORY_DOWNLOADS - ) - ) - } - - @Deprecated("TODO fix UniFile to work with download directory.") - private fun getRelativePath(folder: String?): String { - return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace( - '/', - File.separatorChar - ) - } - - /** - * 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?): UniFile? { - return when { - path.isNullOrBlank() -> getDownloadDir() - path.startsWith("content://") -> UniFile.fromUri(context, path.toUri()) - else -> UniFile.fromFile(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 UniFile?.isDownloadDir(): Boolean { - return this != null && this.filePath == getDownloadDir()?.filePath - } - - private fun delete( - context: Context, - name: String, - folder: String?, - extension: String, - parentId: Int?, - basePath: UniFile? - ): Int { - val displayName = getDisplayName(name, extension) - - // delete all subtitle files - if (extension == "mp4") { - try { - delete(context, name, folder, "vtt", parentId, basePath) - delete(context, name, folder, "srt", parentId, basePath) - } catch (e: Exception) { - logError(e) - } - } - - // If scoped storage and using download dir (not accessible with UniFile) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.isDownloadDir()) { - val relativePath = getRelativePath(folder) - val lastContent = - context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName) - if (lastContent != null) { - context.contentResolver.delete(lastContent, null, null) - } - } else { - val dir = basePath?.gotoDir(folder) - val file = dir?.findFile(displayName) - val success = file?.delete() - if (success != true) return ERROR_DELETING_FILE else { - // Cleans up empty directory - if (dir.listFiles()?.isEmpty() == true) dir.delete() - } -// } - parentId?.let { - downloadDeleteEvent.invoke(parentId) - } - } - return SUCCESS_STOPPED - } - - private fun downloadHLS( - context: Context, - link: ExtractorLink, - name: String, - folder: String?, - parentId: Int?, - startIndex: Int?, - createNotificationCallback: (CreateNotificationMetadata) -> Unit - ): Int { - val extension = "mp4" - fun logcatPrint(vararg items: Any?) { - items.forEach { - println("[HLS]: $it") - } - } - - val m3u8Helper = M3u8Helper() - logcatPrint("initialised the HLS downloader.") - - val m3u8 = M3u8Helper.M3u8Stream( - link.url, link.quality, mapOf("referer" to link.referer) - ) - - var realIndex = startIndex ?: 0 - val basePath = context.getBasePath() - - val relativePath = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && basePath.first.isDownloadDir()) getRelativePath( - folder - ) else folder - - val stream = setupStream(context, name, relativePath, extension, realIndex > 0) - if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode - - if (!stream.resume!!) realIndex = 0 - val fileLengthAdd = stream.fileLength!! - val tsIterator = runBlocking { - m3u8Helper.hlsYield(listOf(m3u8), realIndex) - } - - val displayName = getDisplayName(name, extension) - - val fileStream = stream.fileStream!! - - val firstTs = tsIterator.next() - - var isDone = false - var isFailed = false - var isPaused = false - var bytesDownloaded = firstTs.bytes.size.toLong() + fileLengthAdd - var tsProgress = 1L + realIndex - val totalTs = firstTs.totalTs.toLong() - - fun deleteFile(): Int { - return delete(context, name, relativePath, extension, parentId, basePath.first) - } - /* - Most of the auto generated m3u8 out there have TS of the same size. - And only the last TS might have a different size. - - But oh well, in cases of handmade m3u8 streams this will go all over the place ¯\_(ツ)_/¯ - So ya, this calculates an estimate of how many bytes the file is going to be. - - > (bytesDownloaded/tsProgress)*totalTs - */ - - fun updateInfo() { - parentId?.let { - setKey( - KEY_DOWNLOAD_INFO, - it.toString(), - DownloadedFileInfo( - (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - relativePath ?: "", - displayName, - tsProgress.toString(), - basePath = basePath.second - ) - ) - } - } - - updateInfo() - - fun updateNotification() { - val type = when { - isDone -> DownloadType.IsDone - isFailed -> DownloadType.IsFailed - isPaused -> DownloadType.IsPaused - else -> DownloadType.IsDownloading - } - - parentId?.let { id -> - try { - downloadStatus[id] = type - downloadStatusEvent.invoke(Pair(id, type)) - downloadProgressEvent.invoke( - Triple( - id, - bytesDownloaded, - (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - ) - ) - } catch (e: Exception) { - // IDK MIGHT ERROR - } - } - - createNotificationCallback.invoke( - CreateNotificationMetadata( - type, - bytesDownloaded, - (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - tsProgress, - totalTs - ) - ) - } - - fun stopIfError(ts: M3u8Helper.HlsDownloadData): Int? { - if (ts.errored || ts.bytes.isEmpty()) { - val error: Int = if (!ts.errored) { - logcatPrint("Error: No stream was found.") - ERROR_UNKNOWN - } else { - logcatPrint("Error: Failed to fetch data.") - ERROR_CONNECTION_ERROR - } - isFailed = true - fileStream.close() - deleteFile() - updateNotification() - return error - } - return null - } - - val notificationCoroutine = main { - while (true) { - if (!isDone) { - updateNotification() - } - for (i in 1..10) { - delay(100) - } - } - } - - val downloadEventListener = { event: Pair -> - if (event.first == parentId) { - when (event.second) { - DownloadActionType.Stop -> { - isFailed = true - } - DownloadActionType.Pause -> { - isPaused = - true // Pausing is not supported since well...I need to know the index of the ts it was paused at - // it may be possible to store it in a variable, but when the app restarts it will be lost - } - DownloadActionType.Resume -> { - isPaused = false - } - } - updateNotification() - } - } - - fun closeAll() { - try { - if (parentId != null) - downloadEvent -= downloadEventListener - } catch (e: Exception) { - logError(e) - } - try { - parentId?.let { - downloadStatus.remove(it) - } - } catch (e: Exception) { - logError(e) - // IDK MIGHT ERROR - } - notificationCoroutine.cancel() - } - - stopIfError(firstTs).let { - if (it != null) { - closeAll() - return it - } - } - - if (parentId != null) - downloadEvent += downloadEventListener - - fileStream.write(firstTs.bytes) - - fun onFailed() { - fileStream.close() - deleteFile() - updateNotification() - closeAll() - } - - for (ts in tsIterator) { - while (isPaused) { - if (isFailed) { - onFailed() - return SUCCESS_STOPPED - } - sleep(100) - } - - if (isFailed) { - onFailed() - return SUCCESS_STOPPED - } - - stopIfError(ts).let { - if (it != null) { - closeAll() - return it - } - } - - fileStream.write(ts.bytes) - tsProgress = ts.currentIndex.toLong() - bytesDownloaded += ts.bytes.size.toLong() - logcatPrint("Download progress ${((tsProgress.toFloat() / totalTs.toFloat()) * 100).roundToInt()}%") - updateInfo() - } - isDone = true - fileStream.close() - updateNotification() - - closeAll() - updateInfo() - return SUCCESS_DOWNLOAD_DONE - } - - 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 fun downloadSingleEpisode( - context: Context, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - link: ExtractorLink, - notificationCallback: (Int, Notification) -> Unit, - tryResume: Boolean = false, - ): Int { - val name = getFileName(context, ep) - - // Make sure this is cancelled when download is done or cancelled. - val extractorJob = ioSafe { - if (link.extractorData != null) { - getApiFromNameNull(link.source)?.extractorVerifierJob(link.extractorData) - } - } - - if (link.isM3u8 || URI(link.url).path.endsWith(".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) { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback, - meta.hlsProgress, - meta.hlsTotal - ) - } - }.also { extractorJob.cancel() } - } - - return normalSafeApiCall { - downloadThing(context, link, name, folder, "mp4", tryResume, ep.id) { meta -> - main { - createNotification( - context, - source, - link.name, - ep, - meta.type, - meta.bytesDownloaded, - meta.bytesTotal, - notificationCallback - ) - } - } - }.also { extractorJob.cancel() } ?: ERROR_UNKNOWN - } - - fun downloadCheck( - context: Context, notificationCallback: (Int, Notification) -> Unit, - ): Int? { - if (currentDownloads.size < maxConcurrentDownloads && downloadQueue.size > 0) { - 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(Pair(id, DownloadActionType.Resume)) - /** ID needs to be returned to the work-manager to properly await notification */ - return id - } - - currentDownloads.add(id) - - main { - 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) - ) - val connectionResult = withContext(Dispatchers.IO) { - normalSafeApiCall { - downloadSingleEpisode( - context, - item.source, - item.folder, - item.ep, - link, - notificationCallback, - resume - ).also { println("Single episode finished with return code: $it") } - } - } - if (connectionResult != null && connectionResult > 0) { // SUCCESS - removeKey(KEY_RESUME_PACKAGES, id.toString()) - break - } - } - } catch (e: Exception) { - logError(e) - } finally { - currentDownloads.remove(id) - // Because otherwise notifications will not get caught by the workmanager - downloadCheckUsingWorker(context) - } - } - } - return null - } - - fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { - val res = getDownloadFileInfo(context, id) - if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) - return res - } - - private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? { - try { - val info = - context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null - val base = basePathToFile(context, info.basePath) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) { - val cr = context.contentResolver ?: return null - val fileUri = - cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) - ?: return null - val fileLength = cr.getFileLength(fileUri) ?: return null - if (fileLength == 0L) return null - return DownloadedFileInfoResult(fileLength, info.totalBytes, fileUri) - } else { - - val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName) - -// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) -// val dFile = File(normalPath) - - if (file?.exists() != true) return null - - return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri) - } - } catch (e: Exception) { - logError(e) - return null - } - } - - /** - * Gets the true download size as Scoped Storage sometimes wrongly returns 0. - * */ - fun UniFile.size(): Long { - val len = length() - return if (len <= 1) { - val inputStream = this.openInputStream() - return inputStream.available().toLong().also { inputStream.closeQuietly() } - } else { - len - } - } - - 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, id: Int): Boolean { - val info = - context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false - downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) - downloadProgressEvent.invoke(Triple(id, 0, 0)) - downloadStatusEvent.invoke(Pair(id, DownloadType.IsStopped)) - downloadDeleteEvent.invoke(id) - val base = basePathToFile(context, info.basePath) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && base.isDownloadDir()) { - val cr = context.contentResolver ?: return false - val fileUri = - cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) - ?: return true // FILE NOT FOUND, ALREADY DELETED - - return cr.delete(fileUri, null, null) > 0 // IF DELETED ROWS IS OVER 0 - } else { - val file = base?.gotoDir(info.relativePath)?.findFile(info.displayName) -// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) -// val dFile = File(normalPath) - if (file?.exists() != true) return true - return try { - file.delete() - } catch (e: Exception) { - logError(e) - val cr = context.contentResolver - cr.delete(file.uri, null, null) > 0 - } - } - } - - fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { - return context.getKey(KEY_RESUME_PACKAGES, id.toString()) - } - - fun downloadFromResume( - context: Context, - pkg: DownloadResumePackage, - notificationCallback: (Int, Notification) -> Unit, - setKey: Boolean = true - ) { - if (!currentDownloads.any { it == pkg.item.ep.id }) { -// if (currentDownloads.size == maxConcurrentDownloads) { -// main { -//// showToast( // can be replaced with regular Toast -//// context, -//// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${ -//// context.getString( -//// R.string.queued -//// ) -//// }", -//// Toast.LENGTH_SHORT -//// ) -// } -// } - downloadQueue.addLast(pkg) - downloadCheck(context, notificationCallback) - if (setKey) saveQueue() - } else { - downloadEvent.invoke( - Pair(pkg.item.ep.id, DownloadActionType.Resume) - ) - } - } - - private fun saveQueue() { - try { - val dQueue = - downloadQueue.toList() - .mapIndexed { index, any -> DownloadQueueResumePackage(index, any) } - .toTypedArray() - setKey(KEY_RESUME_QUEUE_PACKAGES, dQueue) - } catch (t : Throwable) { - logError(t) - } - } - - /*fun isMyServiceRunning(context: Context, serviceClass: Class<*>): Boolean { - val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? - for (service in manager!!.getRunningServices(Int.MAX_VALUE)) { - if (serviceClass.name == service.service.className) { - return true - } - } - return false - }*/ - - fun downloadEpisode( - context: Context?, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - notificationCallback: (Int, Notification) -> Unit, - ) { - if (context == null) return - if (links.isNotEmpty()) { - downloadFromResume( - context, - DownloadResumePackage(DownloadItem(source, folder, ep, links), null), - notificationCallback - ) - } - } - - /** Worker stuff */ - private fun startWork(context: Context, key: String) { - val req = OneTimeWorkRequest.Builder(DownloadFileWorkManager::class.java) - .setInputData( - Data.Builder() - .putString("key", key) - .build() - ) - .build() - (WorkManager.getInstance(context)).enqueueUniqueWork( - key, - ExistingWorkPolicy.KEEP, - req - ) - } - - fun downloadCheckUsingWorker( - context: Context, - ) { - startWork(context, DOWNLOAD_CHECK) - } - - fun downloadFromResumeUsingWorker( - context: Context, - pkg: DownloadResumePackage, - ) { - val key = pkg.item.ep.id.toString() - setKey(WORK_KEY_PACKAGE, key, pkg) - startWork(context, key) - } - - // Keys are needed to transfer the data to the worker reliably and without exceeding the data limit - const val WORK_KEY_PACKAGE = "work_key_package" - const val WORK_KEY_INFO = "work_key_info" - - fun downloadEpisodeUsingWorker( - context: Context, - source: String?, - folder: String?, - ep: DownloadEpisodeMetadata, - links: List, - ) { - val info = DownloadInfo( - source, folder, ep, links - ) - - val key = info.ep.id.toString() - setKey(WORK_KEY_INFO, key, info) - startWork(context, key) - } - - data class DownloadInfo( - @JsonProperty("source") val source: String?, - @JsonProperty("folder") val folder: String?, - @JsonProperty("ep") val ep: DownloadEpisodeMetadata, - @JsonProperty("links") val links: List - ) -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt new file mode 100644 index 00000000000..898c30a1ca9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt @@ -0,0 +1,132 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.getFolderPrefix +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.safefile.MediaFileContentType +import com.lagradost.safefile.SafeFile + +object DownloadFileManagement { + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" + internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + var tempName = name + for (c in RESERVED_CHARS) { + tempName = tempName.replace(c, ' ') + } + if (removeSpaces) tempName = tempName.replace(" ", "") + return tempName.replace(" ", " ").trim(' ') + } + + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ + internal fun getFolder( + context: Context, + relativePath: String, + basePath: String? + ): List>? { + val base = basePathToFile(context, basePath) + val folder = + base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null + + //if (folder.isDirectory() != false) return null + + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + internal fun basePathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + internal fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + internal fun getFileName( + context: Context, + metadata: DownloadObjects.DownloadEpisodeMetadata + ): String { + return getFileName(context, metadata.name, metadata.episode, metadata.season) + } + + internal fun getFileName( + context: Context, + epName: String?, + episode: Int?, + season: Int? + ): String { + // kinda ugly ik + return sanitizeFilename( + if (epName == null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" + } else { + "${context.getString(R.string.episode)} $episode" + } + } else { + if (episode != null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" + } else { + "${context.getString(R.string.episode)} $episode - $epName" + } + } else { + epName + } + } + ) + } + + + internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory( + relativePath, + createMissingDirectories = false + ) + ?.findFile(displayName) + } + + internal fun getFolder(currentType: TvType, titleName: String): String { + return if (currentType.isEpisodeBased()) { + val sanitizedFileName = sanitizeFilename(titleName) + "${currentType.getFolderPrefix()}/$sanitizedFileName" + } else currentType.getFolderPrefix() + } + + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * As of writing UniFile is used for everything but download directory on scoped storage. + * Special ContentResolver fuckery is needed for that as UniFile doesn't work. + * */ + fun getDefaultDir(context: Context): SafeFile? { + // See https://www.py4u.net/discuss/614761 + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt new file mode 100644 index 00000000000..d209d544bd7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -0,0 +1,2091 @@ +package com.lagradost.cloudstream3.utils.downloader + + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification +import android.app.PendingIntent +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 com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +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.safe +import com.lagradost.cloudstream3.services.VideoDownloadService +import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD +import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.M3u8Helper2 +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName +import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getDefaultDir +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.toFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.CreateNotificationMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadEpisodeMetadata +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadItem +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadResumePackage +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadStatus +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfo +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfoResult +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.LazyStreamDownloadResponse +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.StreamData +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.appendAndDontOverride +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.cancel +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getEstimatedTimeLeft +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.join +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.safefile.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 java.io.Closeable +import java.io.IOException +import java.io.OutputStream + +const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" +const val DOWNLOAD_CHANNEL_NAME = "Downloads" +const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel" + +object VideoDownloadManager { + fun maxConcurrentDownloads(context: Context): Int = + PreferenceManager.getDefaultSharedPreferences(context) + ?.getInt(context.getString(R.string.download_parallel_key), 3) ?: 3 + + private fun maxConcurrentConnections(context: Context): Int = + PreferenceManager.getDefaultSharedPreferences(context) + ?.getInt(context.getString(R.string.download_concurrent_key), 3) ?: 3 + + private val _currentDownloads: MutableStateFlow> = MutableStateFlow(emptySet()) + val currentDownloads: StateFlow> = _currentDownloads + + 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" + + @get:DrawableRes + val imgDone get() = R.drawable.rddone + + @get:DrawableRes + val imgDownloading get() = R.drawable.rdload + + @get:DrawableRes + val imgPaused get() = R.drawable.rdpause + + @get:DrawableRes + val imgStopped get() = R.drawable.rderror + + @get:DrawableRes + val imgError get() = R.drawable.rderror + + @get:DrawableRes + val pressToPauseIcon get() = R.drawable.ic_baseline_pause_24 + + @get:DrawableRes + val pressToResumeIcon get() = R.drawable.ic_baseline_play_arrow_24 + + @get:DrawableRes + val pressToStopIcon get() = R.drawable.baseline_stop_24 + + enum class DownloadType { + IsPaused, + IsDownloading, + IsDone, + IsFailed, + IsStopped, + IsPending + } + + enum class DownloadActionType { + Pause, + Resume, + Stop, + } + + + /** Invalid input, just skip to the next one as the same args will give the same error */ + private val DOWNLOAD_INVALID_INPUT = + DownloadStatus(retrySame = false, tryNext = true, success = false) + + /** no need to try any other mirror as we have downloaded the file */ + private val DOWNLOAD_SUCCESS = + DownloadStatus(retrySame = false, tryNext = false, success = true) + + /** the user pressed stop, so no need to download anything else */ + private val DOWNLOAD_STOPPED = + DownloadStatus(retrySame = false, tryNext = false, success = true) + + /** the process failed due to some reason, so we retry and also try the next mirror */ + private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false) + + /** The download only downloaded partial */ + private val DOWNLOAD_PARTIAL_SUCCESS = + DownloadStatus(retrySame = true, tryNext = false, success = true) + + /** 50MB minimum size */ + const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 50L + + /** bad config, skip all mirrors as every call to download will have the same bad config */ + private val DOWNLOAD_BAD_CONFIG = + DownloadStatus(retrySame = false, tryNext = false, success = false) + + const val KEY_RESUME_PACKAGES = "download_resume_2" + const val KEY_DOWNLOAD_INFO = "download_info" + + /** A key to save all the downloads which have not yet started and those currently running, using [DownloadQueueWrapper] + * [KEY_RESUME_PACKAGES] can store keys which should not be automatically queued, unlike this key. + */ + const val KEY_RESUME_IN_QUEUE = "download_resume_queue_key" +// private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" + + val downloadStatus = HashMap() + val downloadStatusEvent = Event>() + val downloadDeleteEvent = Event() + val downloadEvent = Event>() + val downloadProgressEvent = Event>() +// val downloadQueue = LinkedList() + + private var hasCreatedNotChannel = false + + private fun Context.createNotificationChannel() { + hasCreatedNotChannel = true + + this.createNotificationChannel( + DOWNLOAD_CHANNEL_ID, + DOWNLOAD_CHANNEL_NAME, + DOWNLOAD_CHANNEL_DESCRIPT + ) + } + + fun cancelAllDownloadNotifications(context: Context) { + val manager = NotificationManagerCompat.from(context) + manager.activeNotifications.forEach { notification -> + if (notification.tag == DOWNLOAD_NOTIFICATION_TAG) { + manager.cancel(DOWNLOAD_NOTIFICATION_TAG, notification.id) + } + } + } + + + /** + * @param hlsProgress will together with hlsTotal display another notification if used, to lessen the confusion about estimated size. + * */ + @SuppressLint("StringFormatInvalid") + private suspend fun createDownloadNotification( + context: Context, + source: String?, + linkName: String?, + ep: DownloadEpisodeMetadata, + state: DownloadType, + progress: Long, + total: Long, + notificationCallback: (Int, Notification) -> Unit, + hlsProgress: Long? = null, + hlsTotal: Long? = null, + bytesPerSecond: Long + ): Notification? { + try { + if (total <= 0) return null// crash, invalid data + + val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) + .setAutoCancel(true) + .setColorized(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setContentTitle(ep.mainName) + .setSmallIcon( + when (state) { + DownloadType.IsDone -> imgDone + DownloadType.IsDownloading -> imgDownloading + DownloadType.IsPaused -> imgPaused + DownloadType.IsFailed -> imgError + DownloadType.IsStopped -> imgStopped + DownloadType.IsPending -> imgDownloading + } + ) + + if (ep.sourceApiName != null) { + builder.setSubText(ep.sourceApiName) + } + + if (source != null) { + val intent = Intent(context, MainActivity::class.java).apply { + data = source.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent = + PendingIntentCompat.getActivity(context, 0, intent, 0, false) + builder.setContentIntent(pendingIntent) + } + + if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { + builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false) + } else if (state == DownloadType.IsPending) { + builder.setProgress(0, 0, true) + } + + val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else "" + val rowTwo = if (ep.season != null && ep.episode != null) { + "${context.getString(R.string.season_short)}${ep.season}:${context.getString(R.string.episode_short)}${ep.episode}" + rowTwoExtra + } else if (ep.episode != null) { + "${context.getString(R.string.episode)} ${ep.episode}" + rowTwoExtra + } else { + (ep.name ?: "") + "" + } + val downloadFormat = context.getString(R.string.download_format) + + if (SDK_INT >= Build.VERSION_CODES.O) { + if (ep.poster != null) { + val poster = withContext(Dispatchers.IO) { + context.getImageBitmapFromUrl(ep.poster) + } + if (poster != null) + builder.setLargeIcon(poster) + } + + val progressPercentage: Long + val progressMbString: String + val totalMbString: String + val suffix: String + + val mbFormat = "%.1f MB" + + if (hlsProgress != null && hlsTotal != null) { + progressPercentage = hlsProgress * 100 / hlsTotal + progressMbString = hlsProgress.toString() + totalMbString = hlsTotal.toString() + suffix = " - $mbFormat".format(progress / 1000000f) + } else { + progressPercentage = progress * 100 / total + progressMbString = mbFormat.format(progress / 1000000f) + totalMbString = mbFormat.format(total / 1000000f) + suffix = "" + } + + val mbPerSecondString = + if (state == DownloadType.IsDownloading) { + " ($mbFormat/s)".format(bytesPerSecond.toFloat() / 1000000f) + } else "" + + val remainingTime = + if (state == DownloadType.IsDownloading) { + getEstimatedTimeLeft(context, bytesPerSecond, progress, total) + } else "" + + val bigText = + when (state) { + DownloadType.IsDownloading, DownloadType.IsPaused -> { + (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString $remainingTime" + } + + DownloadType.IsPending -> { + (if (linkName == null) "" else "$linkName\n") + rowTwo + } + + DownloadType.IsFailed -> { + downloadFormat.format( + context.getString(R.string.download_failed), + rowTwo + ) + } + + DownloadType.IsDone -> { + downloadFormat.format(context.getString(R.string.download_done), rowTwo) + } + + DownloadType.IsStopped -> { + downloadFormat.format( + context.getString(R.string.download_canceled), + rowTwo + ) + } + } + + val bodyStyle = NotificationCompat.BigTextStyle() + bodyStyle.bigText(bigText) + builder.setStyle(bodyStyle) + } else { + val txt = + when (state) { + DownloadType.IsDownloading, DownloadType.IsPaused, DownloadType.IsPending -> { + rowTwo + } + + DownloadType.IsFailed -> { + downloadFormat.format( + context.getString(R.string.download_failed), + rowTwo + ) + } + + DownloadType.IsDone -> { + downloadFormat.format(context.getString(R.string.download_done), rowTwo) + } + + DownloadType.IsStopped -> { + downloadFormat.format( + context.getString(R.string.download_canceled), + rowTwo + ) + } + } + + builder.setContentText(txt) + } + + 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) { + actionTypes.add(DownloadActionType.Pause) + actionTypes.add(DownloadActionType.Stop) + } + + if (state == DownloadType.IsPaused) { + actionTypes.add(DownloadActionType.Resume) + actionTypes.add(DownloadActionType.Stop) + } + if (state == DownloadType.IsPending) { + actionTypes.add(DownloadActionType.Stop) + } + + // ADD ACTIONS + for ((index, i) in actionTypes.withIndex()) { + val actionResultIntent = Intent(context, VideoDownloadService::class.java) + + actionResultIntent.putExtra( + "type", when (i) { + DownloadActionType.Resume -> "resume" + DownloadActionType.Pause -> "pause" + DownloadActionType.Stop -> "stop" + } + ) + + actionResultIntent.putExtra("id", ep.id) + + val pending: PendingIntent = PendingIntent.getService( + // BECAUSE episodes lying near will have the same id +1, index will give the same requested as the previous episode, *100000 fixes this + context, (4337 + index * 1000000 + ep.id), + actionResultIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + builder.addAction( + NotificationCompat.Action( + when (i) { + DownloadActionType.Resume -> pressToResumeIcon + DownloadActionType.Pause -> pressToPauseIcon + DownloadActionType.Stop -> pressToStopIcon + }, when (i) { + DownloadActionType.Resume -> context.getString(R.string.resume) + DownloadActionType.Pause -> context.getString(R.string.pause) + DownloadActionType.Stop -> context.getString(R.string.cancel) + }, pending + ) + ) + } + } + + if (!hasCreatedNotChannel) { + context.createNotificationChannel() + } + + val notification = builder.build() + notificationCallback(ep.id, notification) + with(NotificationManagerCompat.from(context)) { + // notificationId is a unique int for each notification that you must define + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return null + } + notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) + } + return notification + } catch (e: Exception) { + logError(e) + return null + } + } + + + @Throws(IOException::class) + fun setupStream( + context: Context, + name: String, + folder: String?, + extension: String, + tryResume: Boolean, + ): StreamData { + return setupStream( + context.getBasePath().first ?: getDefaultDir(context) + ?: throw IOException("Bad config"), + name, + folder, + extension, + tryResume + ) + } + + /** + * Sets up the appropriate file and creates a data stream from the file. + * Used for initializing downloads and backups. + * */ + @Throws(IOException::class) + fun setupStream( + baseFile: SafeFile, + name: String, + folder: String?, + extension: String, + tryResume: Boolean, + ): StreamData { + val displayName = getDisplayName(name, extension) + + 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) { + subDir.createFileOrThrow(displayName) to 0L + } else { + if (tryResume) { + foundFile to foundFile.lengthOrThrow() + } else { + foundFile.deleteOrThrow() + subDir.createFileOrThrow(displayName) to 0L + } + } + + return StreamData(fileLength, file) + } + + /** 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, + + var totalBytes: Long? = null, + + // notification metadata + private var lastUpdatedMs: Long = 0, + private var lastDownloadedBytes: Long = 0, + 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 + var hlsTotal: Int? = null, + // this is how many segments that has been written to the file + // will always be <= hlsProgress as we may keep some in a buffer + var hlsWrittenProgress: Int = 0, + + // this is used for copy with metadata on how much we have downloaded for setKey + private var downloadFileInfoTemplate: DownloadedFileInfo? = null + ) : Closeable { + fun setResumeLength(length: Long) { + bytesDownloaded = length + bytesWritten = length + 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 var stopListener: (() -> Unit)? = null + + /** on cancel button pressed or failed invoke this once and only once */ + fun setOnStop(callback: (() -> Unit)) { + stopListener = callback + } + + fun removeStopListener() { + stopListener = null + } + + private val downloadEventListener = { event: Pair -> + if (event.first == id) { + when (event.second) { + DownloadActionType.Pause -> { + type = DownloadType.IsPaused + } + + DownloadActionType.Stop -> { + type = DownloadType.IsStopped + stopListener?.invoke() + stopListener = null + } + + DownloadActionType.Resume -> { + type = DownloadType.IsDownloading + } + } + } + } + + private fun updateFileInfo() { + if (id == null) return + downloadFileInfoTemplate?.let { template -> + /** This looks strange, but fixes an issue where we do an instant retry, and it fails immediately, + * eg. by turning off wifi */ + val totalBytesValue = if (approxTotalBytes <= bytesDownloaded) { + val prevInfo = getKey( + KEY_DOWNLOAD_INFO, + id.toString() + ) + + /** If this link is the same as the last cached video link metadata */ + if (prevInfo != null && prevInfo.linkHash == linkHash) { + /** Try to use totalBytes if it exists, otherwise the max of the prev data, + * and download size to ensure total >= downloaded */ + totalBytes ?: maxOf(prevInfo.totalBytes, bytesDownloaded) + } else { + approxTotalBytes + } + } else { + approxTotalBytes + } + + setKey( + KEY_DOWNLOAD_INFO, + id.toString(), + template.copy( + linkHash = linkHash, + totalBytes = totalBytesValue, + extraInfo = if (isHLS) hlsWrittenProgress.toString() else null + ) + ) + } + } + + fun setDownloadFileInfoTemplate(template: DownloadedFileInfo) { + downloadFileInfoTemplate = template + updateFileInfo() + } + + init { + if (id != null) { + downloadEvent += downloadEventListener + } + } + + override fun close() { + // as we may need to resume hls downloads, we save the current written index + if (isHLS || totalBytes == null) { + updateFileInfo() + } + if (id != null) { + downloadEvent -= downloadEventListener + downloadStatus -= id + } + stopListener = null + } + + var type + get() = internalType + set(value) { + internalType = value + notify() + } + + fun onDelete() { + bytesDownloaded = 0 + hlsWrittenProgress = 0 + hlsProgress = 0 + if (id != null) + downloadDeleteEvent(id) + + //internalType = DownloadType.IsStopped + notify() + } + + companion object { + const val UPDATE_RATE_MS: Long = 1000L + } + + @JvmName("DownloadMetaDataNotify") + private fun notify() { + // max 10 sec between notifications, min 0.1s, this is to stop div by zero + val dt = (System.currentTimeMillis() - lastUpdatedMs).coerceIn(100, 10000) + + val bytesPerSecond = + ((bytesDownloaded - lastDownloadedBytes) * 1000L) / dt + + lastDownloadedBytes = bytesDownloaded + lastUpdatedMs = System.currentTimeMillis() + try { + val bytes = approxTotalBytes + + // notification creation + if (isHLS) { + createNotificationCallback( + CreateNotificationMetadata( + internalType, + bytesDownloaded, + bytes, + hlsTotal = hlsTotal?.toLong(), + hlsProgress = hlsProgress.toLong(), + bytesPerSecond = bytesPerSecond + ) + ) + } else { + createNotificationCallback( + CreateNotificationMetadata( + internalType, + bytesDownloaded, + bytes, + bytesPerSecond = bytesPerSecond + ) + ) + } + + // as hls has an approx file size we want to update this metadata + if (isHLS) { + updateFileInfo() + } + + if (internalType == DownloadType.IsStopped || internalType == DownloadType.IsFailed) { + stopListener?.invoke() + stopListener = null + } + + // push all events, this *should* not crash, TODO MUTEX? + if (id != null) { + downloadStatus[id] = type + downloadProgressEvent(Triple(id, bytesDownloaded, bytes)) + downloadStatusEvent(id to type) + } + } catch (t: Throwable) { + logError(t) + if (BuildConfig.DEBUG) { + throw t + } + } + } + + private fun checkNotification() { + if (lastUpdatedMs + UPDATE_RATE_MS > System.currentTimeMillis()) return + notify() + } + + + /** adds the length and pushes a notification if necessary */ + fun addBytes(length: Long) { + bytesDownloaded += length + // we don't want to update the notification after it is paused, + // download progress may not stop directly when we "pause" it + if (type == DownloadType.IsDownloading) checkNotification() + } + + fun addBytesWritten(length: Long) { + bytesWritten += length + } + + /** adds the length + hsl progress and pushes a notification if necessary */ + fun addSegment(length: Long) { + hlsProgress += 1 + addBytes(length) + } + + fun setWrittenSegment(segmentIndex: Int) { + hlsWrittenProgress = segmentIndex + 1 + // in case of abort we need to save every written progress + updateFileInfo() + } + } + + + data class LazyStreamDownloadData( + private val url: String, + private val headers: Map, + private val referer: String, + /** 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, + val totalLength: Long?, + val downloadLength: Long?, + val chuckSize: Long, + val bufferSize: Int, + val isResumed: Boolean, + ) { + val size get() = chuckStartByte.size + + /** returns what byte it has downloaded, + * so start at 10 and download 4 bytes = return 14 + * + * the range is [startByte, endByte) to be able to do [a, b) [b, c) ect + * + * [a, null) will return inclusive to eof = [a, eof] + * + * throws an error if initial get request fails, can be specified as return startByte + * */ + @Throws + private suspend fun resolve( + startByte: Long, + endByte: Long?, + callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) + ): Long = withContext(Dispatchers.IO) { + var currentByte: Long = startByte + val stopAt = endByte ?: Long.MAX_VALUE + if (currentByte >= stopAt) return@withContext currentByte + + val request = app.get( + url, + headers = headers + mapOf( + // range header is inclusive so [startByte, endByte-1] = [startByte, endByte) + // if nothing at end the server will continue until eof + "Range" to "bytes=$startByte-" // ${endByte?.minus(1)?.toString() ?: "" } + ), + referer = referer, + verify = false + ) + val requestStream = request.body.byteStream() + + val buffer = ByteArray(bufferSize) + var read: Int + + try { + while (requestStream.read(buffer, 0, bufferSize).also { read = it } >= 0) { + val start = currentByte + currentByte += read.toLong() + + // this stops overflow + if (currentByte >= stopAt) { + callback(LazyStreamDownloadResponse(buffer, start, stopAt)) + break + } else { + callback(LazyStreamDownloadResponse(buffer, start, currentByte)) + } + } + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + logError(t) + } finally { + requestStream.closeQuietly() + } + + return@withContext currentByte + } + + /** retries the resolve n times and returns true if successful */ + suspend fun resolveSafe( + index: Int, + retries: Int = 3, + callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) + ): Boolean { + var start = chuckStartByte.getOrNull(index) ?: return false + val end = chuckStartByte.getOrNull(index + 1) + + for (i in 0 until retries) { + try { + // in case + start = resolve(start, end, callback) + // no end defined, so we don't care exactly where it ended + if (end == null) return true + // we have download more or exactly what we needed + if (start >= end) return true + } catch (_: IllegalStateException) { + return false + } catch (_: CancellationException) { + return false + } catch (_: Throwable) { + continue + } + } + return false + } + } + + @Throws + suspend fun streamLazy( + url: String, + headers: Map, + referer: String, + startByte: Long, + /** 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, + /** 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) + + val headRequest = app.head(url = url, headers = headers, referer = referer, verify = false) + var contentLength = headRequest.size + if (contentLength != null && contentLength <= 0) contentLength = 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 + } + } + } + + // 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 + // 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 + // = LongArray(2) { 0, 10 } = [0,10) + [10..EOF] + LongArray(((downloadLength + chuckSize - 1) / chuckSize).toInt()) { idx -> + startByte + idx * chuckSize + } + } + + return LazyStreamDownloadData( + url = url, + headers = headers, + referer = referer, + chuckStartByte = ranges, + downloadLength = downloadLength, + totalLength = contentLength, + chuckSize = chuckSize, + bufferSize = bufferSize, + // we have only resumed if we had a downloaded file and we can resume + isResumed = startByte > 0 && hasRangeSupport + ) + } + + + /** download a file that consist of a single stream of data*/ + suspend fun downloadThing( + context: Context, + link: IDownloadableMinimum, + name: String, + folder: String, + extension: String, + tryResume: Boolean, + parentId: Int?, + createNotificationCallback: (CreateNotificationMetadata) -> Unit, + 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) { + if (parallelConnections < 1) { + return@withContext DOWNLOAD_INVALID_INPUT + } + + var fileStream: OutputStream? = null + //var requestStream: InputStream? = null + val metadata = DownloadMetaData( + totalBytes = 0, + bytesDownloaded = 0, + createNotificationCallback = createNotificationCallback, + id = parentId, + linkHash = link.url.hashCode(), + isHLS = false + ) + try { + // get the file path + val (baseFile, basePath) = context.getBasePath() + val displayName = getDisplayName(name, extension) + if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG + + // set up the download file + var stream = setupStream(baseFile, name, folder, extension, tryResume) + + fileStream = stream.open() + + metadata.setResumeLength(stream.startAt) + metadata.type = DownloadType.IsPending + + val items = streamLazy( + url = link.url.replace(" ", "%20"), + referer = link.referer, + startByte = stream.startAt, + headers = link.headers.appendAndDontOverride( + mapOf( + "user-agent" to USER_AGENT, + ) + ) + ) + + // 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( + DownloadedFileInfo( + totalBytes = metadata.approxTotalBytes, + relativePath = folder, + displayName = displayName, + basePath = basePath + ) + ) + + val currentMutex = Mutex() + val current = (0 until items.size).iterator() + + val fileMutex = Mutex() + // start to data + val pendingData: HashMap = + hashMapOf() + + val fileChecker = launch(Dispatchers.IO) { + while (isActive) { + if (stream.exists) { + delay(5000) + continue + } + fileMutex.withLock { + metadata.type = DownloadType.IsStopped + } + break + } + } + + val jobs = (0 until parallelConnections).map { + launch(Dispatchers.IO) { + + // @downloadexplanation + // this may seem a bit complex but it more or less acts as a queue system + // imagine we do the downloading [0,3] and it response in the order 0,2,3,1 + // file: [_,_,_,_] queue: [_,_,_,_] Initial condition + // file: [X,_,_,_] queue: [_,_,_,_] + added 0 directly to file + // file: [X,_,_,_] queue: [_,_,X,_] + added 2 to queue + // file: [X,_,_,_] queue: [_,_,X,X] + added 3 to queue + // file: [X,X,_,_] queue: [_,_,X,X] + added 1 directly to file + // file: [X,X,X,X] queue: [_,_,_,_] write the queue and remove from it + + // note that this is a bit more complex compared to hsl as ever segment + // will return several bytearrays, and is therefore chained by the byte + // so every request has a front and back byte instead of an index + // this *requires* that no gap exist due because of resolve + val callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) = + callback@{ response -> + if (!isActive) return@callback + fileMutex.withLock { + // wait until not paused + while (metadata.type == DownloadType.IsPaused) delay(100) + // if stopped then throw + if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed) { + this.cancel() + return@callback + } + + val responseSize = response.size + metadata.addBytes(response.size) + + if (response.startByte == metadata.bytesWritten) { + // if we are first in the queue then write it directly + fileStream.write( + response.bytes, + 0, + responseSize.toInt() + ) + metadata.addBytesWritten(responseSize) + } else { + // otherwise append to queue, we need to clone the bytes as they will be overridden otherwise + pendingData[response.startByte] = + response.copy(bytes = response.bytes.clone()) + } + + while (true) { + // remove the current queue start, so no possibility of + // while(true) { continue } in case size = 0, and removed extra + // garbage + val pending = pendingData.remove(metadata.bytesWritten) ?: break + + val size = pending.size + + fileStream.write( + pending.bytes, + 0, + size.toInt() + ) + metadata.addBytesWritten(size) + } + } + } + + // 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 + val index = currentMutex.withLock { + if (!current.hasNext()) return@launch + current.nextInt() + } + + // in case something has gone wrong set to failed if the fail is not caused by + // user cancellation + if (!items.resolveSafe(index, callback = callback)) { + fileMutex.withLock { + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } + return@launch + } + } + } + } + + // fast stop as the jobs may be in a slow request + metadata.setOnStop { + jobs.cancel() + } + + jobs.join() + fileChecker.cancel() + + // jobs are finished so we don't want to stop them anymore + metadata.removeStopListener() + if (!stream.exists) metadata.type = DownloadType.IsStopped + + if (metadata.type == DownloadType.IsFailed) { + return@withContext metadata.failedStatus() + } + + if (metadata.type == DownloadType.IsStopped) { + // we need to close before delete + fileStream.closeQuietly() + metadata.onDelete() + stream.delete() + 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) { + // some sort of IO error, this should not happened + // we just rethrow it + logError(e) + throw e + } catch (t: Throwable) { + // some sort of network error, will error + logError(t) + // note that when failing we don't want to delete the file, + // only user interaction has that power + metadata.type = DownloadType.IsFailed + return@withContext metadata.failedStatus() + } finally { + fileStream?.closeQuietly() + //requestStream?.closeQuietly() + metadata.close() + } + } + + private suspend fun downloadHLS( + context: Context, + link: ExtractorLink, + name: String, + folder: String, + parentId: Int?, + startIndex: Int?, + createNotificationCallback: (CreateNotificationMetadata) -> Unit, + parallelConnections: Int = 3 + ): DownloadStatus = withContext(Dispatchers.IO) { + if (parallelConnections < 1) return@withContext DOWNLOAD_INVALID_INPUT + + val metadata = DownloadMetaData( + createNotificationCallback = createNotificationCallback, + id = parentId, + linkHash = link.url.hashCode(), + isHLS = true + ) + var fileStream: OutputStream? = null + try { + val extension = "mp4" + + // the start .ts index + var startAt = startIndex ?: 0 + + // set up the file data + val (baseFile, basePath) = context.getBasePath() + if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG + + 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( + totalBytes = 0, + relativePath = folder, + displayName = displayName, + basePath = basePath + ) + ) + + // do the initial get request to fetch the segments + val m3u8 = M3u8Helper.M3u8Stream( + link.url, link.quality, link.headers.appendAndDontOverride( + mapOf( + "user-agent" to USER_AGENT, + ) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap() + ) + ) + + val items = M3u8Helper2.hslLazy(m3u8, selectBest = true, requireAudio = true) + + metadata.hlsTotal = items.size + metadata.type = DownloadType.IsDownloading + + val currentMutex = Mutex() + val current = (startAt until items.size).iterator() + + val fileMutex = Mutex() + val pendingData: HashMap = hashMapOf() + + val fileChecker = launch(Dispatchers.IO) { + while (isActive) { + if (stream.exists) { + delay(5000) + continue + } + fileMutex.withLock { + metadata.type = DownloadType.IsStopped + } + break + } + } + + // see @downloadexplanation for explanation of this download strategy, + // this keeps all jobs working at all times, + // does several connections in parallel instead of a regular for loop to improve + // download speed + val jobs = (0 until parallelConnections).map { + 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 + val index = currentMutex.withLock { + if (!current.hasNext()) return@launch + current.nextInt() + } + + // in case something has gone wrong set to failed if the fail is not caused by + // user cancellation + val bytes = items.resolveLinkSafe(index) ?: run { + fileMutex.withLock { + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } + 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) + + // directly write the bytes if you are first + if (metadata.hlsWrittenProgress == index) { + fileStream.write(bytes) + + metadata.addBytesWritten(segmentLength) + metadata.setWrittenSegment(index) + } else { + // no need to clone as there will be no modification of this bytearray + pendingData[index] = bytes + } + + // write the cached bytes submitted by other threads + while (true) { + val cache = + pendingData.remove(metadata.hlsWrittenProgress) ?: break + val cacheLength = cache.size.toLong() + + fileStream.write(cache) + + metadata.addBytesWritten(cacheLength) + metadata.setWrittenSegment(metadata.hlsWrittenProgress) + } + } catch (t: Throwable) { + // this is in case of write fail + logError(t) + if (metadata.type != DownloadType.IsStopped) { + metadata.type = DownloadType.IsFailed + } + } + } + } + } + } + + // fast stop as the jobs may be in a slow request + metadata.setOnStop { + jobs.cancel() + } + + jobs.join() + fileChecker.cancel() + + metadata.removeStopListener() + + if (!stream.exists) metadata.type = DownloadType.IsStopped + + if (metadata.type == DownloadType.IsFailed) { + return@withContext metadata.failedStatus() + } + + if (metadata.type == DownloadType.IsStopped) { + // we need to close before delete + fileStream.closeQuietly() + metadata.onDelete() + stream.delete() + return@withContext DOWNLOAD_STOPPED + } + + metadata.type = DownloadType.IsDone + return@withContext DOWNLOAD_SUCCESS + } catch (t: Throwable) { + logError(t) + metadata.type = DownloadType.IsFailed + return@withContext metadata.failedStatus() + } finally { + fileStream?.closeQuietly() + metadata.close() + } + } + + private fun getDisplayName(name: String, extension: String): String { + return "$name.$extension" + } + + private suspend fun downloadSingleEpisode( + context: Context, + source: String?, + folder: String?, + ep: DownloadEpisodeMetadata, + link: ExtractorLink, + 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. + val extractorJob = ioSafe { + if (link.extractorData != null) { + getApiFromNameNull(link.source)?.extractorVerifierJob(link.extractorData) + } + } + + val callback: (CreateNotificationMetadata) -> Unit = { meta -> + main { + createDownloadNotification( + context, + source, + link.name, + ep, + meta.type, + meta.bytesDownloaded, + meta.bytesTotal, + notificationCallback, + meta.hlsProgress, + meta.hlsTotal, + meta.bytesPerSecond + ) + } + } + + try { + 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, 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 (_: Throwable) { + return DOWNLOAD_FAILED + } finally { + extractorJob.cancel() + } + } + + + fun getDownloadFileInfo( + context: Context, + id: Int, + ): DownloadedFileInfoResult? { + try { + val info = + context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null + val file = info.toFile(context) + + // only delete the key if the file is not found + if (file == null || file.exists() == false) { + return null + } + + return DownloadedFileInfoResult( + file.lengthOrThrow(), + info.totalBytes, + file.uriOrThrow() + ) + } catch (e: Exception) { + logError(e) + return null + } + } + + 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, id: Int): Boolean { + val info = + context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false + val file = info.toFile(context) + + val isFileDeleted = file?.delete() == true || file?.exists() == false + + 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()) + } + + 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 + ) { + private val TAG = "EpisodeDownloadInstance" + private var subtitleDownloadJob: Job? = null + private var downloadJob: Job? = null + private var linkLoadingJob: Job? = null + + /** isCompleted just means the download should not be retried. + * It includes stopped by user AND completion of file download. + * */ + var isCompleted = false + set(value) { + field = value + if (value) { + removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) + // Do not emit events when completed as it may also trigger on cancellation. + + + // Force refresh the queue when completed. + // May lead to some redundant calls, but ensures that the queue is always up to date. + DownloadQueueManager.forceRefreshQueue() + } + } + + /** Cancels all active jobs and sets instance to failed. */ + fun cancelDownload() { + val cause = "Cancel call from cancelDownload" + this.subtitleDownloadJob?.cancel(cause) + this.linkLoadingJob?.cancel(cause) + + // Should not cancel the download job, it may need to clean up itself. + // Better to send a status event using isStopped and let it cancel itself. + isCancelled = true + } + + // Run to cancel ongoing work, delete partial work and refresh queue + private fun cleanup(status: DownloadType) { + removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) + val id = downloadQueueWrapper.id + + // Delete subtitles on cancel + safe { + val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) + if (info != null) { + deleteMatchingSubtitles(context, info) + } + } + + downloadStatusEvent.invoke(Pair(id, status)) + downloadStatus[id] = status + downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) + + // Force refresh the queue when failed. + // May lead to some redundant calls, but ensures that the queue is always up to date. + DownloadQueueManager.forceRefreshQueue() + } + + var isCancelled = false + set(value) { + val oldField = field + field = value + + // Clean up cancelled work, but only once + if (value && !oldField) { + cleanup(DownloadType.IsStopped) + } + } + + + /** This failure can be both downloader and user initiated. + * Do not automatically retry in case of failure. */ + var isFailed = false + set(value) { + val oldField = field + field = value + + // Clean up failed work, but only once + if (value && !oldField) { + cleanup(DownloadType.IsFailed) + } + } + + companion object { + private fun displayNotification(context: Context, id: Int, notification: Notification) { + safe { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return@safe + + NotificationManagerCompat.from(context) + .notify(DOWNLOAD_NOTIFICATION_TAG, id, notification) + } + } + } + + private suspend fun downloadFromResume( + downloadResumePackage: DownloadResumePackage, + notificationCallback: (Int, Notification) -> Unit, + ) { + val item = downloadResumePackage.item + val id = item.ep.id + if (currentDownloads.value.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT + downloadEvent.invoke(id to DownloadActionType.Resume) + return + } + + _currentDownloads.update { downloads -> + downloads + id + } + + try { + for (index in (downloadResumePackage.linkIndex ?: 0) until item.links.size) { + val link = item.links[index] + val resume = downloadResumePackage.linkIndex == index + + setKey( + KEY_RESUME_PACKAGES, + id.toString(), + DownloadResumePackage(item, index) + ) + + var connectionResult = + downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + resume + ) + + if (connectionResult.retrySame) { + connectionResult = downloadSingleEpisode( + context, + item.source, + item.folder, + item.ep, + link, + notificationCallback, + true + ) + } + + if (connectionResult.success) { // SUCCESS + isCompleted = true + break + } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { + isFailed = true + break + } + } + } catch (e: Exception) { + isFailed = true + logError(e) + } finally { + isFailed = !isCompleted + _currentDownloads.update { downloads -> + downloads - id + } + } + } + + private suspend fun startDownload( + info: DownloadItem?, + pkg: DownloadResumePackage? + ) { + try { + if (info != null) { + getDownloadResumePackage(context, info.ep.id)?.let { dpkg -> + downloadFromResume(dpkg) { id, notification -> + displayNotification(context, id, notification) + } + } ?: run { + if (info.links.isEmpty()) return + downloadFromResume( + DownloadResumePackage(info, null) + ) { id, notification -> + displayNotification(context, id, notification) + } + } + } else if (pkg != null) { + downloadFromResume(pkg) { id, notification -> + displayNotification(context, id, notification) + } + } + return + } catch (e: Exception) { + isFailed = true + logError(e) + return + } + } + + private suspend fun downloadFromResume() { + val resumePackage = downloadQueueWrapper.resumePackage ?: return + downloadFromResume(resumePackage) { id, notification -> + displayNotification(context, id, notification) + } + } + + fun startDownload() { + Log.d(TAG, "Starting download ${downloadQueueWrapper.id}") + setKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString(), downloadQueueWrapper) + + ioSafe { + if (downloadQueueWrapper.resumePackage != null) { + downloadFromResume() + // Load links if they are not already loaded + } else if (downloadQueueWrapper.downloadItem != null && downloadQueueWrapper.downloadItem.links.isNullOrEmpty()) { + downloadEpisodeWithoutLinks() + } else if (downloadQueueWrapper.downloadItem?.links != null) { + downloadEpisodeWithLinks( + sortUrls(downloadQueueWrapper.downloadItem.links.toSet()), + downloadQueueWrapper.downloadItem.subs + ) + } + } + } + + private fun downloadEpisodeWithLinks( + links: List, + subs: List? + ) { + val downloadItem = downloadQueueWrapper.downloadItem ?: return + try { + // Prepare visual keys + setKey( + DOWNLOAD_HEADER_CACHE, + downloadItem.resultId.toString(), + DownloadObjects.DownloadHeaderCached( + apiName = downloadItem.apiName, + url = downloadItem.resultUrl, + type = downloadItem.resultType, + name = downloadItem.resultName, + poster = downloadItem.resultPoster, + id = downloadItem.resultId, + cacheTime = System.currentTimeMillis(), + ) + ) + setKey( + getFolderName( + DOWNLOAD_EPISODE_CACHE, + downloadItem.resultId.toString() + ), // 3 deep folder for faster access + downloadItem.episode.id.toString(), + DownloadObjects.DownloadEpisodeCached( + name = downloadItem.episode.name, + poster = downloadItem.episode.poster, + episode = downloadItem.episode.episode, + season = downloadItem.episode.season, + id = downloadItem.episode.id, + parentId = downloadItem.resultId, + score = downloadItem.episode.score, + description = downloadItem.episode.description, + cacheTime = System.currentTimeMillis(), + ) + ) + + val meta = + getDownloadEpisodeMetadata( + downloadItem.episode, + downloadItem.resultName, + downloadItem.apiName, + downloadItem.resultPoster, + downloadItem.isMovie, + downloadItem.resultType + ) + + val folder = + getFolder(downloadItem.resultType, downloadItem.resultName) + val src = "$DOWNLOAD_NAVIGATE_TO/${downloadItem.resultId}" + + // DOWNLOAD VIDEO + val info = DownloadItem(src, folder, meta, links) + + this.downloadJob = ioSafe { + startDownload(info, null) + } + + // 1. Checks if the lang should be downloaded + // 2. Makes it into the download format + // 3. Downloads it as a .vtt file + this.subtitleDownloadJob = ioSafe { + try { + val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() + + subs?.filter { subtitle -> + downloadList.any { langTagIETF -> + subtitle.languageCode == langTagIETF || + subtitle.originalName.contains( + fromTagToEnglishLanguageName( + langTagIETF + ) ?: langTagIETF + ) + } + } + ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } + ?.take(3) // max subtitles download hardcoded (?_?) + ?.forEach { link -> + val fileName = getFileName(context, meta) + downloadSubtitle(context, link, fileName, folder) + } + + } catch (_: CancellationException) { + val fileName = getFileName(context, meta) + + val info = DownloadedFileInfo( + totalBytes = 0, + relativePath = folder, + displayName = fileName, + basePath = context.getBasePath().second + ) + + deleteMatchingSubtitles(context, info) + } + } + } catch (e: Exception) { + // The work is only failed if the job did not get started + if (this.downloadJob == null) { + isFailed = true + } + logError(e) + } + } + + private suspend fun downloadEpisodeWithoutLinks() { + val downloadItem = downloadQueueWrapper.downloadItem ?: return + + val generator = RepoLinkGenerator(listOf(downloadItem.episode)) + val currentLinks = mutableSetOf() + val currentSubs = mutableSetOf() + val meta = + getDownloadEpisodeMetadata( + downloadItem.episode, + downloadItem.resultName, + downloadItem.apiName, + downloadItem.resultPoster, + downloadItem.isMovie, + downloadItem.resultType + ) + + createDownloadNotification( + context, + downloadItem.apiName, + txt(R.string.loading).asString(context), + meta, + DownloadType.IsPending, + 0, + 1, + { _, _ -> }, + null, + null, + 0 + )?.let { linkLoadingNotification -> + displayNotification(context, downloadItem.episode.id, linkLoadingNotification) + } + + linkLoadingJob = ioSafe { + generator.generateLinks( + offset = 0, + isCasting = false, + clearCache = false, + sourceTypes = LOADTYPE_INAPP_DOWNLOAD, + callback = { + it.first?.let { link -> + currentLinks.add(link) + } + }, + subtitleCallback = { sub -> + currentSubs.add(sub) + }) + } + + // Wait for link loading completion + linkLoadingJob?.join() + + // Remove link loading notification + NotificationManagerCompat.from(context) + .cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) + + if (linkLoadingJob?.isCancelled == true) { + // Same as if no links, but no toast. + // Cancelled link loading is presumed to be user initiated + isCancelled = true + return + } else if (currentLinks.isEmpty()) { + main { + showToast( + R.string.no_links_found_toast, + Toast.LENGTH_SHORT + ) + } + isFailed = true + return + } else { + main { + showToast( + R.string.download_started, + Toast.LENGTH_SHORT + ) + } + } + + // Profiles should always contain a download type + val profile = QualityDataHelper.getProfiles().first { + it.types.contains( + QualityDataHelper.QualityProfileType.Download + ) + } + + val sortedLinks = currentLinks.sortedBy { link -> + // Negative, because the highest priority should be first + -getLinkPriority(profile.id, link) + } + + downloadEpisodeWithLinks( + sortedLinks, + sortSubs(currentSubs), + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt new file mode 100644 index 00000000000..25a9fdf2a4f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -0,0 +1,224 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.safefile.SafeFile +import java.io.IOException +import java.io.OutputStream +import java.util.Objects + +object DownloadObjects { + /** An item can either be something to resume or something new to start */ + data class DownloadQueueWrapper( + @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?, + @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?, + ) { + init { + assert(resumePackage != null || downloadItem != null) { + "ResumeID and downloadItem cannot both be null at the same time!" + } + } + + /** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */ + fun isCurrentlyDownloading(): Boolean { + return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } + } + + @JsonProperty("id") + val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id + + @JsonProperty("parentId") + val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId + } + + /** General data about the episode and show to start a download from. */ + data class DownloadQueueItem( + @JsonProperty("episode") val episode: ResultEpisode, + @JsonProperty("isMovie") val isMovie: Boolean, + @JsonProperty("resultName") val resultName: String, + @JsonProperty("resultType") val resultType: TvType, + @JsonProperty("resultPoster") val resultPoster: String?, + @JsonProperty("apiName") val apiName: String, + @JsonProperty("resultId") val resultId: Int, + @JsonProperty("resultUrl") val resultUrl: String, + @JsonProperty("links") val links: List? = null, + @JsonProperty("subs") val subs: List? = null, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(null, this) + } + } + + + abstract class DownloadCached( + @JsonProperty("id") open val id: Int, + ) + + data class DownloadEpisodeCached( + @JsonProperty("name") val name: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("season") val season: Int?, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("score") var score: Score? = null, + @JsonProperty("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } + + /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ + data class DownloadHeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) + + data class DownloadResumePackage( + @JsonProperty("item") val item: DownloadItem, + /** Tills which link should get resumed */ + @JsonProperty("linkIndex") val linkIndex: Int?, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(this, null) + } + } + + data class DownloadItem( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List, + ) + + /** Metadata for a specific episode and how to display it. */ + data class DownloadEpisodeMetadata( + @JsonProperty("id") val id: Int, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("mainName") val mainName: String, + @JsonProperty("sourceApiName") val sourceApiName: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("type") val type: TvType?, + ) + + + data class DownloadedFileInfo( + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("relativePath") val relativePath: String, + @JsonProperty("displayName") val displayName: String, + @JsonProperty("extraInfo") val extraInfo: String? = null, + @JsonProperty("basePath") val basePath: String? = null, // null is for legacy downloads. See getBasePath() + // Hash of the link associated with this DownloadFile, used so not override old data in the DownloadedFileInfo + @JsonProperty("linkHash") val linkHash : Int? = null + ) + + data class DownloadedFileInfoResult( + @JsonProperty("fileLength") val fileLength: Long, + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("path") val path: Uri, + ) + + + data class ResumeWatching( + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("episodeId") val episodeId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("updateTime") val updateTime: Long, + @JsonProperty("isFromDownload") val isFromDownload: Boolean, + ) + + + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) + + + data class CreateNotificationMetadata( + val type: VideoDownloadManager.DownloadType, + val bytesDownloaded: Long, + val bytesTotal: Long, + val hlsProgress: Long? = null, + val hlsTotal: Long? = null, + val bytesPerSecond: Long + ) + + data class StreamData( + private val fileLength: Long, + val file: SafeFile, + //val fileStream: OutputStream, + ) { + @Throws(IOException::class) + fun open(): OutputStream { + return file.openOutputStreamOrThrow(resume) + } + + @Throws(IOException::class) + fun openNew(): OutputStream { + return file.openOutputStreamOrThrow(false) + } + + fun delete(): Boolean { + return file.delete() == true + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + + + /** bytes have the size end-start where the byte range is [start,end) + * note that ByteArray is a pointer and therefore cant be stored without cloning it */ + data class LazyStreamDownloadResponse( + val bytes: ByteArray, + val startByte: Long, + val endByte: Long, + ) { + val size get() = endByte - startByte + + override fun toString(): String { + return "$startByte->$endByte" + } + + override fun equals(other: Any?): Boolean { + if (other !is LazyStreamDownloadResponse) return false + return other.startByte == startByte && other.endByte == endByte + } + + override fun hashCode(): Int { + return Objects.hash(startByte, endByte) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt new file mode 100644 index 00000000000..f3866408833 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt @@ -0,0 +1,250 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.util.Log +import androidx.core.content.ContextCompat +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadQueuePackage +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet + +// 1. Put a download on the queue +// 2. The queue manager starts a foreground service to handle the queue +// 3. The service starts work manager jobs to handle the downloads? +object DownloadQueueManager { + private const val TAG = "DownloadQueueManager" + const val QUEUE_KEY = "download_queue_key" + + /** Flow of all active queued download, no active downloads. + * This flow may see many changes, do not place expensive observers. + * downloadInstances is the flow keeping track of active downloads. + * @see com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances + */ + private val _queue: MutableStateFlow> by lazy { + /** Persistent queue */ + val currentValue = getKey>(QUEUE_KEY) ?: emptyArray() + MutableStateFlow(currentValue) + } + + val queue: StateFlow> by lazy { _queue } + + /** Start the queue, marks all queue objects as in progress. + * Note that this may run twice without the service restarting + * because MainActivity may be recreated. */ + fun init(context: Context) { + ioSafe { + _queue.collect { queue -> + setKey(QUEUE_KEY, queue) + } + } + + ioSafe startQueue@{ + // Do not automatically start the queue if safe mode is activated. + if (PluginManager.isSafeMode()) { + // Prevent misleading UI + VideoDownloadManager.cancelAllDownloadNotifications(context) + return@startQueue + } + + val resumeQueue = + getPreResumeIds().filterNot { + VideoDownloadManager.currentDownloads.value.contains(it) + } + .mapNotNull { id -> + getDownloadResumePackage(context, id)?.toWrapper() + ?: getDownloadQueuePackage(context, id) + } + + val newQueue = _queue.updateAndGet { localQueue -> + // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started + (resumeQueue + localQueue).distinctBy { it.id }.toTypedArray() + } + + // Once added to the queue they can be safely removed + removeKeys(KEY_RESUME_IN_QUEUE) + + // Make sure the download buttons display a pending status + newQueue.forEach { obj -> + setQueueStatus(obj.id, VideoDownloadManager.DownloadType.IsPending) + } + + if (newQueue.any()) { + startQueueService(context) + } + } + } + + /** Downloads not yet started or in progress. */ + private fun getPreResumeIds(): Set { + return getKeys(KEY_RESUME_IN_QUEUE)?.mapNotNull { + it.substringAfter("$KEY_RESUME_IN_QUEUE/").toIntOrNull() + }?.toSet() + ?: emptySet() + } + + /** Adds an object to the internal persistent queue. It does not re-add an existing item. @return true if successfully added */ + private fun add(downloadQueueWrapper: DownloadQueueWrapper): Boolean { + Log.d(TAG, "Download added to queue: $downloadQueueWrapper") + val newQueue = _queue.updateAndGet { localQueue -> + // Do not add the same episode twice + if (downloadQueueWrapper.isCurrentlyDownloading() || localQueue.any { it.id == downloadQueueWrapper.id }) { + return@updateAndGet localQueue + } + localQueue + downloadQueueWrapper + } + return newQueue.any { it.id == downloadQueueWrapper.id } + } + + /** Removes all objects with the same id from the internal persistent queue */ + private fun remove(id: Int) { + Log.d(TAG, "Download removed from the queue: $id") + _queue.update { localQueue -> + // The check is to prevent unnecessary updates + if (!localQueue.any { it.id == id }) { + return@update localQueue + } + + localQueue.filter { it.id != id }.toTypedArray() + } + } + + /** Removes all items and returns the previous queue */ + private fun removeAll(): Array { + Log.d(TAG, "Removed everything from queue") + return _queue.getAndUpdate { + emptyArray() + } + } + + private fun reorder(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + _queue.update { localQueue -> + val newIndex = newPosition.coerceIn(0, localQueue.size) + val id = downloadQueueWrapper.id + + val newQueue = localQueue.filter { it.id != id }.toMutableList().apply { + this.add(newIndex, downloadQueueWrapper) + }.toTypedArray() + + newQueue + } + } + + /** Start a real download from the first item in the queue */ + fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { + val first = queue.value.firstOrNull() ?: return null + + remove(first.id) + + val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) + + return downloadInstance + } + + /** Marks the item as in queue for the download button */ + private fun setQueueStatus(id: Int, status: VideoDownloadManager.DownloadType) { + downloadStatusEvent.invoke( + Pair( + id, + status + ) + ) + downloadStatus[id] = status + } + + private fun startQueueService(context: Context?) { + if (context == null) { + Log.d(TAG, "Cannot start download queue service, null context.") + return + } + // Do not restart the download queue service + if (DownloadQueueService.isRunning) { + return + } + ioSafe { + val intent = DownloadQueueService.getIntent(context) + ContextCompat.startForegroundService(context, intent) + } + } + + /** Cancels an active download or removes it from queue depending on where it is. */ + fun cancelDownload(id: Int) { + Log.d(TAG, "Cancelling download: $id") + + val currentInstance = downloadInstances.value.find { it.downloadQueueWrapper.id == id } + + if (currentInstance != null) { + currentInstance.cancelDownload() + } else { + removeFromQueue(id) + } + } + + /** Removes all queued items */ + fun removeAllFromQueue() { + removeAll().forEach { wrapper -> + setQueueStatus(wrapper.id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Removes all objects with the same id from the internal persistent queue */ + fun removeFromQueue(id: Int) { + ioSafe { + remove(id) + setQueueStatus(id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Will move the download queue wrapper to a new position in the queue. + * If the item does not exist it will also insert it. */ + fun reorderItem(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + ioSafe { + reorder(downloadQueueWrapper, newPosition) + } + } + + /** Add a new object to the queue. Will not queue completed downloads or current downloads. */ + fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) = safe { + val context = CloudStreamApp.context ?: return@safe + val fileInfo = getDownloadFileInfo(context, downloadQueueWrapper.id) + val isComplete = fileInfo != null && + // Assure no division by 0 + fileInfo.totalBytes > 0 && + // If more than 98% downloaded then do not add to queue + (fileInfo.fileLength.toFloat() / fileInfo.totalBytes.toFloat()) > 0.98f + // Do not queue completed files! + if (isComplete) return@safe + + if (add(downloadQueueWrapper)) { + setQueueStatus(downloadQueueWrapper.id, VideoDownloadManager.DownloadType.IsPending) + startQueueService(context) + } + } + + + /** Refreshes the queue flow with the same value, but copied. + * Good to run if the downloads are affected by some outside value change. */ + fun forceRefreshQueue() { + _queue.update { localQueue -> + localQueue.copyOf() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt new file mode 100644 index 00000000000..9f2c31d9a37 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt @@ -0,0 +1,165 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.graphics.drawable.toBitmap +import coil3.Extras +import coil3.SingletonImageLoader +import coil3.asDrawable +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap + +/** Separate object with helper functions for the downloader */ +object DownloadUtils { + private val cachedBitmaps = ConcurrentHashMap() + internal fun Context.getImageBitmapFromUrl( + url: String, + headers: Map? = null + ): Bitmap? = safe { + cachedBitmaps[url]?.let { + return@safe it + } + + val imageLoader = SingletonImageLoader.get(this) + + val request = ImageRequest.Builder(this) + .data(url) + .apply { + headers?.forEach { (key, value) -> + extras[Extras.Key(key)] = value + } + } + .build() + + val bitmap = runBlocking { + val result = imageLoader.execute(request) + (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) + ?.toBitmap() + } + + bitmap?.let { + cachedBitmaps.putIfAbsent(url, it) + } + + return@safe bitmap + } + + //calculate the time + internal fun getEstimatedTimeLeft( + context: Context, + bytesPerSecond: Long, + progress: Long, + total: Long + ): String { + if (bytesPerSecond <= 0) return "" + val timeInSec = (total - progress) / bytesPerSecond + val hrs = timeInSec / 3600 + val mins = (timeInSec % 3600) / 60 + val secs = timeInSec % 60 + val timeFormated: UiText? = when { + hrs > 0 -> txt( + R.string.download_time_left_hour_min_sec_format, + hrs, + mins, + secs + ) + + mins > 0 -> txt( + R.string.download_time_left_min_sec_format, + mins, + secs + ) + + secs > 0 -> txt( + R.string.download_time_left_sec_format, + secs + ) + + else -> null + } + return timeFormated?.asString(context) ?: "" + } + + internal fun downloadSubtitle( + context: Context?, + link: ExtractorSubtitleLink, + fileName: String, + folder: String + ) { + ioSafe { + VideoDownloadManager.downloadThing( + context ?: return@ioSafe, + link, + "$fileName ${link.name}", + folder, + if (link.url.contains(".srt")) "srt" else "vtt", + false, + null, createNotificationCallback = {} + ) + } + } + + fun downloadSubtitle( + context: Context?, + link: SubtitleData, + meta: DownloadObjects.DownloadEpisodeMetadata, + ) { + context?.let { ctx -> + val fileName = getFileName(ctx, meta) + val folder = getFolder(meta.type ?: return, meta.mainName) + downloadSubtitle( + ctx, + ExtractorSubtitleLink(link.name, link.url, "", link.headers), + fileName, + folder + ) + } + } + + + /** Helper function to make sure duplicate attributes don't get overridden or inserted without lowercase cmp + * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) + * */ + internal fun Map.appendAndDontOverride(rhs: Map): Map { + val out = this.toMutableMap() + val current = this.keys.map { it.lowercase() } + for ((key, value) in rhs) { + if (current.contains(key.lowercase())) continue + out[key] = value + } + return out + } + + internal fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + internal suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt new file mode 100644 index 00000000000..0db90afeaef --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt +// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md +class AniSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + if (data !is AnimeLoadResponse) return null // Filter actual anime + + val malId = data.getMalId()?.toIntOrNull() ?: return null + val url = + "https://api.aniskip.com/v2/skip-times/$malId/${episode.episode}?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeDurationMs / 1000L}" + + val response = app.get(url).parsed() + + // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work + return response.results?.mapNotNull { stamp -> + val skipType = when (stamp.skipType) { + "op" -> SkipType.Opening + "ed" -> SkipType.Ending + "recap" -> SkipType.Recap + "mixed-ed" -> SkipType.MixedEnding + "mixed-op" -> SkipType.MixedOpening + else -> null + } ?: return@mapNotNull null + val end = (stamp.interval.endTime * 1000.0).toLong() + val start = (stamp.interval.startTime * 1000.0).toLong() + SkipStamp( + type = skipType, + startMs = start, + endMs = end, + ) + } + } + + data class AniSkipResponse( + @JsonSerialize val found: Boolean, + @JsonSerialize val results: List?, + @JsonSerialize val message: String?, + @JsonSerialize val statusCode: Int + ) + + data class Stamp( + @JsonSerialize val interval: AniSkipInterval, + @JsonSerialize val skipType: String, + @JsonSerialize val skipId: String, + @JsonSerialize val episodeLength: Double + ) + + data class AniSkipInterval( + @JsonSerialize val startTime: Double, + @JsonSerialize val endTime: Double + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt new file mode 100644 index 00000000000..f9254576bb5 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt @@ -0,0 +1,370 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap +import java.security.MessageDigest + +class AnimeSkipAuth : AuthAPI() { + override val name = "AnimeSkip" + override val inAppLoginRequirement: AuthLoginRequirement = + AuthLoginRequirement(password = true, username = true) + override val idPrefix = "anime-skip" + override val hasInApp = true + override val createAccountUrl = "https://anime-skip.com/account" + val baseClientId = "as1JgiMbW4wKfmTLWXS79iTDQFll76pk" + fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') + } + + data class LoginRoot( + @JsonProperty("data") + val data: LoginData, + ) + + data class LoginData( + @JsonProperty("login") + val login: Login, + ) + + data class Login( + @JsonProperty("authToken") + val authToken: String, + @JsonProperty("refreshToken") + val refreshToken: String, + @JsonProperty("account") + val account: Account, + ) + + data class ApiRoot( + @JsonProperty("data") + val data: ApiData, + ) + + data class ApiData( + @JsonProperty("myApiClients") + val myApiClients: List, + ) + + data class MyApiClient( + @JsonProperty("id") + val id: String, + ) + + data class Account( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + ) + + data class Payload( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + @JsonProperty("clientId") + val clientId: String, + ) + + override suspend fun user(token: AuthToken?): AuthUser? { + val payload = parseJson(token?.payload ?: return null) + return AuthUser( + name = payload.username, + id = payload.email.hashCode(), + profilePicture = payload.profileUrl + ) + } + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val hash = md5(form.password ?: return null) + val emailOrUserName = form.email ?: form.username ?: return null + + val loginQuery = """ + { + login(usernameEmail: "$emailOrUserName", passwordHash: "$hash") { + authToken + refreshToken + account { + profileUrl + username + email + } + } + } +""" + val loginRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to loginQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val authToken = loginRoot.data.login.authToken + val refreshToken = loginRoot.data.login.refreshToken + val account = loginRoot.data.login.account + + val clientQuery = """ + { + myApiClients { + id + } + } + """.trimIndent() + + val apiRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to clientQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "Authorization" to "Bearer $authToken", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val clientId = apiRoot.data.myApiClients.getOrNull(0)?.id + ?: throw ErrorLoadingException("No API token found") + + val payload = Payload( + profileUrl = account.profileUrl, + username = account.username, + email = account.email, + clientId = clientId, + ) + return AuthToken( + accessToken = authToken, + refreshToken = refreshToken, + payload = payload.toJson() + ) + } +} + +class AnimeSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + val auth = PlainAuthRepo(animeSkipApi) + //val clientId = "ZGfO0sMF3eCwLYf8yMSCJjlynwNGRXWE" + + companion object { + const val MIN_LENGTH: Int = 4 + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String?): String? = + name?.replace(strip, "")?.lowercase() + + private val asciiRegex = Regex("[^a-zA-Z0-9 ]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun asciiName(name: String?): String? = + name?.replace(asciiRegex, "")?.lowercase() + } + + data class Root( + @JsonProperty("data") + val data: Data, + ) + + data class Data( + @JsonProperty("searchShows") + val searchShows: List, + ) + + data class SearchShow( + @JsonProperty("name") + val name: String, + @JsonProperty("originalName") + val originalName: String?, + @JsonProperty("seasonCount") + val seasonCount: Long, + @JsonProperty("episodeCount") + val episodeCount: Long, + @JsonProperty("baseDuration") + val baseDuration: Double, + @JsonProperty("episodes") + val episodes: List, + ) + + data class Episode( + @JsonProperty("number") + val number: String?, + @JsonProperty("absoluteNumber") + val absoluteNumber: String?, + @JsonProperty("season") + val season: String?, + @JsonProperty("timestamps") + val timestamps: List, + ) + + data class Timestamp( + @JsonProperty("at") + val at: Double, + @JsonProperty("type") + val type: Type, + ) + + data class Type( + @JsonProperty("name") + val name: String, + ) + + val cache: ConcurrentHashMap = ConcurrentHashMap() + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val clientId : String = parseJson( + auth.authData()?.token?.payload ?: return null + ).clientId + + when (data) { + is AnimeLoadResponse, is TvSeriesLoadResponse -> { + /** Require episode based anime */ + } + + else -> return null + } + + val query = """{ + searchShows(search: "${data.name}", limit: 1) { + name + originalName + seasonCount + episodeCount + episodes { + number + absoluteNumber + season + baseDuration + timestamps { + at + type { + name + } + } + } + } +}""" + val root = cache[data.name] ?: run { + app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to query), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to clientId + ) + ) + .parsed().data.also { root -> + cache[data.name] = root + } + } + val show = root.searchShows.firstOrNull { show -> + /** Match ascii */ + val ascii1 = asciiName(data.name) + val ascii2 = asciiName(show.name) + if (ascii1 == ascii2 && (ascii1?.length ?: 0) > MIN_LENGTH) { + return@firstOrNull true + } + + if (data !is AnimeLoadResponse) { + return@firstOrNull false + } + + /** Match original name */ + val strip1 = stripName(show.originalName) + val strip2 = stripName(data.japName) + + /** Match english name*/ + val ascii3 = stripName(data.engName) + (strip1 == strip2 && (strip1?.length ?: 0) > MIN_LENGTH) || + (ascii2 == ascii3 && (ascii2?.length ?: 0) > MIN_LENGTH) + } ?: return null + + val showEpisode = when (data) { + is AnimeLoadResponse -> { + val episodeNumber = episode.episode.toString() + /** For anime, match on number */ + show.episodes.firstOrNull { + it.absoluteNumber == episodeNumber + } ?: show.episodes.firstOrNull { + it.number == episodeNumber + } + } + + is TvSeriesLoadResponse -> { + /** For tv-series, match on season + number */ + val seasonNumber = episode.season?.toString() + val episodeNumber = episode.episode.toString() + val episodeIndex = episode.totalEpisodeIndex.toString() + + show.episodes.firstOrNull { + it.season == seasonNumber && it.number == episodeNumber + } ?: show.episodes.firstOrNull { + it.absoluteNumber == episodeIndex + } + } + + else -> null + } ?: return null + + val result = ArrayList() + var pending: SkipStamp? = null + for (stamp in showEpisode.timestamps) { + val startMS = (stamp.at * 1000.0).toLong() + pending?.let { pending -> + result.add(pending.copy(endMs = startMS)) + } + val type = when (stamp.type.name) { + "Intro", "New Intro" -> SkipType.Intro + "Credits" -> SkipType.Credits + "Preview" -> SkipType.Preview + "Recap" -> SkipType.Recap + "Mixed Credits" -> SkipType.MixedEnding + "Filler", "Transition", "Branding", "Canon", "Title Card" -> null + else -> null + } + if (type == null) { + pending = null + continue + } + pending = SkipStamp(type, startMS, 0L) + } + pending?.let { pending -> + result.add(pending.copy(endMs = episodeDurationMs)) + /** Base duration = fucked */ + } + + return result + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt new file mode 100644 index 00000000000..869515f4390 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +class IntroDbSkip : SkipAPI() { + override val name = "IntroDb" + + override val supportedTypes = setOf(TvType.TvSeries, TvType.AsianDrama) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val season = episode.season ?: return null + val imdbId = data.getImdbId() ?: return null + + val url = + "https://api.introdb.app/segments?imdb_id=$imdbId&season=$season&episode=${episode.episode}" + val response = app.get(url).parsed() + + return listOfNotNull( + response.intro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Opening, + startMs = start, + endMs = end + ) + }, + response.recap?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Recap, + startMs = start, + endMs = end + ) + }, + response.outro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Ending, + startMs = start, + endMs = end + ) + } + ) + } + + + data class IntroDbResponse( + @JsonProperty("imdb_id") val imdbId: String?, + val season: Int?, + val episode: Int?, + val intro: Segment?, + val recap: Segment?, + val outro: Segment?, + ) + + data class Segment( + @JsonProperty("start_sec") val startSec: Double?, + @JsonProperty("end_sec") val endSec: Double?, + @JsonProperty("start_ms") val startMs: Long?, + @JsonProperty("end_ms") val endMs: Long?, + val confidence: Double?, + @JsonProperty("submission_count") val submissionCount: Int?, + @JsonProperty("updated_at") val updatedAt: String?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt new file mode 100644 index 00000000000..60cc3ae1e23 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt @@ -0,0 +1,105 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import androidx.annotation.StringRes +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import java.util.concurrent.ConcurrentHashMap + + +enum class SkipType(@StringRes val res: Int) { + Opening(R.string.skip_type_op), + Ending(R.string.skip_type_ed), + Recap(R.string.skip_type_recap), + MixedOpening(R.string.skip_type_mixed_op), + MixedEnding(R.string.skip_type_mixed_ed), + Credits(R.string.skip_type_credits), + Intro(R.string.skip_type_intro), + Preview(R.string.skip_type_preview), +} + +data class SkipStamp( + val type: SkipType, + /** Start position in milliseconds of the skip, where it should start showing up */ + val startMs: Long, + /** End position in milliseconds of the skip, where it will skip to */ + val endMs: Long, + /** Custom visual label instead of using the type. Only use this for content not covered by SkipType */ + val label: String? = null, +) + +data class VideoSkipStamp( + val timestamp: SkipStamp, + val skipToNextEpisode: Boolean, + val source: String, +) { + val uiText = + if (skipToNextEpisode) txt(R.string.next_episode) else + txt( + R.string.skip_type_format, + timestamp.label?.let { txt(it) } ?: txt(timestamp.type.res) + ) +} + +abstract class SkipAPI { + open val name: String = "NONE" + + /** On what types SkipAPI should trigger on */ + abstract val supportedTypes: Set + + /** Get all video skip stamps of the associated episode */ + @Throws + open suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + ): List? { + throw NotImplementedError() + } + + companion object { + private val skipApis: List = listOf(AniSkip(), TheIntroDBSkip(), IntroDbSkip(), AnimeSkip()) + private val cachedStamps = ConcurrentHashMap>() + + /** Get all video timestamps from an episode */ + suspend fun videoStamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + hasNextEpisode: Boolean, + ): List { + cachedStamps[episode.id]?.let { list -> + return list + } + + for (api in skipApis) { + /** Unsupported type, so we do not waste a get call */ + if (!api.supportedTypes.contains(data.type)) { + continue + } + + /** Find first non-empty stamps */ + val stamps = safeAsync { api.stamps(data, episode, episodeDurationMs) } + if (stamps.isNullOrEmpty()) { + continue + } + + return stamps.map { stamp -> + VideoSkipStamp( + timestamp = stamp, + skipToNextEpisode = hasNextEpisode && episodeDurationMs - stamp.endMs < 20_000L, + source = api.name + ) + }.also { stamps -> + /** Put in cache, this is such small data, it should be fine to never clear it */ + cachedStamps[episode.id] = stamps + } + } + return emptyList() + } + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt new file mode 100644 index 00000000000..cc2661cb096 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt @@ -0,0 +1,76 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.app + +/** https://theintrodb.org/docs */ +class TheIntroDBSkip : SkipAPI() { + override val name = "TheIntroDB" + override val supportedTypes = setOf( + TvType.TvSeries, TvType.Cartoon, TvType.Anime, TvType.Movie, + TvType.AsianDrama + ) + + val mainUrl = "https://api.theintrodb.org" + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val idSuffix = + data.getTMDbId()?.let { tmdbId -> "tmdb_id=$tmdbId" } + ?: data.getImdbId()?.let { imdbId -> "imdb_id=$imdbId" } + ?: return null + + val url = if (data.isMovie()) { + "$mainUrl/v2/media?$idSuffix" + } else { + val season = episode.season ?: return null + "$mainUrl/v2/media?$idSuffix&season=$season&episode=${episode.episode}" + } + val root = app.get(url).parsed() + return arrayOf( + root.intro to SkipType.Intro, + root.credits to SkipType.Credits, + root.recap to SkipType.Recap, + root.preview to SkipType.Preview + ).map { (list, type) -> + list.map { stamp -> + SkipStamp( + type, + stamp.startMs ?: 0L, + stamp.endMs ?: episodeDurationMs + ) + } + }.flatten() + } + + data class Root( + @JsonProperty("tmdb_id") + val tmdbId: Long, + @JsonProperty("type") + val type: String, + @JsonProperty("intro") + val intro: List = emptyList(), + @JsonProperty("recap") + val recap: List = emptyList(), + @JsonProperty("credits") + val credits: List = emptyList(), + @JsonProperty("preview") + val preview: List = emptyList(), + ) + + data class Stamp( + @JsonProperty("start_ms") + val startMs: Long?, + @JsonProperty("end_ms") + val endMs: Long?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt index d4725d53e64..c18ad39c682 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -4,6 +4,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 84fa9e97816..00000000000 --- 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 97065514711..00000000000 --- 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 84fa9e97816..00000000000 --- 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 97065514711..00000000000 --- 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/anim/rotate_around_center_point.xml b/app/src/main/res/anim/rotate_around_center_point.xml new file mode 100644 index 00000000000..76e7b39b4fc --- /dev/null +++ b/app/src/main/res/anim/rotate_around_center_point.xml @@ -0,0 +1,13 @@ + + + + + \ 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 00000000000..d2a6b6c4d62 --- /dev/null +++ b/app/src/main/res/color/black_button_ripple.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/button_selector_color.xml b/app/src/main/res/color/button_selector_color.xml new file mode 100644 index 00000000000..9975946dbd7 --- /dev/null +++ b/app/src/main/res/color/button_selector_color.xml @@ -0,0 +1,6 @@ + + + + + + \ 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 00000000000..e6d1f8c9ebb --- /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 0d2834dda7d..208afb18bf4 100644 --- a/app/src/main/res/color/item_select_color.xml +++ b/app/src/main/res/color/item_select_color.xml @@ -1,5 +1,7 @@ - - \ No newline at end of file + + + + diff --git a/app/src/main/res/color/item_select_color_tv.xml b/app/src/main/res/color/item_select_color_tv.xml new file mode 100644 index 00000000000..3042fd58896 --- /dev/null +++ b/app/src/main/res/color/item_select_color_tv.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/player_on_button_tv_attr.xml b/app/src/main/res/color/player_on_button_tv_attr.xml new file mode 100644 index 00000000000..feb1eeb089f --- /dev/null +++ b/app/src/main/res/color/player_on_button_tv_attr.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/toggle_selector.xml b/app/src/main/res/color/toggle_selector.xml index 9bb16931e3f..a7c826044c3 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/color/white_attr_20.xml b/app/src/main/res/color/white_attr_20.xml new file mode 100644 index 00000000000..e0237df0043 --- /dev/null +++ b/app/src/main/res/color/white_attr_20.xml @@ -0,0 +1,4 @@ + + + + \ 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 00000000000..8f1bb3105ed --- /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 00000000000..2d5f74e14e0 --- /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 00000000000..0e80a074e41 --- /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 00000000000..f4b60368baa --- /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 00000000000..c6fd08a380e --- /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 00000000000..938b17ead63 --- /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_help_outline_24.xml b/app/src/main/res/drawable/baseline_help_outline_24.xml new file mode 100644 index 00000000000..3a72cda09c9 --- /dev/null +++ b/app/src/main/res/drawable/baseline_help_outline_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_network_ping_24.xml b/app/src/main/res/drawable/baseline_network_ping_24.xml new file mode 100644 index 00000000000..1caae667139 --- /dev/null +++ b/app/src/main/res/drawable/baseline_network_ping_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_notifications_none_24.xml b/app/src/main/res/drawable/baseline_notifications_none_24.xml new file mode 100644 index 00000000000..cf589c6d4ee --- /dev/null +++ b/app/src/main/res/drawable/baseline_notifications_none_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/baseline_remove_24.xml b/app/src/main/res/drawable/baseline_remove_24.xml index 791a2f81afa..f4455598a3c 100644 --- a/app/src/main/res/drawable/baseline_remove_24.xml +++ b/app/src/main/res/drawable/baseline_remove_24.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:tint="?attr/white"> 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 00000000000..9937885e7b2 --- /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/baseline_stop_24.xml b/app/src/main/res/drawable/baseline_stop_24.xml new file mode 100644 index 00000000000..100cb1fce13 --- /dev/null +++ b/app/src/main/res/drawable/baseline_stop_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_text_snippet_24.xml b/app/src/main/res/drawable/baseline_text_snippet_24.xml new file mode 100644 index 00000000000..c1f3654b27d --- /dev/null +++ b/app/src/main/res/drawable/baseline_text_snippet_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/bg_color_both.xml b/app/src/main/res/drawable/bg_color_both.xml new file mode 100644 index 00000000000..bb71f8731a4 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_both.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_bottom.xml b/app/src/main/res/drawable/bg_color_bottom.xml new file mode 100644 index 00000000000..7c744f19f1d --- /dev/null +++ b/app/src/main/res/drawable/bg_color_bottom.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_center.xml b/app/src/main/res/drawable/bg_color_center.xml new file mode 100644 index 00000000000..7cb4374526c --- /dev/null +++ b/app/src/main/res/drawable/bg_color_center.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_top.xml b/app/src/main/res/drawable/bg_color_top.xml new file mode 100644 index 00000000000..45497d27218 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_top.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_imdb_badge.xml b/app/src/main/res/drawable/bg_imdb_badge.xml new file mode 100644 index 00000000000..de7a6704b5c --- /dev/null +++ b/app/src/main/res/drawable/bg_imdb_badge.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml new file mode 100644 index 00000000000..b4701e42a73 --- /dev/null +++ b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bookmark_star_24px.xml b/app/src/main/res/drawable/bookmark_star_24px.xml new file mode 100644 index 00000000000..81b400d925a --- /dev/null +++ b/app/src/main/res/drawable/bookmark_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/circle_shape_dotted.xml b/app/src/main/res/drawable/circle_shape_dotted.xml new file mode 100644 index 00000000000..6ce2808cd31 --- /dev/null +++ b/app/src/main/res/drawable/circle_shape_dotted.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circular_progress_bar_clockwise.xml b/app/src/main/res/drawable/circular_progress_bar_clockwise.xml new file mode 100644 index 00000000000..a2e7f02269b --- /dev/null +++ b/app/src/main/res/drawable/circular_progress_bar_clockwise.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circular_progress_bar_counter_clockwise.xml b/app/src/main/res/drawable/circular_progress_bar_counter_clockwise.xml new file mode 100644 index 00000000000..477e8db1f2e --- /dev/null +++ b/app/src/main/res/drawable/circular_progress_bar_counter_clockwise.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circular_progress_bar_small_to_large.xml b/app/src/main/res/drawable/circular_progress_bar_small_to_large.xml new file mode 100644 index 00000000000..eed446281d1 --- /dev/null +++ b/app/src/main/res/drawable/circular_progress_bar_small_to_large.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/circular_progress_bar_top_to_bottom.xml b/app/src/main/res/drawable/circular_progress_bar_top_to_bottom.xml new file mode 100644 index 00000000000..f41eea84a27 --- /dev/null +++ b/app/src/main/res/drawable/circular_progress_bar_top_to_bottom.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/clear_all_24px.xml b/app/src/main/res/drawable/clear_all_24px.xml new file mode 100644 index 00000000000..dbbc7dc9f21 --- /dev/null +++ b/app/src/main/res/drawable/clear_all_24px.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/cloud_2_solid.xml b/app/src/main/res/drawable/cloud_2_solid.xml new file mode 100644 index 00000000000..3810b4bf695 --- /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 00000000000..737ff195947 --- /dev/null +++ b/app/src/main/res/drawable/dashed_line_horizontal.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/download_icon_done.xml b/app/src/main/res/drawable/download_icon_done.xml new file mode 100644 index 00000000000..a41ac14ed26 --- /dev/null +++ b/app/src/main/res/drawable/download_icon_done.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_icon_error.xml b/app/src/main/res/drawable/download_icon_error.xml new file mode 100644 index 00000000000..ef56f19ad85 --- /dev/null +++ b/app/src/main/res/drawable/download_icon_error.xml @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_icon_load.xml b/app/src/main/res/drawable/download_icon_load.xml new file mode 100644 index 00000000000..bde9a1606ed --- /dev/null +++ b/app/src/main/res/drawable/download_icon_load.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download_icon_pause.xml b/app/src/main/res/drawable/download_icon_pause.xml new file mode 100644 index 00000000000..08455521822 --- /dev/null +++ b/app/src/main/res/drawable/download_icon_pause.xml @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/episodes_shadow.xml b/app/src/main/res/drawable/episodes_shadow.xml new file mode 100644 index 00000000000..a77cbf25254 --- /dev/null +++ b/app/src/main/res/drawable/episodes_shadow.xml @@ -0,0 +1,8 @@ + + + + \ 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 00000000000..764cb9660e4 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 e57946b65e2..14999011662 100644 --- a/app/src/main/res/drawable/go_back_30.xml +++ b/app/src/main/res/drawable/go_back_30.xml @@ -1,6 +1,7 @@ + + diff --git a/app/src/main/res/drawable/home_icon_outline_24.xml b/app/src/main/res/drawable/home_icon_outline_24.xml new file mode 100644 index 00000000000..2d5f93a674d --- /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 00000000000..2280cdd09d5 --- /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 00000000000..7bd1ebbded5 --- /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_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml index ebe459b2903..dbda1cc01a5 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml @@ -3,6 +3,7 @@ android:viewportWidth="48" android:viewportHeight="48" android:tint="?attr/white" + android:autoMirrored="true" xmlns:android="http://schemas.android.com/apk/res/android"> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_ios_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_ios_24.xml index 6c3197a6dc2..516df382cfe 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_back_ios_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_back_ios_24.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml index 2ec8c110d78..48ac45e75fe 100644 --- a/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml +++ b/app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml @@ -3,6 +3,7 @@ android:viewportWidth="48" android:viewportHeight="48" android:tint="?attr/white" + android:autoMirrored="true" xmlns:android="http://schemas.android.com/apk/res/android"> \ No newline at end of file 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 dad38dca6ce..00000000000 --- 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 70db409b338..7dea8241e9e 100644 --- a/app/src/main/res/drawable/ic_baseline_close_24.xml +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -1,4 +1,4 @@ - diff --git a/app/src/main/res/drawable/ic_baseline_collections_bookmark_24.xml b/app/src/main/res/drawable/ic_baseline_collections_bookmark_24.xml new file mode 100644 index 00000000000..fc90e300842 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_collections_bookmark_24.xml @@ -0,0 +1,6 @@ + + + + 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 00000000000..dba3e567c10 --- /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 00000000000..cd20ad156ac --- /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 00000000000..6aebfabdc7e --- /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 00000000000..941d936faba --- /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 00000000000..66afaed2c7b --- /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_keyboard_arrow_left_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml index ff93af1f8c4..b67188dbafa 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml index fc07eaaeeec..ea9246250d1 100644 --- a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_language_24.xml b/app/src/main/res/drawable/ic_baseline_language_24.xml index 1749952e52c..89b47937107 100644 --- a/app/src/main/res/drawable/ic_baseline_language_24.xml +++ b/app/src/main/res/drawable/ic_baseline_language_24.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_more_vert_24.xml b/app/src/main/res/drawable/ic_baseline_more_vert_24.xml index 249fe2a293d..b6908e96b28 100644 --- a/app/src/main/res/drawable/ic_baseline_more_vert_24.xml +++ b/app/src/main/res/drawable/ic_baseline_more_vert_24.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_baseline_north_west_24.xml b/app/src/main/res/drawable/ic_baseline_north_west_24.xml new file mode 100644 index 00000000000..c46eb4b0cc4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_north_west_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml b/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml index 2003bfe788a..5d6045e7e64 100644 --- a/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml +++ b/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml @@ -1,5 +1,10 @@ - - + + 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 00000000000..2651015ca61 --- /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 00000000000..2e7c9b0703a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_people_24.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_replay_24.xml b/app/src/main/res/drawable/ic_baseline_replay_24.xml new file mode 100644 index 00000000000..e247aa9243a --- /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 00000000000..aed3a562c44 --- /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 00000000000..0326fbd491c --- /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 00000000000..fc533a0e793 --- /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 00000000000..a8c43bbdb34 --- /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 00000000000..452c4dd9969 --- /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_baseline_sort_24.xml b/app/src/main/res/drawable/ic_baseline_sort_24.xml new file mode 100644 index 00000000000..96d46231f1d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_sort_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_star_24.xml b/app/src/main/res/drawable/ic_baseline_star_24.xml index ab0994255aa..2dcadb7fc56 100644 --- a/app/src/main/res/drawable/ic_baseline_star_24.xml +++ b/app/src/main/res/drawable/ic_baseline_star_24.xml @@ -1,5 +1,5 @@ - + android:width="12dp" xmlns:android="http://schemas.android.com/apk/res/android"> 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 00000000000..24d0a77f47e --- /dev/null +++ b/app/src/main/res/drawable/ic_battery.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml b/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml new file mode 100644 index 00000000000..4b8964f8c01 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml @@ -0,0 +1,27 @@ + + + + + + + + + \ No newline at end of file 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 00000000000..ed46f973da7 --- /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 00000000000..5c96e5a5492 --- /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 00000000000..71c2cbfcd90 --- /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 00000000000..8e21fd25e32 --- /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 00000000000..455006b3176 --- /dev/null +++ b/app/src/main/res/drawable/ic_offline_pin_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_account_circle_24.xml b/app/src/main/res/drawable/ic_outline_account_circle_24.xml new file mode 100644 index 00000000000..27c2d574966 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_account_circle_24.xml @@ -0,0 +1,13 @@ + + + + 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 00000000000..928ad0864a4 --- /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 00000000000..e61dcf1ce75 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/indicator_background.xml b/app/src/main/res/drawable/indicator_background.xml new file mode 100644 index 00000000000..ef44fb7cc92 --- /dev/null +++ b/app/src/main/res/drawable/indicator_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/kid_star_24px.xml b/app/src/main/res/drawable/kid_star_24px.xml new file mode 100644 index 00000000000..2efe84195b8 --- /dev/null +++ b/app/src/main/res/drawable/kid_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/library_icon.xml b/app/src/main/res/drawable/library_icon.xml new file mode 100644 index 00000000000..f62dceac087 --- /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 00000000000..eba49782c87 --- /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 00000000000..9c6495bea03 --- /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 00000000000..6d1b6510af1 --- /dev/null +++ b/app/src/main/res/drawable/metadata_overlay_icon.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/netflix_download_batch.xml b/app/src/main/res/drawable/netflix_download_batch.xml new file mode 100644 index 00000000000..8ef633fd236 --- /dev/null +++ b/app/src/main/res/drawable/netflix_download_batch.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/netflix_skip_back.xml b/app/src/main/res/drawable/netflix_skip_back.xml index bb63e9485af..5ad9c1a135e 100644 --- a/app/src/main/res/drawable/netflix_skip_back.xml +++ b/app/src/main/res/drawable/netflix_skip_back.xml @@ -1,23 +1,23 @@ + android:width="850.39dp" + android:height="850.39dp" + android:viewportWidth="850.39" + android:viewportHeight="850.39"> + android:fillColor="#00000000" + android:pathData="M143.05,279.28A317.41,317.41 0,0 0,106.3 428c0,176.13 142.77,318.9 318.9,318.9S744.09,604.16 744.09,428 601.32,109.14 425.2,109.14q-14.15,0 -28,1.2" + android:strokeWidth="45" + android:strokeColor="#fff" /> + android:fillColor="#fff" + android:pathData="M483.083,223.108l-111.666,-111.666l25.442,-25.442l111.666,111.666z" /> + android:fillColor="#fff" + android:pathData="M371.421,111.662l111.666,-111.666l25.442,25.442l-111.666,111.666z" /> + android:fillColor="#fff" + android:pathData="M398.087,223.272l-111.666,-111.666l25.442,-25.442l111.666,111.666z" /> + android:fillColor="#fff" + android:pathData="M286.427,111.826l111.666,-111.666l25.442,25.442l-111.666,111.666z" /> \ No newline at end of file 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 00000000000..9226029a440 --- /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_big_15_gray.xml b/app/src/main/res/drawable/outline_big_15_gray.xml new file mode 100644 index 00000000000..b9450027971 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_15_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_20.xml b/app/src/main/res/drawable/outline_big_20.xml new file mode 100644 index 00000000000..7faf8a889e1 --- /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 00000000000..ebcdc0bf4ee --- /dev/null +++ b/app/src/main/res/drawable/outline_big_20_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_25_gray.xml b/app/src/main/res/drawable/outline_big_25_gray.xml new file mode 100644 index 00000000000..ea5f31a1f7e --- /dev/null +++ b/app/src/main/res/drawable/outline_big_25_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_35_gray.xml b/app/src/main/res/drawable/outline_big_35_gray.xml new file mode 100644 index 00000000000..ab18a1354d1 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_35_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_bookmark_add_24.xml b/app/src/main/res/drawable/outline_bookmark_add_24.xml new file mode 100644 index 00000000000..a4e18af3f84 --- /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_card.xml b/app/src/main/res/drawable/outline_card.xml index 02116bb8859..5716de450d5 100644 --- a/app/src/main/res/drawable/outline_card.xml +++ b/app/src/main/res/drawable/outline_card.xml @@ -1,21 +1,20 @@ + android:color="@android:color/white"> - + android:width="2dp" + android:color="@android:color/white" /> + - + - \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_forced.xml b/app/src/main/res/drawable/outline_drawable_forced.xml new file mode 100644 index 00000000000..16eba83ccf5 --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_forced.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_forced_round.xml b/app/src/main/res/drawable/outline_drawable_forced_round.xml new file mode 100644 index 00000000000..7736f088a9c --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_forced_round.xml @@ -0,0 +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 0b641074d52..aa3a8d0df02 100644 --- a/app/src/main/res/drawable/outline_drawable_less.xml +++ b/app/src/main/res/drawable/outline_drawable_less.xml @@ -1,4 +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 00000000000..29096d867bb --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_less_inset.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_round_20.xml b/app/src/main/res/drawable/outline_drawable_round_20.xml new file mode 100644 index 00000000000..a2e8253b733 --- /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 00000000000..1425ff05a79 --- /dev/null +++ b/app/src/main/res/drawable/pin_ic.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/play_button.xml b/app/src/main/res/drawable/play_button.xml index 04886b6e500..ee3d47dfe97 100644 --- a/app/src/main/res/drawable/play_button.xml +++ b/app/src/main/res/drawable/play_button.xml @@ -1,25 +1,19 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + android:name="vector" + android:width="842dp" + android:height="842dp" + android:viewportWidth="842" + android:viewportHeight="842"> + android:name="path" + android:pathData="M 421.44 17.5 C 336.15 17.5 253.011 44.513 184.01 94.646 C 115.009 144.778 63.626 215.5 37.27 296.616 C 10.914 377.732 10.914 465.148 37.27 546.264 C 63.626 627.38 115.009 698.102 184.01 748.234 C 253.011 798.367 336.15 825.38 421.44 825.38 C 506.73 825.38 589.869 798.367 658.87 748.234 C 727.871 698.102 779.254 627.38 805.61 546.264 C 831.966 465.148 831.966 377.732 805.61 296.616 C 779.254 215.5 727.871 144.778 658.87 94.646 C 589.869 44.513 506.73 17.5 421.44 17.5 Z" + android:fillColor="#B3000000" + android:strokeWidth="1"/> - + android:name="path_2" + android:pathData="M 598.91 419.24 L 333.91 266.24 L 333.91 572.24 L 598.91 419.24 Z" + android:fillColor="#ffffff" + android:strokeWidth="1"/> diff --git a/app/src/main/res/drawable/play_button_transparent.xml b/app/src/main/res/drawable/play_button_transparent.xml new file mode 100644 index 00000000000..caa7041e608 --- /dev/null +++ b/app/src/main/res/drawable/play_button_transparent.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/player_button_tv_attr.xml b/app/src/main/res/drawable/player_button_tv_attr.xml new file mode 100644 index 00000000000..ed83887d2c6 --- /dev/null +++ b/app/src/main/res/drawable/player_button_tv_attr.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 00000000000..0dd8c256a05 --- /dev/null +++ b/app/src/main/res/drawable/player_button_tv_attr_no_bg.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ 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 79bb3af5fe3..8077b418fbe 100644 --- a/app/src/main/res/drawable/player_gradient_tv.xml +++ b/app/src/main/res/drawable/player_gradient_tv.xml @@ -4,10 +4,10 @@ @@ -15,10 +15,10 @@ diff --git a/app/src/main/res/drawable/preview_seekbar_24.xml b/app/src/main/res/drawable/preview_seekbar_24.xml new file mode 100644 index 00000000000..657f6247002 --- /dev/null +++ b/app/src/main/res/drawable/preview_seekbar_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/profile_bg_blue.jpg b/app/src/main/res/drawable/profile_bg_blue.jpg new file mode 100644 index 00000000000..e573439b04e Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_blue.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_dark_blue.jpg b/app/src/main/res/drawable/profile_bg_dark_blue.jpg new file mode 100644 index 00000000000..d59e4888c64 Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_dark_blue.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_orange.jpg b/app/src/main/res/drawable/profile_bg_orange.jpg new file mode 100644 index 00000000000..a97e7179f99 Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_orange.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_pink.jpg b/app/src/main/res/drawable/profile_bg_pink.jpg new file mode 100644 index 00000000000..9d4940f0d9f Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_pink.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_purple.jpg b/app/src/main/res/drawable/profile_bg_purple.jpg new file mode 100644 index 00000000000..15723dba35e Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_purple.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_red.jpg b/app/src/main/res/drawable/profile_bg_red.jpg new file mode 100644 index 00000000000..6a27ff31315 Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_red.jpg differ diff --git a/app/src/main/res/drawable/profile_bg_teal.jpg b/app/src/main/res/drawable/profile_bg_teal.jpg new file mode 100644 index 00000000000..9323665088f Binary files /dev/null and b/app/src/main/res/drawable/profile_bg_teal.jpg differ diff --git a/app/src/main/res/drawable/rating_bg_color.xml b/app/src/main/res/drawable/rating_bg_color.xml new file mode 100644 index 00000000000..4cf33aba0e9 --- /dev/null +++ b/app/src/main/res/drawable/rating_bg_color.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml new file mode 100644 index 00000000000..d1360f9489f --- /dev/null +++ b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_outline.xml b/app/src/main/res/drawable/rounded_outline.xml new file mode 100644 index 00000000000..b85ace8ea19 --- /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 00000000000..5dd7559b39e --- /dev/null +++ b/app/src/main/res/drawable/rounded_select_ripple.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/screen_rotation.xml b/app/src/main/res/drawable/screen_rotation.xml new file mode 100644 index 00000000000..da0ac0fd56a --- /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 00000000000..1d31bb7d024 --- /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 00000000000..bdc9e98d38b --- /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 00000000000..c54a9760dd4 --- /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/simkl_logo.xml b/app/src/main/res/drawable/simkl_logo.xml new file mode 100644 index 00000000000..eb29fb5bcd2 --- /dev/null +++ b/app/src/main/res/drawable/simkl_logo.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/speedup.xml b/app/src/main/res/drawable/speedup.xml new file mode 100644 index 00000000000..879ef852cec --- /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 00000000000..12116eabc2a --- /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 00000000000..26e3f43e88b --- /dev/null +++ b/app/src/main/res/drawable/sun_7_24.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/title_24px.xml b/app/src/main/res/drawable/title_24px.xml new file mode 100644 index 00000000000..3e725ff7a01 --- /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 00000000000..19fcf26d0a1 --- /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 00000000000..558c4ec3e98 --- /dev/null +++ b/app/src/main/res/drawable/video_outline.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-port/player_select_source_and_subs.xml b/app/src/main/res/layout-port/player_select_source_and_subs.xml new file mode 100644 index 00000000000..4710473d4fa --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_and_subs.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/player_select_source_priority.xml b/app/src/main/res/layout-port/player_select_source_priority.xml new file mode 100644 index 00000000000..2cba9c869bb --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_priority.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/subtitle_offset.xml b/app/src/main/res/layout-port/subtitle_offset.xml new file mode 100644 index 00000000000..b6c4f61fd2d --- /dev/null +++ b/app/src/main/res/layout-port/subtitle_offset.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/account_edit_dialog.xml b/app/src/main/res/layout/account_edit_dialog.xml new file mode 100644 index 00000000000..f52c8ea5196 --- /dev/null +++ b/app/src/main/res/layout/account_edit_dialog.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item.xml b/app/src/main/res/layout/account_list_item.xml new file mode 100644 index 00000000000..3cbfc72fb1e --- /dev/null +++ b/app/src/main/res/layout/account_list_item.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item_add.xml b/app/src/main/res/layout/account_list_item_add.xml new file mode 100644 index 00000000000..dea64484fdb --- /dev/null +++ b/app/src/main/res/layout/account_list_item_add.xml @@ -0,0 +1,29 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_list_item_edit.xml b/app/src/main/res/layout/account_list_item_edit.xml new file mode 100644 index 00000000000..3f41a23c230 --- /dev/null +++ b/app/src/main/res/layout/account_list_item_edit.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + \ 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 389a34066d4..e7afb382c87 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 00000000000..b78c0d44c3c --- /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 cbfb9f18f24..c4f7fa39479 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 659ad840ab3..ac6e41a60c1 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_add" + android:text="@string/add_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 00000000000..9f62d56015c --- /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 9003cd211f7..00000000000 --- 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 4905d4546a2..2483a37145c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -14,19 +14,21 @@ + + + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/homeRoot" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation" + android:paddingTop="0dp"> + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/nav_host_fragment" + android:name="androidx.navigation.fragment.NavHostFragment" + android:layout_width="0dp" + android:layout_height="0dp" + app:defaultNavHost="true" + app:layout_constraintBottom_toTopOf="@+id/cast_mini_controller_holder" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:navGraph="@navigation/mobile_navigation" /> + app:headerLayout="@layout/rail_header" + app:itemActiveIndicatorStyle="@style/CustomIndicator" + app:itemIconSize="24dp" + app:itemIconTint="@color/item_select_color_tv" + + app:itemMinHeight="0dp" + app:itemPaddingBottom="0dp" + app:itemPaddingTop="0dp" + app:itemSpacing="12dp" + app:itemTextColor="@color/item_select_color_tv" + app:labelVisibilityMode="unlabeled" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:menu="@menu/bottom_nav_menu" + app:menuGravity="center"> + + + + + android:id="@+id/cast_mini_controller_holder" + android:layout_width="0dp" + android:layout_height="wrap_content" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/nav_rail_view" + tools:layout_height="100dp"> + android:id="@+id/cast_mini_controller" + class="com.lagradost.cloudstream3.ui.MyMiniControllerFragment" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + app:castControlButtons="@array/cast_mini_controller_control_buttons" + app:customCastBackgroundColor="?attr/primaryGrayBackground" + tools:ignore="FragmentTagUsage" /> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/add_account_input.xml b/app/src/main/res/layout/add_account_input.xml index ea48a80f006..4f96b109e9b 100644 --- a/app/src/main/res/layout/add_account_input.xml +++ b/app/src/main/res/layout/add_account_input.xml @@ -80,6 +80,7 @@ android:id="@+id/login_server_input" android:layout_width="match_parent" android:layout_height="wrap_content" + android:autofillHints="no" android:hint="@string/example_ip" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" @@ -96,7 +97,7 @@ android:layout_height="wrap_content" android:autofillHints="password" android:hint="@string/example_password" - android:inputType="textVisiblePassword" + android:inputType="textPassword" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" diff --git a/app/src/main/res/layout/add_remove_sites.xml b/app/src/main/res/layout/add_remove_sites.xml index 9ef6ad6a4c6..653f607f120 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 6f6b4d5bdfa..a8bdf2a3872 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 1c61f8b4da9..519b790da96 100644 --- a/app/src/main/res/layout/add_site_input.xml +++ b/app/src/main/res/layout/add_site_input.xml @@ -62,6 +62,7 @@ + xmlns:tools="http://schemas.android.com/tools" + android:nextFocusDown="@id/nginx_text_input" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/nginx_text_input" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + android:layout_marginBottom="60dp" + android:layout_marginHorizontal="10dp" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" + android:autofillHints="no" + android:inputType="text" + tools:text="nginx.com" + tools:ignore="LabelFor" /> + android:id="@+id/apply_btt_holder" + android:orientation="horizontal" + android:layout_gravity="bottom" + android:gravity="bottom|end" + android:layout_marginTop="-60dp" + android:layout_width="match_parent" + android:layout_height="60dp"> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + android:id="@+id/apply_btt" + style="@style/WhiteButton" /> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + android:id="@+id/cancel_btt" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/bottom_loading.xml b/app/src/main/res/layout/bottom_loading.xml index ab05889d022..1637aa5ad09 100644 --- a/app/src/main/res/layout/bottom_loading.xml +++ b/app/src/main/res/layout/bottom_loading.xml @@ -1,33 +1,61 @@ + + - - + + + 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 new file mode 100644 index 00000000000..3372fe7b1d8 --- /dev/null +++ b/app/src/main/res/layout/bottom_resultview_preview.xml @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/bottom_resultview_preview_tv.xml b/app/src/main/res/layout/bottom_resultview_preview_tv.xml new file mode 100644 index 00000000000..d352cba5c01 --- /dev/null +++ b/app/src/main/res/layout/bottom_resultview_preview_tv.xml @@ -0,0 +1,278 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/bottom_selection_dialog.xml b/app/src/main/res/layout/bottom_selection_dialog.xml index 0532f2506b1..55ca6562e4e 100644 --- a/app/src/main/res/layout/bottom_selection_dialog.xml +++ b/app/src/main/res/layout/bottom_selection_dialog.xml @@ -1,58 +1,65 @@ + + + android:layout_height="wrap_content" + android:layout_marginBottom="10dp"> + + - + tools:text="Test" /> + + android:id="@+id/listview1" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:paddingTop="10dp" + android:dividerHeight="1dp" + android:requiresFadingEdge="vertical" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + tools:listitem="@layout/sort_bottom_single_choice" /> + android:id="@+id/apply_btt_holder" + android:layout_width="match_parent" + android:layout_height="60dp" + android:gravity="center_vertical|end" + android:orientation="horizontal"> + android:id="@+id/apply_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + style="@style/WhiteButton" /> + android:id="@+id/cancel_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/bottom_selection_dialog_direct.xml b/app/src/main/res/layout/bottom_selection_dialog_direct.xml index 0d179ebb6bf..cf31ba1ffbb 100644 --- a/app/src/main/res/layout/bottom_selection_dialog_direct.xml +++ b/app/src/main/res/layout/bottom_selection_dialog_direct.xml @@ -1,34 +1,34 @@ + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + android:id="@+id/text1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_rowWeight="1" + android:layout_marginTop="20dp" + android:layout_marginBottom="10dp" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:textColor="?attr/textColor" + android:textSize="20sp" + android:textStyle="bold" + tools:text="Test" /> + android:id="@+id/listview1" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" + android:layout_marginBottom="60dp" + android:nestedScrollingEnabled="true" + android:nextFocusLeft="@id/apply_btt" + android:nextFocusRight="@id/cancel_btt" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + tools:listitem="@layout/sort_bottom_single_choice_no_checkmark" /> 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 00000000000..01b4834d3d3 --- /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 8403940ceea..4f7bdf74d9f 100644 --- a/app/src/main/res/layout/cast_item.xml +++ b/app/src/main/res/layout/cast_item.xml @@ -1,105 +1,102 @@ - android:nextFocusLeft="@id/episode_poster" - android:nextFocusRight="@id/result_episode_download" - android:id="@+id/episode_holder" - app:cardElevation="0dp" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:cardCornerRadius="@dimen/rounded_image_radius" - app:cardBackgroundColor="@color/transparent" - - android:foreground="@drawable/outline_drawable" - android:layout_margin="5dp"> + android:layout_width="100dp" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="5dp"> + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal"> + android:id="@+id/voice_actor_image_holder" + android:layout_width="70dp" + android:layout_height="70dp" + android:layout_gravity="end|bottom" + android:layout_marginStart="10dp" + android:layout_marginTop="5dp" + android:alpha="0.2" + android:foreground="@drawable/outline_drawable" + app:cardCornerRadius="35dp"> - + android:id="@+id/voice_actor_image" + android:layout_width="match_parent" + + android:layout_height="match_parent" + android:contentDescription="@string/episode_poster_img_des" + android:foreground="@drawable/outline_big_35_gray" + android:scaleType="centerCrop" + tools:src="@drawable/profile_bg_blue" /> + android:layout_width="70dp" + android:layout_height="70dp" + android:foreground="@drawable/outline_drawable" + app:cardCornerRadius="35dp"> - android:scaleType="centerCrop" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:contentDescription="@string/episode_poster_img_des" /> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:gravity="center_horizontal" + android:orientation="vertical" + android:paddingTop="3dp" + android:paddingBottom="3dp"> + android:id="@+id/actor_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:textColor="?attr/textColor" + android:textStyle="bold" + tools:text="Ackerman, Mikasa" /> + android:id="@+id/voice_actor_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:textColor="?attr/grayTextColor" + tools:text="voiceactor" /> + android:id="@+id/actor_extra" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center_horizontal" + android:textColor="?attr/grayTextColor" + tools:text="Main" /> diff --git a/app/src/main/res/layout/chromecast_subtitle_settings.xml b/app/src/main/res/layout/chromecast_subtitle_settings.xml index 624c2201c07..92d0bd35023 100644 --- a/app/src/main/res/layout/chromecast_subtitle_settings.xml +++ b/app/src/main/res/layout/chromecast_subtitle_settings.xml @@ -1,16 +1,21 @@ - + + + android:layout_height="match_parent"> - + android:layout_height="wrap_content" + android:orientation="vertical"> - - - - - + - - - - - - - - - - - - + + - + - - \ No newline at end of file + + diff --git a/app/src/main/res/layout/confirm_exit_dialog.xml b/app/src/main/res/layout/confirm_exit_dialog.xml new file mode 100644 index 00000000000..c312e64e32a --- /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 00000000000..f5d78e83507 --- /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 00000000000..c6685ee29f2 --- /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 00000000000..132091e5f09 --- /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 00000000000..38ff1325f6e --- /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 824d8089d0a..48dc48a049d 100644 --- a/app/src/main/res/layout/dialog_online_subtitles.xml +++ b/app/src/main/res/layout/dialog_online_subtitles.xml @@ -1,140 +1,144 @@ + 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:background="@null" + android:orientation="vertical"> + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginBottom="60dp" + android:baselineAligned="false" + android:orientation="horizontal"> + android:id="@+id/sort_subtitles_holder" + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="50" + android:orientation="vertical"> + - - + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + tools:ignore="UseCompoundDrawables" + android:padding="10dp"> + + - - - - - + android:imeOptions="actionSearch" + android:inputType="text" + + android:paddingStart="-10dp" + android:paddingEnd="10dp" + app:iconifiedByDefault="false" + + app:queryBackground="@color/transparent" + app:queryHint="@string/search_hint" + app:searchIcon="@drawable/search_icon" + tools:ignore="RtlSymmetry"> + - - - - + android:foregroundTint="@color/white" + android:progressTint="@color/white" + android:visibility="visible" + tools:visibility="visible"> + + + + + android:id="@+id/subtitle_adapter" + android:layout_width="match_parent" + android:layout_height="match_parent" + + android:layout_rowWeight="1" + android:background="?attr/primaryBlackBackground" + android:nextFocusLeft="@id/sort_providers" + android:nextFocusRight="@id/cancel_btt" + android:requiresFadingEdge="vertical" + tools:listfooter="@layout/sort_bottom_footer_add_choice" + tools:listitem="@layout/sort_bottom_single_choice" /> + android:id="@+id/apply_btt_holder" + android:layout_width="match_parent" + android:layout_height="60dp" + android:layout_gravity="bottom" + android:layout_marginTop="-60dp" + android:gravity="bottom|end" + android:orientation="horizontal"> + android:id="@+id/apply_btt" + style="@style/WhiteButton" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" /> + android:id="@+id/cancel_btt" + style="@style/BlackButton" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" /> diff --git a/app/src/main/res/layout/download_button.xml b/app/src/main/res/layout/download_button.xml new file mode 100644 index 00000000000..e802324369a --- /dev/null +++ b/app/src/main/res/layout/download_button.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_button_layout.xml b/app/src/main/res/layout/download_button_layout.xml new file mode 100644 index 00000000000..0ceca181ef4 --- /dev/null +++ b/app/src/main/res/layout/download_button_layout.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_button_view.xml b/app/src/main/res/layout/download_button_view.xml new file mode 100644 index 00000000000..6e40a597398 --- /dev/null +++ b/app/src/main/res/layout/download_button_view.xml @@ -0,0 +1,40 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_child_episode.xml b/app/src/main/res/layout/download_child_episode.xml index f2633dd6c3a..cb9c13d5394 100644 --- a/app/src/main/res/layout/download_child_episode.xml +++ b/app/src/main/res/layout/download_child_episode.xml @@ -1,118 +1,112 @@ - android:nextFocusRight="@id/download_child_episode_download" - android:nextFocusLeft="@id/nav_rail_view" + + + + + + + + + + - android:id="@+id/download_child_episode_holder" + - - + android:layout_height="match_parent" + android:foreground="?android:attr/selectableItemBackgroundBorderless"> - - - + android:layout_height="wrap_content" + android:layout_gravity="center"> - + android:layout_gravity="center" + android:layout_marginStart="5dp"> - - android:scrollHorizontally="true" - android:ellipsize="marquee" - android:marqueeRepeatLimit="marquee_forever" - android:singleLine="true" + + - android:textColor="?attr/textColor" - android:layout_width="match_parent" - android:layout_height="match_parent" /> + - + android:id="@+id/download_child_episode_text" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center_vertical" + android:layout_marginStart="10dp" + android:layout_marginEnd="10dp" + android:ellipsize="marquee" + android:gravity="center_vertical" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" + android:singleLine="true" + android:textColor="?attr/textColor" + tools:text="Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1 Episode 1" /> - - - - - + + + - android:id="@+id/download_child_episode_download" - android:visibility="visible" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:padding="10dp" - android:layout_width="50dp" - android:background="?selectableItemBackgroundBorderless" - android:src="@drawable/ic_baseline_play_arrow_24" - app:tint="?attr/textColor" - android:contentDescription="@string/download" /> - + \ 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 da4b36174ba..7b8b2c91eb5 100644 --- a/app/src/main/res/layout/download_header_episode.xml +++ b/app/src/main/res/layout/download_header_episode.xml @@ -1,103 +1,120 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/episode_holder" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="10dp" + 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"> + + + + + + + + + + + + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:foreground="?android:attr/selectableItemBackgroundBorderless" + android:orientation="horizontal"> + android:layout_width="70dp" + android:layout_height="104dp"> + android:id="@+id/download_header_poster" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:contentDescription="@string/episode_poster_img_des" + android:scaleType="centerCrop" + tools:src="@drawable/example_poster" /> + + + + + + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginStart="15dp" + android:layout_marginEnd="70dp" + android:orientation="vertical"> + android:id="@+id/download_header_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="?attr/textColor" + android:textStyle="bold" + tools:text="Perfect Run" /> + android:id="@+id/download_header_info" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="?attr/grayTextColor" + tools:text="1 episode | 285MB" /> + android:id="@+id/download_header_goto_child" + android:layout_width="@dimen/download_size" + android:layout_height="@dimen/download_size" + android:layout_gravity="center_vertical|end" + android:layout_marginStart="-50dp" + android:contentDescription="@string/download" + android:padding="10dp" + android:src="@drawable/ic_baseline_keyboard_arrow_right_24" /> - + - - - - + \ 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 00000000000..86562a5139c --- /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 388e862b2b9..e128f7cec08 100644 --- a/app/src/main/res/layout/empty_layout.xml +++ b/app/src/main/res/layout/empty_layout.xml @@ -1,18 +1,19 @@ - + - - \ No newline at end of file + + diff --git a/app/src/main/res/layout/extra_brightness_overlay.xml b/app/src/main/res/layout/extra_brightness_overlay.xml new file mode 100644 index 00000000000..8f82121bb05 --- /dev/null +++ b/app/src/main/res/layout/extra_brightness_overlay.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_child_downloads.xml b/app/src/main/res/layout/fragment_child_downloads.xml index a3cc8ce834f..0a7b42327ee 100644 --- a/app/src/main/res/layout/fragment_child_downloads.xml +++ b/app/src/main/res/layout/fragment_child_downloads.xml @@ -1,39 +1,95 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/download_child_root" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?attr/primaryGrayBackground" + android:orientation="vertical" + tools:context=".ui.download.DownloadChildFragment"> + + + android:layout_height="wrap_content" + android:orientation="horizontal" + android:background="?attr/primaryGrayBackground" + android:padding="8dp" + android:visibility="gone"> + + + +