diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml index 931db3bd9..f35900673 100644 --- a/.github/ISSUE_TEMPLATE/application-bug.yml +++ b/.github/ISSUE_TEMPLATE/application-bug.yml @@ -80,13 +80,13 @@ body: label: Acknowledgements description: Your issue will be closed if you haven't done these steps. options: + - label: I am sure my issue is related to the app and **NOT some extension**. + required: true - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. required: true - label: I have written a short but informative title. required: true - label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**. required: true - - label: If related to a provider, I have checked the site and it works, but not the app. - required: true - label: I will fill out all of the requested information in this form. required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 250734cdd..b56cdf8ed 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,7 +2,7 @@ blank_issues_enabled: false contact_links: - name: Request a new provider or report bug with an existing provider url: https://github.com/recloudstream - about: Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord. + about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord. - name: Discord url: https://discord.gg/5Hus6fM about: Join our discord for faster support on smaller issues. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 9c35ba56f..e18daebb3 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -27,9 +27,7 @@ body: label: Acknowledgements description: Your issue will be closed if you haven't done these steps. options: + - label: My suggestion is **NOT** about adding a new provider + required: true - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - required: true - - label: I have written a short but informative title. - required: true - - label: I will fill out all of the requested information in this form. - required: true + required: true \ No newline at end of file diff --git a/.github/downloads.jpg b/.github/downloads.jpg deleted file mode 100644 index ca14a664a..000000000 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 72370d3c9..000000000 Binary files a/.github/home.jpg and /dev/null differ diff --git a/.github/locales.py b/.github/locales.py index 1c79c093b..6127d9d80 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -1,12 +1,13 @@ 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-" +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 @@ -19,30 +20,46 @@ rest, after_src = rest.split(END_MARKER) # Load already added langs languages = {} -for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest): - flag, name, iso = lang.groups() - languages[iso] = (flag, name) +for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): + name, iso = lang.groups() + languages[iso] = name # Add not yet added langs for folder in glob.glob(f"{XML_NAME}*"): - iso = folder[len(XML_NAME):] + iso = folder[len(XML_NAME):].replace("+", "-") if iso not in languages.keys(): - entry = iso_map.get(iso.lower(),{'nativeName':iso}) - languages[iso] = ("", entry['nativeName'].split(',')[0]) + entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found + languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple -# Create triples -triples = [] -for iso in sorted(languages.keys()): - flag, name = languages[iso] - triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),') +# Create pairs +pairs = [] +for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name + name = languages[iso] + pairs.append(f'{INDENT}Pair("{name}", "{iso}"),') # Update settings file open(SETTINGS_PATH, "w+",encoding='utf-8').write( before_src + START_MARKER + "\n" + - "\n".join(triples) + + "\n".join(pairs) + "\n" + END_MARKER + after_src -) \ No newline at end of file +) + +# 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 f6959cf31..000000000 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 4dbc9b8d4..000000000 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 784bec892..000000000 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 834307665..30bedcc1b 100644 --- a/.github/workflows/build_to_archive.yml +++ b/.github/workflows/build_to_archive.yml @@ -1,76 +1,93 @@ -name: Archive build - -on: - push: - branches: [ master ] - paths-ignore: - - '*.md' - - '*.json' - - '**/wcokey.txt' - workflow_dispatch: - -concurrency: - group: "Archive-build" - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Generate access token - id: generate_token - uses: tibdex/github-app-token@v1 - with: - app_id: ${{ secrets.GH_APP_ID }} - private_key: ${{ secrets.GH_APP_KEY }} - repository: "recloudstream/secrets" - - name: Generate access token (archive) - id: generate_archive_token - uses: tibdex/github-app-token@v1 - with: - app_id: ${{ secrets.GH_APP_ID }} - private_key: ${{ secrets.GH_APP_KEY }} - repository: "recloudstream/cloudstream-archive" - - uses: actions/checkout@v2 - - name: Set up JDK 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 }} + TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} + MDL_API_KEY: ${{ secrets.MDL_API_KEY }} + + - uses: actions/checkout@v6 + with: + repository: "recloudstream/cloudstream-archive" + token: ${{ steps.generate_archive_token.outputs.token }} + path: "archive" + + - name: Move build + run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk" + + - name: Push archive + run: | + cd $GITHUB_WORKSPACE/archive + git config --local user.email "actions@github.com" + git config --local user.name "GitHub Actions" + git add . + git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit + git push --force diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml index 3c5caad78..d67b8a519 100644 --- a/.github/workflows/generate_dokka.yml +++ b/.github/workflows/generate_dokka.yml @@ -1,64 +1,67 @@ name: Dokka -# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency -concurrency: - group: "dokka" - cancel-in-progress: true - on: push: - branches: - # choose your default branch - - master - - main + branches: [ master ] paths-ignore: - '*.md' +permissions: + contents: read + +concurrency: + group: "dokka" + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/dokka" + - name: Checkout - uses: actions/checkout@master + uses: actions/checkout@v6 with: path: "src" - name: Checkout dokka - uses: actions/checkout@master + uses: actions/checkout@v6 with: repository: "recloudstream/dokka" path: "dokka" token: ${{ steps.generate_token.outputs.token }} - + - name: Clean old builds run: | cd $GITHUB_WORKSPACE/dokka/ - rm -rf "./-cloudstream" + rm -rf "./app" + rm -rf "./library" - - name: Setup JDK 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 deleted file mode 100644 index 108cec82e..000000000 --- a/.github/workflows/issue_action.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Issue automatic actions - -on: - issues: - types: [opened] - -jobs: - issue-moderator: - runs-on: ubuntu-latest - steps: - - name: Generate access token - id: generate_token - uses: tibdex/github-app-token@v1 - with: - app_id: ${{ secrets.GH_APP_ID }} - private_key: ${{ secrets.GH_APP_KEY }} - - name: Similarity analysis - id: similarity - uses: actions-cool/issues-similarity-analysis@v1 - with: - token: ${{ steps.generate_token.outputs.token }} - filter-threshold: 0.60 - title-excludes: '' - comment-title: | - ### Your issue looks similar to these issues: - Please close if duplicate. - comment-body: '${index}. ${similarity} #${number}' - - name: Label if possible duplicate - if: steps.similarity.outputs.similar-issues-found =='true' - uses: actions/github-script@v6 - with: - github-token: ${{ steps.generate_token.outputs.token }} - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ["possible duplicate"] - }) - - uses: actions/checkout@v2 - - name: Automatically close issues that dont follow the issue template - uses: lucasbento/auto-close-issues@v1.0.2 - with: - github-token: ${{ steps.generate_token.outputs.token }} - issue-close-message: | - @${issue.user.login}: hello! :wave: - This issue is being automatically closed because it does not follow the issue template." - closed-issues-label: "invalid" - - name: Check if issue mentions a provider - id: provider_check - env: - GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}" - run: | - wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py" - pip3 install httpx - RES="$(python3 ./check_issue.py)" - echo "name=${RES}" >> $GITHUB_OUTPUT - - name: Comment if issue mentions a provider - if: steps.provider_check.outputs.name != 'none' - uses: actions-cool/issues-helper@v3 - with: - actions: 'create-comment' - token: ${{ steps.generate_token.outputs.token }} - body: | - Hello ${{ github.event.issue.user.login }}. - Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM). - - Found provider name: `${{ steps.provider_check.outputs.name }}` - - name: Label if mentions provider - if: steps.provider_check.outputs.name != 'none' - uses: actions/github-script@v6 - with: - github-token: ${{ steps.generate_token.outputs.token }} - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ["possible provider issue"] - }) - - name: Add eyes reaction to all issues - uses: actions-cool/emoji-helper@v1.0.0 - with: - type: 'issue' - token: ${{ steps.generate_token.outputs.token }} - emoji: 'eyes' - - diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 4ce7dba12..b5b17ba6a 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -8,29 +8,36 @@ on: - '*.json' - '**/wcokey.txt' -concurrency: +concurrency: group: "pre-release" cancel-in-progress: true +permissions: + contents: write + jobs: build: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - - uses: actions/checkout@v2 - - 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,25 @@ jobs: KEY_PWD="$(cat keystore_password.txt)" echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Run Gradle - run: | - ./gradlew assemblePrerelease makeJar androidSourcesJar + run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} + MDL_API_KEY: ${{ secrets.MDL_API_KEY }} + - name: Create pre-release - uses: "marvinpinto/action-automatic-releases@latest" + uses: marvinpinto/action-automatic-releases@latest with: repo_token: "${{ secrets.GITHUB_TOKEN }}" automatic_release_tag: "pre-release" diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 36199cd60..8f5c62866 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -2,22 +2,35 @@ name: Artifact Build on: [pull_request] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - 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 check + - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v7 with: name: pull-request-build path: "app/build/outputs/apk/prerelease/debug/*.apk" diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml index 93cdca449..0a538d5d4 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -1,39 +1,46 @@ -name: Update locale lists +name: Fix locale issues on: - workflow_dispatch: push: + branches: [ master ] paths: - '**.xml' - branches: - - master + workflow_dispatch: -concurrency: - group: "locale-list" +concurrency: + group: "locale" cancel-in-progress: true +permissions: + contents: read + jobs: create: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token - uses: tibdex/github-app-token@v1 + uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream" - - uses: actions/checkout@v2 + + - uses: actions/checkout@v6 with: token: ${{ steps.generate_token.outputs.token }} + + - name: Install dependencies + run: pip3 install lxml requests + - name: Edit files - run: | - python3 .github/locales.py + run: python3 .github/locales.py + - name: Commit to the repo run: | git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" git config --local user.name "recloudstream[bot]" git add . # "echo" returns true so the build succeeds, even if no changed files - git commit -m 'update list of locales' || echo + git commit -m 'chore(locales): fix locale issues' || echo git push diff --git a/.gitignore b/.gitignore index 2ac6c9695..5fc9f0870 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -*.iml -.gradle /local.properties /.idea/caches /.idea/misc.xml @@ -11,6 +9,220 @@ .DS_Store /build /captures -.externalNativeBuild .cxx +.kotlin/* + +# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode +# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode + +### Android ### +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof + +### Android Patch ### +gen-external-apklibs + +# Replacement of .externalNativeBuild directories introduced +# with Android Studio 3.5. + +### Java ### +# Compiled class file +*.class + +# Log file + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### Kotlin ### +# Compiled class file + +# Log file + +# BlueJ files + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml + +### VisualStudioCode ### +.vscode/* + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### AndroidStudio ### +# Covers files to be ignored for android development using Android Studio. + +# Built application files +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle + +# Signing files +.signing/ + +# Local configuration file (sdk path, etc) + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files + +# Android Studio +/*/build/ +/*/local.properties +/*/out +/*/*/build +/*/*/production +.navigation/ +*.ipr +*~ +*.swp + +# Keystore files + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Android Patch + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# NDK +obj/ + +# IntelliJ IDEA +*.iws +/out/ + +# User-specific configurations +.idea/caches/ +.idea/libraries/ +.idea/shelf/ +.idea/workspace.xml +.idea/tasks.xml +.idea/.name +.idea/compiler.xml +.idea/copyright/profiles_settings.xml +.idea/encodings.xml +.idea/misc.xml +.idea/modules.xml +.idea/scopes/scope_settings.xml +.idea/dictionaries +.idea/vcs.xml +.idea/jsLibraryMappings.xml +.idea/datasources.xml +.idea/dataSources.ids +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml +.idea/assetWizardSettings.xml +.idea/gradle.xml +.idea/jarRepositories.xml +.idea/navEditor.xml + +# Legacy Eclipse project files +.classpath +.project +.cproject +.settings/ + +# Mobile Tools for Java (J2ME) + +# Package Files # + +# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) + +## Plugin-specific files: + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Mongo Explorer plugin +.idea/mongoSettings.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### AndroidStudio Patch ### + +!/gradle/wrapper/gradle-wrapper.jar + +# End of https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 1eb497a93..000000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -CloudStream \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 7643783a8..000000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123c2..000000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 5421743a9..000000000 --- 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 d8e956166..000000000 --- a/.idea/discord.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 10c26704e..000000000 --- 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 333d49373..000000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfb..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 7282979ad..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "githubPullRequests.ignoredPullRequestBranches": [ - "master" - ], - "java.configuration.updateBuildConfiguration": "interactive" -} \ No newline at end of file diff --git a/AI-POLICY.md b/AI-POLICY.md new file mode 100644 index 000000000..5409393fb --- /dev/null +++ b/AI-POLICY.md @@ -0,0 +1,11 @@ +# AI Policy + +AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions. + +1. Always state any AI usage in pull requests and issues. + +2. Always test code before making a pull request. We do not want to test your AI generated code. + +3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI. + +4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions. diff --git a/README.md b/README.md index 3430d626f..c2492c5d8 100644 --- a/README.md +++ b/README.md @@ -1,23 +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 -### Screenshots: - - + + +## Installation: + +Our documentation provides the steps to install and configure CloudStream for your streaming needs. + +[Getting Started With CloudStream:](https://recloudstream.github.io/csdocs/) + + + +## Contributing: +We **happily** accept any contributions to our project. To find out where you can start contributing towards the project, please look [at our issues tab](/cloudstream/issues) + + + + + +### Issues: +While we **actively** accept issues and pull requests, we do require you fill out an [template](https://github.com/recloudstream/cloudstream/issues/new/choose) for issues. These include the following: + + + +- [Bug Report Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=bug&projects=&template=application-bug.yml) + - For bug reports, we want as much info as possible, including your downloaded version of CloudeStream, device and updated version (if possible, current API), + expected behavior of the program, and the actual behavior that the program did, most importantly we require clear, reproducible steps of the bug. If your bug can't be reproduced, it is unlikely we'll work on your issue. + + + +- [Feature Request Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yml) + - Before adding a feature request, please check to see if a feature request already has been requested. + + +### Extensions: + +**Further details on creating extensions for CloudStream are found in our documentation.** + +[Guide: For Extension Developers](https://recloudstream.github.io/csdocs/devs/gettingstarted/) + + + +## Further Sources: + +As well as providing clear install steps, our [website](https://dweb.link/ipns/cloudstream.on.fleek.co/) includes a wide variety of other tools, such as: +- [Troubleshooting](https://recloudstream.github.io/csdocs/troubleshooting/) +- [Further CloudStream Repositories](https://recloudstream.github.io/csdocs/repositories/) +- Set-Up for other devices, such as: + - [Android TV](https://recloudstream.github.io/csdocs/other-devices/tv/) + - [Windows](https://recloudstream.github.io/csdocs/other-devices/windows/) + - [Linux](https://recloudstream.github.io/csdocs/other-devices/linux/) +- And more... + + ### Supported languages: + +Even if you can't contribute to the code or documentation, we always look for those who can contribute to translation and language support. Your contribution is exceptionally appreciated; you can check our translation from the figure below. + Translation status - \ No newline at end of file + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c855d281..6c784f3ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,37 +1,96 @@ -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) + alias(libs.plugins.kotlin.serialization) } -val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" -val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() +val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) -fun String.execute() = ByteArrayOutputStream().use { baot -> - if (project.exec { - workingDir = projectDir - commandLine = this@execute.split(Regex("\\s")) - standardOutput = baot - }.exitValue == 0) - String(baot.toByteArray()).trim() - else null +abstract class GenerateGitHashTask : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val headFile: RegularFileProperty + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val headsDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val head = headFile.get().asFile + + val hash = try { + if (head.exists()) { + // Read the commit hash from .git/HEAD + val headContent = head.readText().trim() + if (headContent.startsWith("ref:")) { + val refPath = headContent.substring(5) // e.g., refs/heads/main + val commitFile = File(head.parentFile, refPath) + if (commitFile.exists()) commitFile.readText().trim() else "" + } else headContent // If it's a detached HEAD (commit hash directly) + } else "" // If .git/HEAD doesn't exist + } catch (_: Throwable) { + "" // Just set to an empty string if any exception occurs + }.take(7) // Get the short commit hash + + val outFile = outputDir.file("git-hash.txt").get().asFile + outFile.parentFile.mkdirs() + outFile.writeText(hash) + } +} + +val generateGitHash = tasks.register("generateGitHash") { + val gitDir = layout.projectDirectory.dir("../.git") + + headFile.set(gitDir.file("HEAD")) + headsDir.set(gitDir.dir("refs/heads")) + + outputDir.set(layout.buildDirectory.dir("generated/git")) } android { + @Suppress("UnstableApiUsage") testOptions { unitTests.isReturnDefaultValues = true } + + // 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 +98,36 @@ android { } } - compileSdk = 33 - buildToolsVersion = "30.0.3" + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.lagradost.cloudstream3" - minSdk = 21 - targetSdk = 33 + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = libs.versions.versionCode.get().toInt() + versionName = libs.versions.versionName.get() - versionCode = 57 - versionName = "4.0.0" + manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() - resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") - - resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") - - resValue("bool", "is_prerelease", "false") + // Reads local.properties + val localProperties = gradleLocalProperties(rootDir, project.providers) buildConfigField( - "String", - "BUILDDATE", - "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));" + "long", + "BUILD_DATE", + "${System.currentTimeMillis()}" + ) + buildConfigField( + "String", + "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,182 +135,209 @@ 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.toVersion(javaTarget.target) + targetCompatibility = JavaVersion.toVersion(javaTarget.target) + } - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - freeCompilerArgs = listOf("-Xjvm-default=compatibility") + java { + // Use Java 17 toolchain even if a higher JDK runs the build. + // We still use Java 8 for now which higher JDKs have deprecated. + toolchain { + languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get())) + } } + lint { - abortOnError = false checkReleaseBuilds = false } + + buildFeatures { + buildConfig = true + viewBinding = true + } + + packaging { + jniLibs { + // Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23). + // Note: This may increase app startup time slightly. + useLegacyPackaging = true + } + } + namespace = "com.lagradost.cloudstream3" } -repositories { - maven("https://jitpack.io") -} - dependencies { - implementation("com.google.android.mediahome:video:1.0.0") - implementation("androidx.test.ext:junit-ktx:1.1.3") - testImplementation("org.json:json:20180813") + // Testing + testImplementation(libs.junit) + testImplementation(libs.json) + androidTestImplementation(libs.core) + androidTestImplementation(libs.classgraph) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.instancio.core) + androidTestImplementation(libs.junit.ktx) + androidTestImplementation(libs.kotlin.test) - implementation("androidx.core:core-ktx:1.8.0") - implementation("androidx.appcompat:appcompat:1.4.2") // need target 32 for 1.5.0 + // Android Core & Lifecycle + implementation(libs.core.ktx) + implementation(libs.activity.ktx) + implementation(libs.annotation) + implementation(libs.appcompat) + implementation(libs.fragment.ktx) + implementation(libs.bundles.lifecycle) + implementation(libs.bundles.navigation) + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.serialization.json) // JSON Parser - // dont change this to 1.6.0 it looks ugly af - implementation("com.google.android.material:material:1.5.0") - implementation("androidx.constraintlayout:constraintlayout:2.1.4") - implementation("androidx.navigation:navigation-fragment-ktx:2.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") + // Design & UI + implementation(libs.preference.ktx) + implementation(libs.material) + implementation(libs.constraintlayout) - //implementation("io.karn:khttp-android:0.1.2") //okhttp instead -// implementation("org.jsoup:jsoup:1.13.1") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") + // Coil Image Loading + implementation(libs.bundles.coil) - implementation("androidx.preference:preference-ktx:1.2.0") + // Media 3 (ExoPlayer) + implementation(libs.bundles.media3) + implementation(libs.video) - implementation("com.github.bumptech.glide:glide:4.13.1") - kapt("com.github.bumptech.glide:compiler:4.13.1") - implementation("com.github.bumptech.glide:okhttp3-integration:4.13.0") + // FFmpeg Decoding + implementation(libs.bundles.nextlib) - implementation("jp.wasabeef:glide-transformations:4.3.0") + // Anime-db for filler + implementation(libs.anime.db) - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + // PlayBack + implementation(libs.colorpicker) // Subtitle Color Picker + implementation(libs.newpipeextractor) // For Trailers + implementation(libs.juniversalchardet) // Subtitle Decoding - // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09") + // UI Stuff + implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton) + implementation(libs.palette.ktx) // Palette for Images -> Colors + implementation(libs.tvprovider) + implementation(libs.overlappingpanels) // Gestures + implementation(libs.biometric) // Fingerprint Authentication + implementation(libs.previewseekbar.media3) // SeekBar Preview + implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV - // 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") + // Extensions & Other Libs + implementation(libs.jsoup) // HTML Parser + implementation(libs.rhino) // Run JavaScript + implementation(libs.safefile) // To Prevent the URI File Fu*kery + coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor + implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 + implementation(libs.jackson.module.kotlin) // JSON Parser + implementation(libs.zipline) - //implementation("com.google.android.exoplayer:extension-leanback:2.14.0") - - // Bug reports - implementation("ch.acra:acra-core:5.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.2") - // 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") - - implementation("androidx.tvprovider:tvprovider:1.0.0") - - // used for subtitle decoding https://github.com/albfernandez/juniversalchardet - implementation("com.github.albfernandez:juniversalchardet:2.4.0") - - // slow af yt - //implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT") - - // 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") - - // Library/extensions searching with Levenshtein distance + // Deprecated; will be removed once extensions have time to migrate from using it implementation("me.xdrop:fuzzywuzzy:1.4.0") - // color pallette for images -> colors - implementation("androidx.palette:palette-ktx:1.0.0") + // Torrent Support + implementation(libs.torrentserver) + + // Downloading & Networking + implementation(libs.work.runtime.ktx) + implementation(libs.nicehttp) // HTTP Lib + + implementation(project(":library")) } -tasks.register("androidSourcesJar", Jar::class) { +tasks.register("androidSourcesJar") { archiveClassifier.set("sources") - from(android.sourceSets.getByName("main").java.srcDirs) //full sources + from(android.sourceSets.getByName("main").java.directories) // Full Sources } -// this is used by the gradlew plugin -tasks.register("makeJar", Copy::class) { - from("build/intermediates/compile_app_classes_jar/prereleaseDebug") - into("build") - include("classes.jar") - dependsOn("build") +tasks.register("copyJar") { + dependsOn("build", ":library:jvmJar") + from( + "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", + "../library/build/libs" + ) + into("build/app-classes") + include("classes.jar", "library-jvm*.jar") + // Remove the version + rename("library-jvm.*.jar", "library-jvm.jar") } -tasks.withType().configureEach { - moduleName.set("Cloudstream") +// Merge the app classes and the library classes into classes.jar +tasks.register("makeJar") { + // Duplicates cause hard to catch errors, better to fail at compile time. + duplicatesStrategy = DuplicatesStrategy.FAIL + dependsOn(tasks.getByName("copyJar")) + from( + zipTree("build/app-classes/classes.jar"), + zipTree("build/app-classes/library-jvm.jar") + ) + destinationDirectory.set(layout.buildDirectory) + archiveBaseName = "classes" +} + +tasks.withType { + compilerOptions { + jvmTarget.set(javaTarget) + jvmDefault.set(JvmDefaultMode.ENABLE) + freeCompilerArgs.add("-Xannotation-default-target=param-property") + optIn.addAll( + "com.lagradost.cloudstream3.InternalAPI", + "com.lagradost.cloudstream3.Prerelease", + "kotlin.uuid.ExperimentalUuidApi", + ) + } +} + +dokka { + moduleName = "App" dokkaSourceSets { - named("main") { - sourceLink { - // Unix based directory relative path to the root of the project (where you execute gradle respectively). - localDirectory.set(file("src/main/java")) + configureEach { + suppress = name != "prereleaseDebug" + analysisPlatform = KotlinPlatform.JVM + displayName = "JVM" + documentedVisibilities( + VisibilityModifier.Public, + VisibilityModifier.Protected + ) - // URL showing where the source code can be accessed through the web browser - remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java")) - // Suffix which is used to append the line number to the URL. Use #L for GitHub - remoteLineSuffix.set("#L") + sourceLink { + localDirectory = file("..") + remoteUrl("https://github.com/recloudstream/cloudstream/tree/master") + remoteLineSuffix = "#L" } } } diff --git a/app/lint.xml b/app/lint.xml new file mode 100644 index 000000000..b2f5e8f2b --- /dev/null +++ b/app/lint.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index 81753f6b9..4c5cdea5b 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -1,155 +1,58 @@ 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 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 - } - - 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 - } - } - } - - if (correctResponses == 0 || searchResult == null) { - System.err.println("Api ${api.name} did not return any valid search responses") - return false - } - - 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") - } - - return validResults - } catch (e: Exception) { - if (e.cause is NotImplementedError) { - Assert.fail("Provider has not implemented .load") - } - logError(e) - return false - } + private fun getAllProviders(): Array { + println("Providers: ${APIHolder.allProviders.size}") + return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView } } @Test @@ -158,16 +61,89 @@ class ExampleInstrumentedTest { println("Done providersExist") } + @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) + } + } + @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) + + // 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) + + // 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) + } + } + } + + @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,68 +156,20 @@ class ExampleInstrumentedTest { @Test fun providerCorrectHomepage() { runBlocking { - getAllProviders().amap { api -> - if (api.hasMainPage) { - try { - val f = api.mainPage.first() - val homepage = - api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages)) - 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/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt new file mode 100644 index 000000000..d1a11e003 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt @@ -0,0 +1,135 @@ +package com.lagradost.cloudstream3 + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import dalvik.system.DexFile +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.serializer +import kotlinx.serialization.serializerOrNull +import org.instancio.Instancio +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.reflect.KClass +import kotlin.reflect.jvm.jvmName +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +class SerializationClassTester { + // Same as app, or using app reference + val jacksonMapper = mapper + val kotlinxMapper = json + + @Test + fun isIdenticalSerialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + + val jacksonJson = jacksonMapper.writeValueAsString(instance) + val kotlinxJson = serializeWithKotlinx(kClass, instance) + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical serialization for: ${kClass.jvmName}") + } + } + + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + @Test + fun isIdenticalDeserialization() { + val serializableClasses = findSerializableClasses("com.lagradost") + println("Number of serializable classes: ${serializableClasses.size}") + + serializableClasses.forEach { kClass -> + val instance = Instancio.create(kClass.java) + // Convert to JSON to get example JSON object + // We prefer jackson here because the app may have many jackson JSON strings in local storage + val originalJson = jacksonMapper.writeValueAsString(instance) + + // Create an object from the JSON using kotlinx + val serializer = + kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass) + assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!") + val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson) + + // Create an object from the JSON using jackson + val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java) + + + // Deep inspect both object using the mapper toJson function. + // This deep equality check can be performed using other methods, but this just works. + val jacksonJson = mapperDecoded.toJson() + val kotlinxJson = kotlinxDecoded.toJson() + + assertEquals( + jacksonJson, + kotlinxJson, + """ + Serialization mismatch for: + ${kClass.qualifiedName} + + Jackson: + $jacksonJson + + Kotlinx: + $kotlinxJson + + """.trimIndent() + ) + println("Identical deserialization for: ${kClass.jvmName}") + } + } + + // DEX files are the best solution to read all our classes dynamically. + // ClassGraph() can be used instead, but it only gives results on the JVM, not Android. + @Suppress("DEPRECATION") + private fun findSerializableClasses(packageName: String): List> { + val context = InstrumentationRegistry + .getInstrumentation() + .targetContext + + val dexFile = DexFile(context.packageCodePath) + + return dexFile.entries() + .toList() + .filter { it.startsWith(packageName) } + .mapNotNull { + runCatching { Class.forName(it).kotlin }.getOrNull() + }.filter { kClass -> + // Not possible to use .hasAnnotation() on newer Android versions. + kClass.java.annotations.any { + it is Serializable + } + } + } + + @OptIn(InternalSerializationApi::class) + @Suppress("UNCHECKED_CAST") + private fun serializeWithKotlinx( + kClass: KClass<*>, + value: Any + ): String { + val serializer = kClass.serializer() as KSerializer + return kotlinxMapper.encodeToString(serializer, value) + } +} diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt new file mode 100644 index 000000000..15ad532f8 --- /dev/null +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt @@ -0,0 +1,157 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KeepGeneratedSerializer +import kotlinx.serialization.Serializable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = NonEmptyData.Serializer::class) +data class NonEmptyData( + val title: String = "", + val tags: List = emptyList(), + val meta: Map = emptyMap(), + val name: String = "hello", +) { + object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer()) +} + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = WriteOnlyData.Serializer::class) +data class WriteOnlyData( + val fieldA: String = "", + val fieldB: String = "", +) { + object Serializer : WriteOnlySerializer( + WriteOnlyData.generatedSerializer(), + setOf("fieldB"), + ) +} + +@OptIn(ExperimentalSerializationApi::class) +@KeepGeneratedSerializer +@Serializable(with = MultiWriteOnly.Serializer::class) +data class MultiWriteOnly( + val fieldA: String = "", + val fieldB: String = "", + val fieldC: String = "", +) { + object Serializer : WriteOnlySerializer( + MultiWriteOnly.generatedSerializer(), + setOf("fieldB", "fieldC"), + ) +} + +@Serializable +data class UriData( + @Serializable(with = UriSerializer::class) + val uri: Uri = Uri.EMPTY, +) + +class SerializerTest { + + @Test + fun nonEmptySerializerOmitsEmptyStrings() { + val data = NonEmptyData(title = "", name = "hello") + val result = data.toJson() + assertFalse(result.contains("title")) + assertTrue(result.contains("name")) + } + + @Test + fun nonEmptySerializerOmitsEmptyLists() { + val data = NonEmptyData(tags = emptyList(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("tags")) + } + + @Test + fun nonEmptySerializerOmitsEmptyMaps() { + val data = NonEmptyData(meta = emptyMap(), name = "hello") + val result = data.toJson() + assertFalse(result.contains("meta")) + } + + @Test + fun nonEmptySerializerKeepsNonEmptyFields() { + val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v")) + val result = data.toJson() + assertTrue(result.contains("title")) + assertTrue(result.contains("tags")) + assertTrue(result.contains("meta")) + } + + @Test + fun nonEmptySerializerDoesNotAffectDeserialization() { + val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}""" + val result = parseJson(input) + assertEquals("hello", result.title) + assertEquals(listOf("a"), result.tags) + assertEquals(mapOf("k" to "v"), result.meta) + assertEquals("world", result.name) + } + + @Test + fun writeOnlySerializerOmitsFieldOnSerialize() { + val data = WriteOnlyData(fieldA = "hello", fieldB = "secret") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + } + + @Test + fun writeOnlySerializerDeserializesNormally() { + val input = """{"fieldA":"hello","fieldB":"secret"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("secret", result.fieldB) + } + + @Test + fun writeOnlySerializerDeserializesMissingAsDefault() { + val input = """{"fieldA":"hello"}""" + val result = parseJson(input) + assertEquals("hello", result.fieldA) + assertEquals("", result.fieldB) + } + + @Test + fun writeOnlySerializerHandlesMultipleKeys() { + val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2") + val result = data.toJson() + assertTrue(result.contains("fieldA")) + assertFalse(result.contains("fieldB")) + assertFalse(result.contains("fieldC")) + } + + @Test + fun uriSerializerSerializesUriToString() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val result = data.toJson() + assertTrue(result.contains("https://example.com/path?query=1")) + } + + @Test + fun uriSerializerDeserializesStringToUri() { + val input = """{"uri":"https://example.com/path?query=1"}""" + val result = parseJson(input) + assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri) + } + + @Test + fun uriSerializerRoundtripsCorrectly() { + val data = UriData(uri = Uri.parse("https://example.com/path?query=1")) + val encoded = data.toJson() + val decoded = parseJson(encoded) + assertEquals(data.uri, decoded.uri) + } +} diff --git a/app/src/debug/res/drawable-v24/ic_banner_background.xml b/app/src/debug/res/drawable-v24/ic_banner_background.xml index 7b05b7111..caed023d5 100644 --- a/app/src/debug/res/drawable-v24/ic_banner_background.xml +++ b/app/src/debug/res/drawable-v24/ic_banner_background.xml @@ -25,9 +25,8 @@ android:endY="245.72" android:endX="292.58" android:type="linear"> - - - + + @@ -40,9 +39,8 @@ android:endY="245.72" android:endX="248.76" android:type="linear"> - - - + + @@ -55,46 +53,45 @@ android:endY="245.69" android:endX="210.03" android:type="linear"> - - - + + + android:fillColor="#39A11D"/> + android:fillColor="#39A11D"/> + android:fillColor="#39A11D"/> + android:fillColor="#39A11D"/> + android:fillColor="#39A11D"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> + android:fillColor="#68C671"/> @@ -104,9 +101,9 @@ android:endY="252.3" android:endX="373.57" android:type="linear"> - - - + + + @@ -117,9 +114,9 @@ android:startX="400.11" android:endX="900" android:type="linear"> - - - + + + @@ -132,9 +129,9 @@ android:endY="252.3" android:endX="373.57" android:type="linear"> - - - + + + @@ -145,9 +142,9 @@ android:startX="700.11" android:endX="900.57" android:type="linear"> - - - + + + @@ -158,9 +155,9 @@ android:startX="400.11" android:endX="800.57" android:type="linear"> - - - + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 871c4f698..ee4c978f2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,16 +6,63 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + tools:targetApi="${target_sdk_version}"> + android:supportsPictureInPicture="true" + android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer" + android:launchMode="singleTask" + tools:ignore="DiscouragedApi"> @@ -79,25 +125,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 0351b1ff7..bbe7d97de 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 +/** + * Deprecated alias for CloudStreamApp for backwards compatibility with plugins. + * Use CloudStreamApp instead. + */ +@Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), + level = DeprecationLevel.WARNING +) +class AcraApplication { + companion object { + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), + level = DeprecationLevel.WARNING + ) + val context get() = CloudStreamApp.context -class CustomReportSender : ReportSender { - // Sends all your crashes to google forms - override fun send(context: Context, errorContent: CrashReportData) { - println("Sending report") - val url = - "https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse" - val data = mapOf( - "entry.753293084" to errorContent.toJSON() - ) + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), + level = DeprecationLevel.WARNING + ) + fun removeKeys(folder: String): Int? = + CloudStreamApp.removeKeys(folder) - thread { // to not run it on main thread - runBlocking { - suspendSafeApiCall { - val post = app.post(url, data = data) - println("Report response: $post") - } - } - } + @Deprecated( + message = "AcraApplication is deprecated, use CloudStreamApp instead", + replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), + level = DeprecationLevel.WARNING + ) + fun setKey(path: String, value: T) = + CloudStreamApp.setKey(path, value) - runOnMainThread { // to run it on main looper - normalSafeApiCall { - Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show() - } - } - } + @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 000000000..a9cd9c01e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -0,0 +1,181 @@ +package com.lagradost.cloudstream3 + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import com.lagradost.api.setContext +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser +import com.lagradost.cloudstream3.utils.AppDebug +import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.getKeys +import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.DataStore.removeKeys +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader +import kotlinx.coroutines.runBlocking +import java.io.File +import java.io.FileNotFoundException +import java.io.PrintStream +import java.lang.ref.WeakReference +import java.util.Locale +import kotlin.concurrent.thread +import kotlin.system.exitProcess + +class ExceptionHandler( + val errorFile: File, + val onError: (() -> Unit) +) : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(thread: Thread, error: Throwable) { + try { + val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + thread.threadId() + } else { + @Suppress("DEPRECATION") + thread.id + } + + PrintStream(errorFile).use { ps -> + ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") + ps.println("Fatal exception on thread ${thread.name} ($threadId)") + error.printStackTrace(ps) + } + } catch (_: FileNotFoundException) { + } + try { + onError() + } catch (_: Exception) { + } + exitProcess(1) + } +} + +class CloudStreamApp : Application(), SingletonImageLoader.Factory { + + override fun onCreate() { + super.onCreate() + // If we want to initialize Coil as early as possible, maybe when + // loading an image or GIF in a splash screen activity. + // buildImageLoader(applicationContext) + + ExceptionHandler(filesDir.resolve("last_error")) { + val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) + startActivity(Intent.makeRestartActivityTask(intent!!.component)) + }.also { + exceptionHandler = it + Thread.setDefaultUncaughtExceptionHandler(it) + } + + AppDebug.isDebug = BuildConfig.DEBUG + } + + override fun attachBaseContext(base: Context?) { + super.attachBaseContext(base) + context = base + } + + override fun newImageLoader(context: PlatformContext): ImageLoader { + // Coil module will be initialized globally when first loadImage() is invoked. + return buildImageLoader(applicationContext) + } + + companion object { + var exceptionHandler: ExceptionHandler? = null + + /** Use to get Activity from Context. */ + tailrec fun Context.getActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + } + + private var _context: WeakReference? = null + var context + get() = _context?.get() + private set(value) { + _context = WeakReference(value) + setContext(WeakReference(value)) + } + + fun getKeyClass(path: String, valueType: Class): T? { + return context?.getKey(path, valueType) + } + + fun setKeyClass(path: String, value: T) { + context?.setKey(path, value) + } + + fun removeKeys(folder: String): Int? { + return context?.removeKeys(folder) + } + + fun setKey(path: String, value: T) { + context?.setKey(path, value) + } + + fun setKey(folder: String, path: String, value: T) { + context?.setKey(folder, path, value) + } + + inline fun getKey(path: String, defVal: T?): T? { + return context?.getKey(path, defVal) + } + + inline fun getKey(path: String): T? { + return context?.getKey(path) + } + + inline fun getKey(folder: String, path: String): T? { + return context?.getKey(folder, path) + } + + inline fun getKey(folder: String, path: String, defVal: T?): T? { + return context?.getKey(folder, path, defVal) + } + + fun getKeys(folder: String): List? { + return context?.getKeys(folder) + } + + fun removeKey(folder: String, path: String) { + context?.removeKey(folder, path) + } + + fun removeKey(path: String) { + context?.removeKey(path) + } + + /** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */ + fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) { + context?.openBrowser(url, fallbackWebView, fragment) + } + + /** Will fall back to WebView if in TV or emulator layout. */ + fun openBrowser(url: String, activity: FragmentActivity?) { + openBrowser( + url, + isLayout(TV or EMULATOR), + activity?.supportFragmentManager?.fragments?.lastOrNull() + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index 89f0ae51a..4ce09bd44 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 - var canEnterPipMode: Boolean = false - var canShowPipMode: Boolean = false + // screenWidth and screenHeight does always + // refer to the screen while in landscape mode + val screenWidth: Int + get() { + return max(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + val screenHeight: Int + get() { + return min(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + val screenWidthWithOrientation: Int + get() { + return displayMetrics.widthPixels + } + val screenHeightWithOrientation: Int + get() { + return displayMetrics.heightPixels + } + + var isPipDesired: Boolean = false var isInPIPMode: Boolean = false val onColorSelectedEvent = Event>() val onDialogDismissedEvent = Event() - var playerEventListener: ((PlayerEventType) -> Unit)? = null var keyEventListener: ((Pair) -> Boolean)? = null + var appliedTheme: Int = 0 + var appliedColor: Int = 0 + + private 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) + } + } - var currentToast: Toast? = null - + @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() + val binding = ToastBinding.inflate(act.layoutInflater) + binding.text.text = message.trim() + // custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11) val toast = Toast(act) - toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) toast.duration = duration ?: Toast.LENGTH_SHORT - toast.view = layout - //https://github.com/PureWriter/ToastCompat - toast.show() + toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) + @Suppress("DEPRECATION") + toast.view = + binding.root // FIXME Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version. currentToast = toast + toast.show() + + val handler = Handler(Looper.getMainLooper()) + val ref = WeakReference(toast) + + /* Clean up activity leak */ + handler.postDelayed({ + if (ref.get() == currentToast) { + currentToast = null + } + }, 10_000) + } catch (e: Exception) { logError(e) } } /** - * Not all languages can be fetched from locale with a code. - * This map allows sidestepping the default Locale(languageCode) - * when setting the app language. - **/ - val appLanguageExceptions = hashMapOf( - "zh-rTW" to Locale.TRADITIONAL_CHINESE - ) - - fun setLocale(context: Context?, languageCode: String?) { - if (context == null || languageCode == null) return - val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode) + * Set locale + * @param languageTag shall a IETF BCP 47 conformant tag. + * Check [com.lagradost.cloudstream3.utils.SubtitleHelper]. + * + * See locales on: + * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json + * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry + * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml + * https://iso639-3.sil.org/code_tables/639/data/all + */ + fun setLocale(context: Context?, languageTag: String?) { + if (context == null || languageTag == null) return + val locale = Locale.forLanguageTag(languageTag) val resources: Resources = context.resources val config = resources.configuration Locale.setDefault(locale) @@ -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 + fun init(act: Activity) { + setActivityInstance(act) + ioSafe { Torrent.deleteAllFiles() } + val componentActivity = activity as? ComponentActivity ?: return - act.updateLocale() - act.updateTv() + componentActivity.updateLocale() + componentActivity.updateTv() + AccountManager.initMainAPI() NewPipe.init(DownloaderTestImpl.getInstance()) - for (resumeApp in resumeApps) { - resumeApp.launcher = - act.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val resultCode = result.resultCode - val data = result.data - if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) { - val pos = resumeApp.getPosition(data) - val dur = resumeApp.getDuration(data) - if (dur > 0L && pos > 0L) - DataStoreHelper.setViewPos(getKey(resumeApp.lastId), pos, dur) - removeKey(resumeApp.lastId) - ResultFragment.updateUI() - } + MainActivity.activityResultLauncher = + componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + val actionUid = + getKey("last_click_action") ?: return@registerForActivityResult + Log.d(TAG, "Loading action $actionUid result handler") + val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction + ?: return@registerForActivityResult + action.onResultSafe(act, result.data) + removeKey("last_click_action") + removeKey("last_opened") } - } + } // Ask for notification permissions on Android 13 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( - act, + componentActivity, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { - val requestPermissionLauncher = act.registerForActivityResult( + val requestPermissionLauncher = componentActivity.registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> Log.d(TAG, "Notification permission: $isGranted") @@ -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,208 +377,227 @@ 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 - ) + if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let + val nextView = when (keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus( + act, + currentFocus, + FocusDirection.Start + ) - else -> null - } + KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus( + act, + currentFocus, + FocusDirection.End + ) - if (next != null && next != -1) { - val nextView = act.findViewById(next) - if (nextView != null) { - nextView.requestFocus() - keyEventListener?.invoke(Pair(event, true)) - return true - } - } + KeyEvent.KEYCODE_DPAD_UP -> getNextFocus( + act, + currentFocus, + FocusDirection.Up + ) - 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 - //) - } + 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. + // Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote) + // send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button. + @SuppressLint("RestrictedApi") + if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) && + (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) + ) { + 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 } diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt index 379a91e4c..8da7ca384 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3 import okhttp3.OkHttpClient import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Request import org.schabi.newpipe.extractor.downloader.Response @@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() { - private val client: OkHttpClient + private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build() override fun execute(request: Request): Response { val httpMethod: String = request.httpMethod() val url: String = request.url() @@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do val dataToSend: ByteArray? = request.dataToSend() var requestBody: RequestBody? = null if (dataToSend != null) { - requestBody = RequestBody.create(null, dataToSend) + requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size) } val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() .method(httpMethod, requestBody).url(url) @@ -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/HeaderDecorationBindingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt deleted file mode 100644 index 045a7963a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.lagradost.cloudstream3 - -import android.view.LayoutInflater -import androidx.annotation.LayoutRes -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.ui.HeaderViewDecoration - -fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) { - val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null) - view.addItemDecoration(HeaderViewDecoration(headerView)) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt deleted file mode 100644 index 73859021f..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt +++ /dev/null @@ -1,1592 +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.syncproviders.SyncIdName -import com.lagradost.cloudstream3.ui.player.SubtitleData -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.ui.result.UiText -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 { - 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 - } -} - -/* -// THIS IS WORK IN PROGRESS API -interface ITag { - val name: UiText -} - -data class SimpleTag(override val name: UiText, val data: String) : ITag - -enum class SelectType { - SingleSelect, - MultiSelect, - MultiSelectAndExclude, -} - -enum class SelectValue { - Selected, - Excluded, -} - -interface GenreSelector { - val title: UiText - val id : Int -} - -data class TagSelector( - override val title: UiText, - override val id : Int, - val tags: Set, - val defaultTags : Set = setOf(), - val selectType: SelectType = SelectType.SingleSelect, -) : GenreSelector - -data class BoolSelector( - override val title: UiText, - override val id : Int, - - val defaultValue : Boolean = false, -) : GenreSelector - -data class InputField( - override val title: UiText, - override val id : Int, - - val hint : UiText? = null, -) : GenreSelector - -// This response describes how a user might filter the homepage or search results -data class GenreResponse( - val searchSelectors : List, - val filterSelectors: List = searchSelectors -) */ - -/* -0 = Site not good -1 = All good -2 = Slow, heavy traffic -3 = restricted, must donate 30 benenes to use - */ -const val PROVIDER_STATUS_KEY = "PROVIDER_STATUS_KEY" -const val PROVIDER_STATUS_BETA_ONLY = 3 -const val PROVIDER_STATUS_SLOW = 2 -const val PROVIDER_STATUS_OK = 1 -const val PROVIDER_STATUS_DOWN = 0 - -data class ProvidersInfoJson( - @JsonProperty("name") var name: String, - @JsonProperty("url") var url: String, - @JsonProperty("credentials") var credentials: String? = null, - @JsonProperty("status") var status: Int, -) - -data class SettingsJson( - @JsonProperty("enableAdult") var enableAdult: Boolean = false, -) - - -data class MainPageData( - val name: String, - val data: String, - val horizontalImages: Boolean = false -) - -data class MainPageRequest( - val name: String, - val data: String, - val horizontalImages: Boolean, - //TODO genre selection or smth -) - -fun mainPage(url: String, name: String, horizontalImages: Boolean = false): MainPageData { - return MainPageData(name = name, data = url, horizontalImages = horizontalImages) -} - -fun mainPageOf(vararg elements: MainPageData): List { - return elements.toList() -} - -/** return list of MainPageData with url to name, make for more readable code */ -fun mainPageOf(vararg elements: Pair): List { - return elements.map { (url, name) -> MainPageData(name = name, data = url) } -} - -fun newHomePageResponse( - name: String, - list: List, - hasNext: Boolean? = null, -): HomePageResponse { - return HomePageResponse( - listOf(HomePageList(name, list)), - hasNext = hasNext ?: list.isNotEmpty() - ) -} - -fun newHomePageResponse( - data: MainPageRequest, - list: List, - hasNext: Boolean? = null, -): HomePageResponse { - return HomePageResponse( - listOf(HomePageList(data.name, list, data.horizontalImages)), - hasNext = hasNext ?: list.isNotEmpty() - ) -} - -fun newHomePageResponse(list: HomePageList, hasNext: Boolean? = null): HomePageResponse { - return HomePageResponse(listOf(list), hasNext = hasNext ?: list.list.isNotEmpty()) -} - -fun newHomePageResponse(list: List, hasNext: Boolean? = null): HomePageResponse { - return HomePageResponse(list, hasNext = hasNext ?: list.any { it.list.isNotEmpty() }) -} - -/**Every provider will **not** have try catch built in, so handle exceptions when calling these functions*/ -abstract class MainAPI { - companion object { - var overrideData: HashMap? = null - var settingsForProvider: SettingsJson = SettingsJson() - } - - fun init() { - overrideData?.get(this.javaClass.simpleName)?.let { data -> - overrideWithNewData(data) - } - } - - fun overrideWithNewData(data: ProvidersInfoJson) { - if (!canBeOverridden) return - this.name = data.name - if (data.url.isNotBlank() && data.url != "NONE") - this.mainUrl = data.url - this.storedCredentials = data.credentials - } - - open var name = "NONE" - open var mainUrl = "NONE" - open var storedCredentials: String? = null - open var canBeOverridden: Boolean = true - - /** if this is turned on then it will request the homepage one after the other, - used to delay if they block many request at the same time*/ - open var sequentialMainPage: Boolean = false - - /** in milliseconds, this can be used to add more delay between homepage requests - * on first load if sequentialMainPage is turned on */ - open var sequentialMainPageDelay: Long = 0L - - /** in milliseconds, this can be used to add more delay between homepage requests when scrolling */ - open var sequentialMainPageScrollDelay: Long = 0L - - /** used to keep track when last homepage request was in unixtime ms */ - var lastHomepageRequest: Long = 0L - - open var lang = "en" // ISO_639_1 check SubtitleHelper - - /**If link is stored in the "data" string, so links can be instantly loaded*/ - open val instantLinkLoading = false - - /**Set false if links require referer or for some reason cant be played on a chromecast*/ - open val hasChromecastSupport = true - - /**If all links are encrypted then set this to false*/ - open val hasDownloadSupport = true - - /**Used for testing and can be used to disable the providers if WebView is not available*/ - open val usesWebView = false - - /** Determines which plugin a given provider is from */ - var sourcePlugin: String? = null - - open val hasMainPage = false - open val hasQuickSearch = false - - /** - * A set of which ids the provider can open with getLoadUrl() - * If the set contains SyncIdName.Imdb then getLoadUrl() can be started with - * an Imdb class which inherits from SyncId. - * - * getLoadUrl() is then used to get page url based on that ID. - * - * Example: - * "tt6723592" -> getLoadUrl(ImdbSyncId("tt6723592")) -> "mainUrl/imdb/tt6723592" -> load("mainUrl/imdb/tt6723592") - * - * This is used to launch pages from personal lists or recommendations using IDs. - **/ - open val supportedSyncNames = setOf() - - open val supportedTypes = setOf( - TvType.Movie, - TvType.TvSeries, - TvType.Cartoon, - TvType.Anime, - TvType.OVA, - ) - - open val vpnStatus = VPNStatus.None - open val providerType = ProviderType.DirectProvider - - //emptyList() // - open val mainPage = listOf(MainPageData("", "", false)) - - @WorkerThread - open suspend fun getMainPage( - page: Int, - request: MainPageRequest, - ): HomePageResponse? { - throw NotImplementedError() - } - - @WorkerThread - open suspend fun search(query: String): List? { - throw NotImplementedError() - } - - @WorkerThread - open suspend fun quickSearch(query: String): List? { - throw NotImplementedError() - } - - @WorkerThread - /** - * Based on data from search() or getMainPage() it generates a LoadResponse, - * basically opening the info page from a link. - * */ - open suspend fun load(url: String): LoadResponse? { - throw NotImplementedError() - } - - /** - * Largely redundant feature for most providers. - * - * This job runs in the background when a link is playing in exoplayer. - * First implemented to do polling for sflix to keep the link from getting expired. - * - * This function might be updated to include exoplayer timestamps etc in the future - * if the need arises. - * */ - @WorkerThread - open suspend fun extractorVerifierJob(extractorData: String?) { - throw NotImplementedError() - } - - /**Callback is fired once a link is found, will return true if method is executed successfully*/ - @WorkerThread - open suspend fun loadLinks( - data: String, - isCasting: Boolean, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ): Boolean { - throw NotImplementedError() - } - - /** An okhttp interceptor for used in OkHttpDataSource */ - open fun getVideoInterceptor(extractorLink: ExtractorLink): Interceptor? { - return null - } - - /** - * Get the load() url based on a sync ID like IMDb or MAL. - * Only contains SyncIds based on supportedSyncUrls. - **/ - open suspend fun getLoadUrl(name: SyncIdName, id: String): String? { - return null - } -} - -/** Might need a different implementation for desktop*/ -@SuppressLint("NewApi") -fun base64Decode(string: String): String { - return String(base64DecodeArray(string), Charsets.ISO_8859_1) -} - -@SuppressLint("NewApi") -fun base64DecodeArray(string: String): ByteArray { - return try { - android.util.Base64.decode(string, android.util.Base64.DEFAULT) - } catch (e: Exception) { - Base64.getDecoder().decode(string) - } -} - -@SuppressLint("NewApi") -fun base64Encode(array: ByteArray): String { - return try { - String(android.util.Base64.encode(array, android.util.Base64.NO_WRAP), Charsets.ISO_8859_1) - } catch (e: Exception) { - String(Base64.getEncoder().encode(array)) - } -} - -class ErrorLoadingException(message: String? = null) : Exception(message) - -fun MainAPI.fixUrlNull(url: String?): String? { - if (url.isNullOrEmpty()) { - return null - } - return fixUrl(url) -} - -fun MainAPI.fixUrl(url: String): String { - if (url.startsWith("http") || - // Do not fix JSON objects when passed as urls. - url.startsWith("{\"") - ) { - return url - } - if (url.isEmpty()) { - return "" - } - - val startsWithNoHttp = url.startsWith("//") - if (startsWithNoHttp) { - return "https:$url" - } else { - if (url.startsWith('/')) { - return mainUrl + url - } - return "$mainUrl/$url" - } -} - -fun sortUrls(urls: Set): List { - return urls.sortedBy { t -> -t.quality } -} - -fun sortSubs(subs: Set): List { - return subs.sortedBy { it.name } -} - -fun capitalizeString(str: String): String { - return capitalizeStringNullable(str) ?: str -} - -fun capitalizeStringNullable(str: String?): String? { - if (str == null) - return null - return try { - str.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } catch (e: Exception) { - str - } -} - -fun fixTitle(str: String): String { - return str.split(" ").joinToString(" ") { - it.lowercase() - .replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it } - } -} - -/** 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 eddec15e8..90583011d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -1,21 +1,42 @@ 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.* +import android.view.Gravity +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.CheckBox +import android.widget.ImageView +import android.widget.LinearLayout import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IdRes +import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.constraintlayout.widget.ConstraintLayout +import androidx.cardview.widget.CardView +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.core.view.children +import androidx.core.view.get +import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.view.marginStart import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController @@ -26,210 +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.mvvm.* +import com.lagradost.cloudstream3.CommonActivity.updateTheme +import com.lagradost.cloudstream3.actions.temp.fcast.FcastManager +import com.lagradost.cloudstream3.databinding.ActivityMainBinding +import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding +import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.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.setImage -import com.lagradost.cloudstream3.ui.result.setText +import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.search.SearchFragment import com.lagradost.cloudstream3.ui.search.SearchResultBuilder -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsGeneral import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.html -import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable -import com.lagradost.cloudstream3.utils.AppUtils.loadCache -import com.lagradost.cloudstream3.utils.AppUtils.loadRepository -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.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.InAppUpdater.Companion.runAutoUpdate +import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate import com.lagradost.cloudstream3.utils.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.nicehttp.Requests -import com.lagradost.nicehttp.ResponseParser -import kotlinx.android.synthetic.main.activity_main.* -import kotlinx.android.synthetic.main.bottom_resultview_preview.* -import kotlinx.android.synthetic.main.fragment_result_swipe.* +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.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 +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 - -//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) - -val resumeApps = arrayOf( - VLC, MPV, WEB_VIDEO -) - -// Short name for requests client to make it nicer to use - -var app = Requests(responseParser = object : ResponseParser { - val mapper: ObjectMapper = jacksonObjectMapper().configure( - DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, - false - ) - - override fun parse(text: String, kClass: KClass): T { - return mapper.readValue(text, kClass.java) - } - - override fun parseSafe(text: String, kClass: KClass): T? { - return try { - mapper.readValue(text, kClass.java) - } catch (e: Exception) { - null - } - } - - override fun writeValueAsString(obj: Any): String { - return mapper.writeValueAsString(obj) - } -}).apply { - defaultHeaders = mapOf("user-agent" to USER_AGENT) -} - -class MainActivity : AppCompatActivity(), ColorPickerDialogListener { +class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { + var activityResultLauncher: ActivityResultLauncher? = null + const val TAG = "MAINACT" + const val ANIMATED_OUTLINE: Boolean = false + var lastError: String? = null + + /** Update lastError variable based on error file, to check if app crashed. + * Can be called multiple times without changing the lastError variable changing. + **/ + fun setLastError(context: Context) { + if (lastError != null) return + + val errorFile = context.filesDir.resolve("last_error") + if (errorFile.exists() && errorFile.isFile) { + lastError = errorFile.readText(Charset.defaultCharset()) + errorFile.delete() + } else { + lastError = null + } + } + + private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" + const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" + + /** + * Transient files to delete on application exit. + * Deletes files on onDestroy(). + */ + private var filesToDelete: Set + // This needs to be persistent because the application may exit without calling onDestroy. + get() = getKey>(FILE_DELETE_KEY) ?: setOf() + private set(value) = setKey(FILE_DELETE_KEY, value) + + /** + * Add file to delete on Exit. + */ + fun deleteFileOnExit(file: File) { + filesToDelete = filesToDelete + file.path + } /** * Setting this will automatically enter the query in the search @@ -238,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 @@ -254,6 +257,20 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // kinda shitty solution, but cant com main->home otherwise for popups val bookmarksUpdatedEvent = Event() + /** + * Used by DataStoreHelper to fully reload home when switching accounts + */ + val reloadHomeEvent = Event() + + /** + * Used by DataStoreHelper to fully reload library when switching accounts + */ + val reloadLibraryEvent = Event() + + /** + * Used by DataStoreHelper to fully reload Navigation Rail header picture + */ + val reloadAccountEvent = Event() /** * @return true if the str has launched an app task (be it successful or not) @@ -262,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")) { @@ -274,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 @@ -305,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 = @@ -329,32 +379,93 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { START_ACTION_RESUME_LATEST ) } + } else if (str.startsWith(APP_STRING_SHARE)) { + try { + val data = str.substringAfter("$APP_STRING_SHARE:") + val parts = data.split("?", limit = 2) + loadResult( + String(base64DecodeArray(parts[1]), Charsets.UTF_8), + String(base64DecodeArray(parts[0]), Charsets.UTF_8), + "" + ) + return true + } catch (e: Exception) { + showToast("Invalid Uri", Toast.LENGTH_SHORT) + return false + } } else if (!isWebview) { if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) { this.navigate(R.id.navigation_downloads) return true } else { - for (api in apis) { - if (str.startsWith(api.mainUrl)) { - loadResult(str, api.name) - return true - } + val apiName = extraArgs?.getString(API_NAME_EXTRA_KEY) + ?.takeIf { it.isNotBlank() } + // if provided, try to match the api name instead of the api url + // this is in order to also support providers that use JSON dataUrls + // for example + if (apiName != null) { + loadResult(str, apiName, "") + return true + } + + val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull() + if (matchedApi != null) { + loadResult(str, matchedApi.name, "") + return true } } } } return false } + + + fun centerView(view: View?) { + if (view == null) return + try { + Log.v(TAG, "centerView: $view") + val r = Rect(0, 0, 0, 0) + view.getDrawingRect(r) + val x = r.centerX() + val y = r.centerY() + val dx = r.width() / 2 //screenWidth / 2 + val dy = screenHeight / 2 + val r2 = Rect(x - dx, y - dy, x + dx, y + dy) + view.requestRectangleOnScreen(r2, false) + // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) + } catch (_: Throwable) { + } + } } + var lastPopup: SearchResponse? = null - fun loadPopup(result: SearchResponse) { + var lastPopupJob: Job? = null + fun loadPopup(result: SearchResponse, load: Boolean = true) { lastPopup = result - viewModel.load( - this, result.url, result.apiName, false, if (getApiDubstatusSettings() - .contains(DubStatus.Dubbed) - ) DubStatus.Dubbed else DubStatus.Subbed, null - ) + val syncName = syncViewModel.syncName(result.apiName) + + // based on apiName we decide on if it is a local list or not, this is because + // we want to show a bit of extra UI to sync apis + if (result is SyncAPI.LibraryItem && syncName != null) { + isLocalList = false + syncViewModel.setSync(syncName, result.syncId) + syncViewModel.updateMetaAndUser() + } else { + isLocalList = true + syncViewModel.clear() + } + + lastPopupJob?.cancel() + lastPopupJob = if (load) { + viewModel.load( + this, result.url, result.apiName, false, if (getApiDubstatusSettings() + .contains(DubStatus.Dubbed) + ) DubStatus.Dubbed else DubStatus.Subbed, null + ) + } else { + viewModel.loadSmall(result) + } } override fun onColorSelected(dialogId: Int, color: Int) { @@ -368,6 +479,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) updateLocale() // android fucks me by chaining lang when rotating the phone + updateTheme(this) // Update if system theme val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment @@ -378,7 +490,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, @@ -392,6 +504,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { R.id.navigation_downloads, R.id.navigation_settings, R.id.navigation_download_child, + R.id.navigation_download_queue, R.id.navigation_subtitles, R.id.navigation_chrome_subtitles, R.id.navigation_settings_player, @@ -402,52 +515,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 dontPush = listOf( + /*val dontPush = listOf( R.id.navigation_home, R.id.navigation_search, R.id.navigation_results_phone, R.id.navigation_results_tv, R.id.navigation_player, + R.id.navigation_quick_search, ).contains(destination.id) - nav_host_fragment?.apply { + 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 + ) + } - params.setMargins( - if (!dontPush && isTvSettings()) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0, - params.topMargin, - params.rightMargin, - params.bottomMargin - ) layoutParams = params - } + }*/ - val landscape = when (resources.configuration.orientation) { - Configuration.ORIENTATION_LANDSCAPE -> { - true + 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 + } } - Configuration.ORIENTATION_PORTRAIT -> { - false - } - 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 - - // Hide library on TV since it is not supported yet :( - val isTrueTv = isTrueTvSettings() - nav_view?.menu?.findItem(R.id.navigation_library)?.isVisible = !isTrueTv - nav_rail_view?.menu?.findItem(R.id.navigation_library)?.isVisible = !isTrueTv } //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 { @@ -484,10 +638,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { override fun onResume() { super.onResume() afterPluginsLoadedEvent += ::onAllPluginsLoaded + setActivityInstance(this) try { if (isCastApiAvailable()) { - //mCastSession = mSessionManager.currentCastSession - mSessionManager.addSessionManagerListener(mSessionManagerListener) + mSessionManager?.addSessionManagerListener(mSessionManagerListener) } } catch (e: Exception) { logError(e) @@ -503,7 +657,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } try { if (isCastApiAvailable()) { - mSessionManager.removeSessionManagerListener(mSessionManagerListener) + mSessionManager?.removeSessionManagerListener(mSessionManagerListener) //mCastSession = null } } catch (e: Exception) { @@ -511,19 +665,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() { @@ -531,55 +677,57 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { onUserLeaveHint(this) } - private fun showConfirmExitDialog() { - val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setTitle(R.string.confirm_exit_dialog) - builder.apply { - // Forceful exit since back button can actually go back to setup - setPositiveButton(R.string.yes) { _, _ -> exitProcess(0) } - setNegativeButton(R.string.no) { _, _ -> } + @SuppressLint("ApplySharedPref") // commit since the op needs to be synchronous + private fun showConfirmExitDialog(settingsManager: SharedPreferences) { + val confirmBeforeExit = settingsManager.getInt(getString(R.string.confirm_exit_key), -1) + + if (confirmBeforeExit == 1 || (confirmBeforeExit == -1 && isLayout(PHONE))) { + // finish() causes a bug on some TVs where player + // may keep playing after closing the app. + if (isLayout(TV)) exitProcess(0) else finish() + return } + + val dialogView = layoutInflater.inflate(R.layout.confirm_exit_dialog, null) + val dontShowAgainCheck: CheckBox = dialogView.findViewById(R.id.checkboxDontShowAgain) + val builder: AlertDialog.Builder = AlertDialog.Builder(this) + builder.setView(dialogView) + .setTitle(R.string.confirm_exit_dialog) + .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ } + .setPositiveButton(R.string.yes) { _, _ -> + if (dontShowAgainCheck.isChecked) { + settingsManager.edit(commit = true) { + putInt(getString(R.string.confirm_exit_key), 1) + } + } + // finish() causes a bug on some TVs where player + // may keep playing after closing the app. + if (isLayout(TV)) exitProcess(0) else finish() + } + builder.show().setDefaultFocus() } - private fun backPressed() { - this.window?.navigationBarColor = - this.colorFromAttribute(R.attr.primaryGrayBackground) - this.updateLocale() - this.updateLocale() - - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment - val navController = navHostFragment?.navController - val isAtHome = - navController?.currentDestination?.matchDestination(R.id.navigation_home) == true - - if (isAtHome && isTrueTvSettings()) { - showConfirmExitDialog() - } else { - super.onBackPressed() - } - } - - override fun onBackPressed() { - ((supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.childFragmentManager?.primaryNavigationFragment as? IOnBackPressed)?.onBackPressed() - ?.let { runNormal -> - if (runNormal) backPressed() - } ?: run { - backPressed() - } - } - override fun onDestroy() { + filesToDelete.forEach { path -> + val result = File(path).deleteRecursively() + if (result) { + Log.d(TAG, "Deleted temporary file: $path") + } else { + Log.d(TAG, "Failed to delete temporary file: $path") + } + } + filesToDelete = setOf() val broadcastIntent = Intent() broadcastIntent.action = "restart_service" broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) this.sendBroadcast(broadcastIntent) afterPluginsLoadedEvent -= ::onAllPluginsLoaded + detachBackPressedCallback("MainActivityDefault") super.onDestroy() } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { handleAppIntent(intent) super.onNewIntent(intent) } @@ -588,13 +736,55 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (intent == null) return val str = intent.dataString loadCache() - handleAppIntentUrl(this, str, false) + + handleAppIntentUrl(this, str, false, intent.extras) } private fun NavDestination.matchDestination(@IdRes destId: Int): Boolean = hierarchy.any { it.id == destId } + private var lastNavTime = 0L private fun onNavDestinationSelected(item: MenuItem, navController: NavController): Boolean { + val currentTime = System.currentTimeMillis() + // safeDebounce: Check if a previous tap happened within the last 400ms + if (currentTime - lastNavTime < 400) return false + lastNavTime = currentTime + + val destinationId = item.itemId + + // Check if we are already at the selected destination + if (navController.currentDestination?.id == destinationId) return false + + // Make all nav buttons focus on this specific view when nextFocusRightId + val targetView = when (destinationId) { + // Please note that if R.id.navigation_home is readded, then it will only take affect when + // navigation to home for the second time as onNavDestinationSelected will not get called + // when first loading up the app + + // R.id.navigation_home -> R.id.home_preview_change_api + R.id.navigation_search -> R.id.main_search + R.id.navigation_library -> R.id.main_search + R.id.navigation_downloads -> R.id.download_appbar + else -> null + } + if (targetView != null && isLayout(TV or EMULATOR)) { + val fromView = binding?.navRailView + if (fromView != null) { + fromView.nextFocusRightId = targetView + + for (focusView in arrayOf( + R.id.navigation_downloads, + R.id.navigation_home, + R.id.navigation_search, + R.id.navigation_library, + R.id.navigation_settings, + )) { + fromView.findViewById(focusView)?.nextFocusRightId = targetView + } + } + } + + val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true) .setEnterAnim(R.anim.enter_anim) .setExitAnim(R.anim.exit_anim) @@ -607,11 +797,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { saveState = true ) } - val options = builder.build() return try { - navController.navigate(item.itemId, null, options) - navController.currentDestination?.matchDestination(item.itemId) == true + navController.navigate(destinationId, null, builder.build()) + navController.currentDestination?.matchDestination(destinationId) == true } catch (e: IllegalArgumentException) { + Log.e("NavigationError", "Failed to navigate: ${e.message}") false } } @@ -620,74 +810,381 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { 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 - }) - } + allProviders.withLock { + // Load cloned sites after plugins have been loaded since clones depend on plugins. + try { + getKey>(USER_PROVIDER_API)?.let { list -> + list.forEach { custom -> + allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass } + ?.let { + allProviders.add( + it.javaClass.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] + + viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] + syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java] return super.onCreateView(name, context, attrs) } private fun hidePreviewPopupDialog() { - viewModel.clear() bottomPreviewPopup.dismissSafe(this) + lastPopupJob?.cancel() + lastPopupJob = null + bottomPreviewPopup = null + bottomPreviewBinding = null } - var bottomPreviewPopup: BottomSheetDialog? = null - private fun showPreviewPopupDialog(): BottomSheetDialog { - val ret = (bottomPreviewPopup ?: run { - val builder = - BottomSheetDialog(this) - builder.setContentView(R.layout.bottom_resultview_preview) + 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() - builder + bottomPreviewPopup = builder + binding }) - bottomPreviewPopup = ret + 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 = @@ -696,34 +1193,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() - if (isTvSettings()) { - setContentView(R.layout.activity_main_tv) - } else { - setContentView(R.layout.activity_main) + + // 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) + } + } } - changeStatusBarState(isEmulatorSettings()) + // 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 + ) + } + + // overscan + val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx + binding?.homeRoot?.setPadding(padding, padding, padding, padding) + + changeStatusBarState(isLayout(EMULATOR)) + + /** Biometric stuff for users without accounts **/ + val noAccounts = settingsManager.getBoolean( + getString(R.string.skip_startup_account_select_key), + false + ) || accounts.count() <= 1 + + if (isLayout(PHONE) && isAuthEnabled(this) && noAccounts) { + if (deviceHasPasswordPinLock(this)) { + startBiometricAuthentication(this, R.string.biometric_authentication_title, false) + + promptInfo?.let { prompt -> + biometricPrompt?.authenticate(prompt) + } + + // hide background while authenticating, Sorry moms & dads 🙏 + binding?.navHostFragment?.isInvisible = true + } + } + + // Automatically enable jsdelivr if cant connect to raw.githubusercontent.com + if (this.getKey(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) { + 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()) { - normalSafeApiCall { - showToast(this, R.string.safe_mode_file, Toast.LENGTH_LONG) + 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) @@ -735,24 +1358,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) @@ -771,59 +1407,192 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { builder.show().setDefaultFocus() } + + fun setUserData(status: Resource?) { + if (isLocalList) return + bottomPreviewBinding?.apply { + when (status) { + is Resource.Success -> { + resultviewPreviewBookmark.isEnabled = true + resultviewPreviewBookmark.setText(status.value.status.stringRes) + resultviewPreviewBookmark.setIconResource(status.value.status.iconRes) + } + + is Resource.Failure -> { + resultviewPreviewBookmark.isEnabled = false + resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) + resultviewPreviewBookmark.text = status.errorString + } + + else -> { + resultviewPreviewBookmark.isEnabled = false + resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) + resultviewPreviewBookmark.setText(R.string.loading) + } + } + } + } + + fun setWatchStatus(state: WatchType?) { + if (!isLocalList || state == null) return + + bottomPreviewBinding?.resultviewPreviewBookmark?.apply { + setIconResource(state.iconRes) + setText(state.stringRes) + } + } + + fun setSubscribeStatus(state: Boolean?) { + bottomPreviewBinding?.resultviewPreviewSubscribe?.apply { + if (state != null) { + val drawable = if (state) { + R.drawable.ic_baseline_notifications_active_24 + } else { + R.drawable.baseline_notifications_none_24 + } + setImageResource(drawable) + } + isVisible = state != null + + setOnClickListener { + viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleSubscriptionStatus + + val message = if (newStatus) { + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else { + R.string.subscription_deleted + } + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) + } + } + } + } + + observe(viewModel.watchStatus, ::setWatchStatus) + observe(syncViewModel.userData, ::setUserData) + observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus) + observeNullable(viewModel.page) { resource -> if (resource == null) { - bottomPreviewPopup.dismissSafe(this) + hidePreviewPopupDialog() return@observeNullable } when (resource) { is Resource.Failure -> { - showToast(this, R.string.error) + showToast(R.string.error) + viewModel.clear() hidePreviewPopupDialog() } + is Resource.Loading -> { showPreviewPopupDialog().apply { - resultview_preview_loading?.isVisible = true - resultview_preview_result?.isVisible = false - resultview_preview_loading_shimmer?.startShimmer() + resultviewPreviewLoading.isVisible = true + resultviewPreviewResult.isVisible = false + resultviewPreviewLoadingShimmer.startShimmer() } } + is Resource.Success -> { val d = resource.value showPreviewPopupDialog().apply { - resultview_preview_loading?.isVisible = false - resultview_preview_result?.isVisible = true - resultview_preview_loading_shimmer?.stopShimmer() + resultviewPreviewLoading.isVisible = false + resultviewPreviewResult.isVisible = true + resultviewPreviewLoadingShimmer.stopShimmer() - resultview_preview_title?.text = d.title + resultviewPreviewTitle.text = d.title - resultview_preview_meta_type.setText(d.typeText) - resultview_preview_meta_year.setText(d.yearText) - resultview_preview_meta_duration.setText(d.durationText) - resultview_preview_meta_rating.setText(d.ratingText) + resultviewPreviewMetaType.setText(d.typeText) + resultviewPreviewMetaYear.setText(d.yearText) + resultviewPreviewMetaDuration.setText(d.durationText) + resultviewPreviewMetaRating.setText(d.ratingText) - resultview_preview_description?.setText(d.plotText) - resultview_preview_poster?.setImage( - d.posterImage ?: d.posterBackgroundImage - ) + resultviewPreviewDescription.setTextHtml(d.plotText) + if (isLayout(PHONE)) { + resultviewPreviewPoster.loadImage( + d.posterImage ?: d.posterBackgroundImage, + headers = d.posterHeaders + ) + } else { + resultviewPreviewPoster.loadImage( + d.posterBackgroundImage ?: d.posterImage, + headers = d.posterHeaders + ) + } - resultview_preview_poster?.setOnClickListener { + setUserData(syncViewModel.userData.value) + setWatchStatus(viewModel.watchStatus.value) + setSubscribeStatus(viewModel.subscribeStatus.value) + + resultviewPreviewBookmark.setOnClickListener { //viewModel.updateWatchStatus(WatchType.PLANTOWATCH) - val value = viewModel.watchStatus.value ?: WatchType.NONE + if (isLocalList) { + val value = viewModel.watchStatus.value ?: WatchType.NONE - this@MainActivity.showBottomDialog( - WatchType.values().map { getString(it.stringRes) }.toList(), - value.ordinal, - this@MainActivity.getString(R.string.action_add_to_bookmarks), - showApply = false, - {}) { - viewModel.updateWatchStatus(WatchType.values()[it]) - bookmarksUpdatedEvent(true) + this@MainActivity.showBottomDialog( + WatchType.entries.map { getString(it.stringRes) }.toList(), + value.ordinal, + this@MainActivity.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + viewModel.updateWatchStatus( + WatchType.entries[it], + this@MainActivity + ) + } + } else { + val value = + (syncViewModel.userData.value as? Resource.Success)?.value?.status + ?: SyncWatchType.NONE + + this@MainActivity.showBottomDialog( + SyncWatchType.entries.map { getString(it.stringRes) }.toList(), + value.ordinal, + this@MainActivity.getString(R.string.action_add_to_bookmarks), + showApply = false, + {}) { + syncViewModel.setStatus(SyncWatchType.entries[it].internalId) + syncViewModel.publishUserData() + } } } - if (!isTvSettings()) // dont want this clickable on tv layout - resultview_preview_description?.setOnClickListener { view -> + 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) @@ -833,7 +1602,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } - resultview_preview_more_info?.setOnClickListener { + resultviewPreviewMoreInfo.setOnClickListener { + viewModel.clear() hidePreviewPopupDialog() lastPopup?.let { loadSearchResult(it) @@ -856,15 +1626,24 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // init accounts ioSafe { - for (api in accountManagers) { - api.init() - } + // we need to run this after we init all apis, otherwise currentSyncApi will fuck itself + this@MainActivity.runOnUiThread { + // Change library icon with logo of current api in sync + libraryViewModel = + ViewModelProvider(this@MainActivity)[LibraryViewModel::class.java] + libraryViewModel?.currentApiName?.observe(this@MainActivity) { + val syncAPI = libraryViewModel?.currentSyncApi + Log.i("SYNC_API", "${syncAPI?.name}, ${syncAPI?.idPrefix}") + val icon = if (syncAPI?.idPrefix == localListApi.idPrefix) { + R.drawable.library_icon_selector + } else { + syncAPI?.icon ?: R.drawable.library_icon_selector + } - inAppAuths.amap { api -> - try { - api.initialize() - } catch (e: Exception) { - logError(e) + binding?.apply { + navRailView.menu.findItem(R.id.navigation_library)?.setIcon(icon) + navView.menu.findItem(R.id.navigation_library)?.setIcon(icon) + } } } } @@ -887,12 +1666,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) @@ -905,29 +1690,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) - if (isTvSettings()) { - nav_rail?.background?.alpha = 200 - } else { - nav_rail?.background?.alpha = 255 + + 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_rail?.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 + } } - nav_view?.setOnItemSelectedListener { item -> - onNavDestinationSelected( - item, - navController - ) + + 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() + } + } + } + } + }*/ + } } - navController.addOnDestinationChangedListener { _, destination, _ -> - updateNavBar(destination) + + // 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() @@ -950,17 +1889,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)) { @@ -1001,7 +1935,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { fun buildMediaQueueItem(video: String): MediaQueueItem { // val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO) //movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream") - val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString()) + val mediaInfo = MediaInfo.Builder(video.toUri().toString()) .setStreamType(MediaInfo.STREAM_TYPE_NONE) .setContentType(MimeTypes.IMAGE_JPEG) // .setMetadata(movieMetadata).build() @@ -1027,14 +1961,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" - for (api in allProviders) { - providersAndroidManifestString += "\n" + allProviders.withLock { + for (api in allProviders) { + providersAndroidManifestString += "\n" + } } - println(providersAndroidManifestString) } @@ -1044,13 +1979,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) } @@ -1060,6 +1997,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) @@ -1075,8 +2028,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { } } catch (e: Exception) { logError(e) - } finally { - setKey(HAS_DONE_SETUP_KEY, true) } // Used to check current focus for TV @@ -1088,5 +2039,34 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { // } // } + attachBackPressedCallback("MainActivityDefault") { + setNavigationBarColorCompat(R.attr.primaryGrayBackground) + updateLocale() + runDefault() + } + + // Start the download queue + DownloadQueueManager.init(this) + } + + /** Biometric stuff **/ + override fun onAuthenticationSuccess() { + // make background (nav host fragment) visible again + binding?.navHostFragment?.isInvisible = false + } + + override fun onAuthenticationError() { + finish() + } + + suspend fun checkGithubConnectivity(): Boolean { + 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 469554275..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.lagradost.cloudstream3 - -import com.lagradost.cloudstream3.mvvm.logError -import kotlinx.coroutines.* - -//https://stackoverflow.com/questions/34697828/parallel-operations-on-kotlin-collections -/* -fun Iterable.pmap( - numThreads: Int = maxOf(Runtime.getRuntime().availableProcessors() - 2, 1), - exec: ExecutorService = Executors.newFixedThreadPool(numThreads), - transform: (T) -> R, -): List { - - // default size is just an inlined version of kotlin.collections.collectionSizeOrDefault - val defaultSize = if (this is Collection<*>) this.size else 10 - val destination = Collections.synchronizedList(ArrayList(defaultSize)) - - for (item in this) { - exec.submit { destination.add(transform(item)) } - } - - exec.shutdown() - exec.awaitTermination(1, TimeUnit.DAYS) - - return ArrayList(destination) -}*/ - - -@OptIn(DelicateCoroutinesApi::class) -suspend fun Map.amap(f: suspend (Map.Entry) -> R): List = - with(CoroutineScope(GlobalScope.coroutineContext)) { - map { async { f(it) } }.map { it.await() } - } - -fun Map.apmap(f: suspend (Map.Entry) -> R): List = runBlocking { - map { async { f(it) } }.map { it.await() } -} - - -@OptIn(DelicateCoroutinesApi::class) -suspend fun List.amap(f: suspend (A) -> B): List = - with(CoroutineScope(GlobalScope.coroutineContext)) { - map { async { f(it) } }.map { it.await() } - } - - -fun List.apmap(f: suspend (A) -> B): List = runBlocking { - map { async { f(it) } }.map { it.await() } -} - -fun List.apmapIndexed(f: suspend (index: Int, A) -> B): List = runBlocking { - mapIndexed { index, a -> async { f(index, a) } }.map { it.await() } -} - -@OptIn(DelicateCoroutinesApi::class) -suspend fun List.amapIndexed(f: suspend (index: Int, A) -> B): List = - with(CoroutineScope(GlobalScope.coroutineContext)) { - mapIndexed { index, a -> async { f(index, a) } }.map { it.await() } - } - -// run code in parallel -/*fun argpmap( - vararg transforms: () -> R, - numThreads: Int = maxOf(Runtime.getRuntime().availableProcessors() - 2, 1), - exec: ExecutorService = Executors.newFixedThreadPool(numThreads) -) { - for (item in transforms) { - exec.submit { item.invoke() } - } - - exec.shutdown() - exec.awaitTermination(1, TimeUnit.DAYS) -}*/ - -// built in try catch -fun argamap( - vararg transforms: suspend () -> R, -) = runBlocking { - transforms.map { - async { - try { - it.invoke() - } catch (e: Exception) { - logError(e) - } - } - }.map { it.await() } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt new file mode 100644 index 000000000..a3c4040b5 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt @@ -0,0 +1,26 @@ +package com.lagradost.cloudstream3.actions + +import android.content.Context +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt + +class AlwaysAskAction : VideoClickAction() { + override val name = txt(R.string.player_settings_always_ask) + override val isPlayer = true + + // Only show in settings, not on a video + override fun shouldShow(context: Context?, video: ResultEpisode?): Boolean = video == null + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + // This is handled specially in ResultViewModel2.kt by detecting the AlwaysAskAction + // and showing the player selection dialog instead of executing the action directly + throw NotImplementedError("AlwaysAskAction is handled specially by the calling code") + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt new file mode 100644 index 000000000..ac912cbeb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt @@ -0,0 +1,135 @@ +package com.lagradost.cloudstream3.actions + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.ResultFragment +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled +import com.lagradost.cloudstream3.utils.DataStoreHelper +import java.io.File + +fun updateDurationAndPosition(position: Long, duration: Long) { + if (position <= 0 || duration <= 0) return + val episode = getKey("last_opened") ?: return + DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null) + ResultFragment.updateUI() +} + +/** + * Util method that may be helpful for creating intents for apps that support m3u8 files. + * All sources are written to a temporary m3u8 file, which is then sent to the app. + */ +fun makeTempM3U8Intent( + context: Context, + intent: Intent, + result: LinkLoadingResult +) { + if (result.links.size == 1) { + intent.setDataAndType(result.links.first().url.toUri(), "video/*") + return + } + + intent.apply { + addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + + val outputFile = File.createTempFile("mirrorlist", ".m3u8", context.cacheDir) + var text = "#EXTM3U\n#EXT-X-VERSION:3" + + result.links.forEach { link -> + text += "\n#EXTINF:0,${link.name}\n${link.url}" + } + + //With subtitles it doesn't work for no reason :( + /*for (sub in result.subs) { + val normalizedName = sub.name.replace("[^a-zA-Z0-9 ]".toRegex(), "") + text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${normalizedName}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.languageCode}\",URI=\"${sub.url}\"" + }*/ + + text += "\n#EXT-X-ENDLIST" + outputFile.writeText(text) + + intent.setDataAndType( + FileProvider.getUriForFile( + context, + context.applicationContext.packageName + ".provider", + outputFile + ), "application/x-mpegURL" + ) +} + +abstract class OpenInAppAction( + open val appName: UiText, + open val packageName: String, + private val intentClass: String? = null, + private val action: String = Intent.ACTION_VIEW +) : VideoClickAction() { + override val name: UiText + get() = txt(R.string.episode_action_play_in_format, appName) + + override val isPlayer = true + + override fun shouldShow(context: Context?, video: ResultEpisode?) = + context?.isAppInstalled(packageName) != false + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + if (context == null) return + val intent = Intent(action) + intent.setPackage(packageName) + if (intentClass != null) { + intent.component = ComponentName(packageName, intentClass) + } + putExtra(context, intent, video, result, index) + setKey("last_opened", video) + launchResult(intent) + } + + /** + * Before intent is sent, this function is called to put extra data into the intent. + * @see VideoClickAction.runAction + * */ + @Throws + abstract suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) + + /** + * This function is called when the app is opened again after the intent was sent. + * You can use it to for example update duration and position. + * @see updateDurationAndPosition + */ + @Throws + abstract fun onResult(activity: Activity, intent: Intent?) + + /** Safe version of onResult, we don't trust extension devs to not crash the app */ + fun onResultSafe(activity: Activity, intent: Intent?) { + try { + onResult(activity, intent) + } catch (t: Throwable) { + logError(t) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt new file mode 100644 index 000000000..a864b5fb7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -0,0 +1,205 @@ +package com.lagradost.cloudstream3.actions + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.core.app.ActivityOptionsCompat +import com.lagradost.api.Log +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage +import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction +import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage +import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage +import com.lagradost.cloudstream3.actions.temp.MpvExPackage +import com.lagradost.cloudstream3.actions.temp.MpvKtPackage +import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage +import com.lagradost.cloudstream3.actions.temp.MpvPackage +import com.lagradost.cloudstream3.actions.temp.MpvRxPackage +import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage +import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage +import com.lagradost.cloudstream3.actions.temp.OnlyPlayer +import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction +import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction +import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action +import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage +import com.lagradost.cloudstream3.actions.temp.VlcPackage +import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage +import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.UiText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.Callable +import java.util.concurrent.FutureTask +import kotlin.reflect.jvm.jvmName + +object VideoClickActionHolder { + val allVideoClickActions = atomicListOf( + // Default + PlayInBrowserAction(), + CopyClipboardAction(), + ViewM3U8Action(), + PlayMirrorAction(), + // main support external apps + VlcPackage(), + MpvPackage(), + MpvExPackage(), + NextPlayerPackage(), + JustPlayerPackage(), + FcastAction(), + LibreTorrentPackage(), + BiglyBTPackage(), + // forks/backup apps + VlcNightlyPackage(), + WebVideoCastPackage(), + MpvYTDLPackage(), + MpvKtPackage(), + MpvKtPreviewPackage(), + OnlyPlayer(), + MpvRxPackage(), + // Always Ask option + AlwaysAskAction(), + // added by plugins + // ... + ) + + init { + Log.d("VideoClickActionHolder", "allVideoClickActions: ${allVideoClickActions.map { it.uniqueId() }}") + } + + private const val ACTION_ID_OFFSET = 1000 + + fun makeOptionMap(activity: Activity?, video: ResultEpisode) = allVideoClickActions + // We need to have index before filtering + .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET } + .filter { it.first.shouldShowSafe(activity, video) } + .map { it.first.name to it.second } + + + fun getActionById(id: Int): VideoClickAction? = allVideoClickActions.getOrNull(id - ACTION_ID_OFFSET) + + fun getByUniqueId(uniqueId: String): VideoClickAction? = allVideoClickActions.firstOrNull { it.uniqueId() == uniqueId } + + fun uniqueIdToId(uniqueId: String?): Int? { + if (uniqueId == null) return null + return allVideoClickActions + .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET } + .firstOrNull { it.first.uniqueId() == uniqueId } + ?.second + } + + fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShowSafe(activity, null) } +} + +abstract class VideoClickAction { + abstract val name: UiText + + /** if true, the app will show dialog to select source - result.links[index] */ + open val oneSource : Boolean = false + + /** if true, this action could be selected as default player (one press action) in settings */ + open val isPlayer: Boolean = false + + /** Which type of sources this action can handle. */ + open val sourceTypes: Set = ExtractorLinkType.entries.toSet() + + /** Determines which plugin a given provider is from. This is the full path to the plugin. */ + var sourcePlugin: String? = null + + /** Even if VideoClickAction should not run any UI code, startActivity requires it, + * this is a wrapper for runOnUiThread in a suspended safe context that bubble up exceptions */ + @Throws + suspend fun uiThread(callable : Callable) : T? { + val future = FutureTask{ + try { + Result.success(callable.call()) + } catch (t : Throwable) { + Result.failure(t) + } + } + CommonActivity.activity?.runOnUiThread(future) ?: throw ErrorLoadingException("No UI Activity, this should never happened") + val result = withContext(Dispatchers.IO) { + return@withContext future.get() + } + return result.getOrThrow() + } + + /** Internally uses activityResultLauncher, + * use this when the activity has a result like watched position */ + @Throws + suspend fun launchResult(intent : Intent?, options : ActivityOptionsCompat? = null) { + if (intent == null) { + return + } + + uiThread { + MainActivity.activityResultLauncher?.launch(intent,options) + } + } + + /** Internally uses startActivity, use this when you don't + * have any result that needs to be stored when exiting the activity */ + @Throws + suspend fun launch(intent : Intent?, bundle : Bundle? = null) { + if (intent == null) { + return + } + + uiThread { + CommonActivity.activity?.startActivity(intent, bundle) + } + } + + fun uniqueId() = "$sourcePlugin:${this::class.jvmName}" + + @Throws + abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean + + /** Safe version of shouldShow, as we don't trust extension devs to handle exceptions, + * however no dev *should* throw in shouldShow */ + fun shouldShowSafe(context: Context?, video: ResultEpisode?): Boolean { + return try { + shouldShow(context,video) + } catch (t : Throwable) { + logError(t) + false + } + } + + /** + * This function is called when the action is clicked. + * @param context The current activity + * @param video The episode/movie that was clicked + * @param result The result of the link loading, contains video & subtitle links + * @param index if oneSource is true, this is the index of the selected source + */ + @Throws + abstract suspend fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) + + /** Safe version of runAction, as we don't trust extension devs to handle exceptions */ + fun runActionSafe(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) = ioSafe { + try { + runAction(context, video, result, index) + } catch (_ : NotImplementedError) { + CommonActivity.showToast("runAction has not been implemented for ${name.asStringNull(context)}, please contact the extension developer of $sourcePlugin", Toast.LENGTH_LONG) + } catch (error : ErrorLoadingException) { + CommonActivity.showToast(error.message, Toast.LENGTH_LONG) + } catch (_: ActivityNotFoundException) { + CommonActivity.showToast(R.string.app_not_found_error, Toast.LENGTH_LONG) + } catch (t : Throwable) { + logError(t) + CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt new file mode 100644 index 000000000..a7401c2ff --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/devgianlu/Aria2Android */ +@Suppress("unused") +class Aria2Package : OpenInAppAction( + appName = txt("Aria2"), + packageName = "com.gianlu.aria2android", + intentClass = "com.gianlu.aria2android.MainActivity" +) { + override val oneSource: Boolean = true + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + throw NotImplementedError("Aria2Android is missing getIntent, and onNewIntent, meaning it cant handle intents") + } + + override fun onResult(activity: Activity, intent: Intent?) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt new file mode 100644 index 000000000..3959bb9d3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt @@ -0,0 +1,36 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/BiglySoftware/BiglyBT-Android */ +class BiglyBTPackage : OpenInAppAction( + appName = txt("BiglyBT"), + packageName = "com.biglybt.android.client", + intentClass = "com.biglybt.android.client.activity.IntentHandler" +) { + // Only torrents are supported by the app + override val sourceTypes: Set = + setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT) + + override val oneSource: Boolean = true + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.data = result.links[index!!].url.toUri() + } + + override fun onResult(activity: Activity, intent: Intent?) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt new file mode 100644 index 000000000..d414b6117 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt @@ -0,0 +1,162 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.ui.player.ExtractorUri +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.SubtitleOrigin +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.DrmExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.Qualities +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF +import com.lagradost.cloudstream3.utils.txt + +/** + * If you want to support CloudStream 3 as an external player, then this shows how to play any video link + * For basic interactions, just `intent.data = uri` works + * + * However for more advanced use, CloudStream 3 also supports playlists of MinimalVideoLink and MinimalSubtitleLink with a `String[]` of JSON + * These are passed as LINKS_EXTRA and SUBTITLE_EXTRA respectively + */ +@Suppress("Unused") +class CloudStreamPackage : OpenInAppAction( + appName = txt("CloudStream"), + packageName = BuildConfig.APPLICATION_ID, //"com.lagradost.cloudstream3" or "com.lagradost.cloudstream3.prerelease" + intentClass = "com.lagradost.cloudstream3.ui.player.DownloadedPlayerActivity" +) { + override val oneSource: Boolean = false + + companion object { + const val SUBTITLE_EXTRA: String = "subs" // Json of an array of MinimalVideoLink + const val LINKS_EXTRA: String = "links" // Json of an array of MinimalSubtitleLink + const val TITLE_EXTRA: String = "title" // Unused (String) + const val ID_EXTRA: String = + "id" // Identification number for the video(s), used to store start time (Int) + const val POSITION_EXTRA: String = "pos" // Start time in MS (Long) + const val DURATION_EXTRA: String = "dur" // Duration time in MS (Long) + } + + data class MinimalVideoLink( + @JsonProperty("uri") + val uri: Uri?, + @JsonProperty("url") + val url: String?, + @JsonProperty("mimeType") + val mimeType: String = "video/mp4", + @JsonProperty("name") + val name: String?, + @JsonProperty("headers") + var headers: Map = mapOf(), + @JsonProperty("quality") + val quality: Int?, + ) { + companion object { + fun fromExtractor(link: ExtractorLink): MinimalVideoLink = MinimalVideoLink( + uri = null, + url = link.url, + name = link.name, + mimeType = link.type.getMimeType(), + headers = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) + link.headers, + quality = link.quality + ) + } + + suspend fun toExtractorLink(): Pair = + url?.let { url -> + newExtractorLink( + source = "NONE", + name = name ?: "Unknown", + url = url, + type = ExtractorLinkType.entries.firstOrNull { ty -> ty.getMimeType() == mimeType } + ?: ExtractorLinkType.VIDEO) { + + this@newExtractorLink.headers = + this@MinimalVideoLink.headers + + this@newExtractorLink.quality = + this@MinimalVideoLink.quality ?: Qualities.Unknown.value + } + } to uri?.let { uri -> + ExtractorUri( + uri = uri, + name = name ?: "Unknown", + ) + } + } + + + data class MinimalSubtitleLink( + @JsonProperty("url") + val url: String, + @JsonProperty("mimeType") + val mimeType: String = "text/vtt", + @JsonProperty("name") + val name: String?, + @JsonProperty("headers") + var headers: Map = mapOf(), + ) { + companion object { + fun fromSubtitle(sub: SubtitleData): MinimalSubtitleLink = MinimalSubtitleLink( + url = sub.url, + mimeType = sub.mimeType, + name = sub.originalName, + headers = sub.headers, + ) + } + + fun toSubtitleData(): SubtitleData = SubtitleData( + url = url, + nameSuffix = "", + mimeType = mimeType, + originalName = name ?: "Unknown", + headers = headers, + origin = SubtitleOrigin.URL, + languageCode = fromCodeToLangTagIETF(name) ?: + fromLanguageToTagIETF(name, true) ?: + name, + ) + } + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.apply { + val position = getViewPos(video.id)?.position + if (position != null) + putExtra(POSITION_EXTRA, position) + + putExtra(ID_EXTRA, video.id) + putExtra(TITLE_EXTRA, video.name) + putExtra( + SUBTITLE_EXTRA, + result.subs.map { MinimalSubtitleLink.fromSubtitle(it).toJson() }.toTypedArray() + ) + putExtra( + LINKS_EXTRA, + result.links.filter { it !is ExtractorLinkPlayList && it !is DrmExtractorLink } + .map { MinimalVideoLink.fromExtractor(it).toJson() }.toTypedArray() + ) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + // No results yet + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt new file mode 100644 index 000000000..7e89d7c8c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt @@ -0,0 +1,27 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.content.Context +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper + +class CopyClipboardAction: VideoClickAction() { + override val name = txt("Copy to clipboard") + + override val oneSource = true + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + if (index == null) return + val link = result.links.getOrNull(index) ?: return + clipboardHelper(txt(link.name), link.url) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt new file mode 100644 index 000000000..20eb843c7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt @@ -0,0 +1,37 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/moneytoo/Player/ */ +class JustPlayerPackage : OpenInAppAction( + appName = txt("JustPlayer"), + packageName = "com.brouken.player", + intentClass = "com.brouken.player.PlayerActivity" +) { + override val sourceTypes: Set = + setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH) + + override val oneSource: Boolean = true + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + // While JustPlayer has support for subs, it cant add both subs and links at the same time + // See https://github.com/moneytoo/Player/blob/49d80eb8de7a7bfc662393fdf114788fed1ebb2e/app/src/main/java/com/brouken/player/PlayerActivity.java#L794 + intent.data = result.links[index!!].url.toUri() + } + + override fun onResult(activity: Activity, intent: Intent?) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt new file mode 100644 index 000000000..11d1858c6 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt @@ -0,0 +1,36 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/proninyaroslav/libretorrent */ +class LibreTorrentPackage : OpenInAppAction( + appName = txt("LibreTorrent"), + packageName = "org.proninyaroslav.libretorrent", + intentClass = "org.proninyaroslav.libretorrent.ui.addtorrent.AddTorrentActivity" +) { + // Only torrents are supported by the app + override val sourceTypes: Set = + setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT) + + override val oneSource: Boolean = true + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.data = result.links[index!!].url.toUri() + } + + override fun onResult(activity: Activity, intent: Intent?) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt new file mode 100644 index 000000000..faae39212 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.actions.updateDurationAndPosition +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +class MpvKtPreviewPackage: MpvKtPackage( + appName = "mpvKt Preview", + packageName = "live.mehiz.mpvkt.preview", +) + +open class MpvKtPackage( + appName: String = "mpvKt", + packageName: String = "live.mehiz.mpvkt", +): OpenInAppAction( + appName = txt(appName), + packageName = packageName, + intentClass = "live.mehiz.mpvkt.ui.player.PlayerActivity" +) { + override val oneSource = true + + override val sourceTypes = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + val link = result.links.getOrNull(index ?: 0) ?: return + + intent.apply { + putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) + setDataAndType(link.url.toUri(), "video/*") + + // m3u8 plays, but changing sources feature is not available + // makeTempM3U8Intent(activity, this, result) + + //putExtra("headers", link.headers.flatMap { listOf(it.key, it.value) }.toTypedArray()) + + val position = getViewPos(video.id)?.position + if (position != null) + putExtra("position", position.toInt()) + + putExtra("secure_uri", true) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + val position = intent?.getIntExtra("position", -1)?.toLong() ?: -1 + val duration = intent?.getIntExtra("duration", -1)?.toLong() ?: -1 + updateDurationAndPosition(position, duration) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt new file mode 100644 index 000000000..cd49eb994 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.api.Log +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.actions.makeTempM3U8Intent +import com.lagradost.cloudstream3.actions.updateDurationAndPosition +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +// https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904 +// https://mpv-android.github.io/mpv-android/intent.html + +//https://github.com/marlboro-advance/mpvEx +class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity") + +class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { + override val sourceTypes = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) +} + +open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( + txt(appName), + packageName, + intentClass +) { + override val oneSource = true // mpv has poor playlist support on TV + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.apply { + putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) + putExtra("title", video.name) + + if (index != null) { + setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") + } else { + makeTempM3U8Intent(context, this, result) + } + + val position = getViewPos(video.id)?.position + if (position != null) + putExtra("position", position.toInt()) + + putExtra("secure_uri", true) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + val position = intent?.getIntExtra("position", -1) ?: -1 + val duration = intent?.getIntExtra("duration", -1) ?: -1 + Log.d("MPV", "Position: $position, Duration: $duration") + updateDurationAndPosition(position.toLong(), duration.toLong()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt new file mode 100644 index 000000000..e8bb93a99 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt @@ -0,0 +1,75 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.api.Log +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.actions.updateDurationAndPosition +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/Riteshp2001/mpvRx + * + * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132 + * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56 + * */ +class MpvRxPackage : OpenInAppAction( + appName = txt("mpvRx"), + packageName = "app.gyrolet.mpvrx", + intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity" +) { + override val oneSource = true + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.apply { + putExtra("title", video.name) + val link = result.links[index!!] + val headers = link.headers + + setData(link.url.toUri()) + if (headers.isNotEmpty()) { + // PlayerActivity expects a flat array: [key1, value1, key2, value2, ...] + val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray() + intent.putExtra("headers", flat) + } + /*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146 + intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray()) + intent.putExtra( + "subs.titles", + subs.map { it.name }.toTypedArray(), + ) + intent.putExtra( + "subs.langs", + subs.map { it.languageCode }.toTypedArray(), + ) + val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri() + intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf() )*/ + + if (video.tvType.isEpisodeBased()) { + video.season?.let { intent.putExtra("introdb_season", it) } + video.episode.let { intent.putExtra("introdb_episode", it) } + } + + val position = getViewPos(video.id)?.position + if (position != null) + putExtra("position", position.toInt()) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + val position = intent?.getIntExtra("position", -1) ?: -1 + val duration = intent?.getIntExtra("duration", -1) ?: -1 + Log.d("MPV", "Position: $position, Duration: $duration") + updateDurationAndPosition(position.toLong(), duration.toLong()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt new file mode 100644 index 000000000..5d0923b81 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt @@ -0,0 +1,35 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/anilbeesetti/nextplayer */ +class NextPlayerPackage : OpenInAppAction( + appName = txt("NextPlayer"), + packageName = "dev.anilbeesetti.nextplayer", + intentClass = "dev.anilbeesetti.nextplayer.feature.player.PlayerActivity" +) { + override val sourceTypes: Set = + setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH) + + override val oneSource: Boolean = true + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + intent.data = result.links[index!!].url.toUri() + } + + override fun onResult(activity: Activity, intent: Intent?) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt new file mode 100644 index 000000000..348be440a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt @@ -0,0 +1,44 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.net.toUri +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt + +/** https://github.com/Kindness-Kismet/only_player/tree/main + * https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */ +class OnlyPlayer : OpenInAppAction( + txt("Only Player"), + "one.only.player", + intentClass = "one.only.player.feature.player.PlayerActivity" +) { + override val oneSource = true + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + /** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */ + intent.apply { + val link = result.links[index!!] + setData(link.url.toUri()) + + putExtra("headers", Bundle().apply { + for ((key, value) in link.headers) { + putExtra(key, value) + } + }) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + /* onResult does not get called */ + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt new file mode 100644 index 000000000..bfd2926bf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt @@ -0,0 +1,39 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +class PlayInBrowserAction: VideoClickAction() { + override val name = txt(R.string.episode_action_play_in_format, "Browser") + + override val oneSource = true + + override val isPlayer = true + + override val sourceTypes: Set = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + val link = result.links.getOrNull(index ?: 0) ?: return + val i = Intent(Intent.ACTION_VIEW) + i.data = link.url.toUri() + launch(i) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt new file mode 100644 index 000000000..56512377b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt @@ -0,0 +1,65 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.player.ExtractorUri +import com.lagradost.cloudstream3.ui.player.GeneratorPlayer +import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.player.VideoGenerator +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.txt + +class PlayMirrorAction : VideoClickAction() { + override val name = txt(R.string.episode_action_play_mirror) + + override val oneSource = true + + override val isPlayer = true + + override val sourceTypes: Set = LOADTYPE_INAPP + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + //Implemented a generator to handle the single + val activity = context as? Activity ?: return + val link = index?.let { result.links[it] } + val generatorMirror = object : VideoGenerator(listOf(video)) { + override val hasCache: Boolean = false + override val canSkipLoading: Boolean = false + override fun getId(index: Int): Int = video.id + + override suspend fun generateLinks( + clearCache: Boolean, + sourceTypes: Set, + callback: (Pair) -> Unit, + subtitleCallback: (SubtitleData) -> Unit, + offset: Int, + isCasting: Boolean + ): Boolean { + index?.let { callback(link to null) } + result.subs.forEach { subtitle -> subtitleCallback(subtitle) } + return true + } + } + + activity.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + generatorMirror, 0, result.syncData + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt new file mode 100644 index 000000000..791566862 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.content.Context +import android.content.Intent +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.actions.makeTempM3U8Intent +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt + +class ViewM3U8Action: VideoClickAction() { + override val name = txt(R.string.episode_action_play_in_format, "m3u8 player") + + override val isPlayer = true + + override fun shouldShow(context: Context?, video: ResultEpisode?) = true + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + if (context == null) return + val i = Intent(Intent.ACTION_VIEW) + makeTempM3U8Intent(context, i, result) + launch(i) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt new file mode 100644 index 000000000..46b46a2c2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.net.toUri +import com.lagradost.api.Log +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.actions.makeTempM3U8Intent +import com.lagradost.cloudstream3.actions.updateDurationAndPosition +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos + +// https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898 +// https://wiki.videolan.org/Android_Player_Intents/ + +class VlcNightlyPackage : VlcPackage() { + override val packageName = "org.videolan.vlc.debug" + override val appName = txt("VLC Nightly") +} + +open class VlcPackage: OpenInAppAction( + appName = txt("VLC"), + packageName = "org.videolan.vlc", + intentClass = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + "org.videolan.vlc.gui.video.VideoPlayerActivity" + } else { + null + }, + action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + "org.videolan.vlc.player.result" + } else { + Intent.ACTION_VIEW + } +) { + // while VLC supports multi links, it has poor support, so we disable it for now + override val oneSource = true + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + if (index != null) { + intent.setDataAndType(result.links[index].url.toUri(), "video/*") + } else { + makeTempM3U8Intent(context, intent, result) + } + val position = getViewPos(video.id)?.position ?: 0L + + intent.putExtra("from_start", false) + intent.putExtra("position", position) + intent.putExtra("secure_uri", true) + intent.putExtra("title", video.name) + + val subsLang = getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" + result.subs.firstOrNull { + subsLang == it.languageCode + }?.let { + intent.putExtra("subtitles_location", it.url) + } + } + + override fun onResult(activity: Activity, intent: Intent?) { + val position = intent?.getLongExtra("extra_position", -1) ?: -1 + val duration = intent?.getLongExtra("extra_duration", -1) ?: -1 + Log.d("VLC", "Position: $position, Duration: $duration") + updateDurationAndPosition(position, duration) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt new file mode 100644 index 000000000..963221bb3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt @@ -0,0 +1,61 @@ +package com.lagradost.cloudstream3.actions.temp + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.net.toUri +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.actions.OpenInAppAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.ExtractorLinkType + +// https://www.webvideocaster.com/integrations + +class WebVideoCastPackage: OpenInAppAction( + txt("Web Video Cast"), + "com.instantbits.cast.webvideo" +) { + + override val oneSource = true + + override val sourceTypes = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + + override suspend fun putExtra( + context: Context, + intent: Intent, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + val link = result.links[index ?: 0] + + intent.apply { + setDataAndType(link.url.toUri(), "video/*") + + val title = video.name ?: video.headerName + + putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) + putExtra("title", title) + video.poster?.let { putExtra("poster", it) } + val headers = Bundle().apply { + if (link.referer.isNotBlank()) + putString("Referer", link.referer) + putString("User-Agent", USER_AGENT) + for ((key, value) in link.headers) { + putString(key, value) + } + } + putExtra("android.media.intent.extra.HTTP_HEADERS", headers) + putExtra("secure_uri", true) + } + } + + override fun onResult(activity: Activity, intent: Intent?) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt new file mode 100644 index 000000000..1036a7055 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt @@ -0,0 +1,69 @@ +package com.lagradost.cloudstream3.actions.temp.fcast + +import android.content.Context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.ui.result.LinkLoadingResult +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog + +class FcastAction: VideoClickAction() { + override val name = txt("Fcast to device") + + override val oneSource = true + + override val sourceTypes = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 + ) + + override fun shouldShow(context: Context?, video: ResultEpisode?) = FcastManager.currentDevices.isNotEmpty() + + override suspend fun runAction( + context: Context?, + video: ResultEpisode, + result: LinkLoadingResult, + index: Int? + ) { + val link = result.links.getOrNull(index ?: 0) ?: return + val devices = FcastManager.currentDevices.toList() + uiThread { + context?.getActivity()?.showBottomDialog( + devices.map { it.name }, + -1, + txt(R.string.player_settings_select_cast_device).asString(context), + false, + {}) { + val position = getViewPos(video.id)?.position + castTo(devices.getOrNull(it), link, position) + } + } + } + + + private fun castTo(device: PublicDeviceInfo?, link: ExtractorLink, position: Long?) { + val host = device?.host ?: return + + FcastSession(host).use { session -> + session.sendMessage( + Opcode.Play, + PlayMessage( + link.type.getMimeType(), + link.url, + time = position?.let { it / 1000.0 }, + headers = mapOf( + "referer" to link.referer, + "user-agent" to USER_AGENT + ) + link.headers + ) + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt new file mode 100644 index 000000000..e2cf4f002 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt @@ -0,0 +1,195 @@ +package com.lagradost.cloudstream3.actions.temp.fcast + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdManager.ResolveListener +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.os.ext.SdkExtensions +import android.util.Log +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe + +class FcastManager { + private var nsdManager: NsdManager? = null + + // Used for receiver + private val registrationListenerTcp = DefaultRegistrationListener() + private fun getDeviceName(): String { + return "${Build.MANUFACTURER}-${Build.MODEL}" + } + + /** + * Start the fcast service + * @param registerReceiver If true will register the app as a compatible fcast receiver for discovery in other app + */ + fun init(context: Context, registerReceiver: Boolean) = ioSafe { + nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + val serviceType = "_fcast._tcp" + + if (registerReceiver) { + val serviceName = "$APP_PREFIX-${getDeviceName()}" + + val serviceInfo = NsdServiceInfo().apply { + this.serviceName = serviceName + this.serviceType = serviceType + this.port = TCP_PORT + } + + nsdManager?.registerService( + serviceInfo, + NsdManager.PROTOCOL_DNS_SD, + registrationListenerTcp + ) + } + + nsdManager?.discoverServices( + serviceType, + NsdManager.PROTOCOL_DNS_SD, + DefaultDiscoveryListener() + ) + } + + fun stop() { + nsdManager?.unregisterService(registrationListenerTcp) + } + + inner class DefaultDiscoveryListener : NsdManager.DiscoveryListener { + val tag = "DiscoveryListener" + override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { + Log.d(tag, "Discovery failed: $serviceType, error code: $errorCode") + } + + override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) { + Log.d(tag, "Stop discovery failed: $serviceType, error code: $errorCode") + } + + override fun onDiscoveryStarted(serviceType: String?) { + Log.d(tag, "Discovery started: $serviceType") + } + + override fun onDiscoveryStopped(serviceType: String?) { + Log.d(tag, "Discovery stopped: $serviceType") + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo?) { + // Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback + safe { + if (serviceInfo == null) return@safe + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( + Build.VERSION_CODES.TIRAMISU + ) >= 7 + ) { + nsdManager?.registerServiceInfoCallback( + serviceInfo, + Runnable::run, + object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.e(tag, "Service registration failed: $errorCode") + } + + override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { + Log.d( + tag, + "Service updated: ${serviceInfo.serviceName}," + + "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" + ) + synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } + } + + override fun onServiceLost() { + Log.d(tag, "Service lost: ${serviceInfo.serviceName},") + synchronized(_currentDevices) { + _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } + } + } + + override fun onServiceInfoCallbackUnregistered() {} + }) + } else { + @Suppress("DEPRECATION") + nsdManager?.resolveService(serviceInfo, object : ResolveListener { + override fun onResolveFailed( + serviceInfo: NsdServiceInfo?, + errorCode: Int + ) { + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + + synchronized(_currentDevices) { + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } + + Log.d( + tag, + "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" + ) + } + }) + } + } + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo?) { + if (serviceInfo == null) return + + // May remove duplicates, but net and port is null here, preventing device specific identification + synchronized(_currentDevices) { + _currentDevices.removeAll { + it.rawName == serviceInfo.serviceName + } + } + + Log.d(tag, "Service lost: ${serviceInfo.serviceName}") + } + } + + companion object { + const val APP_PREFIX = "CloudStream" + private val _currentDevices: MutableList = mutableListOf() + val currentDevices: List = _currentDevices + + class DefaultRegistrationListener : NsdManager.RegistrationListener { + val tag = "DiscoveryService" + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { + Log.d(tag, "Service registered: ${serviceInfo.serviceName}") + } + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(tag, "Service registration failed: errorCode=$errorCode") + } + + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) { + Log.d(tag, "Service unregistered: ${serviceInfo.serviceName}") + } + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(tag, "Service unregistration failed: errorCode=$errorCode") + } + } + + const val TCP_PORT = 46899 + } +} + +class PublicDeviceInfo(serviceInfo: NsdServiceInfo) { + val rawName: String = serviceInfo.serviceName + val host: String? = if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + SdkExtensions.getExtensionVersion( + Build.VERSION_CODES.TIRAMISU + ) >= 7 + ) { + serviceInfo.hostAddresses.firstOrNull()?.hostAddress + } else { + @Suppress("DEPRECATION") + serviceInfo.host.hostAddress + } + val name = rawName.replace("-", " ") + host?.let { " $it" } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastSession.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastSession.kt new file mode 100644 index 000000000..326d11191 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastSession.kt @@ -0,0 +1,60 @@ +package com.lagradost.cloudstream3.actions.temp.fcast + +import android.util.Log +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.safefile.closeQuietly +import java.io.DataOutputStream +import java.net.Socket +import kotlin.jvm.Throws + +class FcastSession(private val hostAddress: String): AutoCloseable { + val tag = "FcastSession" + + private var socket: Socket? = null + @Throws + @WorkerThread + fun open(): Socket { + val socket = Socket(hostAddress, FcastManager.TCP_PORT) + this.socket = socket + return socket + } + + override fun close() { + socket?.closeQuietly() + socket = null + } + + @Throws + private fun acquireSocket(): Socket { + return socket ?: open() + } + + fun ping() { + sendMessage(Opcode.Ping, null) + } + + fun sendMessage(opcode: Opcode, message: T) { + ioSafe { + val socket = acquireSocket() + val outputStream = DataOutputStream(socket.getOutputStream()) + + val json = message?.toJson() + val content = json?.toByteArray() ?: ByteArray(0) + + // Little endian starting from 1 + // https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 + val size = content.size + 1 + + val sizeArray = ByteArray(4) { num -> + (size shr 8 * num and 0xff).toByte() + } + + Log.d(tag, "Sending message with size: $size, opcode: $opcode") + outputStream.write(sizeArray) + outputStream.write(ByteArray(1) { opcode.value }) + outputStream.write(content) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/Packets.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/Packets.kt new file mode 100644 index 000000000..26f5cec53 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/Packets.kt @@ -0,0 +1,62 @@ +package com.lagradost.cloudstream3.actions.temp.fcast + +// See https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 +enum class Opcode(val value: Byte) { + None(0), + Play(1), + Pause(2), + Resume(3), + Stop(4), + Seek(5), + PlaybackUpdate(6), + VolumeUpdate(7), + SetVolume(8), + PlaybackError(9), + SetSpeed(10), + Version(11), + Ping(12), + Pong(13); +} + + +data class PlayMessage( + val container: String, + val url: String? = null, + val content: String? = null, + val time: Double? = null, + val speed: Double? = null, + val headers: Map? = null +) + +data class SeekMessage( + val time: Double +) + +data class PlaybackUpdateMessage( + val generationTime: Long, + val time: Double, + val duration: Double, + val state: Int, + val speed: Double +) + +data class VolumeUpdateMessage( + val generationTime: Long, + val volume: Double +) + +data class PlaybackErrorMessage( + val message: String +) + +data class SetSpeedMessage( + val speed: Double +) + +data class SetVolumeMessage( + val volume: Double +) + +data class VersionMessage( + val version: Long +) diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/AStreamHub.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/AStreamHub.kt deleted file mode 100644 index b0051ba76..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/AStreamHub.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import android.util.Log -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -open class AStreamHub : ExtractorApi() { - override val name = "AStreamHub" - override val mainUrl = "https://astreamhub.com" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - app.get(url).document.selectFirst("body > script").let { script -> - val text = script?.html() ?: "" - Log.i("Dev", "text => $text") - if (text.isNotBlank()) { - val m3link = "(?<=file:)(.*)(?=,)".toRegex().find(text) - ?.groupValues?.get(0)?.trim()?.trim('"') ?: "" - Log.i("Dev", "m3link => $m3link") - if (m3link.isNotBlank()) { - sources.add( - ExtractorLink( - name = name, - source = name, - url = m3link, - isM3u8 = true, - quality = Qualities.Unknown.value, - referer = referer ?: url - ) - ) - } - } - } - return sources - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt deleted file mode 100644 index c782b29df..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.base64Decode -import com.lagradost.cloudstream3.utils.* - -open class Acefile : ExtractorApi() { - override val name = "Acefile" - override val mainUrl = "https://acefile.co" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - app.get(url).document.select("script").map { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val data = getAndUnpack(script.data()) - val id = data.substringAfter("{\"id\":\"").substringBefore("\",") - val key = data.substringAfter("var nfck=\"").substringBefore("\";") - app.get("https://acefile.co/local/$id?key=$key").text.let { - base64Decode( - it.substringAfter("JSON.parse(atob(\"").substringBefore("\"))") - ).let { res -> - sources.add( - ExtractorLink( - name, - name, - res.substringAfter("\"file\":\"").substringBefore("\","), - "$mainUrl/", - Qualities.Unknown.value, - ) - ) - } - } - } - } - return sources - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/AsianLoad.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/AsianLoad.kt deleted file mode 100644 index 7a62fb524..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/AsianLoad.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper -import com.lagradost.cloudstream3.utils.getQualityFromName -import java.net.URI - -open class AsianLoad : ExtractorApi() { - override var name = "AsianLoad" - override var mainUrl = "https://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 71fa7066b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/BullStream.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class BullStream : ExtractorApi() { - override val name = "BullStream" - override val mainUrl = "https://bullstream.xyz" - override val requiresReferer = false - val regex = Regex("(?<=sniff\\()(.*)(?=\\)\\);)") - - override suspend fun getUrl(url: String, referer: String?): List? { - val data = regex.find(app.get(url).text)?.value - ?.replace("\"", "") - ?.split(",") - ?: return null - - val m3u8 = "$mainUrl/m3u8/${data[1]}/${data[2]}/master.txt?s=1&cache=${data[4]}" - //println("shiv : $m3u8") - return M3u8Helper.generateM3u8( - name, - m3u8, - url, - headers = mapOf("referer" to url, "accept" to "*/*") - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/ByteShare.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/ByteShare.kt deleted file mode 100644 index 3e0a03c01..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/ByteShare.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.utils.* - -open class ByteShare : ExtractorApi() { - override val name = "ByteShare" - override val mainUrl = "https://byteshare.net" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - sources.add( - ExtractorLink( - name, - name, - url.replace("/embed/", "/download/"), - "", - Qualities.Unknown.value, - ) - ) - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Dailymotion.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Dailymotion.kt deleted file mode 100644 index 125e4bcf9..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Dailymotion.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import java.net.URL - -open class Dailymotion : ExtractorApi() { - override val mainUrl = "https://www.dailymotion.com" - override val name = "Dailymotion" - override val requiresReferer = false - - @Suppress("RegExpSimplifiable") - private val videoIdRegex = "^[kx][a-zA-Z0-9]+\$".toRegex() - - // https://www.dailymotion.com/video/k3JAHfletwk94ayCVIu - // https://www.dailymotion.com/embed/video/k3JAHfletwk94ayCVIu - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val embedUrl = getEmbedUrl(url) ?: return - val doc = app.get(embedUrl).document - val prefix = "window.__PLAYER_CONFIG__ = " - val configStr = doc.selectFirst("script:containsData($prefix)")?.data() ?: return - val config = tryParseJson(configStr.substringAfter(prefix)) ?: return - val id = getVideoId(embedUrl) ?: return - val dmV1st = config.dmInternalData.v1st - val dmTs = config.dmInternalData.ts - val metaDataUrl = - "$mainUrl/player/metadata/video/$id?locale=en&dmV1st=$dmV1st&dmTs=$dmTs&is_native_app=0" - val cookies = mapOf( - "v1st" to dmV1st, - "dmvk" to config.context.dmvk, - "ts" to dmTs.toString() - ) - val metaData = app.get(metaDataUrl, referer = embedUrl, cookies = cookies) - .parsedSafe() ?: return - metaData.qualities.forEach { (key, video) -> - video.forEach { - callback.invoke( - ExtractorLink( - name, - "$name $key", - it.url, - "", - Qualities.Unknown.value, - true - ) - ) - } - } - } - - private fun getEmbedUrl(url: String): String? { - if (url.contains("/embed/")) { - return url - } - val vid = getVideoId(url) ?: return null - return "$mainUrl/embed/video/$vid" - } - - private fun getVideoId(url: String): String? { - val path = URL(url).path - val id = path.substringAfter("video/") - if (id.matches(videoIdRegex)) { - return id - } - return null - } - - data class Config( - val context: Context, - val dmInternalData: InternalData - ) - - data class InternalData( - val ts: Int, - val v1st: String - ) - - data class Context( - @JsonProperty("access_token") val accessToken: String?, - val dmvk: String, - ) - - data class MetaData( - val qualities: Map> - ) - - data class VideoLink( - val type: String, - val url: String - ) - -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/DoodExtractor.kt deleted file mode 100644 index 7ec1fb221..000000000 --- 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 e8f8c49ac..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Fastream.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 -import com.lagradost.cloudstream3.utils.getAndUnpack -import org.jsoup.nodes.Document - -open class Fastream: ExtractorApi() { - override var mainUrl = "https://fastream.to" - override var name = "Fastream" - override val requiresReferer = false - suspend fun getstream( - response: Document, - sources: ArrayList): Boolean{ - response.select("script").amap { script -> - if (script.data().contains(Regex("eval\\(function\\(p,a,c,k,e,[rd]"))) { - val unpacked = getAndUnpack(script.data()) - //val m3u8regex = Regex("((https:|http:)\\/\\/.*\\.m3u8)") - val newm3u8link = unpacked.substringAfter("file:\"").substringBefore("\"") - //val m3u8link = m3u8regex.find(unpacked)?.value ?: return@forEach - generateM3u8( - name, - newm3u8link, - mainUrl - ).forEach { link -> - sources.add(link) - } - } - } - return true - } - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = ArrayList() - val idregex = Regex("emb.html\\?(.*)=") - if (url.contains(Regex("(emb.html.*fastream)"))) { - val id = idregex.find(url)?.destructured?.component1() ?: "" - val response = app.post("https://fastream.to/dl", allowRedirects = false, - data = mapOf( - "op" to "embed", - "file_code" to id, - "auto" to "1" - ) - ).document - getstream(response, sources) - } - val response = app.get(url, referer = url).document - getstream(response, sources) - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt deleted file mode 100644 index bc910a7e1..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Filesim.kt +++ /dev/null @@ -1,57 +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.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import java.net.URI - -class FileMoon : Filesim() { - override val mainUrl = "https://filemoon.to" - override val name = "FileMoon" -} - -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?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - with(app.get(url).document) { - this.select("script").forEach { script -> - if (script.data().contains("eval(function(p,a,c,k,e,d)")) { - val data = getAndUnpack(script.data()) - val foundData = Regex("""sources:\[(.*?)]""").find(data)?.groupValues?.get(1) ?: return@forEach - val fixedData = foundData.replace("file:", """"file":""") - - parseJson>("[$fixedData]").forEach { - callback.invoke( - ExtractorLink( - name, - name, - it.file, - "$mainUrl/", - Qualities.Unknown.value, - URI(it.file).path.endsWith(".m3u8") - ) - ) - } - } - } - } - } - - 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 52c450968..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/GMPlayer.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -open class GMPlayer : ExtractorApi() { - override val name = "GM Player" - override val mainUrl = "https://gmplayer.xyz" - override val requiresReferer = true - - override suspend fun getUrl(url: String, referer: String?): List? { - val ref = referer ?: return null - val id = url.substringAfter("/video/").substringBefore("/") - - val m3u8 = app.post( - "$mainUrl/player/index.php?data=$id&do=getVideo", - mapOf( - "accept" to "*/*", - "referer" to ref, - "x-requested-with" to "XMLHttpRequest", - "origin" to mainUrl - ), - data = mapOf("hash" to id, "r" to ref) - ).parsed().videoSource ?: return null - - return listOf( - ExtractorLink( - this.name, - this.name, - m3u8, - ref, - Qualities.Unknown.value, - headers = mapOf("accept" to "*/*"), - isM3u8 = true - ) - ) - } - - private data class GmResponse( - val videoSource: String? = null - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt deleted file mode 100644 index 2adc00d50..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/GuardareStream.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* - -class Vanfem : GuardareStream() { - override var name = "Vanfem" - override var mainUrl = "https://vanfem.com/" -} - -class CineGrabber : GuardareStream() { - override var name = "CineGrabber" - override var mainUrl = "https://cinegrabber.com" -} - -open class GuardareStream : ExtractorApi() { - override var name = "Guardare" - override var mainUrl = "https://guardare.stream" - override val requiresReferer = false - - data class GuardareJsonData( - @JsonProperty("data") val data: List, - @JsonProperty("captions") val captions: List?, - ) - - data class GuardareData( - @JsonProperty("file") val file: String, - @JsonProperty("label") val label: String, - @JsonProperty("type") val type: String - ) - - - // https://cinegrabber.com/asset/userdata/224879/caption/gqdmzh-71ez76z8/876438.srt - data class GuardareCaptions( - @JsonProperty("id") val id: String, - @JsonProperty("hash") val hash: String, - @JsonProperty("language") val language: String?, - @JsonProperty("extension") val extension: String - ) { - fun getUrl(mainUrl: String, userId: String): String { - return "$mainUrl/asset/userdata/$userId/caption/$hash/$id.$extension" - } - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val response = - app.post(url.replace("/v/", "/api/source/"), data = mapOf("d" to mainUrl)).text - - val jsonVideoData = AppUtils.parseJson(response) - jsonVideoData.data.forEach { - callback.invoke( - ExtractorLink( - 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 f5dde774d..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Hxfile.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson - -class Neonime7n : Hxfile() { - override val name = "Neonime7n" - override val mainUrl = "https://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 6e6f65167..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/JWPlayer.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName - -class Meownime : JWPlayer() { - override val name = "Meownime" - override val mainUrl = "https://meownime.ltd" -} - -class DesuOdchan : JWPlayer() { - override val name = "DesuOdchan" - override val mainUrl = "https://desustream.me/odchan/" -} - -class DesuArcg : JWPlayer() { - override val name = "DesuArcg" - override val mainUrl = "https://desustream.me/arcg/" -} - -class DesuDrive : JWPlayer() { - override val name = "DesuDrive" - override val mainUrl = "https://desustream.me/desudrive/" -} - -class DesuOdvip : JWPlayer() { - override val name = "DesuOdvip" - override val mainUrl = "https://desustream.me/odvip/" -} - -open class JWPlayer : ExtractorApi() { - override val name = "JWPlayer" - override val mainUrl = "https://www.jwplayer.com" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List? { - val sources = mutableListOf() - with(app.get(url).document) { - val data = this.select("script").mapNotNull { script -> - if (script.data().contains("sources: [")) { - script.data().substringAfter("sources: [") - .substringBefore("],").replace("'", "\"") - } else if (script.data().contains("otakudesu('")) { - script.data().substringAfter("otakudesu('") - .substringBefore("');") - } else { - null - } - } - - tryParseJson>("$data")?.map { - sources.add( - ExtractorLink( - name, - name, - it.file, - referer = url, - quality = getQualityFromName( - Regex("(\\d{3,4}p)").find(it.file)?.groupValues?.get( - 1 - ) - ) - ) - ) - } - } - return sources - } - - private data class ResponseSource( - @JsonProperty("file") val file: String, - @JsonProperty("type") val type: String?, - @JsonProperty("label") val label: String? - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Jawcloud.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Jawcloud.kt deleted file mode 100644 index 203a266c1..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Jawcloud.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - - -open class Jawcloud : ExtractorApi() { - override var name = "Jawcloud" - override var mainUrl = "https://jawcloud.co" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List? { - val doc = app.get(url).document - val urlString = doc.select("html body div source").attr("src") - val sources = mutableListOf() - if (urlString.contains("m3u8")) - M3u8Helper.generateM3u8( - name, - urlString, - url, - headers = app.get(url).headers.toMap() - ).forEach { link -> sources.add(link) } - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt deleted file mode 100644 index c28a89003..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Linkbox.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.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?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val id = Regex("""(/file/|id=)(\S+)[&/?]""").find(url)?.groupValues?.get(2) - app.get("$mainUrl/api/open/get_url?itemId=$id", referer=url).parsedSafe()?.data?.rList?.map { link -> - callback.invoke( - ExtractorLink( - name, - name, - link.url, - url, - getQualityFromName(link.resolution) - ) - ) - } - } - - 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 93a280ed5..000000000 --- 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 446571962..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/MultiQuality.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities -import com.lagradost.cloudstream3.utils.getQualityFromName -import java.net.URI - -open class MultiQuality : ExtractorApi() { - override var name = "MultiQuality" - override var mainUrl = "https://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 70e87fbf4..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - -data class DataOptionsJson ( - @JsonProperty("flashvars") var flashvars : Flashvars? = Flashvars(), -) -data class Flashvars ( - @JsonProperty("metadata") var metadata : String? = null, - @JsonProperty("hlsManifestUrl") var hlsManifestUrl : String? = null, //m3u8 -) - -data class MetadataOkru ( - @JsonProperty("videos") var videos: ArrayList = arrayListOf(), -) - -data class Videos ( - @JsonProperty("name") var name : String, - @JsonProperty("url") var url : String, - @JsonProperty("seekSchema") var seekSchema : Int? = null, - @JsonProperty("disallowed") var disallowed : Boolean? = null -) - -class OkRuHttps: OkRu(){ - override var mainUrl = "https://ok.ru" -} - -open class OkRu : ExtractorApi() { - override var name = "Okru" - override var mainUrl = "http://ok.ru" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List? { - val doc = app.get(url).document - val sources = ArrayList() - val datajson = doc.select("div[data-options]").attr("data-options") - if (datajson.isNotBlank()) { - val main = parseJson(datajson) - val metadatajson = parseJson(main.flashvars?.metadata!!) - val servers = metadatajson.videos - servers.forEach { - val quality = it.name.uppercase() - .replace("MOBILE","144p") - .replace("LOWEST","240p") - .replace("LOW","360p") - .replace("SD","480p") - .replace("HD","720p") - .replace("FULL","1080p") - .replace("QUAD","1440p") - .replace("ULTRA","4k") - val extractedurl = it.url.replace("\\\\u0026", "&") - sources.add(ExtractorLink( - name, - name = this.name, - extractedurl, - url, - getQualityFromName(quality), - isM3u8 = false - )) - } - } - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Okrulink.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Okrulink.kt deleted file mode 100644 index 37bb09e3c..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Okrulink.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -data class Okrulinkdata ( - @JsonProperty("status" ) var status : String? = null, - @JsonProperty("url" ) var url : String? = null -) - -open class Okrulink: ExtractorApi() { - override var mainUrl = "https://okru.link" - override var name = "Okrulink" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - val key = url.substringAfter("html?t=") - val request = app.post("https://apizz.okru.link/decoding", allowRedirects = false, - data = mapOf("video" to key) - ).parsedSafe() - if (request?.url != null) { - sources.add( - ExtractorLink( - name, - name, - request.url!!, - "", - Qualities.Unknown.value, - isM3u8 = false - ) - ) - } - return sources - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt deleted file mode 100644 index 45ec4c2f8..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Pelisplus.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.extractorApis -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.loadExtractor -import org.jsoup.Jsoup - -/** - * overrideMainUrl is necessary for for other vidstream clones like vidembed.cc - * If they diverge it'd be better to make them separate. - * */ -open class Pelisplus(val mainUrl: String) { - val name: String = "Vidstream" - - private fun getExtractorUrl(id: String): String { - return "$mainUrl/play?id=$id" - } - - private fun getDownloadUrl(id: String): String { - return "$mainUrl/download?id=$id" - } - - private val normalApis = arrayListOf(MultiQuality()) - - // https://gogo-stream.com/streaming.php?id=MTE3NDg5 - suspend fun getUrl( - id: String, - isCasting: Boolean = false, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ): Boolean { - try { - normalApis.amap { api -> - val url = api.getExtractorUrl(id) - api.getSafeUrl(url, subtitleCallback = subtitleCallback, callback = callback) - } - val extractorUrl = getExtractorUrl(id) - - /** Stolen from GogoanimeProvider.kt extractor */ - suspendSafeApiCall { - val link = getDownloadUrl(id) - println("Generated vidstream download link: $link") - val page = app.get(link, referer = extractorUrl) - - val pageDoc = Jsoup.parse(page.text) - val qualityRegex = Regex("(\\d+)P") - - //a[download] - pageDoc.select(".dowload > a")?.amap { element -> - val href = element.attr("href") ?: return@amap - val qual = if (element.text() - .contains("HDP") - ) "1080" else qualityRegex.find(element.text())?.destructured?.component1() - .toString() - - if (!loadExtractor(href, link, subtitleCallback, callback)) { - callback.invoke( - ExtractorLink( - this.name, - name = this.name, - href, - page.url, - getQualityFromName(qual), - element.attr("href").contains(".m3u8") - ) - ) - } - } - } - - with(app.get(extractorUrl)) { - val document = Jsoup.parse(this.text) - val primaryLinks = document.select("ul.list-server-items > li.linkserver") - //val extractedLinksList: MutableList = mutableListOf() - - // All vidstream links passed to extractors - primaryLinks.distinctBy { it.attr("data-video") }.forEach { element -> - val link = element.attr("data-video") - //val name = element.text() - - // Matches vidstream links with extractors - extractorApis.filter { !it.requiresReferer || !isCasting }.amap { api -> - if (link.startsWith(api.mainUrl)) { - api.getSafeUrl(link, extractorUrl, subtitleCallback, callback) - } - } - } - return true - } - } catch (e: Exception) { - return false - } - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Solidfiles.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Solidfiles.kt deleted file mode 100644 index cc34781cf..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Solidfiles.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.getQualityFromName - - -open class Solidfiles : ExtractorApi() { - override val name = "Solidfiles" - override val mainUrl = "https://www.solidfiles.com" - override val requiresReferer = false - - override suspend fun getUrl(url: String, referer: String?): List { - val sources = mutableListOf() - with(app.get(url).document) { - this.select("script").map { script -> - if (script.data().contains("\"streamUrl\":")) { - val data = script.data().substringAfter("constant('viewerOptions', {").substringBefore("});") - val source = tryParseJson("{$data}") - val quality = Regex("\\d{3,4}p").find(source!!.nodeName)?.groupValues?.get(0) - sources.add( - ExtractorLink( - name, - name, - source.streamUrl, - referer = url, - quality = getQualityFromName(quality) - ) - ) - } - } - } - return sources - } - - - private data class ResponseSource( - @JsonProperty("streamUrl") val streamUrl: String, - @JsonProperty("nodeName") val nodeName: String - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/SpeedoStream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/SpeedoStream.kt deleted file mode 100644 index 8ef6c4638..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/SpeedoStream.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -class SpeedoStream1 : SpeedoStream() { - override val mainUrl = "https://speedostream.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 ece8dc4bb..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/StreamTape.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -class StreamTapeNet : StreamTape() { - override var mainUrl = "https://streamtape.net" -} - -class ShaveTape : StreamTape(){ - override var mainUrl = "https://shavetape.cash" -} - -open class StreamTape : ExtractorApi() { - override var name = "StreamTape" - override var mainUrl = "https://streamtape.com" - override val requiresReferer = false - - private val linkRegex = - Regex("""'robotlink'\)\.innerHTML = '(.+?)'\+ \('(.+?)'\)""") - - override suspend fun getUrl(url: String, referer: String?): List? { - with(app.get(url)) { - linkRegex.find(this.text)?.let { - val extractedUrl = - "https:${it.groups[1]!!.value + it.groups[2]!!.value.substring(3)}" - return listOf( - ExtractorLink( - name, - name, - extractedUrl, - url, - Qualities.Unknown.value, - ) - ) - } - } - return null - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt deleted file mode 100644 index dd49d9945..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Supervideo.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - -data class Files( - @JsonProperty("file") val id: String, - @JsonProperty("label") val label: String? = null, -) - -open class Supervideo : ExtractorApi() { - override var name = "Supervideo" - override var mainUrl = "https://supervideo.tv" - override val requiresReferer = false - override suspend fun getUrl(url: String, referer: String?): List? { - val extractedLinksList: MutableList = mutableListOf() - val response = app.get(url).text - val jstounpack = Regex("eval((.|\\n)*?)").find(response)?.groups?.get(1)?.value - val unpacjed = JsUnpacker(jstounpack).unpack() - val extractedUrl = - unpacjed?.let { Regex("""sources:((.|\n)*?)image""").find(it) }?.groups?.get(1)?.value.toString() - .replace("file", """"file"""").replace("label", """"label"""") - .substringBeforeLast(",") - val parsedlinks = parseJson>(extractedUrl) - parsedlinks.forEach { data -> - if (data.label.isNullOrBlank()) { // mp4 links (with labels) are slow. Use only m3u8 link. - M3u8Helper.generateM3u8( - name, - data.id, - url, - headers = mapOf("referer" to url) - ).forEach { link -> - extractedLinksList.add(link) - } - } - } - return extractedLinksList - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt deleted file mode 100644 index 28a2eb20d..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Tomatomatela.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.app -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.module.kotlin.readValue -import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.mapper -import com.lagradost.cloudstream3.utils.AppUtils.parseJson - - -class Cinestart: Tomatomatela() { - override var name: String = "Cinestart" - override val mainUrl: String = "https://cinestart.net" - override val details = "vr.php?v=" -} - -class TomatomatelalClub: Tomatomatela() { - override var name: String = "Tomatomatela" - override val mainUrl: String = "https://tomatomatela.club" -} - -open class Tomatomatela : ExtractorApi() { - override var name = "Tomatomatela" - override val mainUrl = "https://tomatomatela.com" - override val requiresReferer = false - private data class Tomato ( - @JsonProperty("status") val status: Int, - @JsonProperty("file") val file: String? - ) - open val details = "details.php?v=" - open val embeddetails = "/embed.html#" - override suspend fun getUrl(url: String, referer: String?): List? { - val link = url.replace("$mainUrl$embeddetails","$mainUrl/$details") - val sources = ArrayList() - val server = app.get(link, allowRedirects = false, - headers = mapOf( - "User-Agent" to USER_AGENT, - "Accept" to "application/json, text/javascript, */*; q=0.01", - "Accept-Language" to "en-US,en;q=0.5", - "X-Requested-With" to "XMLHttpRequest", - "DNT" to "1", - "Connection" to "keep-alive", - "Sec-Fetch-Dest" to "empty", - "Sec-Fetch-Mode" to "cors", - "Sec-Fetch-Site" to "same-origin" - - ) - ).parsedSafe() - if (server?.file != null) { - sources.add( - ExtractorLink( - name, - name, - server.file, - "", - Qualities.Unknown.value, - isM3u8 = false - ) - ) - } - return sources - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Uqload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Uqload.kt deleted file mode 100644 index 5109acc36..000000000 --- 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 a27bf1887..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import kotlinx.coroutines.delay -import java.net.URI - -class VidSrcExtractor2 : VidSrcExtractor() { - override val mainUrl = "https://vidsrc.me/embed" - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val newUrl = url.lowercase().replace(mainUrl, super.mainUrl) - super.getUrl(newUrl, referer, subtitleCallback, callback) - } -} - -open class VidSrcExtractor : ExtractorApi() { - override val name = "VidSrc" - private val absoluteUrl = "https://v2.vidsrc.me" - override val mainUrl = "$absoluteUrl/embed" - override val requiresReferer = false - - companion object { - /** Infinite function to validate the vidSrc pass */ - suspend fun validatePass(url: String) { - val uri = URI(url) - val host = uri.host - - // Basically turn https://tm3p.vidsrc.stream/ -> https://vidsrc.stream/ - val referer = host.split(".").let { - val size = it.size - "https://" + it.subList(maxOf(0, size - 2), size).joinToString(".") + "/" - } - - while (true) { - app.get(url, referer = referer) - delay(60_000) - } - } - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val iframedoc = app.get(url).document - - val serverslist = - iframedoc.select("div#sources.button_content div#content div#list div").map { - val datahash = it.attr("data-hash") - if (datahash.isNotBlank()) { - val links = try { - app.get( - "$absoluteUrl/srcrcp/$datahash", - referer = "https://rcp.vidsrc.me/" - ).url - } catch (e: Exception) { - "" - } - links - } else "" - } - - serverslist.amap { server -> - val linkfixed = server.replace("https://vidsrc.xyz/", "https://embedsito.com/") - if (linkfixed.contains("/prorcp")) { - val srcresponse = app.get(server, referer = absoluteUrl).text - val m3u8Regex = Regex("((https:|http:)//.*\\.m3u8)") - val srcm3u8 = m3u8Regex.find(srcresponse)?.value ?: return@amap - val passRegex = Regex("""['"](.*set_pass[^"']*)""") - val pass = passRegex.find(srcresponse)?.groupValues?.get(1)?.replace( - Regex("""^//"""), "https://" - ) - - callback.invoke( - ExtractorLink( - this.name, - this.name, - srcm3u8, - "https://vidsrc.stream/", - Qualities.Unknown.value, - extractorData = pass, - isM3u8 = true - ) - ) - } else { - loadExtractor(linkfixed, url, subtitleCallback, callback) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/VideoVard.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VideoVard.kt deleted file mode 100644 index 30a1d8fe6..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VideoVard.kt +++ /dev/null @@ -1,271 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 -import kotlinx.coroutines.delay -import java.math.BigInteger - -class VideovardSX : WcoStream() { - override var mainUrl = "https://videovard.sx" -} - -open class VideoVard : ExtractorApi() { - override var name = "Videovard" // Cause works for animekisa and wco - override var mainUrl = "https://videovard.to" - override val requiresReferer = false - - //The following code was extracted from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/parsers/anime/extractors/VideoVard.kt - override suspend fun getUrl(url: String, referer: String?): List { - val id = url.substringAfter("e/").substringBefore("/") - val sources = mutableListOf() - val hash = app.get("$mainUrl/api/make/download/$id").parsed() - delay(11_000) - val resm3u8 = app.post( - "$mainUrl/api/player/setup", - mapOf("Referer" to "$mainUrl/"), - data = mapOf( - "cmd" to "get_stream", - "file_code" to id, - "hash" to hash.hash!! - ) - ).parsed() - val m3u8 = decode(resm3u8.src!!, resm3u8.seed) - sources.addAll( - generateM3u8( - name, - m3u8, - mainUrl, - headers = mapOf("Referer" to mainUrl) - ) - ) - return sources - } - - companion object { - private val big0 = 0.toBigInteger() - private val big3 = 3.toBigInteger() - private val big4 = 4.toBigInteger() - private val big15 = 15.toBigInteger() - private val big16 = 16.toBigInteger() - private val big255 = 255.toBigInteger() - - private fun decode(dataFile: String, seed: String): String { - val dataSeed = replace(seed) - val newDataSeed = binaryDigest(dataSeed) - val newDataFile = bytes2blocks(ascii2bytes(dataFile)) - var list = listOf(1633837924, 1650680933).map { it.toBigInteger() } - val xorList = mutableListOf() - for (i in newDataFile.indices step 2) { - val temp = newDataFile.slice(i..i + 1) - xorList += xorBlocks(list, tearDecode(temp, newDataSeed)) - list = temp - } - - val result = replace(unPad(blocks2bytes(xorList)).map { it.toInt().toChar() }.joinToString("")) - return padLastChars(result) - } - - private fun binaryDigest(input: String): List { - val keys = listOf(1633837924, 1650680933, 1667523942, 1684366951).map { it.toBigInteger() } - var list1 = keys.slice(0..1) - var list2 = list1 - val blocks = bytes2blocks(digestPad(input)) - - for (i in blocks.indices step 4) { - list1 = tearCode(xorBlocks(blocks.slice(i..i + 1), list1), keys).toMutableList() - list2 = tearCode(xorBlocks(blocks.slice(i + 2..i + 3), list2), keys).toMutableList() - - val temp = list1[0] - list1[0] = list1[1] - list1[1] = list2[0] - list2[0] = list2[1] - list2[1] = temp - } - - return listOf(list1[0], list1[1], list2[0], list2[1]) - } - - private fun tearDecode(a90: List, a91: List): MutableList { - var (a95, a96) = a90 - - var a97 = (-957401312).toBigInteger() - for (_i in 0 until 32) { - a96 -= ((((a95 shl 4) xor rShift(a95, 5)) + a95) xor (a97 + a91[rShift(a97, 11).and(3.toBigInteger()).toInt()])) - a97 += 1640531527.toBigInteger() - a95 -= ((((a96 shl 4) xor rShift(a96, 5)) + a96) xor (a97 + a91[a97.and(3.toBigInteger()).toInt()])) - - } - - return mutableListOf(a95, a96) - } - - private fun digestPad(string: String): List { - val empList = mutableListOf() - val length = string.length - val extra = big15 - (length.toBigInteger() % big16) - empList.add(extra) - for (i in 0 until length) { - empList.add(string[i].code.toBigInteger()) - } - for (i in 0 until extra.toInt()) { - empList.add(big0) - } - - return empList - } - - private fun bytes2blocks(a22: List): List { - val empList = mutableListOf() - val length = a22.size - var listIndex = 0 - - for (i in 0 until length) { - val subIndex = i % 4 - val shiftedByte = a22[i] shl (3 - subIndex) * 8 - - if (subIndex == 0) { - empList.add(shiftedByte) - } else { - empList[listIndex] = empList[listIndex] or shiftedByte - } - - if (subIndex == 3) listIndex += 1 - } - - return empList - } - - private fun blocks2bytes(inp: List): List { - val tempList = mutableListOf() - inp.indices.forEach { i -> - tempList += (big255 and rShift(inp[i], 24)) - tempList += (big255 and rShift(inp[i], 16)) - tempList += (big255 and rShift(inp[i], 8)) - tempList += (big255 and inp[i]) - } - return tempList - } - - private fun unPad(a46: List): List { - val evenOdd = a46[0].toInt().mod(2) - return (1 until (a46.size - evenOdd)).map { - a46[it] - } - } - - private fun xorBlocks(a76: List, a77: List): List { - return listOf(a76[0] xor a77[0], a76[1] xor a77[1]) - } - - private fun rShift(input: BigInteger, by: Int): BigInteger { - return (input.mod(4294967296.toBigInteger()) shr by) - } - - private fun tearCode(list1: List, list2: List): MutableList { - var a1 = list1[0] - var a2 = list1[1] - var temp = big0 - - for (_i in 0 until 32) { - a1 += (a2 shl 4 xor rShift(a2, 5)) + a2 xor temp + list2[(temp and big3).toInt()] - temp -= 1640531527.toBigInteger() - a2 += (a1 shl 4 xor rShift(a1, 5)) + a1 xor temp + list2[(rShift(temp, 11) and big3).toInt()] - } - return mutableListOf(a1, a2) - } - - private fun ascii2bytes(input: String): List { - val abc = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" - val abcMap = abc.mapIndexed { i, c -> c to i.toBigInteger() }.toMap() - var index = -1 - val length = input.length - var listIndex = 0 - val bytes = mutableListOf() - - while (true) { - for (i in input) { - if (abc.contains(i)) { - index++ - break - } - } - - bytes.add((abcMap[input.getOrNull(index)?:return bytes]!! * big4)) - - while (true) { - index++ - if (abc.contains(input[index])) { - break - } - } - - var temp = abcMap[input[index]]!! - - bytes[listIndex] = bytes[listIndex] or rShift(temp, 4) - listIndex++ - temp = (big15.and(temp)) - - if ((temp == big0) && (index == (length - 1))) return bytes - - bytes.add((temp * big4 * big4)) - - while (true) { - index++ - if (index >= length) return bytes - if (abc.contains(input[index])) break - } - - temp = abcMap[input[index]]!! - bytes[listIndex] = bytes[listIndex] or rShift(temp, 2) - listIndex++ - temp = (big3 and temp) - if ((temp == big0) && (index == (length - 1))) { - return bytes - } - bytes.add((temp shl 6)) - for (i in input) { - index++ - if (abc.contains(input[index])) { - break - } - } - bytes[listIndex] = bytes[listIndex] or abcMap[input[index]]!! - listIndex++ - } - } - - private fun replace(a: String): String { - val map = mapOf( - '0' to '5', - '1' to '6', - '2' to '7', - '5' to '0', - '6' to '1', - '7' to '2' - ) - var b = "" - a.forEach { - b += if (map.containsKey(it)) map[it] else it - } - return b - } - - private fun padLastChars(input:String):String{ - return if(input.reversed()[3].isDigit()) input - else input.dropLast(4) - } - - private data class HashResponse( - val hash: String? = null, - val version:String? = null - ) - - private data class SetupResponse( - val seed: String, - val src: String?=null, - val link:String?=null - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidmoly.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidmoly.kt deleted file mode 100644 index 615cfd74f..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidmoly.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.* -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson - -class Vidmolyme : Vidmoly() { - override val mainUrl = "https://vidmoly.me" -} - -open class Vidmoly : ExtractorApi() { - override val name = "Vidmoly" - override val mainUrl = "https://vidmoly.to" - override val requiresReferer = true - - private fun String.addMarks(str: String): String { - return this.replace(Regex("\"?$str\"?"), "\"$str\"") - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - - val script = app.get( - url, - referer = referer, - ).document.select("script") - .find { it.data().contains("sources:") }?.data() - val videoData = script?.substringAfter("sources: [") - ?.substringBefore("],")?.addMarks("file") - val subData = script?.substringAfter("tracks: [")?.substringBefore("]")?.addMarks("file") - ?.addMarks("label")?.addMarks("kind") - - tryParseJson(videoData)?.file?.let { m3uLink -> - M3u8Helper.generateM3u8( - name, - m3uLink, - "$mainUrl/" - ).forEach(callback) - } - - tryParseJson>("[${subData}]") - ?.filter { it.kind == "captions" }?.map { - subtitleCallback.invoke( - SubtitleFile( - it.label.toString(), - fixUrl(it.file.toString()) - ) - ) - } - - } - - private data class Source( - @JsonProperty("file") val file: String? = null, - ) - - private data class SubSource( - @JsonProperty("file") val file: String? = null, - @JsonProperty("label") val label: String? = null, - @JsonProperty("kind") val kind: String? = null, - ) - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidstream.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidstream.kt deleted file mode 100644 index 7eb7fbacf..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Vidstream.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.amap -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.argamap -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.extractorApis -import com.lagradost.cloudstream3.utils.getQualityFromName -import com.lagradost.cloudstream3.utils.loadExtractor -import org.jsoup.Jsoup - -/** - * overrideMainUrl is necessary for for other vidstream clones like vidembed.cc - * If they diverge it'd be better to make them separate. - * */ -class Vidstream(val mainUrl: String) { - val name: String = "Vidstream" - - private fun getExtractorUrl(id: String): String { - return "$mainUrl/streaming.php?id=$id" - } - - private fun getDownloadUrl(id: String): String { - return "$mainUrl/download?id=$id" - } - - private val normalApis = arrayListOf(MultiQuality()) - - // https://gogo-stream.com/streaming.php?id=MTE3NDg5 - suspend fun getUrl( - id: String, - isCasting: Boolean = false, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit, - ): Boolean { - val extractorUrl = getExtractorUrl(id) - argamap( - { - normalApis.amap { api -> - val url = api.getExtractorUrl(id) - api.getSafeUrl( - url, - callback = callback, - subtitleCallback = subtitleCallback - ) - } - }, { - /** Stolen from GogoanimeProvider.kt extractor */ - val link = getDownloadUrl(id) - println("Generated vidstream download link: $link") - val page = app.get(link, referer = extractorUrl) - - val pageDoc = Jsoup.parse(page.text) - val qualityRegex = Regex("(\\d+)P") - - //a[download] - pageDoc.select(".dowload > a")?.amap { element -> - val href = element.attr("href") ?: return@amap - val qual = if (element.text() - .contains("HDP") - ) "1080" else qualityRegex.find(element.text())?.destructured?.component1() - .toString() - - if (!loadExtractor(href, link, subtitleCallback, callback)) { - callback.invoke( - ExtractorLink( - this.name, - name = this.name, - href, - page.url, - getQualityFromName(qual), - element.attr("href").contains(".m3u8") - ) - ) - } - } - }, { - with(app.get(extractorUrl)) { - val document = Jsoup.parse(this.text) - val primaryLinks = document.select("ul.list-server-items > li.linkserver") - //val extractedLinksList: MutableList = mutableListOf() - - // All vidstream links passed to extractors - primaryLinks.distinctBy { it.attr("data-video") }.forEach { element -> - val link = element.attr("data-video") - //val name = element.text() - - // Matches vidstream links with extractors - extractorApis.filter { !it.requiresReferer || !isCasting }.amap { api -> - if (link.startsWith(api.mainUrl)) { - api.getSafeUrl(link, extractorUrl, subtitleCallback, callback) - } - } - } - } - } - ) - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt deleted file mode 100644 index 12a76a9b2..000000000 --- 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 ad3f01508..000000000 --- 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 6cc486cd5..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/WcoStream.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.cipher -import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.encrypt -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.Qualities - -class Vidstreamz : WcoStream() { - override var mainUrl = "https://vidstreamz.online" -} - -class Vizcloud : WcoStream() { - override var mainUrl = "https://vizcloud2.ru" -} - -class Vizcloud2 : WcoStream() { - override var mainUrl = "https://vizcloud2.online" -} - -class VizcloudOnline : WcoStream() { - override var mainUrl = "https://vizcloud.online" -} - -class VizcloudXyz : WcoStream() { - override var mainUrl = "https://vizcloud.xyz" -} - -class VizcloudLive : WcoStream() { - override var mainUrl = "https://vizcloud.live" -} - -class VizcloudInfo : WcoStream() { - override var mainUrl = "https://vizcloud.info" -} - -class MwvnVizcloudInfo : WcoStream() { - override var mainUrl = "https://mwvn.vizcloud.info" -} - -class VizcloudDigital : WcoStream() { - override var mainUrl = "https://vizcloud.digital" -} - -class VizcloudCloud : WcoStream() { - override var mainUrl = "https://vizcloud.cloud" -} - -class VizcloudSite : WcoStream() { - override var mainUrl = "https://vizcloud.site" -} - -class Mcloud : WcoStream() { - override var name = "Mcloud" - override var mainUrl = "https://mcloud.to" - override val requiresReferer = true -} - -open class WcoStream : ExtractorApi() { - override var name = "VidStream" // Cause works for animekisa and wco - override var mainUrl = "https://vidstream.pro" - override val requiresReferer = false - private val regex = Regex("(.+?/)e(?:mbed)?/([a-zA-Z0-9]+)") - - companion object { - // taken from https://github.com/saikou-app/saikou/blob/b35364c8c2a00364178a472fccf1ab72f09815b4/app/src/main/java/ani/saikou/parsers/anime/extractors/VizCloud.kt - // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md - private var lastChecked = 0L - private const val jsonLink = - "https://raw.githubusercontent.com/chenkaslowankiya/BruhFlow/main/keys.json" - private var cipherKey: VizCloudKey? = null - suspend fun getKey(): VizCloudKey { - cipherKey = - if (cipherKey != null && (lastChecked - System.currentTimeMillis()) < 1000 * 60 * 30) cipherKey!! - else { - lastChecked = System.currentTimeMillis() - app.get(jsonLink).parsed() - } - return cipherKey!! - } - - data class VizCloudKey( - @JsonProperty("cipherKey") val cipherKey: String, - @JsonProperty("mainKey") val mainKey: String, - @JsonProperty("encryptKey") val encryptKey: String, - @JsonProperty("dashTable") val dashTable: String - ) - - private const val baseTable = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+=/_" - - private fun dashify(id: String, dashTable: String): String { - val table = dashTable.split(" ") - return id.mapIndexedNotNull { i, c -> - table.getOrNull((baseTable.indexOf(c) * 16) + (i % 16)) - }.joinToString("-") - } - } - - //private val key = "LCbu3iYC7ln24K7P" // key credits @Modder4869 - override suspend fun getUrl(url: String, referer: String?): List { - val group = regex.find(url)?.groupValues!! - - val host = group[1] - val viz = getKey() - val id = encrypt( - cipher( - viz.cipherKey, - encrypt(group[2], viz.encryptKey).also { println(it) } - ).also { println(it) }, - viz.encryptKey - ).also { println(it) } - - val link = - "${host}mediainfo/${dashify(id, viz.dashTable)}?key=${viz.mainKey}" // - val response = app.get(link, referer = referer) - - data class Sources(@JsonProperty("file") val file: String) - data class Media(@JsonProperty("sources") val sources: List) - data class Data(@JsonProperty("media") val media: Media) - data class Response(@JsonProperty("data") val data: Data) - - - if (!response.text.startsWith("{")) throw ErrorLoadingException("Seems like 9Anime kiddies changed stuff again, Go touch some grass for bout an hour Or use a different Server") - return response.parsed().data.media.sources.map { - ExtractorLink(name, it.file,it.file,host,Qualities.Unknown.value,it.file.contains(".m3u8")) - } - - } -} diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt deleted file mode 100644 index 23704e90a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/YoutubeExtractor.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.schemaStripRegex -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor -import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeStreamLinkHandlerFactory -import org.schabi.newpipe.extractor.stream.SubtitlesStream -import org.schabi.newpipe.extractor.stream.VideoStream - -class YoutubeShortLinkExtractor : YoutubeExtractor() { - override val mainUrl = "https://youtu.be" - - override fun getExtractorUrl(id: String): String { - return "$mainUrl/$id" - } -} - -class YoutubeMobileExtractor : YoutubeExtractor() { - override val mainUrl = "https://m.youtube.com" -} -class YoutubeNoCookieExtractor : YoutubeExtractor() { - override val mainUrl = "https://www.youtube-nocookie.com" -} - -open class YoutubeExtractor : ExtractorApi() { - override val mainUrl = "https://www.youtube.com" - override val requiresReferer = false - override val name = "YouTube" - - companion object { - private var ytVideos: MutableMap> = mutableMapOf() - private var ytVideosSubtitles: MutableMap> = mutableMapOf() - } - - override fun getExtractorUrl(id: String): String { - return "$mainUrl/watch?v=$id" - } - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - if (ytVideos[url].isNullOrEmpty()) { - val link = - YoutubeStreamLinkHandlerFactory.getInstance().fromUrl( - url.replace( - schemaStripRegex, "" - ) - ) - - val s = object : YoutubeStreamExtractor( - ServiceList.YouTube, - link - ) { - - } - s.fetchPage() - ytVideos[url] = s.videoStreams - ytVideosSubtitles[url] = try { - s.subtitlesDefault.filterNotNull() - } catch (e: Exception) { - logError(e) - emptyList() - } - } - ytVideos[url]?.mapNotNull { - if (it.isVideoOnly || it.height <= 0) return@mapNotNull null - - ExtractorLink( - this.name, - this.name, - it.url ?: return@mapNotNull null, - "", - it.height - ) - }?.forEach(callback) - ytVideosSubtitles[url]?.mapNotNull { - SubtitleFile(it.languageTag ?: return@mapNotNull null, it.url ?: return@mapNotNull null) - }?.forEach(subtitleCallback) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Zorofile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Zorofile.kt deleted file mode 100644 index 43c4eefb2..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Zorofile.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.lagradost.cloudstream3.extractors - -import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder.getCaptchaToken -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.SubtitleFile -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.M3u8Helper - -open class Zorofile : ExtractorApi() { - override val name = "Zorofile" - override val mainUrl = "https://zorofile.com" - override val requiresReferer = true - - override suspend fun getUrl( - url: String, - referer: String?, - subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit - ) { - val id = url.split("?").first().split("/").last() - val token = app.get( - url, - referer = referer - ).document.select("button.g-recaptcha").attr("data-sitekey").let { captchaKey -> - getCaptchaToken( - url, - captchaKey, - referer = referer - ) - } ?: throw ErrorLoadingException("can't bypass captcha") - - val data = app.post( - "$mainUrl/dl", - data = mapOf( - "op" to "embed", - "file_code" to id, - "auto" to "1", - "referer" to "$referer/", - "g-recaptcha-response" to token - ), - referer = url, - headers = mapOf( - "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", - "Content-Type" to "application/x-www-form-urlencoded", - "Origin" to mainUrl, - "Sec-Fetch-Dest" to "iframe", - "Sec-Fetch-Mode" to "navigate", - "Sec-Fetch-Site" to "same-origin", - "Sec-Fetch-User" to "?1", - "Upgrade-Insecure-Requests" to "1", - ) - ).document.select("script").find { it.data().contains("var holaplayer;") }?.data() - ?.substringAfter("sources: [")?.substringBefore("],")?.replace("src", "\"src\"") - ?.replace("type", "\"type\"") - - tryParseJson("$data")?.let { res -> - return M3u8Helper.generateM3u8( - name, - res.src ?: return@let, - "$mainUrl/", - headers = mapOf( - "Origin" to mainUrl, - ) - ).forEach(callback) - } - } - - private data class Sources( - @JsonProperty("src") val src: String? = null, - @JsonProperty("type") val type: String? = null, - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt deleted file mode 100644 index e8ac18769..000000000 --- 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 afe956cc5..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt +++ /dev/null @@ -1,214 +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) } } -} - -fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) { - liveData.observe(this) { action(it) } -} - -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 000000000..482ec05fc --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.mvvm + +import android.view.View +import androidx.activity.ComponentActivity +import androidx.core.view.doOnAttach +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.ui.BaseFragment + +/** NOTE: Only one observer at a time per value */ +fun ComponentActivity.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } +} + +/** NOTE: Only one observer at a time per value */ +fun ComponentActivity.observeNullable(liveData: LiveData, action: (T?) -> Unit) { + liveData.removeObservers(this) + liveData.observe(this, action) +} + +/** NOTE: Only one observer at a time per value */ +fun BaseFragment.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } +} + +/** + * Attaches an observable to the root binding, instead of the fragment. This is more efficient as + * it will not call observe if the view is in the background. + * + * NOTE: Only one observer at a time per value + * */ +fun BaseFragment.observeNullable( + liveData: LiveData, action: (T?) -> Unit +) { + val root = this.binding?.root + if (root == null) { + liveData.removeObservers(this) + liveData.observe(this, action) + } else { + root.doOnAttach { view -> + // On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case + val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable + liveData.removeObservers(owner) + liveData.observe(owner, action) + } + } +} + +/** NOTE: Only one observer at a time per value */ +fun View.observe(liveData: LiveData, action: (T) -> Unit) { + observeNullable(liveData) { t -> t?.run(action) } +} + +/** NOTE: Only one observer at a time per value */ +fun View.observeNullable(liveData: LiveData, action: (T?) -> Unit) { + doOnAttach { view -> + // On attach should make findViewTreeLifecycleOwner non-null + val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner() + if(owner == null) { + debugException { "Expected non-null findViewTreeLifecycleOwner" } + return@doOnAttach + } + liveData.removeObservers(owner) + liveData.observe(owner, action) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt index 6950d9614..9efa88a37 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -5,11 +5,14 @@ import android.webkit.CookieManager import androidx.annotation.AnyThread import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugWarning -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.nicehttp.Requests.Companion.await import com.lagradost.nicehttp.cookies import kotlinx.coroutines.runBlocking -import okhttp3.* +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response import java.net.URI @@ -17,6 +20,8 @@ import java.net.URI class CloudflareKiller : Interceptor { companion object { const val TAG = "CloudflareKiller" + private val ERROR_CODES = listOf(403, 503) + private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare") fun parseCookieMap(cookie: String): Map { return cookie.split(";").associate { val split = it.split("=") @@ -27,7 +32,7 @@ class CloudflareKiller : Interceptor { init { // Needs to clear cookies between sessions to generate new cookies. - normalSafeApiCall { + safe { // This can throw an exception on unsupported devices :( CookieManager.getInstance().removeAllCookies(null) } @@ -48,15 +53,23 @@ class CloudflareKiller : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = runBlocking { val request = chain.request() - val cookies = savedCookies[request.url.host] - if (cookies == null) { - bypassCloudflare(request)?.let { - Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}") - return@runBlocking it + when (val cookies = savedCookies[request.url.host]) { + null -> { + val response = chain.proceed(request) + if(!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) { + return@runBlocking response + } else { + response.close() + bypassCloudflare(request)?.let { + Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}") + return@runBlocking it + } + } + } + else -> { + return@runBlocking proceed(request, cookies) } - } else { - return@runBlocking proceed(request, cookies) } debugWarning({ true }) { "Failed cloudflare at: ${request.url}" } @@ -64,7 +77,7 @@ class CloudflareKiller : Interceptor { } private fun getWebViewCookie(url: String): String? { - return normalSafeApiCall { + return safe { CookieManager.getInstance()?.getCookie(url) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt index 55e092513..4127799e8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt @@ -84,4 +84,24 @@ fun OkHttpClient.Builder.addQuad9Dns() = ( "9.9.9.9", "149.112.112.112", ) - )) \ No newline at end of file + )) + +fun OkHttpClient.Builder.addDnsSbDns() = ( + addGenericDns( + "https://doh.dns.sb/dns-query", + //https://dns.sb/guide/ + listOf( + "185.222.222.222", + "45.11.45.11", + ) + )) + +fun OkHttpClient.Builder.addCanadianShieldDns() = ( + addGenericDns( + "https://private.canadianshield.cira.ca/dns-query", + //https://www.cira.ca/en/canadian-shield/configure/summary-cira-canadian-shield-dns-resolver-addresses/ + listOf( + "149.112.121.10", + "149.112.122.10", + ) + )) diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt index a1d84f6cd..6234297d0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -2,9 +2,10 @@ package com.lagradost.cloudstream3.network import android.content.Context import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.Prerelease import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ignoreAllSSLErrors import okhttp3.Cache @@ -15,14 +16,38 @@ import org.conscrypt.Conscrypt import java.io.File import java.security.Security -fun Requests.initClient(context: Context): OkHttpClient { - normalSafeApiCall { Security.insertProviderAt(Conscrypt.newProvider(), 1) } +// Backwards compatible constructor, mark as deprecated later +fun Requests.initClient(context: Context) { + this.baseClient = buildDefaultClient(context) +} + +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) { + this.baseClient = buildDefaultClient(context, ignoreSSL) +} + + +// Backwards compatible constructor, mark as deprecated later +fun buildDefaultClient(context: Context): OkHttpClient { + return buildDefaultClient(context, false) +} + +/** Only use ignoreSSL if you know what you are doing*/ +@Prerelease +fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient { + safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0) - baseClient = OkHttpClient.Builder() + val baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) - .ignoreAllSSLErrors() + .apply { + if (ignoreSSL) { + ignoreAllSSLErrors() + } + } .cache( // Note that you need to add a ResponseInterceptor to make this 100% active. // The server response dictates if and when stuff should be cached. @@ -38,6 +63,8 @@ fun Requests.initClient(context: Context): OkHttpClient { 4 -> addAdGuardDns() 5 -> addDNSWatchDns() 6 -> addQuad9Dns() + 7 -> addDnsSbDns() + 8 -> addCanadianShieldDns() } } // Needs to be build as otherwise the other builders will change this object @@ -45,11 +72,6 @@ fun Requests.initClient(context: Context): OkHttpClient { return baseClient } -//val Request.cookies: Map -// get() { -// return this.headers.getCookies("Cookie") -// } - private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) /** diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt index 242baf59e..e1496db06 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -2,69 +2,39 @@ package com.lagradost.cloudstream3.plugins import android.content.Context import android.content.res.Resources -import kotlin.Throws -import com.lagradost.cloudstream3.MainAPI -import com.lagradost.cloudstream3.APIHolder -import com.lagradost.cloudstream3.utils.ExtractorApi -import com.lagradost.cloudstream3.utils.extractorApis import android.util.Log -import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import kotlin.Throws -const val PLUGIN_TAG = "PluginInstance" - -abstract class Plugin { +abstract class Plugin : BasePlugin() { /** * Called when your Plugin is loaded * @param context Context */ @Throws(Throwable::class) open fun load(context: Context) { + // If not overridden by an extension then try the cross-platform load() + load() } /** - * Called when your Plugin is being unloaded + * Used to register VideoClickAction instances + * @param element VideoClickAction you want to register */ - @Throws(Throwable::class) - open fun beforeUnload() { - } - - /** - * Used to register providers instances of MainAPI - * @param element MainAPI provider you want to register - */ - fun registerMainAPI(element: MainAPI) { - Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI") - element.sourcePlugin = this.__filename - // Race condition causing which would case duplicates if not for distinctBy - APIHolder.allProviders.add(element) - APIHolder.addPluginMapping(element) - } - - /** - * Used to register extractor instances of ExtractorApi - * @param element ExtractorApi provider you want to register - */ - fun registerExtractorAPI(element: ExtractorApi) { - Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi") - element.sourcePlugin = this.__filename - extractorApis.add(element) - } - - class Manifest { - @JsonProperty("name") var name: String? = null - @JsonProperty("pluginClassName") var pluginClassName: String? = null - @JsonProperty("version") var version: Int? = null - @JsonProperty("requiresResources") var requiresResources: Boolean = false + fun registerVideoClickAction(element: VideoClickAction) { + Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") + element.sourcePlugin = this.filename + VideoClickActionHolder.allVideoClickActions.add(element) } /** * This will contain your resources if you specified requiresResources in gradle */ var resources: Resources? = null - var __filename: String? = null /** * This will add a button in the settings allowing you to add custom settings */ var openSettings: ((context: Context) -> Unit)? = null -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 3533d6a8d..debd3f0eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -1,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() } @@ -149,27 +185,30 @@ object PluginManager { private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins" - public var currentlyLoading: String? = null + var currentlyLoading: String? = null // Maps filepath to plugin - val plugins: MutableMap = - LinkedHashMap() + val plugins: MutableMap = + LinkedHashMap() // Maps urls to plugin - val urlPlugins: MutableMap = - LinkedHashMap() + val urlPlugins: MutableMap = + LinkedHashMap() - private val classLoaders: MutableMap = - HashMap() + private val classLoaders: MutableMap = + HashMap() - 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) ) @@ -199,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 @@ -209,7 +248,7 @@ object PluginManager { })?.let { savedData -> // OnlinePluginData(savedData, onlineData) loadPlugin( - activity, + context, File(savedData.filePath), savedData ) @@ -222,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 } @@ -252,7 +299,7 @@ object PluginManager { val updatedPlugins = mutableListOf() - outdatedPlugins.apmap { pluginData -> + outdatedPlugins.amap { pluginData -> if (pluginData.isDisabled) { //updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name)) unloadPlugin(pluginData.savedData.filePath) @@ -260,6 +307,7 @@ object PluginManager { downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true @@ -273,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) // } @@ -287,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 } @@ -302,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 @@ -316,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 - } - //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 - } + //Omit non-NSFW if mode is set to NSFW only + if (mode == AutoDownloadMode.NsfwOnly) { + if (!tvtypes.contains(TvType.NSFW.name)) { + return@mapNotNull null } } + //Omit NSFW, if disabled + 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, @@ -343,10 +415,11 @@ object PluginManager { } //Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}") - notDownloadedPlugins.apmap { pluginData -> + notDownloadedPlugins.amap { pluginData -> downloadPlugin( activity, pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled @@ -368,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 ) @@ -384,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() @@ -413,24 +516,64 @@ object PluginManager { val sortedPlugins = dir.listFiles() // Always sort plugins alphabetically for reproducible results - Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: $sortedPlugins") + Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: ${sortedPlugins?.size}") - sortedPlugins?.sortedBy { it.name }?.apmap { file -> - maybeLoadPlugin(activity, file) + // Use app-specific external files directory and copy the file there. + // We have to do this because on Android 14+, it otherwise gives SecurityException + // due to dex files and setReadOnly seems to have no effect unless it it here. + val pluginDirectory = File(context.getExternalFilesDir(null), "plugins") + if (!pluginDirectory.exists()) { + pluginDirectory.mkdirs() // Ensure the plugins directory exists + } + + // Make sure all local plugins are fully refreshed. + removeKey(PLUGINS_KEY_LOCAL) + + sortedPlugins?.sortedBy { it.name }?.amap { file -> + try { + val destinationFile = File(pluginDirectory, file.name) + + // Only copy the file if the destination file doesn't exist or if it + // has been modified (check file length and modification time). + if (!destinationFile.exists() || + destinationFile.length() != file.length() || + destinationFile.lastModified() != file.lastModified() + ) { + + // Copy the file to the app-specific plugin directory + file.copyTo(destinationFile, overwrite = true) + + // After copying, set the destination file's modification time + // to match the source file. We do this for performance so that we + // can check the modification time and not make redundant writes. + destinationFile.setLastModified(file.lastModified()) + } + + // Load the plugin after it has been copied + maybeLoadPlugin(context, destinationFile) + } catch (t: Throwable) { + Log.e(TAG, "Failed to copy the file") + logError(t) + } } loadedLocalPlugins = true afterPluginsLoadedEvent.invoke(forceReload) } + /** @return true if safe mode is enabled in any possible way. */ + fun isSafeMode(): Boolean { + return checkSafeModeFile() || lastError != null + } + /** * This can be used to override any extension loading to fix crashes! * @return true if safe mode file is present **/ fun checkSafeModeFile(): Boolean { - return normalSafeApiCall { + return safe { val folder = File(CLOUD_STREAM_FOLDER) - if (!folder.exists()) return@normalSafeApiCall false + if (!folder.exists()) return@safe false val files = folder.listFiles { _, name -> name.equals("safe", ignoreCase = true) } @@ -441,25 +584,33 @@ object PluginManager { /** * @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.readText()) } } @@ -469,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)) @@ -482,32 +635,44 @@ 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) + synchronized(plugins) { + plugins[filePath] = pluginInstance + } + synchronized(classLoaders) { + classLoaders[loader] = pluginInstance + } + synchronized(urlPlugins) { + urlPlugins[data.url ?: filePath] = pluginInstance + } + if (pluginInstance is Plugin) { + pluginInstance.load(context) + } else { + pluginInstance.load() + } Log.i(TAG, "Loaded plugin ${data.internalName} successfully") currentlyLoading = null true } catch (e: Throwable) { Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}") showToast( - 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 @@ -530,16 +695,33 @@ object PluginManager { } // remove all registered apis - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach { + APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { removePluginMapping(it) } - APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename } - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename } - classLoaders.values.removeIf { v -> v == plugin } + APIHolder.allProviders.withLock { + APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename } + } - plugins.remove(absolutePath) - urlPlugins.values.removeIf { v -> v == plugin } + extractorApis.withLock { + extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename } + } + + VideoClickActionHolder.allVideoClickActions.withLock { + VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename } + } + + synchronized(classLoaders) { + classLoaders.values.removeIf { v -> v == plugin } + } + + synchronized(plugins) { + plugins.remove(absolutePath) + } + + synchronized(urlPlugins) { + urlPlugins.values.removeIf { v -> v == plugin } + } } /** @@ -569,25 +751,27 @@ object PluginManager { suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, repositoryUrl: String, loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) - return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) + return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, + pluginHash: String?, internalName: String, file: File, - loadPlugin: Boolean + loadPlugin: Boolean, ): Boolean { try { Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names - val newFile = downloadPluginToFile(pluginUrl, file) ?: return false + val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false val data = PluginData( internalName, @@ -630,6 +814,84 @@ object PluginManager { } } + /** + * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. + * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! + */ + @Suppress("FunctionName") + @InternalAPI + @Throws + suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) { + assertNonRecursiveCallstack() + + showToast(activity.getString(R.string.starting_plugin_update_manually), Toast.LENGTH_LONG) + + ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity) + afterPluginsLoadedEvent.invoke(false) + + val urls = (getKey>(REPOSITORIES_KEY) + ?: emptyArray()) + PREBUILT_REPOSITORIES + val onlinePlugins = urls.toList().amap { + getRepoPlugins(it.url)?.toList() ?: emptyList() + }.flatten().distinctBy { it.second.url } + + val allPlugins = getPluginsOnline().flatMap { savedData -> + onlinePlugins + .filter { it.second.internalName == savedData.internalName } + .mapNotNull { onlineData -> + OnlinePluginData(savedData, onlineData).takeIf { it.validOnlineData(activity) } + } + }.distinctBy { it.onlineData.second.url } + + val updatedPlugins = mutableListOf() + + allPlugins.amap { pluginData -> + if (pluginData.isDisabled) { + Log.e( + "PluginManager", + "Unloading disabled plugin: ${pluginData.onlineData.second.name}" + ) + unloadPlugin(pluginData.savedData.filePath) + } else { + val existingFile = File(pluginData.savedData.filePath) + if (existingFile.exists()) existingFile.delete() + + if (downloadPlugin( + activity, + pluginData.onlineData.second.url, + pluginData.onlineData.second.fileHash, + pluginData.savedData.internalName, + existingFile, + true + ) + ) { + updatedPlugins.add(pluginData.onlineData.second.name) + } + } + }.also { + main { + val message = if (updatedPlugins.isNotEmpty()) { + activity.getString(R.string.plugins_updated_manually, updatedPlugins.size) + } else { + activity.getString(R.string.no_plugins_updated_manually) + } + showToast(message, Toast.LENGTH_LONG) + + val notificationText = UiText.StringResource( + R.string.plugins_updated_manually, + listOf(updatedPlugins.size) + ) + createNotification(activity, notificationText, updatedPlugins) + + } + } + + loadedOnlinePlugins = true + afterPluginsLoadedEvent.invoke(false) + + Log.i("PluginManager", "Plugin update done!") + } + private fun Context.createNotificationChannel() { hasCreatedNotChanel = true // Create the NotificationChannel, but only on API 26+ because @@ -681,9 +943,14 @@ object PluginManager { } val notification = builder.build() - with(NotificationManagerCompat.from(context)) { - // notificationId is a unique int for each notification that you must define - notify((System.currentTimeMillis() / 1000).toInt(), notification) + // notificationId is a unique int for each notification that you must define + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + NotificationManagerCompat.from(context) + .notify((System.currentTimeMillis() / 1000).toInt(), notification) } return notification } catch (e: Exception) { @@ -691,4 +958,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 e77b2d541..07d6aaa37 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -1,14 +1,17 @@ package com.lagradost.cloudstream3.plugins import android.content.Context +import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeAsync import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY @@ -16,16 +19,19 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.BufferedInputStream import java.io.File -import java.io.InputStream -import java.io.OutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicInteger /** * Comes with the app, always available in the app, non removable. * */ data class Repository( + @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("description") val description: String?, @JsonProperty("manifestVersion") val manifestVersion: Int, @@ -59,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?, ) @@ -71,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() @@ -83,11 +119,12 @@ object RepositoryManager { 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 } } } @@ -95,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() @@ -126,21 +163,52 @@ 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() + // Prevent corrupting the plugin file if the operation fails + val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) + + val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body + + body.byteStream().use { body -> + tempFile.outputStream().use { fileSteam -> + body.copyTo(fileSteam) + } + } + + if (expectedFileHash != null) { + val downloadHash = sha256(tempFile) + if (expectedFileHash != downloadHash) { + tempFile.delete() + throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.") + } + } + + // We prefer the operation to be atomic + try { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + tempFile.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) } - file.createNewFile() - val body = app.get(pluginUrl).okhttpResponse.body - write(body.byteStream(), file.outputStream()) file } } @@ -179,7 +247,7 @@ object RepositoryManager { // Unload all plugins, not using deletePlugin since we // delete all data and files in deleteRepositoryData - normalSafeApiCall { + safe { file.listFiles { plugin: File -> unloadPlugin(plugin.absolutePath) false @@ -188,13 +256,4 @@ object RepositoryManager { PluginManager.deleteRepositoryData(file.absolutePath) } - - private fun write(stream: InputStream, output: OutputStream) { - val input = BufferedInputStream(stream) - val dataBuffer = ByteArray(512) - var readBytes: Int - while (input.read(dataBuffer).also { readBytes = it } != -1) { - output.write(dataBuffer, 0, readBytes) - } - } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt index f099ad1a7..85a806f0b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -2,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 +object VotingApi { + private const val LOGKEY = "VotingApi" + private const val API_DOMAIN = "https://api.countify.xyz" - enum class VoteType(val value: Int) { - UPVOTE(1), - DOWNVOTE(-1), - NONE(0) - } - - private val apiDomain = "https://api.countapi.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) - } - - fun SitePlugin.getVoteType(): VoteType { - return getVoteType(url) - } - - fun SitePlugin.canVote(): Boolean { - return canVote(this.url) - } - - // 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 { + 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 + } + + 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 + } + + 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 - } + fun hasVoted(pluginUrl: String) = + getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false - 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 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 000000000..f130831c6 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.services + +import android.content.Context +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import java.util.concurrent.TimeUnit + +const val BACKUP_CHANNEL_ID = "cloudstream3.backups" +const val BACKUP_WORK_NAME = "work_backup" +const val BACKUP_CHANNEL_NAME = "Backups" +const val BACKUP_CHANNEL_DESCRIPTION = "Notifications for background backups" +const val BACKUP_NOTIFICATION_ID = 938712898 // Random unique + +class BackupWorkManager(val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + companion object { + fun enqueuePeriodicWork(context: Context?, intervalHours: Long) { + if (context == null) return + + if (intervalHours == 0L) { + WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME) + return + } + + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + .build() + + val periodicSyncDataWork = + PeriodicWorkRequest.Builder( + BackupWorkManager::class.java, + intervalHours, + TimeUnit.HOURS + ) + .addTag(BACKUP_WORK_NAME) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + BACKUP_WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + periodicSyncDataWork + ) + + // Uncomment below for testing + +// val oneTimeBackupWork = +// OneTimeWorkRequest.Builder(BackupWorkManager::class.java) +// .addTag(BACKUP_WORK_NAME) +// .setConstraints(constraints) +// .build() +// +// WorkManager.getInstance(context).enqueue(oneTimeBackupWork) + } + } + + private val backupNotificationBuilder = + NotificationCompat.Builder(context, BACKUP_CHANNEL_ID) + .setColorized(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setAutoCancel(true) + .setContentTitle(context.getString(R.string.pref_category_backup)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(context.colorFromAttribute(R.attr.colorPrimary)) + .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big) + + override suspend fun doWork(): Result { + context.createNotificationChannel( + BACKUP_CHANNEL_ID, + BACKUP_CHANNEL_NAME, + BACKUP_CHANNEL_DESCRIPTION + ) + + val foregroundInfo = if (SDK_INT >= 29) + ForegroundInfo( + BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) else ForegroundInfo(BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build()) + setForeground(foregroundInfo) + + BackupUtils.backup(context) + + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt new file mode 100644 index 000000000..e07747a86 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt @@ -0,0 +1,279 @@ +package com.lagradost.cloudstream3.services + +import android.Manifest +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC +import android.os.Build.VERSION.SDK_INT +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.MainActivity.Companion.setLastError +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugWarning +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.system.measureTimeMillis +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class DownloadQueueService : Service() { + companion object { + const val TAG = "DownloadQueueService" + const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue" + const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service" + const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification." + const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique + @Volatile + var isRunning = false + + fun getIntent( + context: Context, + ): Intent { + return Intent(context, DownloadQueueService::class.java) + } + + private val _downloadInstances: MutableStateFlow> = + MutableStateFlow(emptyList()) + + /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances. + * Completed or failed instances are automatically removed by the download queue service. + * + */ + val downloadInstances: StateFlow> = + _downloadInstances + + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + instances to queue + } + .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads -> + Triple(instances, queue, currentDownloads) + } + } + + + private val baseNotification by lazy { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = + PendingIntentCompat.getActivity(this, 0, intent, 0, false) + + val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0) + val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0) + + NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID) + .setOngoing(true) // Make it persistent + .setAutoCancel(false) + .setColorized(false) + .setOnlyAlertOnce(true) + .setSilent(true) + .setShowWhen(false) + // If low priority then the notification might not show :( + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setColor(this.colorFromAttribute(R.attr.colorPrimary)) + .setContentText(activeDownloads) + .setSubText(activeQueue) + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.download_icon_load) + } + + + private fun updateNotification(context: Context, downloads: Int, queued: Int) { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + + val activeDownloads = + resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) + val activeQueue = + resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued) + + val newNotification = baseNotification + .setContentText(activeDownloads) + .setSubText(activeQueue) + .build() + + safe { + NotificationManagerCompat.from(context) + .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) + } + } + + // We always need to listen to events, even before the download is launched. + // Stopping link loading is an event which can trigger before downloading. + val downloadEventListener = { event: Pair -> + when (event.second) { + VideoDownloadManager.DownloadActionType.Stop -> { + removeKey(KEY_RESUME_PACKAGES, event.first.toString()) + removeKey(KEY_RESUME_IN_QUEUE, event.first.toString()) + DownloadQueueManager.cancelDownload(event.first) + } + + else -> {} + } + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + override fun onCreate() { + isRunning = true + val context: Context = this // To make code more readable + + Log.d(TAG, "Download queue service started.") + this.createNotificationChannel( + DOWNLOAD_QUEUE_CHANNEL_ID, + DOWNLOAD_QUEUE_CHANNEL_NAME, + DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION + ) + if (SDK_INT >= 29) { + startForeground( + DOWNLOAD_QUEUE_NOTIFICATION_ID, + baseNotification.build(), + FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build()) + } + + downloadEvent += downloadEventListener + + val queueJob = ioSafe { + // Ensure this is up to date to prevent race conditions with MainActivity launches + setLastError(context) + // Early return, to prevent waiting for plugins in safe mode + if (lastError != null) return@ioSafe + + // Try to ensure all plugins are loaded before starting the downloader. + // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough + val timeout = 15.seconds + val timeTaken = withTimeoutOrNull(timeout) { + measureTimeMillis { + while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) { + delay(100.milliseconds) + } + } + } + + debugWarning({ timeTaken == null || timeTaken > 3_000 }, { + "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms" + }) + debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) + + totalDownloadFlow + .debounce { (instances, queue) -> + // Filter away incorrect transient queue states. + // For example when we pop the queue and add a download instance there exists a transient state where + // there is no queue and no download instances (leading to an early exit) + if (instances.isEmpty() && queue.isEmpty()) { + 500.milliseconds + } else { + 0.milliseconds + } + } + .takeWhile { (instances, queue) -> + // Stop if destroyed + isRunning + // Run as long as there is a queue to process + && (instances.isNotEmpty() || queue.isNotEmpty()) + // Run as long as there are no app crashes + && lastError == null + } + .collect { (_, queue, currentDownloads) -> + // Remove completed or failed + val newInstances = _downloadInstances.updateAndGet { currentInstances -> + currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled } + } + + val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) + val currentInstanceCount = newInstances.size + + val newDownloads = minOf( + // Cannot exceed the max downloads + maxOf(0, maxDownloads - currentInstanceCount), + // Cannot start more downloads than the queue size + queue.size + ) + + // Cant start multiple downloads at once. If this is rerun it may start too many downloads. + if (newDownloads > 0) { + _downloadInstances.update { instances -> + val downloadInstance = DownloadQueueManager.popQueue(context) + if (downloadInstance != null) { + downloadInstance.startDownload() + instances + downloadInstance + } else { + instances + } + } + } + + // The downloads actually displayed to the user with a notification + val currentVisualDownloads = + currentDownloads.size + newInstances.count { + currentDownloads.contains(it.downloadQueueWrapper.id) + .not() + } + // Just the queue + val currentVisualQueue = queue.size + + updateNotification(context, currentVisualDownloads, currentVisualQueue) + } + } + + // Stop self regardless of job outcome + queueJob.invokeOnCompletion { throwable -> + if (throwable != null) { + logError(throwable) + } + safe { + stopSelf() + } + } + } + + override fun onDestroy() { + Log.d(TAG, "Download queue service stopped.") + downloadEvent -= downloadEventListener + isRunning = false + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY // We want the service restarted if its killed + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onTimeout(reason: Int) { + stopSelf() + Log.e(TAG, "Service stopped due to timeout: $reason") + } + +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt b/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt similarity index 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 1625981e0..fa7754718 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstallerService.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt @@ -1,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,21 +157,21 @@ class PackageInstallerService : Service() { } override fun onDestroy() { - receivers.forEach { - try { - this.unregisterReceiver(it) - } catch (_: IllegalArgumentException) { - // Receiver not registered - } - } + installer?.unregisterInstallActionReceiver() + installer = null + this.stopSelf() super.onDestroy() } override fun onBind(i: Intent?): IBinder? = null + override fun onTimeout(reason: Int) { + stopSelf() + Log.e("PackageInstallerService", "Service stopped due to timeout: $reason") + } + companion object { private const val EXTRA_URL = "EXTRA_URL" - private const val LOG_TAG = "PackageInstallerService" const val UPDATE_CHANNEL_ID = "cloudstream3.updates" const val UPDATE_CHANNEL_NAME = "App Updates" @@ -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 000000000..7134650ed --- /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 be2fe75b0..d63b18cdc 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 -class VideoDownloadService : IntentService("VideoDownloadService") { - override fun onHandleIntent(intent: Intent?) { +/** 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 + } + + 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 } -} \ No newline at end of file + + override fun onDestroy() { + downloadScope.coroutineContext.cancel() + super.onDestroy() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt index 77a1b0b58..9e6f241fb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt @@ -1,20 +1,93 @@ package com.lagradost.cloudstream3.subtitles -import androidx.annotation.WorkerThread -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import androidx.core.net.toUri +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.player.SubtitleOrigin +import okio.BufferedSource +import okio.buffer +import okio.sink +import okio.source +import java.io.File +import java.util.zip.ZipInputStream -interface AbstractSubProvider { - @WorkerThread - suspend fun search(query: SubtitleSearch): List? { - throw NotImplementedError() +/** + * A builder for subtitle files. + * @see addUrl + * @see addFile + */ +class SubtitleResource { + fun downloadFile(source: BufferedSource): File { + val file = File.createTempFile("temp-subtitle", ".tmp").apply { + deleteFileOnExit(this) + } + val sink = file.sink().buffer() + sink.writeAll(source) + sink.close() + source.close() + + return file } - @WorkerThread - suspend fun load(data: SubtitleEntity): String? { - throw NotImplementedError() + private fun unzip(file: File): List> { + val entries = mutableListOf>() + + ZipInputStream(file.inputStream()).use { zipInputStream -> + var zipEntry = zipInputStream.nextEntry + + while (zipEntry != null) { + val tempFile = File.createTempFile("unzipped-subtitle", ".tmp").apply { + deleteFileOnExit(this) + } + entries.add(zipEntry.name to tempFile) + + tempFile.sink().buffer().use { buffer -> + buffer.writeAll(zipInputStream.source()) + } + + zipEntry = zipInputStream.nextEntry + } + } + return entries + } + + data class SingleSubtitleResource( + val name: String?, + val url: String, + val origin: SubtitleOrigin + ) + + private var resources: MutableList = mutableListOf() + + fun getSubtitles(): List { + return resources.toList() + } + + fun addUrl(url: String?, name: String? = null) { + if (url == null) return + this.resources.add( + SingleSubtitleResource(name, url, SubtitleOrigin.URL) + ) + } + + fun addFile(file: File, name: String? = null) { + this.resources.add( + SingleSubtitleResource(name, file.toUri().toString(), SubtitleOrigin.DOWNLOADED_FILE) + ) + deleteFileOnExit(file) + } + + suspend fun addZipUrl( + url: String, + nameGenerator: (String, File) -> String? = { _, _ -> null } + ) { + val source = app.get(url).okhttpResponse.body.source() + val zip = downloadFile(source) + val realFiles = unzip(zip) + zip.deleteRecursively() + realFiles.forEach { (name, subtitleFile) -> + addFile(subtitleFile, nameGenerator(name, subtitleFile)) + } } } -interface AbstractSubApi : AbstractSubProvider, AuthAPI \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt index f6424c4c7..685b499bb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt @@ -19,8 +19,11 @@ class AbstractSubtitleEntities { data class SubtitleSearch( var query: String = "", - var imdb: Long? = null, var lang: String? = null, + var imdbId: String? = null, + var tmdbId: Int? = null, + var malId: Int? = null, + var aniListId: Int? = null, var epNumber: Int? = null, var seasonNumber: Int? = null, var year: Int? = null diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index f17086c17..3bc5f2733 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -1,135 +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() - val localListApi = LocalList() - - // 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), SyncRepo(localListApi) - ) - - 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 8b085bc0b..184a9fbcc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -1,23 +1,280 @@ package com.lagradost.cloudstream3.syncproviders -interface AuthAPI { - val name: String - val icon: Int? +import android.util.Base64 +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID +import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed +import com.lagradost.cloudstream3.syncproviders.providers.AniListApi +import com.lagradost.cloudstream3.syncproviders.providers.LocalList +import com.lagradost.cloudstream3.syncproviders.providers.MALApi +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi +import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi +import com.lagradost.cloudstream3.syncproviders.providers.SimklApi +import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi +import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.txt +import java.net.URL +import java.security.SecureRandom +import java.util.Date +import java.util.concurrent.TimeUnit - val requiresLogin: Boolean +data class AuthLoginPage( + /** The website to open to authenticate */ + val url: String, + /** + * State/control code to verify against the redirectUrl to make sure the request is valid. + * This parameter will be saved, and then used in AuthAPI::login. + * */ + val payload: String? = null, +) - val createAccountUrl : String? +data class AuthToken( + /** + * This is the general access tokens/api token representing a logged in user. + * + * `Access tokens are the thing that applications use to make API requests on behalf of a user.` + * */ + @JsonProperty("accessToken") + val accessToken: String? = null, + /** + * For OAuth a special refresh token is issues to refresh the access token. + * */ + @JsonProperty("refreshToken") + val refreshToken: String? = null, + /** In UnixTime (sec) when it expires */ + @JsonProperty("accessTokenLifetime") + val accessTokenLifetime: Long? = null, + /** In UnixTime (sec) when it expires */ + @JsonProperty("refreshTokenLifetime") + val refreshTokenLifetime: Long? = null, + /** Sometimes AuthToken needs to be customized to store e.g. username/password, + * this acts as a catch all to store text or JSON data. */ + @JsonProperty("payload") + val payload: String? = null, +) { + fun isAccessTokenExpired(marginSec: Long = 10L) = + accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime - // don't change this as all keys depend on it - val idPrefix: String + fun isRefreshTokenExpired(marginSec: Long = 10L) = + refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime +} - // if this returns null then you are not logged in - fun loginInfo(): LoginInfo? - fun logOut() +data class AuthUser( + /** Account display-name, can also be email if name does not exist */ + @JsonProperty("name") + val name: String?, + /** Unique account identifier, + * if a subsequent login is done then it will be refused if another account with the same id exists*/ + @JsonProperty("id") + val id: Int, + /** Profile picture URL */ + @JsonProperty("profilePicture") + val profilePicture: String? = null, + /** Profile picture Headers of the URL */ + @JsonProperty("profilePictureHeader") + val profilePictureHeaders: Map? = null +) +/** + * Stores all information that should be used to authorize access. + * Be aware that token and user may change independently when a refresh is needed, + * and as such there should be no strong pairing between the two. + * + * Any local set/get key should use user.id.toString(), + * as token.accessToken (even hashed) is unsecure, and will rotate. + * */ +data class AuthData( + @JsonProperty("user") + val user: AuthUser, + @JsonProperty("token") + val token: AuthToken, +) + +data class AuthPinData( + val deviceCode: String, + val userCode: String, + /** QR Code url */ + val verificationUrl: String, + /** In seconds */ + val expiresIn: Int, + /** Check if the code has been verified interval */ + val interval: Int, +) + +/** The login field requirements to display to the user */ +data class AuthLoginRequirement( + val password: Boolean = false, + val username: Boolean = false, + val email: Boolean = false, + val server: Boolean = false, +) + +/** What the user responds to the AuthLoginRequirement */ +data class AuthLoginResponse( + @JsonProperty("password") + val password: String?, + @JsonProperty("username") + val username: String?, + @JsonProperty("email") + val email: String?, + @JsonProperty("server") + val server: String?, +) + +/** Stateless Authentication class used for all personalized content */ +abstract class AuthAPI { + open val name: String = "NONE" + open val idPrefix: String = "NONE" + + /** Drawable icon of the service */ + open val icon: Int? = null + + /** If this service requires an account to use */ + open val requiresLogin: Boolean = true + + /** Link to a website for creating a new account */ + open val createAccountUrl: String? = null + + /** The sensitive redirect URL from OAuth should contain "/redirectUrlIdentifier" to trigger the login */ + open val redirectUrlIdentifier: String? = null + + /** Has OAuth2 login support, including login, loginRequest and refreshToken */ + open val hasOAuth2: Boolean = false + + /** Has on device pin support, aka login with a QR code */ + open val hasPin: Boolean = false + + /** Has in app login support, aka login with a dialog */ + open val hasInApp: Boolean = false + + /** The requirements to login in app */ + open val inAppLoginRequirement: AuthLoginRequirement? = null + + companion object { + val unixTime: Long + get() = System.currentTimeMillis() / 1000L + val unixTimeMs: Long + get() = System.currentTimeMillis() + + fun splitRedirectUrl(redirectUrl: String): Map { + return splitQuery( + URL( + redirectUrl.replace(APP_STRING, "https").replace("/#", "?") + ) + ) + } + + fun generateCodeVerifier(): String { + // It is recommended to use a URL-safe string as code_verifier. + // See section 4 of RFC 7636 for more details. + val secureRandom = SecureRandom() + val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 + secureRandom.nextBytes(codeVerifierBytes) + return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=') + .replace("+", "-") + .replace("/", "_").replace("\n", "") + } + } + + /** Is this url a valid redirect url for this service? */ + @Throws + open fun isValidRedirectUrl(url: String): Boolean = + redirectUrlIdentifier != null && url.contains("/$redirectUrlIdentifier") + + /** OAuth2 login from a valid redirectUrl, and payload given in loginRequest */ + @Throws + open suspend fun login(redirectUrl: String, payload: String?): AuthToken? = + throw NotImplementedError() + + /** OAuth2 login request, asking the service to provide a url to open in the browser */ + @Throws + open fun loginRequest(): AuthLoginPage? = throw NotImplementedError() + + /** Pin login request, asking the service to provide an verificationUrl to display with a QR code */ + @Throws + open suspend fun pinRequest(): AuthPinData? = throw NotImplementedError() + + /** OAuth2 token refresh, this ensures that all token passed to other functions will be valid */ + @Throws + open suspend fun refreshToken(token: AuthToken): AuthToken? = throw NotImplementedError() + + /** Pin login, this will be called periodically while logging in to check if the pin has been verified by the user */ + @Throws + open suspend fun login(payload: AuthPinData): AuthToken? = throw NotImplementedError() + + /** In app login */ + @Throws + open suspend fun login(form: AuthLoginResponse): AuthToken? = throw NotImplementedError() + + /** Get the visible user account */ + @Throws + open suspend fun user(token: AuthToken?): AuthUser? = throw NotImplementedError() + + /** + * An optional security measure to make sure that even if an attacker gets ahold of the token, it will be invalid. + * + * Note that this will currently only be called *once* on logout, + * and as such any network issues it will fail silently, and the token will not be revoked. + **/ + @Throws + open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError() + + @Throws + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + fun toRepo(): AuthRepo = when (this) { + is SubtitleAPI -> SubtitleRepo(this) + is SyncAPI -> SyncRepo(this) + else -> throw NotImplementedError("Unknown inheritance from AuthAPI") + } + + @Suppress("DEPRECATION_ERROR") + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + fun loginInfo(): LoginInfo? { + return this.toRepo().authUser()?.let { user -> + LoginInfo( + profilePicture = user.profilePicture, + name = user.name, + accountIndex = -1, + ) + } + } + + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) + suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + @Suppress("DEPRECATION_ERROR") + return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow() + } + + @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) class LoginInfo( val profilePicture: String? = null, val name: String?, val accountIndex: Int, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt new file mode 100644 index 000000000..645a19e3a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -0,0 +1,168 @@ +package com.lagradost.cloudstream3.syncproviders + +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID +import com.lagradost.cloudstream3.utils.txt + +/** General-purpose repo */ +class PlainAuthRepo(api: AuthAPI) : AuthRepo(api) + +/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ +abstract class AuthRepo(open val api: AuthAPI) { + fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false + val idPrefix get() = api.idPrefix + val name get() = api.name + val icon get() = api.icon + val requiresLogin get() = api.requiresLogin + val createAccountUrl get() = api.createAccountUrl + val hasOAuth2 get() = api.hasOAuth2 + val hasPin get() = api.hasPin + val hasInApp get() = api.hasInApp + val inAppLoginRequirement get() = api.inAppLoginRequirement + val isAvailable get() = !api.requiresLogin || authUser() != null + + companion object { + private val oauthPayload: MutableMap = mutableMapOf() + } + + @Throws + protected suspend fun freshAuth(): AuthData? { + val data = authData() ?: return null + if (data.token.isAccessTokenExpired()) { + val newToken = api.refreshToken(data.token) ?: return null + val newAuth = AuthData(user = data.user, token = newToken) + refreshUser(newAuth) + return newAuth + } + return data + } + + @Throws + fun openOAuth2Page(): Boolean { + val page = api.loginRequest() ?: return false + synchronized(oauthPayload) { + oauthPayload.put(idPrefix, page.payload) + } + openBrowser(page.url) + return true + } + + fun openOAuth2PageWithToast() { + try { + if (!openOAuth2Page()) { + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } catch (t: Throwable) { + logError(t) + if (t is ErrorLoadingException && t.message != null) { + showToast(t.message) + return + } + showToast(txt(R.string.authenticated_user_fail, api.name)) + } + } + + suspend fun logout(from: AuthUser) { + val currentAccounts = AccountManager.accounts(idPrefix) + val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id } + if (newAccounts.size < currentAccounts.size) { + AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray()) + AccountManager.updateAccountsId(idPrefix, 0) + } + + for (oldAccount in oldAccounts) { + try { + api.invalidateToken(oldAccount.token) + } catch (_: NotImplementedError) { + // no-op + } catch (t: Throwable) { + logError(t) + } + } + } + + fun refreshUser(newAuth: AuthData) { + val currentAccounts = AccountManager.accounts(idPrefix) + val newAccounts = currentAccounts.map { + if (it.user.id == newAuth.user.id) { + newAuth + } else { + it + } + }.toTypedArray() + AccountManager.updateAccounts(idPrefix, newAccounts) + } + + fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix]?.let { id -> + AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id } + } + } + + fun authToken(): AuthToken? = authData()?.token + + fun authUser(): AuthUser? = authData()?.user + + val accounts + get() = synchronized(AccountManager.cachedAccounts) { + AccountManager.cachedAccounts[idPrefix] ?: emptyArray() + } + var accountId + get() = synchronized(AccountManager.cachedAccountIds) { + AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID + } + set(value) { + AccountManager.updateAccountsId(idPrefix, value) + } + + @Throws + suspend fun pinRequest() = + api.pinRequest() + + @Throws + private suspend fun setupLogin(token: AuthToken): Boolean { + val user = api.user(token) ?: return false + + val newAccount = AuthData( + token = token, + user = user, + ) + + val currentAccounts = AccountManager.accounts(idPrefix) + if (currentAccounts.any { it.user.id == newAccount.user.id }) { + throw ErrorLoadingException("Already logged into this account") + } + + val newAccounts = currentAccounts + newAccount + AccountManager.updateAccounts(idPrefix, newAccounts) + AccountManager.updateAccountsId(idPrefix, user.id) + if (this is SyncRepo) { + requireLibraryRefresh = true + } + return true + } + + @Throws + suspend fun login(form: AuthLoginResponse): Boolean { + return setupLogin(api.login(form) ?: return false) + } + + @Throws + suspend fun login(payload: AuthPinData): Boolean { + return setupLogin(api.login(payload) ?: return false) + } + + @Throws + suspend fun login(redirectUrl: String): Boolean { + return setupLogin( + api.login( + redirectUrl, + synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt new file mode 100644 index 000000000..5efb88e5b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt @@ -0,0 +1,14 @@ +package com.lagradost.cloudstream3.syncproviders + +/** Work in progress */ +abstract class BackupAPI : AuthAPI() { + open val filename : String = "cloudstream-backup.json" + + /** Get the backup file as a JSON string from the remote storage. Return null if not found/empty */ + @Throws + open suspend fun downloadFile(auth: AuthData?) : String? = throw NotImplementedError() + + /** Get the backup file as a JSON string from the remote storage. */ + @Throws + open suspend fun uploadFile(auth: AuthData?, data : String) : String? = throw NotImplementedError() +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt deleted file mode 100644 index 8b6fdf463..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/InAppAuthAPI.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders - -import androidx.annotation.WorkerThread - -interface InAppAuthAPI : AuthAPI { - data class LoginData( - val username: String? = null, - val password: String? = null, - val server: String? = null, - val email: String? = null, - ) - - // this is for displaying the UI - val requiresPassword: Boolean - val requiresUsername: Boolean - val requiresServer: Boolean - val requiresEmail: Boolean - - // if this is false we can assume that getLatestLoginData returns null and wont be called - // this is used in case for some reason it is not preferred to store any login data besides the "token" or encrypted data - val storesPasswordInPlainText: Boolean - - // return true if logged in successfully - suspend fun login(data: LoginData): Boolean - - // used to fill the UI if you want to edit any data about your login info - fun getLatestLoginData(): LoginData? -} - -abstract class InAppAuthAPIManager(defIndex: Int) : AccountManager(defIndex), InAppAuthAPI { - override val requiresPassword = false - override val requiresUsername = false - override val requiresEmail = false - override val requiresServer = false - override val storesPasswordInPlainText = true - override val requiresLogin = true - - // runs on startup - @WorkerThread - open suspend fun initialize() { - } - - override fun logOut() { - throw NotImplementedError() - } - - override val idPrefix: String - get() = throw NotImplementedError() - - override val name: String - get() = throw NotImplementedError() - - override val icon: Int? = null - - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { - throw NotImplementedError() - } - - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { - throw NotImplementedError() - } - - override fun loginInfo(): AuthAPI.LoginInfo? { - throw NotImplementedError() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt deleted file mode 100644 index ef74edfcb..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders - -import androidx.fragment.app.FragmentActivity - -interface OAuth2API : AuthAPI { - val key: String - val redirectUrl: String - - suspend fun handleRedirect(url: String) : Boolean - fun authenticate(activity: FragmentActivity?) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt new file mode 100644 index 000000000..a1149b5f8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt @@ -0,0 +1,37 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource + +/** + * Stateless subtitle class for external subtitles. + * + * All non-null `AuthToken` will be non-expired when each function is called. + */ +abstract class SubtitleAPI : AuthAPI() { + @WorkerThread + @Throws + open suspend fun search(auth: AuthData?, query: SubtitleSearch): List? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun load(auth: AuthData?, subtitle: SubtitleEntity): String? = + throw NotImplementedError() + + @WorkerThread + @Throws + open suspend fun SubtitleResource.getResources(auth: AuthData?, subtitle: SubtitleEntity) { + this.addUrl(load(auth, subtitle)) + } + + @WorkerThread + @Throws + suspend fun resource(auth: AuthData?, subtitle: SubtitleEntity): SubtitleResource { + return SubtitleResource().apply { + this.getResources(auth, subtitle) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt new file mode 100644 index 000000000..0b8c3e5ae --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt @@ -0,0 +1,95 @@ +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.APIHolder.unixTime +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf + +/** Stateless safe abstraction of SubtitleAPI */ +class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { + companion object { + data class SavedSearchResponse( + val unixTime: Long, + val response: List, + val query: SubtitleSearch + ) + + data class SavedResourceResponse( + val unixTime: Long, + val response: SubtitleResource, + val query: SubtitleEntity + ) + + // maybe make this a generic struct? right now there is a lot of boilerplate + private val searchCache = atomicListOf() + private var searchCacheIndex: Int = 0 + private val resourceCache = atomicListOf() + private var resourceCacheIndex: Int = 0 + const val CACHE_SIZE = 20 + } + + @WorkerThread + suspend fun resource(data: SubtitleEntity): Result = runCatching { + val cached = resourceCache.withLock { + var found: SubtitleResource? = null + for (item in resourceCache) { + // 20 min save + if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { + found = item.response + break + } + } + found + } + if (cached != null) return@runCatching cached + + val returnValue = api.resource(freshAuth(), data) + resourceCache.withLock { + val add = SavedResourceResponse(unixTime, returnValue, data) + if (resourceCache.size > CACHE_SIZE) { + resourceCache[resourceCacheIndex] = add // rolling cache + resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE + } else { + resourceCache.add(add) + } + } + returnValue + } + + @WorkerThread + suspend fun search(query: SubtitleSearch): Result> { + return runCatching { + val cached = searchCache.withLock { + var found: List? = null + for (item in searchCache) { + // 120 min save + if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { + found = item.response + break + } + } + found + } + + if (cached != null) return@runCatching cached + val returnValue = api.search(freshAuth(), query) ?: emptyList() + + // only cache valid return values + if (returnValue.isNotEmpty()) { + val add = SavedSearchResponse(unixTime, returnValue, query) + searchCache.withLock { + if (searchCache.size > CACHE_SIZE) { + searchCache[searchCacheIndex] = add // rolling cache + searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE + } else { + searchCache.add(add) + } + } + } + returnValue + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt index 8c76c5bf4..f30a64748 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -1,159 +1,194 @@ -package com.lagradost.cloudstream3.syncproviders - -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.result.UiText -import me.xdrop.fuzzywuzzy.FuzzySearch - -enum class SyncIdName { - Anilist, - MyAnimeList, - Trakt, - Imdb, - LocalList -} - -interface SyncAPI : OAuth2API { - /** - * Set this to true if the user updates something on the list like watch status or score - **/ - var requireLibraryRefresh: Boolean - val mainUrl: String - - /** - * Allows certain providers to open pages from - * library links. - **/ - val syncIdName: SyncIdName - - /** - -1 -> None - 0 -> Watching - 1 -> Completed - 2 -> OnHold - 3 -> Dropped - 4 -> PlanToWatch - 5 -> ReWatching - */ - suspend fun score(id: String, status: SyncStatus): Boolean - - suspend fun getStatus(id: String): SyncStatus? - - suspend fun getResult(id: String): SyncResult? - - suspend fun search(name: String): List? - - suspend fun getPersonalLibrary(): LibraryMetadata? - - fun getIdFromUrl(url: String): String - - data class SyncSearchResult( - override val name: String, - override val apiName: String, - var syncId: String, - override val url: String, - override var posterUrl: String?, - override var type: TvType? = null, - override var quality: SearchQuality? = null, - override var posterHeaders: Map? = null, - override var id: Int? = null, - ) : SearchResponse - - 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, - ) - - - data class Page( - val title: UiText, var items: List - ) { - fun sort(method: ListSorting?, query: String? = null) { - items = when (method) { - ListSorting.Query -> - if (query != null) { - items.sortedBy { - -FuzzySearch.partialRatio( - query.lowercase(), it.name.lowercase() - ) - } - } else items - ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating ?: 0) } - ListSorting.RatingLow -> items.sortedBy { (it.personalRating ?: 0) } - ListSorting.AlphabeticalA -> items.sortedBy { it.name } - ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() - ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } - ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } - else -> items - } - } - } - - data class LibraryMetadata( - val allLibraryLists: List, - val supportedListSorting: Set - ) - - data class LibraryList( - val name: UiText, - val items: List - ) - - data class LibraryItem( - override val name: String, - override val url: String, - /** - * Unique unchanging string used for data storage. - * This should be the actual id when you change scores and status - * since score changes from library might get added in the future. - **/ - val syncId: String, - val episodesCompleted: Int?, - val episodesTotal: Int?, - /** Out of 100 */ - val personalRating: Int?, - val lastUpdatedUnixTime: Long?, - override val apiName: String, - override var type: TvType?, - override var posterUrl: String?, - override var posterHeaders: Map?, - override var quality: SearchQuality?, - override var id: Int? = null, - ) : SearchResponse -} \ No newline at end of file +package com.lagradost.cloudstream3.syncproviders + +import androidx.annotation.WorkerThread +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.Levenshtein +import com.lagradost.cloudstream3.utils.UiText +import java.util.Date + +/** + * Stateless synchronization class, used for syncing status about a specific movie/show. + * + * All non-null `AuthToken` will be non-expired when each function is called. + */ +abstract class SyncAPI : AuthAPI() { + /** + * Set this to true if the user updates something on the list like watch status or score + **/ + open var requireLibraryRefresh: Boolean = true + open val mainUrl: String = "NONE" + + /** Currently unused, but will be used to correctly render the UI. + * This should specify what sync watch types can be used with this service. */ + open val supportedWatchTypes: Set = SyncWatchType.entries.toSet() + /** + * Allows certain providers to open pages from + * library links. + **/ + open val syncIdName: SyncIdName? = null + + /** Modify the current status of an item */ + @Throws + @WorkerThread + open suspend fun updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean = throw NotImplementedError() + + /** Get the current status of an item */ + @Throws + @WorkerThread + open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? = + throw NotImplementedError() + + /** Get metadata about an item */ + @Throws + @WorkerThread + open suspend fun load(auth: AuthData?, id: String): SyncResult? = throw NotImplementedError() + + /** Search this service for any results for a given query */ + @Throws + @WorkerThread + open suspend fun search(auth: AuthData?, query: String): List? = + throw NotImplementedError() + + /** Get the current library/bookmarks of this service */ + @Throws + @WorkerThread + open suspend fun library(auth: AuthData?): LibraryMetadata? = throw NotImplementedError() + + /** Helper function, may be used in the future */ + @Throws + open fun urlToId(url: String): String? = null + + data class SyncSearchResult( + override val name: String, + override val apiName: String, + var syncId: String, + override val url: String, + override var posterUrl: String?, + override var type: TvType? = null, + override var quality: SearchQuality? = null, + override var posterHeaders: Map? = null, + override var id: Int? = null, + override var score: Score? = null, + ) : SearchResponse + + abstract class AbstractSyncStatus { + abstract var status: SyncWatchType + abstract var score: Score? + abstract var watchedEpisodes: Int? + abstract var isFavorite: Boolean? + abstract var maxEpisodes: Int? + } + + data class SyncStatus( + override var status: SyncWatchType, + override var score: Score?, + override var watchedEpisodes: Int?, + override var isFavorite: Boolean? = null, + override var maxEpisodes: Int? = null, + ) : AbstractSyncStatus() + + data class SyncResult( + /**Used to verify*/ + var id: String, + + var totalEpisodes: Int? = null, + + var title: String? = null, + var publicScore: Score? = null, + /**In minutes*/ + var duration: Int? = null, + var synopsis: String? = null, + var airStatus: ShowStatus? = null, + var nextAiring: NextAiring? = null, + var studio: List? = null, + var genres: List? = null, + var synonyms: List? = null, + var trailers: List? = null, + var isAdult: Boolean? = null, + var posterUrl: String? = null, + var backgroundPosterUrl: String? = null, + + /** In unixtime */ + var startDate: Long? = null, + /** In unixtime */ + var endDate: Long? = null, + var recommendations: List? = null, + var nextSeason: SyncSearchResult? = null, + var prevSeason: SyncSearchResult? = null, + var actors: List? = null, + ) + + data class Page( + val title: UiText, var items: List + ) { + fun sort(method: ListSorting?, query: String? = null) { + items = when (method) { + ListSorting.Query -> + if (query != null) { + items.sortedBy { + -Levenshtein.partialRatio( + query.lowercase(), it.name.lowercase() + ) + } + } else items + + ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) } + ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) } + ListSorting.AlphabeticalA -> items.sortedBy { it.name } + ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() + ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } + ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } + ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } + ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } + else -> items + } + } + } + + data class LibraryMetadata( + val allLibraryLists: List, + val supportedListSorting: Set + ) + + data class LibraryList( + val name: UiText, + val items: List + ) + + data class LibraryItem( + override val name: String, + override val url: String, + /** + * Unique unchanging string used for data storage. + * This should be the actual id when you change scores and status + * since score changes from library might get added in the future. + **/ + val syncId: String, + val episodesCompleted: Int?, + val episodesTotal: Int?, + val personalRating: Score?, + val lastUpdatedUnixTime: Long?, + override val apiName: String, + override var type: TvType?, + override var posterUrl: String?, + override var posterHeaders: Map?, + override var quality: SearchQuality?, + val releaseDate: Date?, + override var id: Int? = null, + val plot: String? = null, + override var score: Score? = null, + val tags: List? = null + ) : SearchResponse +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt index 85b877e02..de82624fc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -1,48 +1,30 @@ -package com.lagradost.cloudstream3.syncproviders - -import com.lagradost.cloudstream3.ErrorLoadingException -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall -import com.lagradost.cloudstream3.mvvm.safeApiCall - -class SyncRepo(private val repo: SyncAPI) { - val idPrefix = repo.idPrefix - val name = repo.name - val icon = repo.icon - val mainUrl = repo.mainUrl - val requiresLogin = repo.requiresLogin - val syncIdName = repo.syncIdName - var requireLibraryRefresh: Boolean - get() = repo.requireLibraryRefresh - set(value) { - repo.requireLibraryRefresh = value - } - - suspend fun score(id: String, status: SyncAPI.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() } - } - - suspend fun getPersonalLibrary(): Resource { - return safeApiCall { repo.getPersonalLibrary() ?: throw ErrorLoadingException() } - } - - fun hasAccount(): Boolean { - return normalSafeApiCall { repo.loginInfo() != null } ?: false - } - - fun getIdFromUrl(url: String): String? = normalSafeApiCall { - repo.getIdFromUrl(url) - } -} \ No newline at end of file +package com.lagradost.cloudstream3.syncproviders + +/** Stateless safe abstraction of SyncAPI */ +class SyncRepo(override val api: SyncAPI) : AuthRepo(api) { + val syncIdName = api.syncIdName + var requireLibraryRefresh: Boolean + get() = api.requireLibraryRefresh + set(value) { + api.requireLibraryRefresh = value + } + + suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result = + runCatching { + val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus) + requireLibraryRefresh = true + status + } + + suspend fun status(id: String): Result = runCatching { + api.status(freshAuth(), id) + } + + suspend fun load(id: String): Result = runCatching { + api.load(freshAuth(), id) + } + + suspend fun library(): Result = runCatching { + api.library(freshAuth()) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt index 507c5e2ac..144efff99 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt @@ -1,108 +1,205 @@ package com.lagradost.cloudstream3.syncproviders.providers -import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.subtitles.AbstractSubApi -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.utils.SubtitleHelper +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName -class Addic7ed : AbstractSubApi { +class Addic7ed : SubtitleAPI() { override val name = "Addic7ed" override val idPrefix = "addic7ed" override val requiresLogin = false - override val icon: Nothing? = null - override val createAccountUrl: Nothing? = null - - override fun loginInfo(): Nothing? = null - - override fun logOut() {} companion object { - const val host = "https://www.addic7ed.com" + const val HOST = "https://www.addic7ed.com" const val TAG = "ADDIC7ED" } - private fun fixUrl(url: String): String { - return if (url.startsWith("/")) host + url - else if (!url.startsWith("http")) "$host/$url" + private fun String.fixUrl(): String { + val url = this + return if (url.startsWith("/")) HOST + url + else if (!url.startsWith("http")) "$HOST/$url" else url - } - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List { - val lang = query.lang - val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString()) - val queryText = query.query.trim() + override suspend fun search( + auth: AuthData?, + query: SubtitleSearch + ): List? { + val langTagIETF = query.lang ?: AllLanguagesName + val langNumAddic7ed = + langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0 + val langName = + langTagIETF2Addic7ed[langTagIETF]?.second ?: + fromTagToEnglishLanguageName(langTagIETF) ?: + "Completed" // this bypasses language filtering + val title = query.query.trim() val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 + val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title + var downloadPage = "" - fun cleanResources( - results: MutableList, - name: String, - link: String, - headers: Map, + fun newSubtitleEntity ( + displayName: String?, + link: String?, isHearingImpaired: Boolean - ) { - results.add( - AbstractSubtitleEntities.SubtitleEntity( - idPrefix = idPrefix, - name = name, - lang = queryLang.toString(), - data = link, - source = this.name, - type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, - epNumber = epNum, - seasonNumber = seasonNum, - year = yearNum, - headers = headers, - isHearingImpaired = isHearingImpaired - ) + ): SubtitleEntity? { + if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null + return SubtitleEntity( + idPrefix = this.idPrefix, + name = displayName, + lang = langTagIETF, + data = link, + source = this.name, + type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, + epNumber = epNum, + seasonNumber = seasonNum, + year = yearNum, + headers = mapOf("referer" to "$HOST/"), + isHearingImpaired = isHearingImpaired ) } - val title = queryText.substringBefore("(").trim() - val url = "$host/search.php?search=${title}&Submit=Search" - val hostDocument = app.get(url).document - var searchResult = "" - if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url - else if (!hostDocument.select("table.tabel") - .isNullOrEmpty() - ) searchResult = hostDocument.select("a:contains($title)").attr("href").toString() - else { - val show = - hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(") - ?.substringBefore(",") + val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search") + val hostDocument = response.document + + // 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name + if (response.url.contains("/movie/") || response.url.contains("/serie/")) + downloadPage = response.url + + // 2nd case: found tv series ep list. Redirected to $HOST/show/1234 + else if (response.url.contains("/show/")) { + val showId = response.url.substringAfterLast("/") val doc = app.get( - "$host/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", - referer = "$host/" + "$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0", + referer = "$HOST/" ).document - doc.select("#season tr:contains($queryLang)").mapNotNull { node -> - if (node.selectFirst("td")?.text() - ?.toIntOrNull() == seasonNum && node.select("td:eq(1)") - .text() - .toIntOrNull() == epNum - ) searchResult = fixUrl(node.select("a").attr("href")) + + // get direct subtitles links from list + return doc.select("#season tbody tr").mapNotNull { node -> + if (node.select("td:eq(1)").text().toIntOrNull() == epNum) + newSubtitleEntity( + displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(), + link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(), + isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty() + ) + else null } + // 3rd case: found several or no results. Still in $HOST/search.php?search=title + } else {// (response.url.contains("/search.php")) + downloadPage = hostDocument.select("table.tabel a").selectFirst({ + // tv series + if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]" + // movie + year + else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)" + // movie + else "a[href~=movie\\/]" + }())?.attr("href")?.fixUrl() ?: return null } - val results = mutableListOf() - val document = app.get( - url = fixUrl(searchResult), - ).document - document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node -> - val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${ - node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration") - }" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}" - val link = fixUrl(node.select("a.buttonDownload").attr("href")) + // filter download page by language. Do not work for movies :/ + if (downloadPage.contains("/serie/")) + downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed" + val doc = app.get(url = downloadPage).document + + // get subtitles links from download page + return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node -> + val displayName = + doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" + + node.parent()!!.select(".NewsTitle").text().substringAfter("Version ").substringBefore(", Duration") + val link = + node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl() val isHearingImpaired = - !node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty() - cleanResources(results, name, link, mapOf("referer" to "$host/"), isHearingImpaired) + node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty() + + newSubtitleEntity(displayName, link, isHearingImpaired) } - return results } - override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String { - return data.data + override suspend fun load( + auth: AuthData?, + subtitle: SubtitleEntity + ): String? { + return subtitle.data } + + // Missing (?_?) + // Pair("2", ""), + // Pair("3", ""), + // Pair("33", ""), + // Pair("34", ""), + // Do not modify unless Addic7ed changes them! + // as they are the exact values from their website + private val langTagIETF2Addic7ed = mapOf( + "ar" to Pair("38", "Arabic"), + "az" to Pair("48", "Azerbaijani"), + "bg" to Pair("35", "Bulgarian"), + "bn" to Pair("47", "Bengali"), + "bs" to Pair("44", "Bosnian"), + "ca" to Pair("12", "Català"), + "cs" to Pair("14", "Czech"), + "cy" to Pair("65", "Welsh"), + "da" to Pair("30", "Danish"), + "de" to Pair("11", "German"), + "el" to Pair("27", "Greek"), + "en" to Pair("1", "English"), + "es-419" to Pair("6", "Spanish (Latin America)"), + "es-ar" to Pair("69", "Spanish (Argentina)"), + "es-es" to Pair("5", "Spanish (Spain)"), + "es" to Pair("4", "Spanish"), + "et" to Pair("54", "Estonian"), + "eu" to Pair("13", "Euskera"), + "fa" to Pair("43", "Persian"), + "fi" to Pair("28", "Finnish"), + "fr-ca" to Pair("53", "French (Canadian)"), + "fr" to Pair("8", "French"), + "gl" to Pair("15", "Galego"), + "he" to Pair("23", "Hebrew"), + "hi" to Pair("55", "Hindi"), + "hr" to Pair("31", "Croatian"), + "hu" to Pair("20", "Hungarian"), + "hy" to Pair("50", "Armenian"), + "id" to Pair("37", "Indonesian"), + "is" to Pair("56", "Icelandic"), + "it" to Pair("7", "Italian"), + "ja" to Pair("32", "Japanese"), + "kn" to Pair("66", "Kannada"), + "ko" to Pair("42", "Korean"), + "lt" to Pair("58", "Lithuanian"), + "lv" to Pair("57", "Latvian"), + "mk" to Pair("49", "Macedonian"), + "ml" to Pair("67", "Malayalam"), + "mr" to Pair("62", "Marathi"), + "ms" to Pair("40", "Malay"), + "nl" to Pair("17", "Dutch"), + "no" to Pair("29", "Norwegian"), + "pl" to Pair("21", "Polish"), + "pt-br" to Pair("10", "Portuguese (Brazilian)"), + "pt" to Pair("9", "Portuguese"), + "ro" to Pair("26", "Romanian"), + "ru" to Pair("19", "Russian"), + "si" to Pair("60", "Sinhala"), + "sk" to Pair("25", "Slovak"), + "sl" to Pair("22", "Slovenian"), + "sq" to Pair("52", "Albanian"), + "sr-latn" to Pair("36", "Serbian (Latin)"), + "sr" to Pair("39", "Serbian (Cyrillic)"), + "sv" to Pair("18", "Swedish"), + "ta" to Pair("59", "Tamil"), + "te" to Pair("63", "Telugu"), + "th" to Pair("46", "Thai"), + "tl" to Pair("68", "Tagalog"), + "tlh" to Pair("61", "Klingon"), + "tr" to Pair("16", "Turkish"), + "uk" to Pair("51", "Ukrainian"), + "vi" to Pair("45", "Vietnamese"), + "yue" to Pair("64", "Cantonese"), + "zh-hans" to Pair("41", "Chinese (Simplified)"), + "zh-hant" to Pair("24", "Chinese (Traditional)"), + ) } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index 7d9de43aa..7a46b4113 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -1,91 +1,91 @@ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes -import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.Actor +import com.lagradost.cloudstream3.ActorData +import com.lagradost.cloudstream3.ActorRole +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.NextAiring +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.splitQuery import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject -import java.net.URL +import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear +import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder -import java.util.* +import java.util.Locale -class AniListApi(index: Int) : AccountManager(index), SyncAPI { +class AniListApi : SyncAPI() { override var name = "AniList" - override val key = "6871" - override val redirectUrl = "anilistlogin" override val idPrefix = "anilist" + + val key = "6871" + override val redirectUrlIdentifier = "anilistlogin" override var requireLibraryRefresh = true + override val hasOAuth2 = true override var mainUrl = "https://anilist.co" override val icon = R.drawable.ic_anilist_icon - override val requiresLogin = false override val createAccountUrl = "$mainUrl/signup" override val syncIdName = SyncIdName.Anilist - override fun loginInfo(): AuthAPI.LoginInfo? { - // context.getUser(true)?. - getKey(accountId, ANILIST_USER_KEY)?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = user.picture, - name = user.name, - accountIndex = accountIndex - ) - } - return null + override fun loginRequest(): AuthLoginPage? = + AuthLoginPage("https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token") + + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val sanitizer = splitRedirectUrl(redirectUrl) + val token = AuthToken( + accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"), + //refreshToken = sanitizer["refresh_token"], + accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), + ) + return token } - override fun logOut() { - requireLibraryRefresh = true - removeAccountKeys() + // https://docs.anilist.co/guide/auth/ + override suspend fun refreshToken(token: AuthToken): AuthToken? { + // AniList access tokens are long-lived. They will remain valid for 1 year from the time they are issued. + // Refresh tokens are not currently supported. Once a token expires, you will need to re-authenticate your users. + return super.refreshToken(token) } - override fun authenticate(activity: FragmentActivity?) { - val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token" - openBrowser(request, activity) + override suspend fun user(token: AuthToken?): AuthUser? { + val user = getUser(token ?: return null) + ?: throw ErrorLoadingException("Unable to fetch user data") + + return AuthUser( + id = user.id, + name = user.name, + profilePicture = user.picture, + ) } - override suspend fun handleRedirect(url: String): Boolean { - val sanitizer = - splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR - val token = sanitizer["access_token"]!! - val expiresIn = sanitizer["expires_in"]!! + override fun urlToId(url: String): String? = + url.removePrefix("$mainUrl/anime/").removeSuffix("/") - val endTime = unixTime + expiresIn.toLong() - - switchToNewAccount() - setKey(accountId, ANILIST_UNIXTIME_KEY, endTime) - setKey(accountId, ANILIST_TOKEN_KEY, token) - val user = getUser() - requireLibraryRefresh = true - return user != null - } - - override fun getIdFromUrl(url: String): String { - return url.removePrefix("$mainUrl/anime/").removeSuffix("/") - } private fun getUrlFromId(id: Int): String { return "$mainUrl/anime/$id" } - override suspend fun search(name: String): List? { + override suspend fun search(auth : AuthData?, query: String): List? { val data = searchShows(name) ?: return null - return data.data?.Page?.media?.map { + return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, this.name, @@ -96,10 +96,10 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun getResult(id: String): SyncAPI.SyncResult { + override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") - val season = getSeason(internalId).data.Media + val season = getSeason(internalId).data.media return SyncAPI.SyncResult( season.id.toString(), @@ -138,11 +138,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } ) }, - publicScore = season.averageScore?.times(100), + publicScore = Score.from100(season.averageScore), recommendations = season.recommendations?.edges?.mapNotNull { rec -> val recMedia = rec.node.mediaRecommendation SyncAPI.SyncSearchResult( - name = recMedia.title?.userPreferred ?: return@mapNotNull null, + name = recMedia?.title?.userPreferred ?: return@mapNotNull null, this.name, recMedia.id?.toString() ?: return@mapNotNull null, getUrlFromId(recMedia.id), @@ -158,37 +158,38 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) } - override suspend fun getStatus(id: String): SyncAPI.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 - ).also { - requireLibraryRefresh = requireLibraryRefresh || it - } + fromIntToAnimeStatus(newStatus.status.internalId), + newStatus.score, + newStatus.watchedEpisodes + ) } companion object { + const val MAX_STALE = 60 * 10 private val aniListStatusString = arrayOf("CURRENT", "COMPLETED", "PAUSED", "DROPPED", "PLANNING", "REPEATING") - const val ANILIST_UNIXTIME_KEY: String = "anilist_unixtime" // When token expires - const val ANILIST_TOKEN_KEY: String = "anilist_token" // anilist token for api - const val ANILIST_USER_KEY: String = "anilist_user" // user data like profile const val ANILIST_CACHED_LIST: String = "anilist_cached_list" private fun fixName(name: String): String { @@ -299,12 +300,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { //println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}") val shows = searchShows(name.replace(blackListRegex, "")) - shows?.data?.Page?.media?.find { + shows?.data?.page?.media?.find { (malId ?: "NONE") == it.idMal.toString() }?.let { return it } val filtered = - shows?.data?.Page?.media?.filter { + shows?.data?.page?.media?.filter { (((it.startDate.year ?: year.toString()) == year.toString() || year == null)) } @@ -458,21 +459,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } - fun initGetUser() { - if (getAuth() == null) return - ioSafe { - getUser() - } - } - - private fun checkToken(): Boolean { - return unixTime > getKey( - accountId, - ANILIST_UNIXTIME_KEY, 0L - )!! - } - - private suspend fun getDataAboutId(id: Int): AniListTitleHolder? { + private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? { val q = """query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id) Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query) @@ -482,7 +469,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { mediaListEntry { progress status - score (format: POINT_10) + score (format: POINT_100) } title { english @@ -491,10 +478,10 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } }""" - val data = postApi(q, true) + val data = postApi(auth.token, q, true) val d = parseJson(data ?: return null) - val main = d.data?.Media + val main = d.data?.media if (main?.mediaListEntry != null) { return AniListTitleHolder( title = main.title, @@ -519,37 +506,24 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } - private fun getAuth(): String? { - return getKey( - accountId, - ANILIST_TOKEN_KEY - ) + private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? { + return app.post( + "https://graphql.anilist.co/", + headers = mapOf( + "Authorization" to "Bearer ${token.accessToken ?: return null}", + if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache" + ), + cacheTime = 0, + data = mapOf( + "query" to URLEncoder.encode( + q, + "UTF-8" + ) + ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) + timeout = 5 // REASONABLE TIMEOUT + ).text.replace("\\/", "/") } - private suspend fun postApi(q: String, cache: Boolean = false): String? { - return suspendSafeApiCall { - if (!checkToken()) { - app.post( - "https://graphql.anilist.co/", - headers = mapOf( - "Authorization" to "Bearer " + (getAuth() - ?: return@suspendSafeApiCall null), - if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache" - ), - cacheTime = 0, - data = mapOf( - "query" to URLEncoder.encode( - q, - "UTF-8" - ) - ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) - timeout = 5 // REASONABLE TIMEOUT - ).text.replace("\\/", "/") - } else { - null - } - } - } data class MediaRecommendation( @JsonProperty("id") val id: Int, @@ -595,7 +569,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { //@JsonProperty("source") val source: String, @JsonProperty("episodes") val episodes: Int, @JsonProperty("title") val title: Title, - //@JsonProperty("description") val description: String, + @JsonProperty("description") val description: String?, @JsonProperty("coverImage") val coverImage: CoverImage, @JsonProperty("synonyms") val synonyms: List, @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, @@ -621,7 +595,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { this.media.id.toString(), this.progress, this.media.episodes, - this.score, + Score.from100(this.score), this.updatedAt.toLong(), "AniList", TvType.Anime, @@ -629,7 +603,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ?: this.media.coverImage.medium, null, null, - null + this.media.seasonYear.toYear(), + null, + plot = this.media.description, ) } } @@ -644,30 +620,26 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class Data( - @JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection + @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection ) - private fun getAniListListCached(): Array? { - return getKey(ANILIST_CACHED_LIST) as? Array - } - - private suspend fun getAniListAnimeListSmart(): Array? { - if (getAuth() == null) return null - - if (checkToken()) return null + private suspend fun getAniListAnimeListSmart(auth: AuthData): Array? { return if (requireLibraryRefresh) { - val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray() + val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray() if (list != null) { - setKey(ANILIST_CACHED_LIST, list) + setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list) } list } else { - getAniListListCached() + getKey>( + ANILIST_CACHED_LIST, + auth.user.id.toString() + ) as? Array } } - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { - val list = getAniListAnimeListSmart()?.groupBy { + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { + val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { convertAniListStringToStatus(it.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten() @@ -675,7 +647,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { // To fill empty lists when AniList does not return them val baseMap = - AniListStatusType.values().filter { it.value >= 0 }.associate { + AniListStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -686,16 +658,16 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) ) } - private suspend fun getFullAniListList(): FullAnilistList? { - /** WARNING ASSUMES ONE USER! **/ - - val userID = getKey(accountId, ANILIST_USER_KEY)?.id ?: return null + private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? { + val userID = auth.user.id val mediaType = "ANIME" val query = """ @@ -738,11 +710,11 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { } } """ - val text = postApi(query) + val text = postApi(auth.token, query) return text?.toKotlinObject() } - suspend fun toggleLike(id: Int): Boolean { + suspend fun toggleLike(auth : AuthData, id: Int): Boolean { val q = """mutation (${'$'}animeId: Int = $id) { ToggleFavourite (animeId: ${'$'}animeId) { anime { @@ -755,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 { @@ -801,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 } @@ -826,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) @@ -846,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( @@ -881,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( @@ -1011,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( @@ -1026,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?, ) @@ -1058,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( @@ -1098,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( @@ -1131,7 +1127,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class GetSearchPage( - @JsonProperty("Page") val Page: GetSearchData?, + @JsonProperty("Page") val page: GetSearchData?, ) data class GetSearchData( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt deleted file mode 100644 index 7ec168da8..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders.providers - -import androidx.fragment.app.FragmentActivity -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.OAuth2API - -//TODO dropbox sync -class Dropbox : OAuth2API { - override val idPrefix = "dropbox" - override var name = "Dropbox" - override val key = "zlqsamadlwydvb2" - override val redirectUrl = "dropboxlogin" - override val requiresLogin = true - override val createAccountUrl: String? = null - - override val icon: Int - get() = TODO("Not yet implemented") - - override fun authenticate(activity: FragmentActivity?) { - TODO("Not yet implemented") - } - - override suspend fun handleRedirect(url: String): Boolean { - TODO("Not yet implemented") - } - - override fun logOut() { - TODO("Not yet implemented") - } - - override fun loginInfo(): AuthAPI.LoginInfo? { - TODO("Not yet implemented") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt deleted file mode 100644 index 668d10bd7..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt +++ /dev/null @@ -1,265 +0,0 @@ -package com.lagradost.cloudstream3.syncproviders.providers - -import android.util.Log -import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.imdbUrlToIdNullable -import com.lagradost.cloudstream3.subtitles.AbstractSubApi -import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.utils.SubtitleHelper - -class IndexSubtitleApi : AbstractSubApi { - override val name = "IndexSubtitle" - override val idPrefix = "indexsubtitle" - override val requiresLogin = false - override val icon: Nothing? = null - override val createAccountUrl: Nothing? = null - - override fun loginInfo(): Nothing? = null - - override fun logOut() {} - - - companion object { - const val host = "https://indexsubtitle.com" - const val TAG = "INDEXSUBS" - } - - private fun fixUrl(url: String): String { - if (url.startsWith("http")) { - return url - } - if (url.isEmpty()) { - return "" - } - - val startsWithNoHttp = url.startsWith("//") - if (startsWithNoHttp) { - return "https:$url" - } else { - if (url.startsWith('/')) { - return host + url - } - return "$host/$url" - } - } - - private fun getOrdinal(num: Int?): String? { - return when (num) { - 1 -> "First" - 2 -> "Second" - 3 -> "Third" - 4 -> "Fourth" - 5 -> "Fifth" - 6 -> "Sixth" - 7 -> "Seventh" - 8 -> "Eighth" - 9 -> "Ninth" - 10 -> "Tenth" - 11 -> "Eleventh" - 12 -> "Twelfth" - 13 -> "Thirteenth" - 14 -> "Fourteenth" - 15 -> "Fifteenth" - 16 -> "Sixteenth" - 17 -> "Seventeenth" - 18 -> "Eighteenth" - 19 -> "Nineteenth" - 20 -> "Twentieth" - 21 -> "Twenty-First" - 22 -> "Twenty-Second" - 23 -> "Twenty-Third" - 24 -> "Twenty-Fourth" - 25 -> "Twenty-Fifth" - 26 -> "Twenty-Sixth" - 27 -> "Twenty-Seventh" - 28 -> "Twenty-Eighth" - 29 -> "Twenty-Ninth" - 30 -> "Thirtieth" - 31 -> "Thirty-First" - 32 -> "Thirty-Second" - 33 -> "Thirty-Third" - 34 -> "Thirty-Fourth" - 35 -> "Thirty-Fifth" - else -> null - } - } - - private fun isRightEps(text: String, seasonNum: Int?, epNum: Int?): Boolean { - val FILTER_EPS_REGEX = - Regex("(?i)((Chapter\\s?0?${epNum})|((Season)?\\s?0?${seasonNum}?\\s?(Episode)\\s?0?${epNum}[^0-9]))|(?i)((S?0?${seasonNum}?E0?${epNum}[^0-9])|(0?${seasonNum}[a-z]0?${epNum}[^0-9]))") - return text.contains(FILTER_EPS_REGEX) - } - - private fun haveEps(text: String): Boolean { - val HAVE_EPS_REGEX = - Regex("(?i)((Chapter\\s?0?\\d)|((Season)?\\s?0?\\d?\\s?(Episode)\\s?0?\\d))|(?i)((S?0?\\d?E0?\\d)|(0?\\d[a-z]0?\\d))") - return text.contains(HAVE_EPS_REGEX) - } - - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List { - val imdbId = query.imdb ?: 0 - val lang = query.lang - val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString()) - val queryText = query.query - val epNum = query.epNumber ?: 0 - val seasonNum = query.seasonNumber ?: 0 - val yearNum = query.year ?: 0 - - val urlItems = ArrayList() - - fun cleanResources( - results: MutableList, - name: String, - link: String - ) { - results.add( - AbstractSubtitleEntities.SubtitleEntity( - idPrefix = idPrefix, - name = name, - lang = queryLang.toString(), - data = link, - source = this.name, - type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, - epNumber = epNum, - seasonNumber = seasonNum, - year = yearNum, - ) - ) - } - - val document = app.get("$host/?search=$queryText").document - - document.select("div.my-3.p-3 div.media").map { block -> - if (seasonNum > 0) { - val name = block.select("strong.text-primary, strong.text-info").text().trim() - val season = getOrdinal(seasonNum) - if ((block.selectFirst("a")?.attr("href") - ?.contains( - "$season", - ignoreCase = true - )!! || name.contains( - "$season", - ignoreCase = true - )) && name.contains(queryText, ignoreCase = true) - ) { - block.select("div.media").mapNotNull { - urlItems.add( - fixUrl( - it.selectFirst("a")!!.attr("href") - ) - ) - } - } - } else { - if (block.selectFirst("strong")!!.text().trim() - .matches(Regex("(?i)^$queryText\$")) - ) { - if (block.select("span[title=Release]").isNullOrEmpty()) { - block.select("div.media").mapNotNull { - val urlItem = fixUrl( - it.selectFirst("a")!!.attr("href") - ) - val itemDoc = app.get(urlItem).document - val id = imdbUrlToIdNullable( - itemDoc.selectFirst("div.d-flex span.badge.badge-primary")?.parent() - ?.attr("href") - )?.toLongOrNull() - val year = itemDoc.selectFirst("div.d-flex span.badge.badge-success") - ?.ownText() - ?.trim().toString() - Log.i(TAG, "id => $id \nyear => $year||$yearNum") - if (imdbId > 0) { - if (id == imdbId) { - urlItems.add(urlItem) - } - } else { - if (year.contains("$yearNum")) { - urlItems.add(urlItem) - } - } - } - } else { - if (block.select("span[title=Release]").text().trim() - .contains("$yearNum") - ) { - block.select("div.media").mapNotNull { - urlItems.add( - fixUrl( - it.selectFirst("a")!!.attr("href") - ) - ) - } - } - } - } - } - } - Log.i(TAG, "urlItems => $urlItems") - val results = mutableListOf() - - urlItems.forEach { url -> - val request = app.get(url) - if (request.isSuccessful) { - request.document.select("div.my-3.p-3 div.media").map { block -> - if (block.select("span.d-block span[data-original-title=Language]").text() - .trim() - .contains("$queryLang") - ) { - var name = block.select("strong.text-primary, strong.text-info").text().trim() - val link = fixUrl(block.selectFirst("a")!!.attr("href")) - if (seasonNum > 0) { - when { - isRightEps(name, seasonNum, epNum) -> { - cleanResources(results, name, link) - } - !(haveEps(name)) -> { - name = "$name (S${seasonNum}:E${epNum})" - cleanResources(results, name, link) - } - } - } else { - cleanResources(results, name, link) - } - } - } - } - } - return results - } - - override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? { - val seasonNum = data.seasonNumber - val epNum = data.epNumber - - val req = app.get(data.data) - - if (req.isSuccessful) { - val document = req.document - val link = if (document.select("div.my-3.p-3 div.media").size == 1) { - fixUrl( - document.selectFirst("div.my-3.p-3 div.media a")!!.attr("href") - ) - } else { - document.select("div.my-3.p-3 div.media").firstNotNullOf { block -> - val name = - block.selectFirst("strong.d-block")?.text()?.trim().toString() - if (seasonNum!! > 0) { - if (isRightEps(name, seasonNum, epNum)) { - fixUrl(block.selectFirst("a")!!.attr("href")) - } else { - null - } - } else { - fixUrl(block.selectFirst("a")!!.attr("href")) - } - } - } - return link - } - - return null - - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt index 724d72163..e15a77c64 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt @@ -1,8 +1,676 @@ package com.lagradost.cloudstream3.syncproviders.providers + +import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.ShowStatus +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType +import com.lagradost.cloudstream3.ui.library.ListSorting +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.txt +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.ZoneId +import java.util.Date +import java.util.Locale + +const val KITSU_MAX_SEARCH_LIMIT = 20 + +class KitsuApi: SyncAPI() { + override var name = "Kitsu" + override val idPrefix = "kitsu" + + private val apiUrl = "https://kitsu.io/api/edge" + private val fallbackApiUrl = "https://kitsu.app/api/edge" + private val oauthUrl = "https://kitsu.io/api/oauth" + private val fallbackOauthUrl = "https://kitsu.app/api/oauth" + override val hasInApp = true + override val mainUrl = "https://kitsu.app" + override val icon = R.drawable.kitsu_icon + override val syncIdName = SyncIdName.Kitsu + override val createAccountUrl = mainUrl + + override val supportedWatchTypes = setOf( + SyncWatchType.WATCHING, + SyncWatchType.COMPLETED, + SyncWatchType.PLANTOWATCH, + SyncWatchType.DROPPED, + SyncWatchType.ONHOLD, + SyncWatchType.NONE + ) + + override val inAppLoginRequirement = AuthLoginRequirement( + password = true, + email = true + ) + + private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request() + + try { + + val response = chain.proceed(request); + + if (response.isSuccessful) return response + + response.close() + + } catch (_: Exception) { + } + + val fallbackRequest: Request = request.newBuilder() + .url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl)) + .build() + + return chain.proceed(fallbackRequest) + + } + } + + private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl) + private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl) + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.email ?: return null + val password = form.password ?: return null + + val grantType = "password" + + val token = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to grantType, + "username" to username, + "password" to password + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessTokenLifetime = unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken, + ) + } + + override suspend fun refreshToken(token: AuthToken): AuthToken { + val res = app.post( + "$oauthUrl/token", + data = mapOf( + "grant_type" to "refresh_token", + "refresh_token" to token.refreshToken!! + ), + interceptor = oauthFallbackInterceptor + ).parsed() + + return AuthToken( + accessToken = res.accessToken, + refreshToken = res.refreshToken, + accessTokenLifetime = unixTime + res.expiresIn.toLong() + ) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val user = app.get( + "$apiUrl/users?filter[self]=true", + headers = mapOf( + "Authorization" to "Bearer ${token?.accessToken ?: return null}" + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + if (user.data.isEmpty()) { + return null + } + + return AuthUser( + id = user.data[0].id.toInt(), + name = user.data[0].attributes.name, + profilePicture = user.data[0].attributes.avatar?.original + ) + } + + override suspend fun search(auth: AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") + val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth", + ), cacheTime = 0, + interceptor = apiFallbackInterceptor + ).parsed() + + return res.data.map { + val attributes = it.attributes + + val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" + + SyncSearchResult( + title, + this.name, + it.id, + "$mainUrl/anime/${it.id}/", + attributes.posterImage?.large ?: attributes.posterImage?.medium + ) + } + } + + override suspend fun load(auth : AuthData?, id: String): SyncResult? { + val auth = auth?.token?.accessToken ?: return null + if (id.toIntOrNull() == null) { + return null + } + + data class KitsuResponse( + @field:JsonProperty(value = "data") + val data: KitsuNode, + ) + + val url = + "$apiUrl/anime/$id" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.attributes + + return SyncResult( + id = id, + totalEpisodes = anime.episodeCount, + title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), + publicScore = Score.from(anime.ratingTwenty, 20), + duration = anime.episodeLength, + synopsis = anime.synopsis, + airStatus = when(anime.status) { + "finished" -> ShowStatus.Completed + "current" -> ShowStatus.Ongoing + else -> null + }, + nextAiring = null, + studio = null, + genres = null, + trailers = null, + startDate = LocalDate.parse(anime.startDate).toEpochDay(), + endDate = LocalDate.parse(anime.endDate).toEpochDay(), + recommendations = null, + nextSeason =null, + prevSeason = null, + actors = null, + ) + + } + + override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? { + val accessToken = auth?.token?.accessToken ?: return null + val userId = auth.user.id + + val selectedFields = arrayOf("status","ratingTwenty", "progress") + + val url = + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}" + + val anime = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $accessToken" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.firstOrNull()?.attributes + + if (anime == null) { + return SyncStatus( + score = null, + status = SyncWatchType.NONE, + isFavorite = null, + watchedEpisodes = null + ) + } + + return SyncStatus( + score = Score.from(anime.ratingTwenty, 20), + status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)), + isFavorite = null, + watchedEpisodes = anime.progress, + ) + } + suspend fun getAnimeIdByTitle(title: String): String? { + + val animeSelectedFields = arrayOf("titles","canonicalTitle") + val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" + + val res = app.get(url, interceptor = apiFallbackInterceptor).parsed() + + return res.data.firstOrNull()?.id + + } + + override fun urlToId(url: String): String? = + Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first() + + override suspend fun updateStatus( + auth : AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean { + + return setScoreRequest( + auth ?: return false, + id.toIntOrNull() ?: return false, + fromIntToAnimeStatus(newStatus.status), + newStatus.score?.toInt(20), + newStatus.watchedEpisodes + ) + } + + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: KitsuStatusType? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + + val libraryEntryId = getAnimeLibraryEntryId(auth, id) + + // Exists entry for anime in library + if (libraryEntryId != null) { + + // Delete anime from library + if (status == null || status == KitsuStatusType.None) { + + val res = app.delete( + "$apiUrl/library-entries/$libraryEntryId", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + interceptor = apiFallbackInterceptor + ) + + + return res.isSuccessful + + } + + return setScoreRequest( + auth, + libraryEntryId, + kitsuStatusAsString[maxOf(0, status.value)], + score, + numWatchedEpisodes + ) + + } + + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)], + ), + "relationships" to mapOf( + "anime" to mapOf( + "data" to mapOf( + "type" to "anime", + "id" to id.toString() + ) + ), + "user" to mapOf( + "data" to mapOf( + "type" to "users", + "id" to auth.user.id + ) + ) + ) + ) + ) + + val res = app.post( + "$apiUrl/library-entries", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor + ) + + return res.isSuccessful + + } + + @Suppress("UNCHECKED_CAST") + private suspend fun setScoreRequest( + auth : AuthData, + id: Int, + status: String? = null, + score: Int? = null, + numWatchedEpisodes: Int? = null, + ): Boolean { + val data = mapOf( + "data" to mapOf( + "type" to "libraryEntries", + "id" to id.toString(), + "attributes" to mapOf( + "ratingTwenty" to score, + "progress" to numWatchedEpisodes, + "status" to status + ) + ) + ) + + val res = app.patch( + "$apiUrl/library-entries/$id", + headers = mapOf( + "content-type" to "application/vnd.api+json", + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + requestBody = data.toJson().toRequestBody(), + interceptor = apiFallbackInterceptor + ) + + + return res.isSuccessful + + } + + private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? { + + val userId = auth.user.id + + val res = app.get( + "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id", + headers = mapOf( + "Authorization" to "Bearer ${auth.token.accessToken}" + ), + interceptor = apiFallbackInterceptor + ).parsed().data.firstOrNull() ?: return null + + return res.id.toInt() + + } + + override suspend fun library(auth : AuthData?): LibraryMetadata? { + val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy { + convertToStatus(it.attributes.status ?: "").stringRes + }?.mapValues { group -> + group.value.map { it.toLibraryItem() } + } ?: emptyMap() + + // To fill empty lists when Kitsu does not return them + val baseMap = + KitsuStatusType.entries.filter { it.value >= 0 }.associate { + it.stringRes to emptyList() + } + + return LibraryMetadata( + (baseMap + list).map { LibraryList(txt(it.key), it.value) }, + setOf( + ListSorting.AlphabeticalA, + ListSorting.AlphabeticalZ, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, + ListSorting.RatingHigh, + ListSorting.RatingLow, + ) + ) + } + + private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array? { + return if (requireLibraryRefresh) { + val list = getKitsuAnimeList(auth.token, auth.user.id) + setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list) + list + } else { + getKey>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array + } + } + + private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array { + + val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount") + val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status") + val limit = 500 + var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}" + + val fullList = mutableListOf() + + while (true) { + + val data: KitsuResponse = getKitsuAnimeListSlice(token, url) + + data.data.forEachIndexed { index, value -> + value.anime = data.included?.get(index) + } + + fullList.addAll(data.data) + + url = data.links?.next ?: break + } + + + return fullList.toTypedArray() + } + + private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse { + val res = app.get( + url, headers = mapOf( + "Authorization" to "Bearer ${token.accessToken}", + ), + interceptor = apiFallbackInterceptor + ).parsed() + return res + } + + + data class ResponseToken( + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String, + ) + + data class KitsuNode( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuNodeAttributes, + /* User list anime node */ + @JsonProperty("relationships") val relationships: KitsuRelationships?, + var anime: KitsuAnimeData? + ) { + fun toLibraryItem(): LibraryItem { + + val animeItem = this.anime + + val numEpisodes = animeItem?.attributes?.episodeCount + + val startDate = animeItem?.attributes?.startDate + + val posterImage = animeItem?.attributes?.posterImage + + val canonicalTitle = animeItem?.attributes?.canonicalTitle + val titles = animeItem?.attributes?.titles + + val animeId = animeItem?.id + + val synopsis: String? = animeItem?.attributes?.synopsis + + return LibraryItem( + canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(), + "https://kitsu.app/anime/${animeId}/", + this.id, + this.attributes.progress, + numEpisodes, + Score.from(this.attributes.ratingTwenty, 20), + parseDateLong(this.attributes.updatedAt), + "Kitsu", + TvType.Anime, + posterImage?.large ?: posterImage?.medium, + null, + null, + plot = synopsis, + releaseDate = if (startDate == null) null else try { + Date.from(LocalDate.parse(startDate).atStartOfDay() + .atZone(ZoneId.systemDefault()) + .toInstant()) + } catch (_: RuntimeException) { + null + } + ) + } + + } + + data class KitsuAnimeAttributes( + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + ) + + data class KitsuAnimeData( + @JsonProperty("id") val id: String, + @JsonProperty("attributes") val attributes: KitsuAnimeAttributes, + ) + + + data class KitsuNodeAttributes( + /* General attributes */ + @JsonProperty("titles") val titles: KitsuTitles?, + @JsonProperty("canonicalTitle") val canonicalTitle: String?, + @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, + @JsonProperty("synopsis") val synopsis: String?, + @JsonProperty("startDate") val startDate: String?, + @JsonProperty("endDate") val endDate: String?, + @JsonProperty("episodeCount") val episodeCount: Int?, + @JsonProperty("episodeLength") val episodeLength: Int?, + /* User attributes */ + @JsonProperty("name") val name: String?, + @JsonProperty("location") val location: String?, + @JsonProperty("createdAt") val createdAt: String?, + @JsonProperty("avatar") val avatar: KitsuUserAvatar?, + /* User list anime attributes */ + @JsonProperty("progress") val progress: Int?, + @JsonProperty("ratingTwenty") val ratingTwenty: Int?, + @JsonProperty("updatedAt") val updatedAt: String?, + @JsonProperty("status") val status: String?, + ) + + data class KitsuRelationships( + @JsonProperty("anime") val anime: KitsuRelationshipsAnime? + ) + + data class KitsuRelationshipsAnime( + @JsonProperty("links") val links: KitsuLinks? + ) + + data class KitsuPosterImage( + @JsonProperty("large") val large: String?, + @JsonProperty("medium") val medium: String?, + ) + + data class KitsuTitles( + @JsonProperty("en_jp") val enJp: String?, + @JsonProperty("ja_jp") val jaJp: String? + ) + + data class KitsuUserAvatar( + @JsonProperty("original") val original: String? + ) + + data class KitsuLinks( + /* Pagination */ + @JsonProperty("first") val first: String?, + @JsonProperty("next") val next: String?, + @JsonProperty("last") val last: String?, + /* Relationships */ + @JsonProperty("related") val related: String? + ) + + data class KitsuResponse( + @JsonProperty("links") val links: KitsuLinks?, + @JsonProperty("data") val data: List, + /* When requesting related info (User library entry -> anime) */ + @JsonProperty("included") val included: List?, + ) + + + companion object { + + const val KITSU_CACHED_LIST: String = "kitsu_cached_list" + private fun parseDateLong(string: String?): Long? { + return try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse( + string ?: return null + )?.time?.div(1000) + } catch (e: Exception) { + null + } + } + + private val kitsuStatusAsString = + arrayOf("current", "completed", "on_hold", "dropped", "planned") + private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType { + return when (inp) { + SyncWatchType.NONE -> KitsuStatusType.None + SyncWatchType.WATCHING -> KitsuStatusType.Watching + SyncWatchType.COMPLETED -> KitsuStatusType.Completed + SyncWatchType.ONHOLD -> KitsuStatusType.OnHold + SyncWatchType.DROPPED -> KitsuStatusType.Dropped + SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch + SyncWatchType.REWATCHING -> KitsuStatusType.Watching + } + } + + enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) { + Watching(0, R.string.type_watching), + Completed(1, R.string.type_completed), + OnHold(2, R.string.type_on_hold), + Dropped(3, R.string.type_dropped), + PlanToWatch(4, R.string.type_plan_to_watch), + None(-1, R.string.type_none) + } + + private fun convertToStatus(string: String): KitsuStatusType { + return when (string) { + "current" -> KitsuStatusType.Watching + "completed" -> KitsuStatusType.Completed + "on_hold" -> KitsuStatusType.OnHold + "dropped" -> KitsuStatusType.Dropped + "planned" -> KitsuStatusType.PlanToWatch + else -> KitsuStatusType.None + } + } + } +} // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md @@ -142,4 +810,4 @@ query { val canonical: String? = null ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt index 0b081220e..8f0d7ca6d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt @@ -1,66 +1,32 @@ package com.lagradost.cloudstream3.syncproviders.providers -import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioWork +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState +import com.lagradost.cloudstream3.utils.txt -class LocalList : SyncAPI { +class LocalList : SyncAPI() { override val name = "Local" + override val idPrefix = "local" + override val icon: Int = R.drawable.ic_baseline_storage_24 override val requiresLogin = false - override val createAccountUrl: Nothing? = null - override val idPrefix = "local" + override val createAccountUrl = null override var requireLibraryRefresh = true - - override fun loginInfo(): AuthAPI.LoginInfo { - return AuthAPI.LoginInfo( - null, - null, - 0 - ) - } - - override fun logOut() { - - } - - override val key: String = "" - override val redirectUrl = "" - override suspend fun handleRedirect(url: String): Boolean { - return true - } - - override fun authenticate(activity: FragmentActivity?) { - } - - override val mainUrl = "" override val syncIdName = SyncIdName.LocalList - override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { - return true - } - override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { - return null - } - - override suspend fun getResult(id: String): SyncAPI.SyncResult? { - return null - } - - override suspend fun search(name: String): List? { - return null - } - - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { + override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { val watchStatusIds = ioWork { getAllWatchStateIds()?.map { id -> Pair(id, getResultWatchState(id)) @@ -68,33 +34,59 @@ class LocalList : SyncAPI { }?.distinctBy { it.first } ?: return null val list = ioWork { - watchStatusIds.groupBy { - it.second.stringRes - }.mapValues { group -> + val isTrueTv = isLayout(TV) + + val baseMap = WatchType.entries.filter { it != WatchType.NONE }.associate { + // None is not something to display + it.stringRes to emptyList() + } + mapOf( + R.string.favorites_list_name to emptyList() + ) + if (!isTrueTv) { + mapOf( + R.string.subscription_list_name to emptyList() + ) + } else { + emptyMap() + } + + val watchStatusMap = watchStatusIds.groupBy { it.second.stringRes }.mapValues { group -> group.value.mapNotNull { getBookmarkedData(it.first)?.toLibraryItem(it.first.toString()) } } + + val favoritesMap = mapOf(R.string.favorites_list_name to getAllFavorites().mapNotNull { + it.toLibraryItem() + }) + + // Don't show subscriptions on TV + val result = if (isTrueTv) { + baseMap + watchStatusMap + favoritesMap + } else { + val subscriptionsMap = + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull { + it.toLibraryItem() + }) + + baseMap + watchStatusMap + subscriptionsMap + favoritesMap + } + + result } - val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate { - // None is not something to display - it.stringRes to emptyList() - } - return SyncAPI.LibraryMetadata( - (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) }, + return LibraryMetadata( + list.map { LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, -// ListSorting.UpdatedNew, -// ListSorting.UpdatedOld, + ListSorting.UpdatedNew, + ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, // ListSorting.RatingHigh, // ListSorting.RatingLow, + ) ) } - - override fun getIdFromUrl(url: String): String { - return url - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt index 5164b6067..ba0195be6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -1,105 +1,138 @@ package com.lagradost.cloudstream3.syncproviders.providers -import android.util.Base64 import androidx.annotation.StringRes -import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager -import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginPage +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting -import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.AppUtils.parseJson -import com.lagradost.cloudstream3.utils.AppUtils.splitQuery +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject -import java.net.URL -import java.security.SecureRandom -import java.text.ParseException +import com.lagradost.cloudstream3.utils.txt import java.text.SimpleDateFormat -import java.util.* +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.Locale /** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */ const val MAL_MAX_SEARCH_LIMIT = 25 -class MALApi(index: Int) : AccountManager(index), SyncAPI { +class MALApi : SyncAPI() { override var name = "MAL" - override val key = "1714d6f2f4f7cc19644384f8c4629910" - override val redirectUrl = "mallogin" override val idPrefix = "mal" - override var mainUrl = "https://myanimelist.net" + + val key = "1714d6f2f4f7cc19644384f8c4629910" private val apiUrl = "https://api.myanimelist.net" + override val hasOAuth2 = true + override val redirectUrlIdentifier: String? = "mallogin" + override val mainUrl = "https://myanimelist.net" override val icon = R.drawable.mal_logo - override val requiresLogin = false override val syncIdName = SyncIdName.MyAnimeList - override var requireLibraryRefresh = true override val createAccountUrl = "$mainUrl/register.php" - override fun logOut() { - requireLibraryRefresh = true - removeAccountKeys() - } + override val supportedWatchTypes = setOf( + SyncWatchType.WATCHING, + SyncWatchType.COMPLETED, + SyncWatchType.PLANTOWATCH, + SyncWatchType.DROPPED, + SyncWatchType.ONHOLD, + SyncWatchType.NONE + ) - override fun loginInfo(): AuthAPI.LoginInfo? { - //getMalUser(true)? - getKey(accountId, MAL_USER_KEY)?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = user.picture, - name = user.name, - accountIndex = accountIndex - ) + data class PayLoad( + val requestId: Int, + val codeVerifier: String + ) + + override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { + val payloadData = parseJson(payload!!) + val sanitizer = splitRedirectUrl(redirectUrl) + val state = sanitizer["state"]!! + + if (state != "RequestID${payloadData.requestId}") { + return null } - return null - } - private fun getAuth(): String? { - return getKey( - accountId, - MAL_TOKEN_KEY + val currentCode = sanitizer["code"]!! + + val token = app.post( + "$mainUrl/v1/oauth2/token", + data = mapOf( + "client_id" to key, + "code" to currentCode, + "code_verifier" to payloadData.codeVerifier, + "grant_type" to "authorization_code" + ) + ).parsed() + return AuthToken( + accessTokenLifetime = unixTime + token.expiresIn.toLong(), + refreshToken = token.refreshToken, + accessToken = token.accessToken ) } - override suspend fun search(name: String): List { + override suspend fun user(token: AuthToken?): AuthUser? { + val user = app.get( + "$apiUrl/v2/users/@me", + headers = mapOf( + "Authorization" to "Bearer ${token?.accessToken ?: return null}" + ), cacheTime = 0 + ).parsed() + return AuthUser( + id = user.id, + name = user.name, + profilePicture = user.picture + ) + } + + override suspend fun search(auth : AuthData?, query: String): List? { + val auth = auth?.token?.accessToken ?: return null val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT" - val auth = getAuth() ?: return emptyList() val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", ), cacheTime = 0 - ).text - return parseJson(res).data.map { + ).parsed() + return res.data.map { val node = it.node SyncAPI.SyncSearchResult( node.title, this.name, node.id.toString(), "$mainUrl/anime/${node.id}/", - node.main_picture?.large ?: node.main_picture?.medium + node.mainPicture?.large ?: node.mainPicture?.medium ) } } - override fun getIdFromUrl(url: String): String { - return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() - } + override fun urlToId(url: String): String? = + Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() - override suspend fun score(id: String, status: SyncAPI.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 - ).also { - requireLibraryRefresh = requireLibraryRefresh || it - } + fromIntToAnimeStatus(newStatus.status), + newStatus.score?.toInt(10), + newStatus.watchedEpisodes + ) } data class MalAnime( @@ -176,7 +209,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private fun parseDate(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd")?.parse(string ?: return null)?.time + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time } catch (e: Exception) { null } @@ -188,18 +221,18 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { apiName = this.name, syncId = node.id.toString(), url = "$mainUrl/anime/${node.id}", - posterUrl = node.main_picture?.large + posterUrl = node.mainPicture?.large ) } - override suspend fun getResult(id: String): SyncAPI.SyncResult? { + override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { + val auth = auth?.token?.accessToken ?: return null val internalId = id.toIntOrNull() ?: return null val url = "$apiUrl/v2/anime/$internalId?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,created_at,updated_at,media_type,status,genres,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,related_manga,recommendations,studios,statistics" - val auth = getAuth() val res = app.get( - url, headers = if (auth == null) emptyMap() else mapOf( + url, headers = mapOf( "Authorization" to "Bearer $auth" ) ).text @@ -208,7 +241,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { id = internalId.toString(), totalEpisodes = malAnime.numEpisodes, title = malAnime.title, - publicScore = malAnime.mean?.toFloat()?.times(1000)?.toInt(), + publicScore = Score.from10(malAnime.mean), duration = malAnime.averageEpisodeDuration, synopsis = malAnime.synopsis, airStatus = when (malAnime.status) { @@ -238,16 +271,23 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { - val internalId = id.toIntOrNull() ?: return null + override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { + val auth = auth?.token?.accessToken ?: return null + + // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get + val url = + "$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status" + val data = app.get( + url, headers = mapOf( + "Authorization" to "Bearer $auth" + ), cacheTime = 0 + ).parsed().myListStatus - val data = - getDataAboutMalId(internalId)?.my_list_status //?: throw ErrorLoadingException("No my_list_status") return SyncAPI.SyncStatus( - score = data?.score, - status = malStatusAsString.indexOf(data?.status), + score = Score.from10(data?.score), + status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)), isFavorite = null, - watchedEpisodes = data?.num_episodes_watched, + watchedEpisodes = data?.numEpisodesWatched, ) } @@ -255,14 +295,17 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private val malStatusAsString = arrayOf("watching", "completed", "on_hold", "dropped", "plan_to_watch") - const val MAL_USER_KEY: String = "mal_user" // user data like profile const val MAL_CACHED_LIST: String = "mal_cached_list" - const val MAL_UNIXTIME_KEY: String = "mal_unixtime" // When token expires - const val MAL_REFRESH_TOKEN_KEY: String = "mal_refresh_token" // refresh token - const val MAL_TOKEN_KEY: String = "mal_token" // anilist token for api fun convertToStatus(string: String): MalStatusType { - return fromIntToAnimeStatus(malStatusAsString.indexOf(string)) + return when (string) { + "watching" -> MalStatusType.Watching + "completed" -> MalStatusType.Completed + "on_hold" -> MalStatusType.OnHold + "dropped" -> MalStatusType.Dropped + "plan_to_watch" -> MalStatusType.PlanToWatch + else -> MalStatusType.None + } } enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) { @@ -274,22 +317,21 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { None(-1, R.string.type_none) } - private fun fromIntToAnimeStatus(inp: Int): MalStatusType {//= AniListStatusType.values().first { it.value == inp } + private fun fromIntToAnimeStatus(inp: SyncWatchType): MalStatusType {//= AniListStatusType.values().first { it.value == inp } return when (inp) { - -1 -> MalStatusType.None - 0 -> MalStatusType.Watching - 1 -> MalStatusType.Completed - 2 -> MalStatusType.OnHold - 3 -> MalStatusType.Dropped - 4 -> MalStatusType.PlanToWatch - 5 -> MalStatusType.Watching - else -> MalStatusType.None + SyncWatchType.NONE -> MalStatusType.None + SyncWatchType.WATCHING -> MalStatusType.Watching + SyncWatchType.COMPLETED -> MalStatusType.Completed + SyncWatchType.ONHOLD -> MalStatusType.OnHold + SyncWatchType.DROPPED -> MalStatusType.Dropped + SyncWatchType.PLANTOWATCH -> MalStatusType.PlanToWatch + SyncWatchType.REWATCHING -> MalStatusType.Watching } } private fun parseDateLong(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( string ?: return null )?.time?.div(1000) } catch (e: Exception) { @@ -298,85 +340,38 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - override suspend fun handleRedirect(url: String): Boolean { - val sanitizer = - splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR - val state = sanitizer["state"]!! - if (state == "RequestID$requestId") { - val currentCode = sanitizer["code"]!! - - val res = app.post( - "$mainUrl/v1/oauth2/token", - data = mapOf( - "client_id" to key, - "code" to currentCode, - "code_verifier" to codeVerifier, - "grant_type" to "authorization_code" - ) - ).text - - if (res.isNotBlank()) { - switchToNewAccount() - storeToken(res) - val user = getMalUser() - requireLibraryRefresh = true - return user != null - } - } - return false - } - - override fun authenticate(activity: FragmentActivity?) { - // It is recommended to use a URL-safe string as code_verifier. - // See section 4 of RFC 7636 for more details. - - val secureRandom = SecureRandom() - val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 - secureRandom.nextBytes(codeVerifierBytes) - codeVerifier = - Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=').replace("+", "-") - .replace("/", "_").replace("\n", "") + override fun loginRequest(): AuthLoginPage? { + val codeVerifier = generateCodeVerifier() + val requestId = ++requestIdCounter val codeChallenge = codeVerifier val request = "$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId" - openBrowser(request, activity) + + return AuthLoginPage( + url = request, + payload = PayLoad(requestId, codeVerifier).toJson() + ) } - private var requestId = 0 - private var codeVerifier = "" + override suspend fun refreshToken(token: AuthToken): AuthToken? { + val res = app.post( + "$mainUrl/v1/oauth2/token", + data = mapOf( + "client_id" to key, + "grant_type" to "refresh_token", + "refresh_token" to token.refreshToken!! + ) + ).parsed() - private fun storeToken(response: String) { - try { - if (response != "") { - val token = parseJson(response) - setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime)) - setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token) - setKey(accountId, MAL_TOKEN_KEY, token.access_token) - requireLibraryRefresh = true - } - } catch (e: Exception) { - logError(e) - } + return AuthToken( + accessToken = res.accessToken, + refreshToken = res.refreshToken, + accessTokenLifetime = unixTime + res.expiresIn.toLong() + ) } - private suspend fun refreshToken() { - try { - val res = app.post( - "$mainUrl/v1/oauth2/token", - data = mapOf( - "client_id" to key, - "grant_type" to "refresh_token", - "refresh_token" to getKey( - accountId, - MAL_REFRESH_TOKEN_KEY - )!! - ) - ).text - storeToken(res) - } catch (e: Exception) { - logError(e) - } - } + private var requestIdCounter = 0 + private val allTitles = hashMapOf() @@ -393,55 +388,66 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class Node( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String, - @JsonProperty("main_picture") val main_picture: MainPicture?, - @JsonProperty("alternative_titles") val alternative_titles: AlternativeTitles?, - @JsonProperty("media_type") val media_type: String?, - @JsonProperty("num_episodes") val num_episodes: Int?, + @JsonProperty("main_picture") val mainPicture: MainPicture?, + @JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?, + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("num_episodes") val numEpisodes: Int?, @JsonProperty("status") val status: String?, - @JsonProperty("start_date") val start_date: String?, - @JsonProperty("end_date") val end_date: String?, - @JsonProperty("average_episode_duration") val average_episode_duration: Int?, + @JsonProperty("start_date") val startDate: String?, + @JsonProperty("end_date") val endDate: String?, + @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?, @JsonProperty("synopsis") val synopsis: String?, @JsonProperty("mean") val mean: Double?, @JsonProperty("genres") val genres: List?, @JsonProperty("rank") val rank: Int?, @JsonProperty("popularity") val popularity: Int?, - @JsonProperty("num_list_users") val num_list_users: Int?, - @JsonProperty("num_favorites") val num_favorites: Int?, - @JsonProperty("num_scoring_users") val num_scoring_users: Int?, - @JsonProperty("start_season") val start_season: StartSeason?, + @JsonProperty("num_list_users") val numListUsers: Int?, + @JsonProperty("num_favorites") val numFavorites: Int?, + @JsonProperty("num_scoring_users") val numScoringUsers: Int?, + @JsonProperty("start_season") val startSeason: StartSeason?, @JsonProperty("broadcast") val broadcast: Broadcast?, @JsonProperty("nsfw") val nsfw: String?, - @JsonProperty("created_at") val created_at: String?, - @JsonProperty("updated_at") val updated_at: String? + @JsonProperty("created_at") val createdAt: String?, + @JsonProperty("updated_at") val updatedAt: String? ) data class ListStatus( @JsonProperty("status") val status: String?, @JsonProperty("score") val score: Int, - @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, - @JsonProperty("is_rewatching") val is_rewatching: Boolean, - @JsonProperty("updated_at") val updated_at: String, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) data class Data( @JsonProperty("node") val node: Node, - @JsonProperty("list_status") val list_status: ListStatus?, + @JsonProperty("list_status") val listStatus: ListStatus?, ) { fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( this.node.title, "https://myanimelist.net/anime/${this.node.id}/", this.node.id.toString(), - this.list_status?.num_episodes_watched, - this.node.num_episodes, - this.list_status?.score?.times(10), - parseDateLong(this.list_status?.updated_at), + this.listStatus?.numEpisodesWatched, + this.node.numEpisodes, + Score.from10(this.listStatus?.score), + parseDateLong(this.listStatus?.updatedAt), "MAL", TvType.Anime, - this.node.main_picture?.large ?: this.node.main_picture?.medium, + this.node.mainPicture?.large ?: this.node.mainPicture?.medium, null, null, + plot = this.node.synopsis, + releaseDate = if (this.node.startDate == null) null else try { + Date.from( + Instant.from( + DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") + .parse(this.node.startDate) + ) + ) + } catch (_: RuntimeException) { + null + } ) } } @@ -467,35 +473,20 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ) data class Broadcast( - @JsonProperty("day_of_the_week") val day_of_the_week: String?, - @JsonProperty("start_time") val start_time: String? + @JsonProperty("day_of_the_week") val dayOfTheWeek: String?, + @JsonProperty("start_time") val startTime: String? ) - private fun getMalAnimeListCached(): Array? { - return getKey(MAL_CACHED_LIST) as? Array - } - - private suspend fun getMalAnimeListSmart(): Array? { - if (getAuth() == null) return null - return if (requireLibraryRefresh) { - val list = getMalAnimeList() - setKey(MAL_CACHED_LIST, list) - list - } else { - getMalAnimeListCached() - } - } - - override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { - val list = getMalAnimeListSmart()?.groupBy { - convertToStatus(it.list_status?.status ?: "").stringRes + override suspend fun library(auth : AuthData?): LibraryMetadata? { + val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { + convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } } ?: emptyMap() // To fill empty lists when MAL does not return them val baseMap = - MalStatusType.values().filter { it.value >= 0 }.associate { + MalStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -506,19 +497,30 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) ) } - private suspend fun getMalAnimeList(): Array { - checkMalToken() + private suspend fun getMalAnimeListSmart(auth : AuthData): Array? { + return if (requireLibraryRefresh) { + val list = getMalAnimeList(auth.token) + setKey(MAL_CACHED_LIST, auth.user.id.toString(), list) + list + } else { + getKey>(MAL_CACHED_LIST, auth.user.id.toString()) as? Array + } + } + + private suspend fun getMalAnimeList(token: AuthToken): Array { var offset = 0 val fullList = mutableListOf() val offsetRegex = Regex("""offset=(\d+)""") while (true) { - val data: MalList = getMalAnimeListSlice(offset) ?: break + val data: MalList = getMalAnimeListSlice(token, offset) ?: break fullList.addAll(data.data) offset = data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() } @@ -527,130 +529,33 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { return fullList.toTypedArray() } - private suspend fun getMalAnimeListSlice(offset: Int = 0): MalList? { + private suspend fun getMalAnimeListSlice(token: AuthToken, offset: Int = 0): MalList? { val user = "@me" - val auth = getAuth() ?: return null // Very lackluster docs // https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get val url = "$apiUrl/v2/users/$user/animelist?fields=list_status,num_episodes,media_type,status,start_date,end_date,synopsis,alternative_titles,mean,genres,rank,num_list_users,nsfw,average_episode_duration,num_favorites,popularity,num_scoring_users,start_season,favorites_info,broadcast,created_at,updated_at&nsfw=1&limit=100&offset=$offset" val res = app.get( url, headers = mapOf( - "Authorization" to "Bearer $auth", + "Authorization" to "Bearer ${token.accessToken}", ), cacheTime = 0 ).text return res.toKotlinObject() } - private suspend fun getDataAboutMalId(id: Int): SmallMalAnime? { - // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get - val url = - "$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status" - val res = app.get( - url, headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return null) - ), cacheTime = 0 - ).text - - return parseJson(res) - } - - suspend fun setAllMalData() { - val user = "@me" - var isDone = false - var index = 0 - allTitles.clear() - checkMalToken() - while (!isDone) { - val res = app.get( - "$apiUrl/v2/users/$user/animelist?fields=list_status&limit=1000&offset=${index * 1000}", - headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return) - ), cacheTime = 0 - ).text - val values = parseJson(res) - val titles = - values.data.map { MalTitleHolder(it.list_status, it.node.id, it.node.title) } - for (t in titles) { - allTitles[t.id] = t - } - isDone = titles.size < 1000 - index++ - } - } - - fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? { - // No time remaining if the show has already ended - try { - endDate?.let { - if (SimpleDateFormat("yyyy-MM-dd").parse(it).time < System.currentTimeMillis()) return@convertJapanTimeToTimeRemaining null - } - } catch (e: ParseException) { - logError(e) - } - - // Unparseable date: "2021 7 4 other null" - // Weekday: other, date: null - if (date.contains("null") || date.contains("other")) { - return null - } - - val currentDate = Calendar.getInstance() - val currentMonth = currentDate.get(Calendar.MONTH) + 1 - val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH) - val currentYear = currentDate.get(Calendar.YEAR) - - val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm") - dateFormat.timeZone = TimeZone.getTimeZone("Japan") - val parsedDate = - dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null - val timeDiff = (parsedDate.time - System.currentTimeMillis()) / 1000 - - // if it has already aired this week add a week to the timer - val updatedTimeDiff = - if (timeDiff > -60 * 60 * 24 * 7 && timeDiff < 0) timeDiff + 60 * 60 * 24 * 7 else timeDiff - return secondsToReadable(updatedTimeDiff.toInt(), "Now") - - } - - private suspend fun checkMalToken() { - if (unixTime > (getKey( - accountId, - MAL_UNIXTIME_KEY - ) ?: 0L) - ) { - refreshToken() - } - } - - private suspend fun getMalUser(setSettings: Boolean = true): MalUser? { - checkMalToken() - val res = app.get( - "$apiUrl/v2/users/@me", - headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return null) - ), cacheTime = 0 - ).text - - val user = parseJson(res) - if (setSettings) { - setKey(accountId, MAL_USER_KEY, user) - registerAccount() - } - return user - } - private suspend fun setScoreRequest( + token: AuthToken, id: Int, status: MalStatusType? = null, score: Int? = null, - num_watched_episodes: Int? = null, + numWatchedEpisodes: Int? = null, ): Boolean { val res = setScoreRequest( + token, id, if (status == null) null else malStatusAsString[maxOf(0, status.value)], score, - num_watched_episodes + numWatchedEpisodes ) return if (res.isNullOrBlank()) { @@ -667,22 +572,24 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } + @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( + token: AuthToken, id: Int, status: String? = null, score: Int? = null, - num_watched_episodes: Int? = null, + numWatchedEpisodes: Int? = null, ): String? { val data = mapOf( "status" to status, "score" to score?.toString(), - "num_watched_episodes" to num_watched_episodes?.toString() - ).filter { it.value != null } as Map + "num_watched_episodes" to numWatchedEpisodes?.toString() + ).filterValues { it != null } as Map return app.put( "$apiUrl/v2/anime/$id/my_list_status", headers = mapOf( - "Authorization" to "Bearer " + (getAuth() ?: return null) + "Authorization" to "Bearer ${token.accessToken}" ), data = data ).text @@ -690,10 +597,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class ResponseToken( - @JsonProperty("token_type") val token_type: String, - @JsonProperty("expires_in") val expires_in: Int, - @JsonProperty("access_token") val access_token: String, - @JsonProperty("refresh_token") val refresh_token: String, + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String, ) data class MalRoot( @@ -702,7 +609,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class MalDatum( @JsonProperty("node") val node: MalNode, - @JsonProperty("list_status") val list_status: MalStatus, + @JsonProperty("list_status") val listStatus: MalStatus, ) data class MalNode( @@ -719,16 +626,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class MalStatus( @JsonProperty("status") val status: String, @JsonProperty("score") val score: Int, - @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, - @JsonProperty("is_rewatching") val is_rewatching: Boolean, - @JsonProperty("updated_at") val updated_at: String, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) data class MalUser( @JsonProperty("id") val id: Int, @JsonProperty("name") val name: String, @JsonProperty("location") val location: String, - @JsonProperty("joined_at") val joined_at: String, + @JsonProperty("joined_at") val joinedAt: String, @JsonProperty("picture") val picture: String?, ) @@ -741,9 +648,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class SmallMalAnime( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String?, - @JsonProperty("num_episodes") val num_episodes: Int, - @JsonProperty("my_list_status") val my_list_status: MalStatus?, - @JsonProperty("main_picture") val main_picture: MalMainPicture?, + @JsonProperty("num_episodes") val numEpisodes: Int, + @JsonProperty("my_list_status") val myListStatus: MalStatus?, + @JsonProperty("main_picture") val mainPicture: MalMainPicture?, ) data class MalSearchNode( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt index 3e372c2d5..4b17fdb29 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt @@ -2,38 +2,44 @@ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty -import com.google.common.collect.BiMap -import com.google.common.collect.HashBiMap -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.subtitles.AbstractSubApi +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities -import com.lagradost.cloudstream3.syncproviders.AuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI -import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.utils.AppUtils -import java.net.URLEncoder -import java.nio.charset.StandardCharsets +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag -class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { - override val idPrefix = "opensubtitles" +class OpenSubtitlesApi : SubtitleAPI() { override val name = "OpenSubtitles" + override val idPrefix = "opensubtitles" + override val icon = R.drawable.open_subtitles_icon - override val requiresPassword = true - override val requiresUsername = true + override val hasInApp = true + override val inAppLoginRequirement = AuthLoginRequirement( + password = true, + username = true, + ) + override val createAccountUrl = "https://www.opensubtitles.com/en/users/sign_up" companion object { - const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile - const val apiKey = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" - const val host = "https://api.opensubtitles.com/api/v1" + const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" + const val HOST = "https://api.opensubtitles.com/api/v1" const val TAG = "OPENSUBS" - const val coolDownDuration: Long = 1000L * 30L // CoolDown if 429 error code in ms + const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms var currentCoolDown: Long = 0L - var currentSession: SubtitleOAuthEntity? = null + const val userAgent = "Cloudstream3 v0.2" + val headers = mapOf("user-agent" to userAgent, "Api-Key" to API_KEY) } private fun canDoRequest(): Boolean { @@ -47,125 +53,59 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi } private fun throwGotTooManyRequests() { - currentCoolDown = unixTimeMs + coolDownDuration + currentCoolDown = unixTimeMs + COOLDOWN_DURATION throw ErrorLoadingException("Too many requests") } - private fun getAuthKey(): SubtitleOAuthEntity? { - return getKey(accountId, OPEN_SUBTITLES_USER_KEY) + override suspend fun refreshToken(token: AuthToken): AuthToken? { + return login(parseJson(token.payload ?: return null)) } - private fun setAuthKey(data: SubtitleOAuthEntity?) { - if (data == null) removeKey(accountId, OPEN_SUBTITLES_USER_KEY) - currentSession = data - setKey(accountId, OPEN_SUBTITLES_USER_KEY, data) + override suspend fun user(token: AuthToken?): AuthUser? { + val user = parseJson(token?.payload ?: return null) + val username = user.username ?: return null + return AuthUser( + id = username.hashCode(), + name = username + ) } - override fun loginInfo(): AuthAPI.LoginInfo? { - getAuthKey()?.let { user -> - return AuthAPI.LoginInfo( - profilePicture = null, - name = user.user, - accountIndex = accountIndex - ) - } - return null - } + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val username = form.username ?: return null + val password = form.password ?: return null - override fun getLatestLoginData(): InAppAuthAPI.LoginData? { - val current = getAuthKey() ?: return null - return InAppAuthAPI.LoginData(username = current.user, current.pass) - } - - /* - Authorize app to connect to API, using username/password. - Required to run at startup. - Returns OAuth entity with valid access token. - */ - override suspend fun initialize() { - currentSession = getAuthKey() ?: return // just in case the following fails - initLogin(currentSession?.user ?: return, currentSession?.pass ?: return) - } - - override fun logOut() { - setAuthKey(null) - removeAccountKeys() - currentSession = getAuthKey() - } - - private suspend fun initLogin(username: String, password: String): Boolean { - //Log.i(TAG, "DATA = [$username] [$password]") val response = app.post( - url = "$host/login", + url = "$HOST/login", headers = mapOf( - "Api-Key" to apiKey, - "Content-Type" to "application/json" - ), - data = mapOf( + "Content-Type" to "application/json", + ) + headers, + json = mapOf( "username" to username, "password" to password - ) + ), + ).parsed() + + return AuthToken( + accessToken = response.token + ?: throw ErrorLoadingException("Invalid password or username"), + /// JWT token is valid 24 hours after successfully authentication of user + accessTokenLifetime = unixTime + 60 * 60 * 24, + payload = form.toJson() ) - //Log.i(TAG, "Responsecode = ${response.code}") - //Log.i(TAG, "Result => ${response.text}") - - if (response.isSuccessful) { - AppUtils.tryParseJson(response.text)?.let { token -> - setAuthKey( - SubtitleOAuthEntity( - user = username, - pass = password, - access_token = token.token ?: run { - return false - }) - ) - } - return true - } - return false - } - - override suspend fun login(data: InAppAuthAPI.LoginData): Boolean { - val username = data.username ?: throw ErrorLoadingException("Requires Username") - val password = data.password ?: throw ErrorLoadingException("Requires Password") - switchToNewAccount() - try { - if (initLogin(username, password)) { - registerAccount() - return true - } - } catch (e: Exception) { - logError(e) - switchToOldAccount() - } - switchToOldAccount() - return false - } - - /** - * Some languages do not use the normal country codes on OpenSubtitles - * */ - private val languageExceptions = mapOf( -// "pt" to "pt-PT", -// "pt" to "pt-BR" - ) - private fun fixLanguage(language: String?) : String? { - return languageExceptions[language] ?: language - } - // O(n) but good enough, BiMap did not want to work properly - private fun fixLanguageReverse(language: String?) : String? { - return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language } /** * Fetch subtitles using token authenticated on previous method (see authorize). * Returns list of Subtitles which user can select to download (see load). * */ - override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? { + override suspend fun search( + auth : AuthData?, + query: AbstractSubtitleEntities.SubtitleSearch + ): List? { throwIfCantDoRequest() - val fixedLang = fixLanguage(query.lang) + val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: "" - val imdbId = query.imdb ?: 0 + val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val queryText = query.query val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 @@ -176,17 +116,17 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi val searchQueryUrl = when (imdbId > 0) { //Use imdb_id to search if its valid - true -> "$host/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" - false -> "$host/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" + true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" + false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" } val req = app.get( url = searchQueryUrl, headers = mapOf( - Pair("Api-Key", apiKey), Pair("Content-Type", "application/json") - ) + ) + headers, ) + Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}") Log.i(TAG, "Search Req => ${req.text}") if (!req.isSuccessful) { if (req.code == 429) @@ -207,12 +147,12 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi //Use any valid name/title in hierarchy val name = filename ?: featureDetails?.movieName ?: featureDetails?.title ?: featureDetails?.parentTitle ?: attr.release ?: query.query - val lang = fixLanguageReverse(attr.language)?: "" + val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: "" val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie - val isHearingImpaired = attr.hearing_impaired ?: false + val isHearingImpaired = attr.hearingImpaired ?: false //Log.i(TAG, "Result id/name => ${item.id} / $name") item.attributes?.files?.forEach { file -> val resultData = file.fileId?.toString() ?: "" @@ -221,7 +161,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = name, - lang = lang, + lang = langTagIETF, data = resultData, type = type, source = this.name, @@ -241,22 +181,26 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi Process data returned from search. Returns string url for the subtitle file. */ - override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? { + + override suspend fun load( + auth : AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity + ): String? { + if(auth == null) return null throwIfCantDoRequest() val req = app.post( - url = "$host/download", + url = "$HOST/download", headers = mapOf( Pair( "Authorization", - "Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}" + "Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" ), - Pair("Api-Key", apiKey), Pair("Content-Type", "application/json"), Pair("Accept", "*/*") - ), + ) + headers, data = mapOf( - Pair("file_id", data.data) + Pair("file_id", subtitle.data) ) ) Log.i(TAG, "Request result => (${req.code}) ${req.text}") @@ -274,13 +218,6 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi return null } - - data class SubtitleOAuthEntity( - var user: String, - var pass: String, - var access_token: String, - ) - data class OAuthToken( @JsonProperty("token") var token: String? = null, @JsonProperty("status") var status: Int? = null @@ -303,7 +240,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi @JsonProperty("url") var url: String? = null, @JsonProperty("files") var files: List? = listOf(), @JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(), - @JsonProperty("hearing_impaired") var hearing_impaired: Boolean? = null, + @JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null, ) data class ResultFiles( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt new file mode 100644 index 000000000..84a498bb0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -0,0 +1,1085 @@ +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.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.AppUtils.tryParseJson +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? { + val cache = getKey(SIMKL_CACHE_KEY, path)?.let { + tryParseJson>(it) + } + + return if (cache?.isFresh() == true) { + debugPrint { + "Cache hit at: $SIMKL_CACHE_KEY/$path. " + + "Remains fresh for ${cache.remainingTime().inWholeDays} days or ${cache.remainingTime().inWholeSeconds} seconds." + } + cache.obj + } else { + debugPrint { "Cache miss at: $SIMKL_CACHE_KEY/$path" } + removeKey(SIMKL_CACHE_KEY, path) + null + } + } + } + + companion object { + private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID + private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET + const val SIMKL_CACHED_LIST: String = "simkl_cached_list" + const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time" + + /** 2014-09-01T09:10:11Z -> 1409562611 */ + private const val 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 000000000..19122768e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt @@ -0,0 +1,167 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities +import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.SubtitleHelper + +class SubSourceApi : SubtitleAPI() { + override val name = "SubSource" + override val idPrefix = "subsource" + + override val requiresLogin = false + + companion object { + const val APIURL = "https://api.subsource.net/api" + const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub" + } + + override suspend fun search( + auth: AuthData?, + query: AbstractSubtitleEntities.SubtitleSearch + ): List? { + + //Only supports Imdb Id search for now + if (query.imdbId == null) return null + val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang) + val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie + + val searchRes = app.post( + url = "$APIURL/searchMovie", + data = mapOf( + "query" to query.imdbId!! + ) + ).parsedSafe() ?: return null + + val postData = if (type == TvType.TvSeries) { + mapOf( + "langs" to "[]", + "movieName" to searchRes.found.first().linkName, + "season" to "season-${query.seasonNumber}" + ) + } else { + mapOf( + "langs" to "[]", + "movieName" to searchRes.found.first().linkName, + ) + } + + val getMovieRes = app.post( + url = "$APIURL/getMovie", + data = postData + ).parsedSafe().let { + // api doesn't has episode number or lang filtering + if (type == TvType.Movie) { + it?.subs?.filter { sub -> + sub.lang == queryLang + } + } else { + it?.subs?.filter { sub -> + sub.releaseName!!.contains( + String.format( + null, + "E%02d", + query.epNumber + ) + ) && sub.lang == queryLang + } + } + } ?: return null + + return getMovieRes.map { subtitle -> + AbstractSubtitleEntities.SubtitleEntity( + idPrefix = this.idPrefix, + name = subtitle.releaseName!!, + lang = subtitle.lang!!, + data = SubData( + movie = subtitle.linkName!!, + lang = subtitle.lang, + id = subtitle.subId.toString(), + ).toJson(), + type = type, + source = this.name, + epNumber = query.epNumber, + seasonNumber = query.seasonNumber, + isHearingImpaired = subtitle.hi == 1, + ) + } + } + + override suspend fun SubtitleResource.getResources( + auth: AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity + ) { + val parsedSub = parseJson(subtitle.data) + + val subRes = app.post( + url = "$APIURL/getSub", + data = mapOf( + "movie" to parsedSub.movie, + "lang" to subtitle.lang, + "id" to parsedSub.id + ) + ).parsedSafe() ?: return + + this.addZipUrl( + "$DOWNLOADENDPOINT/${subRes.sub.downloadToken}" + ) { name, _ -> + name + } + } + + data class ApiSearch( + @JsonProperty("success") val success: Boolean, + @JsonProperty("found") val found: List, + ) + + data class Found( + @JsonProperty("id") val id: Long, + @JsonProperty("title") val title: String, + @JsonProperty("seasons") val seasons: Long, + @JsonProperty("type") val type: String, + @JsonProperty("releaseYear") val releaseYear: Long, + @JsonProperty("linkName") val linkName: String, + ) + + data class ApiResponse( + @JsonProperty("success") val success: Boolean, + @JsonProperty("movie") val movie: Movie, + @JsonProperty("subs") val subs: List, + ) + + data class Movie( + @JsonProperty("id") val id: Long? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("year") val year: Long? = null, + @JsonProperty("fullName") val fullName: String? = null, + ) + + data class Sub( + @JsonProperty("hi") val hi: Int? = null, + @JsonProperty("fullLink") val fullLink: String? = null, + @JsonProperty("linkName") val linkName: String? = null, + @JsonProperty("lang") val lang: String? = null, + @JsonProperty("releaseName") val releaseName: String? = null, + @JsonProperty("subId") val subId: Long? = null, + ) + + data class SubData( + @JsonProperty("movie") val movie: String, + @JsonProperty("lang") val lang: String, + @JsonProperty("id") val id: String, + ) + + data class SubTitleLink( + @JsonProperty("sub") val sub: SubToken, + ) + + data class SubToken( + @JsonProperty("downloadToken") val downloadToken: String, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt new file mode 100644 index 000000000..1f1e6de44 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt @@ -0,0 +1,259 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities +import com.lagradost.cloudstream3.subtitles.SubtitleResource +import com.lagradost.cloudstream3.syncproviders.AuthData +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.TvType + +class SubDlApi : SubtitleAPI() { + override val name = "SubDL" + override val idPrefix = "subdl" + + override val icon = R.drawable.subdl_logo_big + override val hasInApp = true + override val inAppLoginRequirement = AuthLoginRequirement(password = true, email = true) + override val requiresLogin = true + override val createAccountUrl = "https://subdl.com/panel/register" + + companion object { + const val APIURL = "https://apiold.subdl.com" + const val APIENDPOINT = "$APIURL/api/v1/subtitles" + const val DOWNLOADENDPOINT = "https://dl.subdl.com" + } + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val email = form.email ?: return null + val password = form.password ?: return null + val tokenResponse = app.post( + url = "$APIURL/login", + json = mapOf( + "email" to email, + "password" to password + ) + ).parsed() + + val apiResponse = app.get( + url = "$APIURL/user/userApi", + headers = mapOf( + "Authorization" to "Bearer ${tokenResponse.token}" + ) + ).parsed() + + return AuthToken(accessToken = apiResponse.apiKey, payload = email) + } + + override suspend fun user(token: AuthToken?): AuthUser? { + val name = token?.payload ?: return null + return AuthUser(id = name.hashCode(), name = name) + } + + override suspend fun search( + auth : AuthData?, + query: AbstractSubtitleEntities.SubtitleSearch + ): List? { + if (auth == null) return null + val apiKey = auth.token.accessToken ?: return null + val queryText = query.query + val epNum = query.epNumber ?: 0 + val seasonNum = query.seasonNumber ?: 0 + val yearNum = query.year ?: 0 + val langSubdlCode = langTagIETF2subdl[query.lang.toString()] ?: query.lang + + val idQuery = when { + query.imdbId != null -> "&imdb_id=${query.imdbId}" + query.tmdbId != null -> "&tmdb_id=${query.tmdbId}" + else -> null + } + + val epQuery = if (epNum > 0) "&episode_number=$epNum" else "" + val seasonQuery = if (seasonNum > 0) "&season_number=$seasonNum" else "" + val yearQuery = if (yearNum > 0) "&year=$yearNum" else "" + + val searchQueryUrl = when (idQuery) { + //Use imdb/tmdb id to search if its valid + null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" + else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" + } + + val req = app.get( + url = searchQueryUrl, + headers = mapOf( + "Accept" to "application/json" + ) + ) + + return req.parsedSafe()?.subtitles?.map { subtitle -> + + val langTagIETF = + langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?: + subtitle.lang + val resEpNum = subtitle.episode ?: query.epNumber + val resSeasonNum = subtitle.season ?: query.seasonNumber + val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie + + AbstractSubtitleEntities.SubtitleEntity( + idPrefix = this.idPrefix, + name = subtitle.releaseName, + lang = langTagIETF, + data = "${DOWNLOADENDPOINT}${subtitle.url}", + type = type, + source = this.name, + epNumber = resEpNum, + seasonNumber = resSeasonNum, + isHearingImpaired = subtitle.hearingImpaired ?: false, + ) + } + } + + override suspend fun SubtitleResource.getResources( + auth: AuthData?, + subtitle: AbstractSubtitleEntities.SubtitleEntity + ) { + this.addZipUrl(subtitle.data) { name, _ -> + name + } + } + + data class SubtitleOAuthEntity( + @JsonProperty("userEmail") var userEmail: String, + @JsonProperty("pass") var pass: String, + @JsonProperty("name") var name: String? = null, + @JsonProperty("accessToken") var accessToken: String? = null, + @JsonProperty("apiKey") var apiKey: String? = null, + ) + + data class OAuthTokenResponse( + @JsonProperty("token") val token: String, + @JsonProperty("userData") val userData: UserData? = null, + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("message") val message: String? = null, + ) + + data class UserData( + @JsonProperty("email") val email: String, + @JsonProperty("name") val name: String, + @JsonProperty("country") val country: String, + @JsonProperty("scStepCode") val scStepCode: String, + @JsonProperty("scVerified") val scVerified: Boolean, + @JsonProperty("username") val username: String? = null, + @JsonProperty("scUsername") val scUsername: String, + ) + + data class ApiKeyResponse( + @JsonProperty("ok") val ok: Boolean? = false, + @JsonProperty("api_key") val apiKey: String, + @JsonProperty("usage") val usage: Usage? = null, + ) + + data class Usage( + @JsonProperty("total") val total: Long? = 0, + @JsonProperty("today") val today: Long? = 0, + ) + + data class ApiResponse( + @JsonProperty("status") val status: Boolean? = null, + @JsonProperty("results") val results: List? = null, + @JsonProperty("subtitles") val subtitles: List? = null, + ) + + data class Result( + @JsonProperty("sd_id") val sdId: Int? = null, + @JsonProperty("type") val type: String? = null, + @JsonProperty("name") val name: String? = null, + @JsonProperty("imdb_id") val imdbId: String? = null, + @JsonProperty("tmdb_id") val tmdbId: Long? = null, + @JsonProperty("first_air_date") val firstAirDate: String? = null, + @JsonProperty("year") val year: Int? = null, + ) + + data class Subtitle( + @JsonProperty("release_name") val releaseName: String, + @JsonProperty("name") val name: String, + @JsonProperty("lang") val lang: String, // subdl language code + @JsonProperty("author") val author: String? = null, + @JsonProperty("url") val url: String? = null, + @JsonProperty("subtitlePage") val subtitlePage: String? = null, + @JsonProperty("season") val season: Int? = null, + @JsonProperty("episode") val episode: Int? = null, + @JsonProperty("language") val language: String? = null, // full language name + @JsonProperty("hi") val hearingImpaired: Boolean? = null, + ) + + // https://subdl.com/api-files/language_list.json + // most of it is IETF BPC 47 conformant tag + // but there are some exceptions + private val langTagIETF2subdl = mapOf( + "en-bg" to "BG_EN", // "Bulgarian_English" + "en-de" to "EN_DE", // "English_German" + "en-hu" to "HU_EN", // "Hungarian_English" + "en-nl" to "NL_EN", // "Dutch_English" + "pt-br" to "BR_PT", // "Brazillian Portuguese" + "zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?) + // "ar" to "AR", // "Arabic" + // "az" to "AZ", // "Azerbaijani" + // "be" to "BE", // "Belarusian" + // "bg" to "BG", // "Bulgarian" + // "bn" to "BN", // "Bengali" + // "bs" to "BS", // "Bosnian" + // "ca" to "CA", // "Catalan" + // "cs" to "CS", // "Czech" + // "da" to "DA", // "Danish" + // "de" to "DE", // "German" + // "el" to "EL", // "Greek" + // "en" to "EN", // "English" + // "eo" to "EO", // "Esperanto" + // "es" to "ES", // "Spanish" + // "et" to "ET", // "Estonian" + // "fa" to "FA", // "Farsi_Persian" + // "fi" to "FI", // "Finnish" + // "fr" to "FR", // "French" + // "he" to "HE", // "Hebrew" + // "hi" to "HI", // "Hindi" + // "hr" to "HR", // "Croatian" + // "hu" to "HU", // "Hungarian" + // "id" to "ID", // "Indonesian" + // "is" to "IS", // "Icelandic" + // "it" to "IT", // "Italian" + // "ja" to "JA", // "Japanese" + // "ka" to "KA", // "Georgian" + // "kl" to "KL", // "Greenlandic" + // "ko" to "KO", // "Korean" + // "ku" to "KU", // "Kurdish" + // "lt" to "LT", // "Lithuanian" + // "lv" to "LV", // "Latvian" + // "mk" to "MK", // "Macedonian" + // "ml" to "ML", // "Malayalam" + // "mni" to "MNI", // "Manipuri" + // "ms" to "MS", // "Malay" + // "my" to "MY", // "Burmese" + // "nl" to "NL", // "Dutch" + // "no" to "NO", // "Norwegian" + // "pl" to "PL", // "Polish" + // "pt" to "PT", // "Portuguese" + // "ro" to "RO", // "Romanian" + // "ru" to "RU", // "Russian" + // "si" to "SI", // "Sinhala" + // "sk" to "SK", // "Slovak" + // "sl" to "SL", // "Slovenian" + // "sq" to "SQ", // "Albanian" + // "sr" to "SR", // "Serbian" + // "sv" to "SV", // "Swedish" + // "ta" to "TA", // "Tamil" + // "te" to "TE", // "Telugu" + // "th" to "TH", // "Thai" + // "tl" to "TL", // "Tagalog" + // "tr" to "TR", // "Turkish" + // "uk" to "UK", // "Ukranian" + // "ur" to "UR", // "Urdu" + // "vi" to "VI", // "Vietnamese" + // "zh" to "ZH", // "Chinese BG code" + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index 4ab2e8e29..8ec082520 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -1,22 +1,37 @@ package com.lagradost.cloudstream3.ui -import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SearchResponseList +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.fixUrl import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall -import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf +import com.lagradost.cloudstream3.newSearchResponseList +import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope.coroutineContext import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout class APIRepository(val api: MainAPI) { companion object { + // 2 minute timeout to prevent bad extensions/extractors from hogging the resources + // No real provider should take longer, so we hard kill them. + private const val DEFAULT_TIMEOUT = 120_000L + private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT + private const val MIN_TIMEOUT = 5_000L + var dubStatusActive = HashSet() val noneApi = object : MainAPI() { @@ -40,16 +55,18 @@ class APIRepository(val api: MainAPI) { val hash: Pair ) - private val cache = threadSafeListOf() + private val cache = atomicListOf() private var cacheIndex: Int = 0 - const val cacheSize = 20 + const val CACHE_SIZE = 20 + + fun getTimeout(desired: Long?): Long { + return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) + } } private fun afterPluginsLoaded(forceReload: Boolean) { if (forceReload) { - synchronized(cache) { - cache.clear() - } + cache.clear() } } @@ -67,54 +84,66 @@ class APIRepository(val api: MainAPI) { suspend fun load(url: String): Resource { return safeApiCall { - if (isInvalidData(url)) throw ErrorLoadingException() - val fixedUrl = api.fixUrl(url) - val lookingForHash = Pair(api.name, fixedUrl) + withTimeout(getTimeout(api.loadTimeoutMs)) { + if (isInvalidData(url)) throw ErrorLoadingException() + val fixedUrl = api.fixUrl(url) + val lookingForHash = Pair(api.name, fixedUrl) - synchronized(cache) { - for (item in cache) { - // 10 min save - if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { - return@safeApiCall item.response + val cached = cache.withLock { + var found: LoadResponse? = null + for (item in cache) { + // 10 min save + if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { + found = item.response + break + } } + found } + + if (cached != null) return@withTimeout cached + api.load(fixedUrl)?.also { response -> + // Remove all blank tags as early as possible + response.tags = response.tags?.filter { it.isNotBlank() } + val add = SavedLoadResponse(unixTime, response, lookingForHash) + + cache.withLock { + if (cache.size > CACHE_SIZE) { + cache[cacheIndex] = add // rolling cache + cacheIndex = (cacheIndex + 1) % CACHE_SIZE + } else { + cache.add(add) + } + } + } ?: throw ErrorLoadingException() } - - api.load(fixedUrl)?.also { response -> - // Remove all blank tags as early as possible - response.tags = response.tags?.filter { it.isNotBlank() } - val add = SavedLoadResponse(unixTime, response, lookingForHash) - - synchronized(cache) { - if (cache.size > cacheSize) { - cache[cacheIndex] = add // rolling cache - cacheIndex = (cacheIndex + 1) % cacheSize - } else { - cache.add(add) - } - } - } ?: throw ErrorLoadingException() } } - suspend fun search(query: String): Resource> { + suspend fun search(query: String, page: Int): Resource { if (query.isEmpty()) - return Resource.Success(emptyList()) + return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { - return@safeApiCall (api.search(query) - ?: throw ErrorLoadingException()) -// .filter { typesActive.contains(it.type) } - .toList() + withTimeout(getTimeout(api.searchTimeoutMs)) { + (api.search(query, page) + ?: throw ErrorLoadingException()) + // .filter { typesActive.contains(it.type) } + } } } - suspend fun quickSearch(query: String): Resource> { + suspend fun quickSearch(query: String): Resource { if (query.isEmpty()) - return Resource.Success(emptyList()) + return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { - api.quickSearch(query) ?: throw ErrorLoadingException() + withTimeout(getTimeout(api.quickSearchTimeoutMs)) { + newSearchResponseList( + api.quickSearch(query) ?: throw ErrorLoadingException(), + false + ) + } } } @@ -126,38 +155,40 @@ class APIRepository(val api: MainAPI) { suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource> { return safeApiCall { - api.lastHomepageRequest = unixTimeMS - - nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data -> - listOf( - api.getMainPage( - page, - MainPageRequest(data.name, data.data, data.horizontalImages) - ) - ) - } ?: run { - if (api.sequentialMainPage) { - var first = true - api.mainPage.map { data -> - if (!first) // dont want to sleep on first request - delay(api.sequentialMainPageDelay) - first = false + withTimeout(getTimeout(api.getMainPageTimeoutMs)) { + api.lastHomepageRequest = unixTimeMS + nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data -> + listOf( api.getMainPage( page, MainPageRequest(data.name, data.data, data.horizontalImages) ) - } - } else { - with(CoroutineScope(coroutineContext)) { + ) + } ?: run { + if (api.sequentialMainPage) { + var first = true api.mainPage.map { data -> - async { - api.getMainPage( - page, - MainPageRequest(data.name, data.data, data.horizontalImages) - ) - } - }.map { it.await() } + if (!first) // dont want to sleep on first request + delay(api.sequentialMainPageDelay) + first = false + + api.getMainPage( + page, + MainPageRequest(data.name, data.data, data.horizontalImages) + ) + } + } else { + with(CoroutineScope(coroutineContext)) { + api.mainPage.map { data -> + async { + api.getMainPage( + page, + MainPageRequest(data.name, data.data, data.horizontalImages) + ) + } + }.map { it.await() } + } } } } @@ -174,14 +205,16 @@ class APIRepository(val api: MainAPI) { data: String, isCasting: Boolean, subtitleCallback: (SubtitleFile) -> Unit, - callback: (ExtractorLink) -> Unit + callback: (ExtractorLink) -> Unit, ): Boolean { if (isInvalidData(data)) return false // this makes providers cleaner return try { - api.loadLinks(data, isCasting, subtitleCallback, callback) + withTimeout(getTimeout(api.loadLinksTimeoutMs)) { + api.loadLinks(data, isCasting, subtitleCallback, callback) + } } catch (throwable: Throwable) { logError(throwable) return false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt new file mode 100644 index 000000000..4ebb7564c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -0,0 +1,327 @@ +package com.lagradost.cloudstream3.ui + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.view.children +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import androidx.viewbinding.ViewBinding +import coil3.dispose +import java.util.WeakHashMap +import java.util.concurrent.CopyOnWriteArrayList + +open class ViewHolderState(val view: ViewBinding) : ViewHolder(view.root) { + open fun save(): T? = null + open fun restore(state: T) = Unit +} + +abstract class NoStateAdapter( + diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() +) : BaseAdapter(0, diffCallback) + +/** Creates a new shared pool, using the supplied lambda as a constructor. + * + * The reason for this complicated structure is that a pool should not be shared between contexts + * as it makes coil fuck up, and theming. + * */ +fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair, RecyclerView.RecycledViewPool.() -> Unit> = + WeakHashMap() to lambda + +/** Sets the shared pool of the recyclerview */ +fun RecyclerView.setRecycledViewPool(pool: Pair, RecyclerView.RecycledViewPool.() -> Unit>) { + val ctx = context ?: return + synchronized(pool.first) { + this.setRecycledViewPool(pool.first.getOrPut(ctx) { + RecyclerView.RecycledViewPool().apply(pool.second) + }) + } +} + +/** Clears the shared pool of views */ +fun Pair, RecyclerView.RecycledViewPool.() -> Unit>.clear() { + synchronized(this.first) { + for (pool in this.first.values) { + pool?.clear() + } + } +} + +/** + * BaseAdapter is a persistent state stored adapter that supports headers and footers. + * This should be used for restoring eg scroll or focus related to a view when it is recreated. + * + * Id is a per fragment based unique id used to store the underlying data done in an internal ViewModel. + * + * diffCallback is how the view should be handled when updating, override onUpdateContent for updates + * + * NOTE: + * + * By default it should save automatically, but you can also call save(recycle) + * + * By default no state is stored, but doing an id != 0 will store + * + * By default no headers or footers exist, override footers and headers count + */ +abstract class BaseAdapter< + T : Any, + S : Any>( + val id: Int = 0, + diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() +) : RecyclerView.Adapter>() { + open val footers: Int = 0 + open val headers: Int = 0 + + val immutableCurrentList: List get() = mDiffer.currentList + + fun getItem(position: Int): T { + return mDiffer.currentList[position] + } + + fun getItemOrNull(position: Int): T? { + return mDiffer.currentList.getOrNull(position) + } + + private val mDiffer: AsyncListDiffer = AsyncListDiffer( + object : NonFinalAdapterListUpdateCallback(this) { + override fun onMoved(fromPosition: Int, toPosition: Int) { + super.onMoved(fromPosition + headers, toPosition + headers) + } + + override fun onRemoved(position: Int, count: Int) { + super.onRemoved(position + headers, count) + } + + override fun onChanged(position: Int, count: Int, payload: Any?) { + super.onChanged(position + headers, count, payload) + } + + override fun onInserted(position: Int, count: Int) { + super.onInserted(position + headers, count) + } + }, + AsyncDifferConfig.Builder(diffCallback).build() + ) + + /** + * Instantly submits a **new and fresh** list. This means that no changes like moves are done as + * we assume the new list is not the same thing as the old list, nothing is shared. + * + * The views are rendered instantly as a result, so no fade/pop-ins or similar. + * + * Use `submitList` for general use, as that can reuse old views. + * */ + open fun submitIncomparableList(list: List?, commitCallback : Runnable? = null) { + // This leverages a quirk in the submitList function that has a fast case for null arrays + // What this implies is that as long as we do a double submit we can ensure no pop-ins, + // as the changes are the entire list instead of calculating deltas + submitList(null) + submitList(list, commitCallback) + } + + /** + * @param commitCallback Optional runnable that is executed when the List is committed, if it is committed. + * This is needed for some tasks as submitList will use a background thread for diff + * */ + open fun submitList(list: Collection?, commitCallback : Runnable? = null) { + // deep copy at least the top list, because otherwise adapter can go crazy + if (list.isNullOrEmpty()) { + mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList() + } else { + mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback) + } + } + + override fun getItemCount(): Int { + return mDiffer.currentList.size + footers + headers + } + + open fun onUpdateContent(holder: ViewHolderState, item: T, position: Int) = + onBindContent(holder, item, position) + + open fun onBindContent(holder: ViewHolderState, item: T, position: Int) = Unit + open fun onBindFooter(holder: ViewHolderState) = Unit + open fun onBindHeader(holder: ViewHolderState) = Unit + open fun onCreateContent(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateCustomContent( + parent: ViewGroup, + viewType: Int + ) = onCreateContent(parent) + + open fun onCreateFooter(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateCustomFooter( + parent: ViewGroup, + viewType: Int + ) = onCreateFooter(parent) + + open fun onCreateHeader(parent: ViewGroup): ViewHolderState = throw NotImplementedError() + open fun onCreateCustomHeader( + parent: ViewGroup, + viewType: Int + ) = onCreateHeader(parent) + + override fun onViewAttachedToWindow(holder: ViewHolderState) {} + override fun onViewDetachedFromWindow(holder: ViewHolderState) {} + + @Suppress("UNCHECKED_CAST") + fun save(recyclerView: RecyclerView) { + for (child in recyclerView.children) { + val holder = + recyclerView.findContainingViewHolder(child) as? ViewHolderState ?: continue + setState(holder) + } + } + + fun clearState() { + layoutManagerStates[id]?.clear() + } + + @Suppress("UNCHECKED_CAST") + private fun getState(holder: ViewHolderState): S? = + layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S + + private fun setState(holder: ViewHolderState) { + if (id == 0) return + if (!layoutManagerStates.contains(id)) { + layoutManagerStates[id] = HashMap() + } + layoutManagerStates[id]?.let { map -> + map[holder.absoluteAdapterPosition] = holder.save() + } + } + + private val attachListener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) = Unit + override fun onViewDetachedFromWindow(v: View) { + if (v !is RecyclerView) return + save(v) + } + } + + final override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + recyclerView.addOnAttachStateChangeListener(attachListener) + super.onAttachedToRecyclerView(recyclerView) + } + + final override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + recyclerView.removeOnAttachStateChangeListener(attachListener) + super.onDetachedFromRecyclerView(recyclerView) + } + + open fun customContentViewType(item: T): Int = 0 + open fun customFooterViewType(): Int = 0 + open fun customHeaderViewType(): Int = 0 + + final override fun getItemViewType(position: Int): Int { + if (position < headers) { + return HEADER or customHeaderViewType() + } + val realPosition = position - headers + if (realPosition >= mDiffer.currentList.size) { + return FOOTER or customFooterViewType() + } + return CONTENT or customContentViewType(getItem(realPosition)) + } + + final override fun onViewRecycled(holder: ViewHolderState) { + setState(holder) + onClearView(holder) + super.onViewRecycled(holder) + } + + /** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data. + * + * If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources. + * + * Use this with `clearImage` + * */ + open fun onClearView(holder: ViewHolderState) {} + + final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState { + return when (viewType and TYPE_MASK) { + CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK) + HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK) + FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK) + else -> throw NotImplementedError() + } + } + + // https://medium.com/@domen.lanisnik/efficiently-updating-recyclerview-items-using-payloads-1305f65f3068 + override fun onBindViewHolder( + holder: ViewHolderState, + position: Int, + payloads: MutableList + ) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + return + } + when (getItemViewType(position) and TYPE_MASK) { + CONTENT -> { + val realPosition = position - headers + val item = getItem(realPosition) + onUpdateContent(holder, item, realPosition) + } + + FOOTER -> { + onBindFooter(holder) + } + + HEADER -> { + onBindHeader(holder) + } + } + } + + final override fun onBindViewHolder(holder: ViewHolderState, position: Int) { + when (getItemViewType(position) and TYPE_MASK) { + CONTENT -> { + val realPosition = position - headers + val item = getItem(realPosition) + onBindContent(holder, item, realPosition) + } + + FOOTER -> { + onBindFooter(holder) + } + + HEADER -> { + onBindHeader(holder) + } + } + + getState(holder)?.let { state -> + holder.restore(state) + } + } + + companion object { + val layoutManagerStates = hashMapOf>() + fun clearImage(image: ImageView?) { + image?.dispose() + } + + // Use the lowermost MASK_SIZE bits for the custom content, + // use the uppermost 32 - MASK_SIZE to the type + private const val MASK_SIZE = 28 + private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1 + private const val TYPE_MASK = CUSTOM_MASK.inv() + const val HEADER: Int = 3 shl MASK_SIZE + const val FOOTER: Int = 2 shl MASK_SIZE + /** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */ + const val CONTENT: Int = 1 shl MASK_SIZE + } +} + +class BaseDiffCallback( + val itemSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() }, + val contentSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() } +) : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem) + override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem) + override fun getChangePayload(oldItem: T, newItem: T): Any? = Any() +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt new file mode 100644 index 000000000..72955e7cf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt @@ -0,0 +1,278 @@ +package com.lagradost.cloudstream3.ui + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.LayoutRes +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceFragmentCompat +import androidx.viewbinding.ViewBinding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding +import com.lagradost.cloudstream3.utils.txt + +/** + * A base Fragment class that simplifies ViewBinding usage and handles view inflation safely. + * + * This class allows two modes of creating ViewBinding: + * 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes. + * 2. Bind: Using `bind()` on an existing root view. + * + * It also provides hooks for: + * - Safe initialization of the binding (`onBindingCreated`) + * - Automatic padding adjustment for system bars (`fixPadding`) + * - Optional layout resource selection via `pickLayout()` + * + * @param T The type of ViewBinding for this Fragment. + * @param bindingCreator The strategy used to create the binding instance. + */ +private interface BaseFragmentHelper { + val bindingCreator: BaseFragment.BindingCreator + + var _binding: T? + val binding: T? get() = _binding + + fun createBinding( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val layoutId = pickLayout() + val root: View? = layoutId?.let { inflater.inflate(it, container, false) } + _binding = try { + when (val creator = bindingCreator) { + is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false) + is BaseFragment.BindingCreator.Bind -> { + if (root != null) creator.fn(root) + else throw IllegalStateException("Root view is null for bind()") + } + } + } catch (t: Throwable) { + showToast( + txt(R.string.unable_to_inflate, t.message ?: ""), + Toast.LENGTH_LONG + ) + logError(t) + null + } + + return _binding?.root ?: root + } + + /** + * Called after the fragment's view has been created. + * + * This method is `final` to ensure that the binding is properly initialized and + * system bar padding adjustments are applied before any subclass logic runs. + * Subclasses should use [onBindingCreated] instead of overriding this method directly. + */ + fun onViewReady(view: View, savedInstanceState: Bundle?) { + fixLayout(view) + binding?.let { onBindingCreated(it, savedInstanceState) } + } + + /** + * Called when the binding is safely created and view is ready. + * Can be overridden to provide fragment-specific initialization. + * + * @param binding The safely created ViewBinding. + * @param savedInstanceState Saved state bundle or null. + */ + fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { + onBindingCreated(binding) + } + + /** + * Called when the binding is safely created and view is ready. + * Overload without savedInstanceState for convenience. + * + * @param binding The safely created ViewBinding. + */ + fun onBindingCreated(binding: T) {} + + /** + * Pick a layout resource ID for the fragment. + * + * Return `null` by default. Override to provide a layout resource when using + * `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`. + * + * @return Layout resource ID or null. + */ + @LayoutRes + fun pickLayout(): Int? = null + + /** + * Ensures the layout of the root view is correctly adjusted for the current configuration. + * + * This may include applying padding for system bars, adjusting insets, or performing other + * layout updates. `fixLayout` should remain idempotent, as it can be called multiple + * times on the same view, such as during configuration changes (e.g. device rotation) or when + * the view is recreated. + * + * @param view The root view to adjust. + */ + fun fixLayout(view: View) +} + +abstract class BaseFragment( + override val bindingCreator: BindingCreator +) : Fragment(), BaseFragmentHelper { + override var _binding: T? = null + + /** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */ + fun dispatchBackPressed() { + try { + activity?.onBackPressedDispatcher?.onBackPressed() + } catch (_: IllegalStateException) { + // FragmentManager is already executing transactions, so try again + delayedDispatchBackPressed(5) + } catch (t: Throwable) { + logError(t) + } + } + + /** Recursive back press when available */ + private fun delayedDispatchBackPressed(remaining: Int) { + if (remaining <= 0) return + binding?.root?.postDelayed({ + try { + activity?.onBackPressedDispatcher?.onBackPressed() + } catch (_: IllegalStateException) { + // FragmentManager is already executing transactions, so try again + delayedDispatchBackPressed(remaining - 1) + } catch (t: Throwable) { + logError(t) + } + }, 200) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** + * Called when the device configuration changes (e.g., orientation). + * Re-applies system bar padding fixes to the root view to ensure it + * readjusts for orientation changes. + */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + /** + * Sealed class representing the two strategies for creating a ViewBinding instance. + */ + sealed class BindingCreator { + + /** + * Use the standard inflate() method for creating the binding. + * + * @param fn Lambda that inflates the binding. + */ + class Inflate( + val fn: (LayoutInflater, ViewGroup?, Boolean) -> T + ) : BindingCreator() + + /** + * Use bind() on an existing root view to create the binding. This should + * be used if you are differing per device layouts, such as different + * layouts for TV and Phone. + * + * @param fn Lambda that binds the root view. + */ + class Bind( + val fn: (View) -> T + ) : BindingCreator() + } +} + +abstract class BaseDialogFragment( + override val bindingCreator: BaseFragment.BindingCreator +) : DialogFragment(), BaseFragmentHelper { + override var _binding: T? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** @see [BaseFragment.onConfigurationChanged] for documentation. */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +abstract class BaseBottomSheetDialogFragment( + override val bindingCreator: BaseFragment.BindingCreator +) : BottomSheetDialogFragment(), BaseFragmentHelper { + override var _binding: T? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = createBinding(inflater, container, savedInstanceState) + + final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + onViewReady(view, savedInstanceState) + } + + /** @see [BaseFragment.onConfigurationChanged] for documentation. */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + view?.let { fixLayout(it) } + } + + /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setSystemBarsPadding() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + setSystemBarsPadding() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index 46ddce09c..2aadfb13c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -3,12 +3,16 @@ package com.lagradost.cloudstream3.ui import android.os.Bundle import android.util.Log import android.view.Menu -import android.view.View.* -import android.widget.* +import android.view.View.GONE +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import android.widget.AbsListView +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.ListView import androidx.appcompat.app.AlertDialog -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF @@ -23,12 +27,13 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall -import com.lagradost.cloudstream3.sortSubs import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo @@ -97,9 +102,6 @@ data class MetadataHolder( class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { - private val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() - init { view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setOnClickListener { @@ -232,12 +234,27 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi loadMirror(index + 1) } } else { - awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) { + val mediaLoadOptions = + MediaLoadOptions.Builder() + .setPlayPosition(startAt) + .setAutoplay(true) + .build() + awaitLinks( + remoteMediaClient?.load( + mediaItem, + mediaLoadOptions + ) + ) { loadMirror(index + 1) } } } catch (e: Exception) { - awaitLinks(remoteMediaClient?.load(mediaItem, true, startAt)) { + val mediaLoadOptions = + MediaLoadOptions.Builder() + .setPlayPosition(startAt) + .setAutoplay(true) + .build() + awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) { loadMirror(index + 1) } } @@ -262,6 +279,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi var isLoadingMore = false + override fun onMediaStatusUpdated() { super.onMediaStatusUpdated() val meta = getCurrentMetaData() @@ -280,7 +298,13 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val currentDuration = remoteMediaClient?.streamDuration val currentPosition = remoteMediaClient?.approximateStreamPosition if (currentDuration != null && currentPosition != null) - DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration) + DataStoreHelper.setViewPosAndResume( + epData.id, + currentPosition, + currentDuration, + epData, + meta.episodes.getOrNull(index + 1) + ) } catch (t: Throwable) { logError(t) } @@ -294,14 +318,19 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi val generator = RepoLinkGenerator(listOf(epData)) val isSuccessful = safeApiCall { - generator.generateLinks(clearCache = false, isCasting = true, + generator.generateLinks( + clearCache = false, + sourceTypes = LOADTYPE_CHROMECAST, callback = { it.first?.let { link -> currentLinks.add(link) } }, subtitleCallback = { currentSubs.add(it) - }) + }, + offset = 0, + isCasting = true + ) } val sortedLinks = sortUrls(currentLinks) @@ -414,4 +443,4 @@ class ControllerActivity : ExpandedControllerActivity() { SkipNextEpisodeController(skipOpButton) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt similarity index 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 b4c07792e..302358538 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -3,12 +3,14 @@ package com.lagradost.cloudstream3.ui import android.content.Context import android.util.AttributeSet import android.view.View +import androidx.core.content.withStyledAttributes +import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.abs -class GrdLayoutManager(val context: Context, _spanCount: Int) : - GridLayoutManager(context, _spanCount) { +class GrdLayoutManager(val context: Context, spanCount: Int) : + GridLayoutManager(context, spanCount) { override fun onFocusSearchFailed( focused: View, focusDirection: Int, @@ -24,7 +26,7 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : } } - override fun onRequestChildFocus( + /*override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, child: View, @@ -32,13 +34,17 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : ): 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) { false } - } + }*/ // Allows moving right and left with focus https://gist.github.com/vganin/8930b41f55820ec49e4d override fun onInterceptFocusSearch(focused: View, direction: Int): View? { @@ -65,32 +71,47 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) : 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 } @@ -134,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 556ebd34e..000000000 --- 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 000000000..9be862077 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt @@ -0,0 +1,177 @@ +package com.lagradost.cloudstream3.ui + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ObjectAnimator +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.View +import android.view.animation.AccelerateInterpolator +import android.view.animation.LinearInterpolator +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.random.Random + +class EasterEggMonkeFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate) +) { + + // planet of monks + private val monkeys: List = listOf( + R.drawable.monke_benene, + R.drawable.monke_burrito, + R.drawable.monke_coco, + R.drawable.monke_cookie, + R.drawable.monke_flusdered, + R.drawable.monke_funny, + R.drawable.monke_like, + R.drawable.monke_party, + R.drawable.monke_sob, + R.drawable.monke_drink, + R.drawable.benene, + R.drawable.ic_launcher_foreground, + R.drawable.quick_novel_icon, + ) + + private val activeMonkeys = mutableListOf() + private var spawningJob: Job? = null + + override fun fixLayout(view: View) = Unit + + override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) { + activity?.hideSystemUI() + spawningJob = lifecycleScope.launch { + delay(1000) + while (isActive) { + spawnMonkey(binding) + delay(500) + } + } + } + + private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) { + val newMonkey = ImageView(context ?: return).apply { + setImageResource(monkeys.random()) + isVisible = true + } + + val initialScale = Random.nextFloat() * 1.5f + 0.5f + newMonkey.scaleX = initialScale + newMonkey.scaleY = initialScale + + newMonkey.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + val monkeyW = newMonkey.measuredWidth * initialScale + val monkeyH = newMonkey.measuredHeight * initialScale + + newMonkey.x = Random.nextFloat() * (binding.frame.width.toFloat() - monkeyW) + newMonkey.y = Random.nextFloat() * (binding.frame.height.toFloat() - monkeyH) + + binding.frame.addView(newMonkey, FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT + )) + + activeMonkeys.add(newMonkey) + + newMonkey.alpha = 0f + ObjectAnimator.ofFloat(newMonkey, View.ALPHA, 0f, 1f).apply { + duration = Random.nextLong(1000, 2500) + interpolator = AccelerateInterpolator() + start() + } + + @SuppressLint("ClickableViewAccessibility") + newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) } + + startFloatingAnimation(newMonkey, binding) + } + + private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { + val floatUpAnimator = ObjectAnimator.ofFloat( + monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat() + ).apply { + duration = Random.nextLong(8000, 15000) + interpolator = LinearInterpolator() + } + + floatUpAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + binding.frame.removeView(monkey) + activeMonkeys.remove(monkey) + } + }) + + floatUpAnimator.start() + monkey.tag = floatUpAnimator + } + + private fun handleTouch( + view: View, + event: MotionEvent, + binding: FragmentEasterEggMonkeBinding + ): Boolean { + val monkey = view as ImageView + when (event.action) { + MotionEvent.ACTION_DOWN -> { + (monkey.tag as? ObjectAnimator)?.pause() + return true + } + + MotionEvent.ACTION_MOVE -> { + // Update both X and Y positions properly + monkey.x = event.rawX - monkey.width / 2 + monkey.y = event.rawY - monkey.height / 2 + + // Check if monkey touches the screen edge + if (isTouchingEdge(monkey, binding)) { + removeMonkey(monkey, binding) + } + return true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (isTouchingEdge(monkey, binding)) { + removeMonkey(monkey, binding) + } else { + startFloatingAnimation(monkey, binding) + } + return true + } + } + return false + } + + private fun isTouchingEdge(monkey: ImageView, binding: FragmentEasterEggMonkeBinding): Boolean { + return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width || + monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height + } + + private fun removeMonkey(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { + // Fade out and remove the monkey + ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply { + duration = 300 + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + binding.frame.removeView(monkey) + activeMonkeys.remove(monkey) + } + }) + start() + } + } + + override fun onDestroyView() { + super.onDestroyView() + activity?.showSystemUI() + spawningJob?.cancel() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt deleted file mode 100644 index 40c03012a..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/HeaderViewDecoration.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.lagradost.cloudstream3.ui - -import android.graphics.Canvas -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView - -class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() { - override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { - super.onDraw(c, parent, state) - customView.layout(parent.left, 0, parent.right, customView.measuredHeight) - for (i in 0 until parent.childCount) { - val view = parent.getChildAt(i) - if (parent.getChildAdapterPosition(view) == 0) { - c.save() - val height = customView.measuredHeight - val top = view.top - height - c.translate(0f, top.toFloat()) - customView.draw(c) - c.restore() - break - } - } - } - - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State - ) { - if (parent.getChildAdapterPosition(view) == 0) { - customView.measure( - View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST), - View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST) - ) - outRect.set(0, customView.measuredHeight, 0, 0) - } else { - outRect.setEmpty() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt index aba6395f9..bd8541e6b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt @@ -7,12 +7,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.RelativeLayout +import androidx.core.content.withStyledAttributes import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.toPx -import java.lang.ref.WeakReference class MyMiniControllerFragment : MiniControllerFragment() { @@ -25,26 +25,15 @@ class MyMiniControllerFragment : MiniControllerFragment() { // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { - super.onInflate(context, attributeSet, bundle) - - // somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks???? if (currentColor == 0) { - WeakReference( - context.obtainStyledAttributes( - attributeSet, - R.styleable.CustomCast - ) - ).apply { - if (get() - ?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true - ) { - currentColor = - get() - ?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0 + context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { + if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { + currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) } - get()?.recycle() - }.clear() + } } + + super.onInflate(context, attributeSet, bundle) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -53,8 +42,8 @@ class MyMiniControllerFragment : MiniControllerFragment() { // SEE https://github.com/dandar3/android-google-play-services-cast-framework/blob/master/res/layout/cast_mini_controller.xml try { val progressBar: ProgressBar? = view.findViewById(R.id.progressBar) - val containerAll: LinearLayout? = view.findViewById(R.id.container_all) - val containerCurrent: RelativeLayout? = view.findViewById(R.id.container_current) + val containerAll: LinearLayout? = view.findViewById(com.google.android.gms.cast.framework.R.id.container_all) + val containerCurrent: RelativeLayout? = view.findViewById(com.google.android.gms.cast.framework.R.id.container_current) context?.let { ctx -> progressBar?.setBackgroundColor( @@ -79,4 +68,4 @@ class MyMiniControllerFragment : MiniControllerFragment() { // JUST IN CASE } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt new file mode 100644 index 000000000..12a5ae2a2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt @@ -0,0 +1,39 @@ +package com.lagradost.cloudstream3.ui + +import android.annotation.SuppressLint +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListUpdateCallback +import androidx.recyclerview.widget.RecyclerView + + +/** + * ListUpdateCallback that dispatches update events to the given adapter. + * + * @see DiffUtil.DiffResult.dispatchUpdatesTo + */ +open class NonFinalAdapterListUpdateCallback +/** + * Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter. + * + * @param mAdapter The Adapter to send updates to. + */(private var mAdapter: RecyclerView.Adapter<*>) : + ListUpdateCallback { + + override fun onInserted(position: Int, count: Int) { + mAdapter.notifyItemRangeInserted(position, count) + } + + override fun onRemoved(position: Int, count: Int) { + mAdapter.notifyItemRangeRemoved(position, count) + } + + override fun onMoved(fromPosition: Int, toPosition: Int) { + mAdapter.notifyItemMoved(fromPosition, toPosition) + } + + @SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly + override fun onChanged(position: Int, count: Int, payload: Any?) { + mAdapter.notifyItemRangeChanged(position, count, payload) + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt index eb4eb6665..ec0ef5c6b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt @@ -13,6 +13,20 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24); companion object { - fun fromInternalId(id: Int?) = values().find { value -> value.internalId == id } ?: NONE + fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE } -} \ No newline at end of file +} + +enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { + NONE(-1, R.string.type_none, R.drawable.ic_baseline_add_24), + WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24), + COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24), + ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24), + DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24), + PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24), + REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24); + + companion object { + fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt index 19e24f741..0d951bf6a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -1,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 000000000..92d33d0f3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt @@ -0,0 +1,211 @@ +package com.lagradost.cloudstream3.ui.account + +import android.os.Build +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import coil3.transform.RoundedCornersTransformation +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding +import com.lagradost.cloudstream3.databinding.AccountListItemBinding +import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage + +class AccountAdapter( + private val accountSelectCallback: (DataStoreHelper.Account) -> Unit, + private val accountCreateCallback: (DataStoreHelper.Account) -> Unit, + private val accountEditCallback: (DataStoreHelper.Account) -> Unit, + private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit +) : NoStateAdapter() { + + companion object { + const val VIEW_TYPE_SELECT_ACCOUNT = 0 + const val VIEW_TYPE_EDIT_ACCOUNT = 2 + } + + + override val footers: Int = 1 + var viewType = VIEW_TYPE_SELECT_ACCOUNT + + override fun customContentViewType(item: DataStoreHelper.Account): Int { + return viewType + } + + override fun onBindContent( + holder: ViewHolderState, + item: DataStoreHelper.Account, + position: Int + ) { + when (val binding = holder.view) { + is AccountListItemBinding -> binding.apply { + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + + accountName.text = item.name + accountImage.loadImage(item.image) + lockIcon.isVisible = item.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount + + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + root.foreground = ContextCompat.getDrawable( + root.context, + R.drawable.outline_drawable + ) + } + } else { + root.setOnLongClickListener { + showAccountEditDialog( + context = root.context, + account = item, + isNewAccount = false, + accountEditCallback = { account -> + accountEditCallback.invoke( + account + ) + }, + accountDeleteCallback = { account -> + accountDeleteCallback.invoke( + account + ) + } + ) + + true + } + } + + root.setOnClickListener { + accountSelectCallback.invoke(item) + } + } + + is AccountListItemEditBinding -> binding.apply { + val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode + + val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex + + accountName.text = item.name + accountImage.loadImage(item.image) { + RoundedCornersTransformation(10f) + } + lockIcon.isVisible = item.lockPin != null + outline.isVisible = !isTv && isLastUsedAccount + + if (isTv) { + // For emulator but this is fine on TV also + root.isFocusableInTouchMode = true + if (isLastUsedAccount) { + root.requestFocus() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + root.foreground = ContextCompat.getDrawable( + root.context, + R.drawable.outline_drawable + ) + } + } + + root.setOnClickListener { + showAccountEditDialog( + context = root.context, + account = item, + isNewAccount = false, + accountEditCallback = { account -> accountEditCallback.invoke(account) }, + accountDeleteCallback = { account -> + accountDeleteCallback.invoke( + account + ) + } + ) + } + } + } + } + + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? AccountListItemAddBinding ?: return + binding.apply { + root.setOnClickListener { + val accounts = this@AccountAdapter.immutableCurrentList + + val remainingImages = + DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } + .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) } + .toSet() + + val image = + DataStoreHelper.profileImages.indexOf( + remainingImages.randomOrNull() + ?: DataStoreHelper.profileImages.random() + ) + val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 + + val accountName = root.context.getString(R.string.account) + + showAccountEditDialog( + root.context, + DataStoreHelper.Account( + keyIndex = keyIndex, + name = "$accountName $keyIndex", + customImage = null, + defaultImageIndex = image + ), + isNewAccount = true, + accountEditCallback = { account -> accountCreateCallback.invoke(account) }, + accountDeleteCallback = {} + ) + } + } + } + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + AccountListItemAddBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + when (viewType) { + VIEW_TYPE_SELECT_ACCOUNT -> { + AccountListItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } + + VIEW_TYPE_EDIT_ACCOUNT -> { + AccountListItemEditBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + } + + else -> throw IllegalArgumentException("Invalid view type") + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt new file mode 100644 index 000000000..1d6b41e5b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt @@ -0,0 +1,413 @@ +package com.lagradost.cloudstream3.ui.account + +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.text.Editable +import android.view.LayoutInflater +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import coil3.ImageLoader +import coil3.request.ImageRequest +import coil3.request.allowHardware +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding +import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding +import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding +import com.lagradost.cloudstream3.databinding.LockPinDialogBinding +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod + +object AccountHelper { + fun showAccountEditDialog( + context: Context, + account: DataStoreHelper.Account, + isNewAccount: Boolean, + accountEditCallback: (DataStoreHelper.Account) -> Unit, + accountDeleteCallback: (DataStoreHelper.Account) -> Unit + ) { + val binding = AccountEditDialogBinding.inflate(LayoutInflater.from(context), null, false) + val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setView(binding.root) + + var currentEditAccount = account + val dialog = builder.show() + + if (!isNewAccount) binding.title.setText(R.string.edit_account) + + // Set up the dialog content + binding.accountName.text = Editable.Factory.getInstance()?.newEditable(account.name) + binding.accountName.doOnTextChanged { text, _, _, _ -> + currentEditAccount = currentEditAccount.copy(name = text?.toString() ?: "") + } + + binding.deleteBtt.isGone = isNewAccount + binding.deleteBtt.setOnClickListener { + val dialogClickListener = DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + accountDeleteCallback.invoke(account) + dialog?.dismissSafe() + } + + DialogInterface.BUTTON_NEGATIVE -> { + dialog?.dismissSafe() + } + } + } + + try { + AlertDialog.Builder(context).setTitle(R.string.delete).setMessage( + context.getString(R.string.delete_message).format( + currentEditAccount.name + ) + ) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (t: Throwable) { + logError(t) + } + } + + binding.cancelBtt.setOnClickListener { + dialog?.dismissSafe() + } + + // Handle the profile picture and its interactions + binding.accountImage.loadImage(account.image) + binding.accountImage.setOnClickListener { + // Roll the image forwards once + currentEditAccount = currentEditAccount.copy(customImage = null) + currentEditAccount = + currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size) + binding.accountImage.loadImage(currentEditAccount.image) + } + + // Handle applying changes + binding.applyBtt.setOnClickListener { + if (currentEditAccount.lockPin != null) { + // Ask for the current PIN + showPinInputDialog(context, currentEditAccount.lockPin, false) { pin -> + if (pin == null) return@showPinInputDialog + // PIN is correct, proceed to update the account + accountEditCallback.invoke(currentEditAccount) + dialog.dismissSafe() + } + } else { + // No lock PIN set, proceed to update the account + accountEditCallback.invoke(currentEditAccount) + dialog.dismissSafe() + } + } + + // Handle setting or changing the PIN + if (currentEditAccount.keyIndex == getDefaultAccount(context).keyIndex) { + binding.lockProfileCheckbox.isVisible = false + if (currentEditAccount.lockPin != null) { + currentEditAccount = currentEditAccount.copy(lockPin = null) + } + } + + var canSetPin = true + + binding.lockProfileCheckbox.isChecked = currentEditAccount.lockPin != null + + binding.lockProfileCheckbox.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + if (canSetPin) { + showPinInputDialog(context, null, true) { pin -> + if (pin == null) { + binding.lockProfileCheckbox.isChecked = false + return@showPinInputDialog + } + + currentEditAccount = currentEditAccount.copy(lockPin = pin) + } + } + } else { + if (currentEditAccount.lockPin != null) { + // Ask for the current PIN + showPinInputDialog(context, currentEditAccount.lockPin, true) { pin -> + if (pin == null || pin != currentEditAccount.lockPin) { + canSetPin = false + binding.lockProfileCheckbox.isChecked = true + } else { + currentEditAccount = currentEditAccount.copy(lockPin = null) + } + } + } + } + } + + canSetPin = true + + binding.editProfilePhotoButton.setOnClickListener({ + val bottomSheetDialog = BottomSheetDialog(context) + val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context)) + bottomSheetDialog.setContentView(sheetBinding.root) + bottomSheetDialog.show() + + sheetBinding.apply { + text1.text = context.getString(R.string.edit_profile_image_title) + nginxTextInput.hint = context.getString(R.string.edit_profile_image_hint) + + applyBtt.setOnClickListener({ + val url = sheetBinding.nginxTextInput.text.toString() + if (url.isNotEmpty()) { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .listener( + onSuccess = { _, _ -> + currentEditAccount = currentEditAccount.copy(customImage = url) + binding.accountImage.loadImage(url) + showToast( + R.string.edit_profile_image_success, + Toast.LENGTH_SHORT + ) + bottomSheetDialog.dismiss() + }, + onError = { _, _ -> + showToast( + R.string.edit_profile_image_error_invalid, + Toast.LENGTH_SHORT + ) + } + ) + .build() + imageLoader.enqueue(request) + } else { + showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT) + } + bottomSheetDialog.dismissSafe() + }) + sheetBinding.cancelBtt.setOnClickListener({ + bottomSheetDialog.dismissSafe() + }) + } + }) + } + + fun showPinInputDialog( + context: Context, + currentPin: String?, + editAccount: Boolean, + forStartup: Boolean = false, + errorText: String? = null, + callback: (String?) -> Unit + ) { + fun TextView.visibleWithText(@StringRes textRes: Int) { + isVisible = true + setText(textRes) + } + + fun TextView.visibleWithText(text: String?) { + isVisible = true + setText(text) + } + + val binding = LockPinDialogBinding.inflate(LayoutInflater.from(context)) + + val isPinSet = currentPin != null + val isNewPin = editAccount && !isPinSet + val isEditPin = editAccount && isPinSet + + val titleRes = if (isEditPin) R.string.enter_current_pin else R.string.enter_pin + + var isPinValid = false + + val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setView(binding.root) + .setTitle(titleRes) + .setNegativeButton(R.string.cancel) { _, _ -> + callback.invoke(null) + } + .setOnCancelListener { + callback.invoke(null) + } + .setOnDismissListener { + if (!isPinValid) { + callback.invoke(null) + } + } + + if (forStartup) { + val currentAccount = DataStoreHelper.accounts.firstOrNull { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } + + builder.setTitle(context.getString(R.string.enter_pin_with_name, currentAccount?.name)) + builder.setOnDismissListener { + if (!isPinValid) { + context.getActivity()?.finish() + } + } + // So that if they don't know the PIN for the current account, + // they don't get completely locked out + builder.setNeutralButton(R.string.use_default_account) { _, _ -> + val activity = context.getActivity() + if (activity is AccountSelectActivity) { + isPinValid = true + activity.accountViewModel.handleAccountSelect(getDefaultAccount(context), activity) + } + } + } + + if (isNewPin) { + if (errorText != null) binding.pinEditTextError.visibleWithText(errorText) + builder.setPositiveButton(R.string.setup_done) { _, _ -> + if (!isPinValid) { + // If the done button is pressed and there is an error, + // ask again, and mention the error that caused this. + showPinInputDialog( + context = binding.root.context, + currentPin = null, + editAccount = true, + errorText = binding.pinEditTextError.text.toString(), + callback = callback + ) + } else { + val enteredPin = binding.pinEditText.text.toString() + callback.invoke(enteredPin) + } + } + } + + val dialog = builder.create() + + binding.pinEditText.doOnTextChanged { text, _, _, _ -> + val enteredPin = text.toString() + val isEnteredPinValid = enteredPin.length == 4 + + if (isEnteredPinValid) { + if (isPinSet) { + if (enteredPin != currentPin) { + binding.pinEditTextError.visibleWithText(R.string.pin_error_incorrect) + binding.pinEditText.text = null + isPinValid = false + } else { + binding.pinEditTextError.isVisible = false + isPinValid = true + + callback.invoke(enteredPin) + dialog.dismissSafe() + } + } else { + binding.pinEditTextError.isVisible = false + isPinValid = true + } + } else if (isNewPin) { + binding.pinEditTextError.visibleWithText(R.string.pin_error_length) + isPinValid = false + } + } + + // Detect IME_ACTION_DONE + binding.pinEditText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE && isPinValid) { + val enteredPin = binding.pinEditText.text.toString() + callback.invoke(enteredPin) + dialog.dismissSafe() + } + true + } + + // We don't want to accidentally have the dialog dismiss when clicking outside of it. + // That is what the cancel button is for. + dialog.setCanceledOnTouchOutside(false) + + dialog.show() + + // Auto focus on PIN input and show keyboard + binding.pinEditText.requestFocus() + binding.pinEditText.postDelayed({ + showInputMethod(binding.pinEditText) + }, 200) + } + + fun Activity?.showAccountSelectLinear() { + val activity = this as? MainActivity ?: return + val viewModel = ViewModelProvider(activity)[AccountViewModel::class.java] + + val binding: AccountSelectLinearBinding = AccountSelectLinearBinding.inflate( + LayoutInflater.from(activity) + ) + + val builder = BottomSheetDialog(activity) + builder.setContentView(binding.root) + builder.show() + + binding.manageAccountsButton.setOnClickListener { + activity.navigate( + R.id.accountSelectActivity, + Bundle().apply { putBoolean("isEditingFromMainActivity", true) } + ) + builder.dismissSafe() + } + + val recyclerView: RecyclerView = binding.accountRecyclerView + + val itemSize = recyclerView.resources.getDimensionPixelSize( + R.dimen.account_select_linear_item_size + ) + + recyclerView.addItemDecoration(AccountSelectLinearItemDecoration(itemSize)) + + recyclerView.setLinearListLayout(isHorizontal = true) + + val currentAccount = DataStoreHelper.accounts.firstOrNull { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } ?: getDefaultAccount(activity) + + // We want to make sure the accounts are up-to-date + viewModel.handleAccountSelect( + currentAccount, + activity, + reloadForActivity = true + ) + + activity.observe(viewModel.accounts) { liveAccounts -> + recyclerView.adapter = AccountAdapter( + accountSelectCallback = { account -> + viewModel.handleAccountSelect(account, activity) + builder.dismissSafe() + }, + accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) }, + accountEditCallback = { viewModel.handleAccountUpdate(it, activity) }, + accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) } + ).apply { + submitList(liveAccounts) + } + + activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> + // Scroll to current account (which is focused by default) + val layoutManager = recyclerView.layoutManager as LinearLayoutManager + layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt new file mode 100644 index 000000000..ad323c7d1 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt @@ -0,0 +1,219 @@ +package com.lagradost.cloudstream3.ui.account + +import android.annotation.SuppressLint +import android.os.Bundle +import android.util.Log +import androidx.fragment.app.FragmentActivity +import androidx.activity.viewModels +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.CommonActivity.loadThemes +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.ActivityAccountSelectBinding +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.AutofitRecyclerView +import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_EDIT_ACCOUNT +import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_SELECT_ACCOUNT +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.BiometricAuthenticator +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication +import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts +import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex +import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount +import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.openActivity +import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat + +class AccountSelectActivity : FragmentActivity(), BiometricCallback { + + companion object { + var hasLoggedIn: Boolean = false + } + + val accountViewModel: AccountViewModel by viewModels() + + @SuppressLint("NotifyDataSetChanged") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Are we editing and coming from MainActivity? + val isEditingFromMainActivity = intent.getBooleanExtra( + "isEditingFromMainActivity", + false + ) + + // Sometimes we start this activity when we have already logged in + // For example when using cloudstreamsearch:// + // In those cases we want to just go to the main activity instantly + if (hasLoggedIn && !isEditingFromMainActivity) { + navigateToMainActivity() + return + } + + loadThemes(this) + + enableEdgeToEdgeCompat() + setNavigationBarColorCompat(R.attr.primaryBlackBackground) + + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val skipStartup = settingsManager.getBoolean( + getString(R.string.skip_startup_account_select_key), false + ) || accounts.count() <= 1 + + fun askBiometricAuth() { + + if (isLayout(PHONE) && isAuthEnabled(this)) { + if (deviceHasPasswordPinLock(this)) { + startBiometricAuthentication( + this, + R.string.biometric_authentication_title, + false + ) + + promptInfo?.let { prompt -> + biometricPrompt?.authenticate(prompt) + } + } + } + } + + observe(accountViewModel.isAllowedLogin) { isAllowedLogin -> + if (isAllowedLogin) { + // We are allowed to continue to MainActivity + navigateToMainActivity() + } + } + + // Don't show account selection if there is only + // one account that exists + if (!isEditingFromMainActivity && skipStartup) { + val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex } + if (currentAccount?.lockPin != null) { + CommonActivity.init(this) + accountViewModel.handleAccountSelect(currentAccount, this, true) + } else { + if (accounts.count() > 1) { + showToast( + this, getString( + R.string.logged_account, + currentAccount?.name + ) + ) + } + + navigateToMainActivity() + } + + return + } + + CommonActivity.init(this) + + val binding = ActivityAccountSelectBinding.inflate(layoutInflater) + setContentView(binding.root) + fixSystemBarsPadding(binding.root, padTop = false) + + val recyclerView: AutofitRecyclerView = binding.accountRecyclerView + + observe(accountViewModel.accounts) { liveAccounts -> + val adapter = AccountAdapter( + // Handle the selected account + accountSelectCallback = { + accountViewModel.handleAccountSelect(it, this) + }, + accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) }, + accountEditCallback = { + accountViewModel.handleAccountUpdate(it, this) + // We came from MainActivity, return there + // and switch to the edited account + if (isEditingFromMainActivity) { + setAccount(it) + navigateToMainActivity() + } + }, + accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) } + ).apply { + submitList(liveAccounts) + } + + recyclerView.adapter = adapter + + if (isLayout(TV or EMULATOR)) { + binding.editAccountButton.setBackgroundResource( + R.drawable.player_button_tv_attr_no_bg + ) + } + + observe(accountViewModel.selectedKeyIndex) { selectedKeyIndex -> + // Scroll to current account (which is focused by default) + val layoutManager = recyclerView.layoutManager as GridLayoutManager + layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) + } + + observe(accountViewModel.isEditing) { isEditing -> + if (isEditing) { + binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24) + binding.title.setText(R.string.manage_accounts) + adapter.viewType = VIEW_TYPE_EDIT_ACCOUNT + } else { + binding.editAccountButton.setImageResource(R.drawable.ic_baseline_edit_24) + binding.title.setText(R.string.select_an_account) + adapter.viewType = VIEW_TYPE_SELECT_ACCOUNT + } + + adapter.notifyDataSetChanged() + } + + if (isEditingFromMainActivity) { + accountViewModel.setIsEditing(true) + } + + binding.editAccountButton.setOnClickListener { + // We came from MainActivity, return there + // and resume its state + if (isEditingFromMainActivity) { + navigateToMainActivity() + return@setOnClickListener + } + + accountViewModel.toggleIsEditing() + } + + if (isLayout(TV or EMULATOR)) { + recyclerView.spanCount = if (liveAccounts.count() + 1 <= 6) { + liveAccounts.count() + 1 + } else 6 + } + } + + askBiometricAuth() + } + + @SuppressLint("UnsafeIntentLaunch") + private fun navigateToMainActivity() { + hasLoggedIn = true + // We want to propagate any intent we get here to MainActivity since this is just an intermediary + openActivity(MainActivity::class.java, baseIntent = intent) + finish() // Finish the account selection activity + } + + override fun onAuthenticationSuccess() { + Log.i(BiometricAuthenticator.TAG, "Authentication successful in AccountSelectActivity") + } + + override fun onAuthenticationError() { + finish() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt new file mode 100644 index 000000000..eb907b344 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt @@ -0,0 +1,14 @@ +package com.lagradost.cloudstream3.ui.account + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class AccountSelectLinearItemDecoration(private val size: Int) : RecyclerView.ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val layoutParams = view.layoutParams as RecyclerView.LayoutParams + layoutParams.width = size + layoutParams.height = size + view.layoutParams = layoutParams + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt new file mode 100644 index 000000000..96eaf52a7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt @@ -0,0 +1,129 @@ +package com.lagradost.cloudstream3.ui.account + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAccounts +import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount +import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount + +class AccountViewModel : ViewModel() { + private fun getAllAccounts(): List { + return context?.let { getAccounts(it) } ?: DataStoreHelper.accounts.toList() + } + + private val _accounts: MutableLiveData> = MutableLiveData(getAllAccounts()) + val accounts: LiveData> = _accounts + + private val _isEditing = MutableLiveData(false) + val isEditing: LiveData = _isEditing + + private val _isAllowedLogin = MutableLiveData(false) + val isAllowedLogin: LiveData = _isAllowedLogin + + private val _selectedKeyIndex = MutableLiveData( + getAllAccounts().indexOfFirst { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } + ) + val selectedKeyIndex: LiveData = _selectedKeyIndex + + fun setIsEditing(value: Boolean) { + _isEditing.postValue(value) + } + + fun toggleIsEditing() { + _isEditing.postValue(!(_isEditing.value ?: false)) + } + + fun handleAccountUpdate( + account: DataStoreHelper.Account, + context: Context + ) { + val currentAccounts = getAccounts(context).toMutableList() + + val overrideIndex = currentAccounts.indexOfFirst { it.keyIndex == account.keyIndex } + + if (overrideIndex != -1) { + currentAccounts[overrideIndex] = account + } else currentAccounts.add(account) + + val currentHomePage = DataStoreHelper.currentHomePage + + setAccount(account) + + DataStoreHelper.currentHomePage = currentHomePage + DataStoreHelper.accounts = currentAccounts.toTypedArray() + + _accounts.postValue(getAccounts(context)) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + MainActivity.reloadAccountEvent(true) + } + + fun handleAccountDelete( + account: DataStoreHelper.Account, + context: Context + ) { + removeKeys(account.keyIndex.toString()) + + val currentAccounts = getAccounts(context).toMutableList() + + currentAccounts.removeIf { it.keyIndex == account.keyIndex } + + DataStoreHelper.accounts = currentAccounts.toTypedArray() + + if (account.keyIndex == DataStoreHelper.selectedKeyIndex) { + setAccount(getDefaultAccount(context)) + } + + _accounts.postValue(getAccounts(context)) + _selectedKeyIndex.postValue(getAllAccounts().indexOfFirst { + it.keyIndex == DataStoreHelper.selectedKeyIndex + }) + MainActivity.reloadAccountEvent(true) + } + + fun handleAccountSelect( + account: DataStoreHelper.Account, + context: Context, + forStartup: Boolean = false, + reloadForActivity: Boolean = false + ) { + if (reloadForActivity) { + _accounts.postValue(getAccounts(context)) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + MainActivity.reloadAccountEvent(true) + return + } + + // Check if the selected account has a lock PIN set + if (account.lockPin != null) { + // The selected account has a PIN set, prompt the user to enter the PIN + showPinInputDialog( + context, + account.lockPin, + false, + forStartup + ) { pin -> + if (pin == null) return@showPinInputDialog + // Pin is correct, proceed + _isAllowedLogin.postValue(true) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + setAccount(account) + MainActivity.reloadAccountEvent(true) + } + } else { + // No PIN set for the selected account, proceed + _isAllowedLogin.postValue(true) + _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) + setAccount(account) + MainActivity.reloadAccountEvent(true) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt new file mode 100644 index 000000000..1b48143a6 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -0,0 +1,433 @@ +package com.lagradost.cloudstream3.ui.download + +import android.annotation.SuppressLint +import android.text.format.Formatter.formatShortFileSize +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.CheckBox +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.DiffUtil +import androidx.viewbinding.ViewBinding +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding +import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects + +const val DOWNLOAD_ACTION_PLAY_FILE = 0 +const val DOWNLOAD_ACTION_DELETE_FILE = 1 +const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2 +const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 +const val DOWNLOAD_ACTION_DOWNLOAD = 4 +const val DOWNLOAD_ACTION_LONG_CLICK = 5 +const val DOWNLOAD_ACTION_CANCEL_PENDING = 6 + +const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 +const val DOWNLOAD_ACTION_LOAD_RESULT = 1 + +sealed class VisualDownloadCached { + abstract val currentBytes: Long + abstract val totalBytes: Long + abstract val data: DownloadObjects.DownloadCached + abstract var isSelected: Boolean + + data class Child( + override val currentBytes: Long, + override val totalBytes: Long, + override val data: DownloadObjects.DownloadEpisodeCached, + override var isSelected: Boolean, + ) : VisualDownloadCached() + + data class Header( + override val currentBytes: Long, + override val totalBytes: Long, + override val data: DownloadObjects.DownloadHeaderCached, + override var isSelected: Boolean, + val child: DownloadObjects.DownloadEpisodeCached?, + val currentOngoingDownloads: Int, + val totalDownloads: Int, + ) : VisualDownloadCached() +} + +data class DownloadClickEvent( + val action: Int, + val data: DownloadObjects.DownloadEpisodeCached +) + +data class DownloadHeaderClickEvent( + val action: Int, + val data: DownloadObjects.DownloadHeaderCached +) + +class DownloadAdapter( + private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, + private val onItemClickEvent: (DownloadClickEvent) -> Unit, + private val onItemSelectionChanged: (Int, Boolean) -> Unit, +) : NoStateAdapter(DiffCallback()) { + + private var isMultiDeleteState: Boolean = false + + companion object { + private const val VIEW_TYPE_HEADER = 0 + private const val VIEW_TYPE_CHILD = 1 + } + + + private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) { + if (binding !is DownloadHeaderEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + episodeHolder.apply { + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } else { + setOnLongClickListener { + onItemSelectionChanged.invoke(data.id, true) + true + } + } + } + + downloadHeaderPoster.apply { + loadImage(data.poster) + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } else { + setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_LOAD_RESULT, + data + ) + ) + } + } + + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } + downloadHeaderTitle.text = data.name + val formattedSize = formatShortFileSize(binding.root.context, card.totalBytes) + + if (card.child != null) { + handleChildDownload(card, formattedSize) + } else handleParentDownload(card, formattedSize) + + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected + } + } + } + + private fun DownloadHeaderEpisodeBinding.handleChildDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + card.child ?: return + downloadHeaderGotoChild.isVisible = false + + val posDur = getViewPos(card.data.id) + watchProgressContainer.isVisible = true + downloadHeaderEpisodeProgress.apply { + isVisible = posDur != null + posDur?.let { + val max = (it.duration / 1000).toInt() + val progress = (it.position / 1000).toInt() + + if (max > 0 && progress >= (0.95 * max).toInt()) { + playIcon.setImageResource(R.drawable.ic_baseline_check_24) + isVisible = false + } else { + playIcon.setImageResource(R.drawable.netflix_play) + this.max = max + this.progress = progress + isVisible = true + } + } + } + + downloadButton.resetView() + val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) + // We will let the view model handle this + downloadButton.doSetProgress = false + downloadButton.progressBar.progressDrawable = + downloadButton.getDrawableFromStatus(status) + ?.let { ContextCompat.getDrawable(downloadButton.context, it) } + downloadHeaderInfo.text = formattedSize + } else { + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } + + downloadHeaderInfo.isVisible = true + downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) + downloadButton.isVisible = !isMultiDeleteState + + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + card.child + ) + ) + } + } + } + + private fun DownloadHeaderEpisodeBinding.handleParentDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + downloadButton.resetViewData() + watchProgressContainer.isVisible = false + downloadButton.isVisible = false + downloadHeaderEpisodeProgress.isVisible = false + downloadHeaderGotoChild.isVisible = !isMultiDeleteState + + try { + downloadHeaderInfo.isVisible = true + downloadHeaderInfo.text = + downloadHeaderInfo.context.getString(R.string.extra_info_format).format( + card.totalDownloads, + downloadHeaderInfo.context.resources.getQuantityString( + R.plurals.episodes, + card.totalDownloads + ), + formattedSize + ) + } catch (e: Exception) { + downloadHeaderInfo.text = null + logError(e) + } + + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_GO_TO_CHILD, + card.data + ) + ) + } + } + } + + private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) { + if (binding !is DownloadChildEpisodeBinding || card == null) return + + val data = card.data + binding.apply { + val posDur = getViewPos(data.id) + downloadChildEpisodeProgress.apply { + isVisible = posDur != null + posDur?.let { + val max = (it.duration / 1000).toInt() + val progress = (it.position / 1000).toInt() + + if (max > 0 && progress >= (0.95 * max).toInt()) { + downloadChildEpisodePlay.setImageResource(R.drawable.ic_baseline_check_24) + isVisible = false + } else { + downloadChildEpisodePlay.setImageResource(R.drawable.play_button_transparent) + this.max = max + this.progress = progress + isVisible = true + } + } + } + + downloadButton.resetView() + val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) + // We will let the view model handle this + downloadButton.doSetProgress = false + downloadButton.progressBar.progressDrawable = + downloadButton.getDrawableFromStatus(status) + ?.let { ContextCompat.getDrawable(downloadButton.context, it) } + downloadChildEpisodeTextExtra.text = + formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) + } else { + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } + + downloadButton.setDefaultClickListener( + data, + downloadChildEpisodeTextExtra, + onItemClickEvent + ) + downloadButton.isVisible = !isMultiDeleteState + + downloadChildEpisodeText.apply { + text = context.getNameFull(data.name, data.episode, data.season) + isSelected = true // Needed for text repeating + } + + downloadChildEpisodeHolder.setOnClickListener { + onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) + } + + downloadChildEpisodeHolder.apply { + when { + isMultiDeleteState -> { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } + + else -> { + setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + data + ) + ) + } + + setOnLongClickListener { + onItemSelectionChanged.invoke(data.id, true) + true + } + } + } + } + + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected + } + } + } + + override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = when (viewType) { + VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false) + VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) + else -> throw IllegalArgumentException("Invalid view type") + } + return ViewHolderState(binding) + } + + override fun onBindContent( + holder: ViewHolderState, + item: VisualDownloadCached, + position: Int + ) { + when (val binding = holder.view) { + is DownloadHeaderEpisodeBinding -> bindHeader( + binding, + item as? VisualDownloadCached.Header + ) + + is DownloadChildEpisodeBinding -> bindChild( + binding, + item as? VisualDownloadCached.Child + ) + } + } + + override fun customContentViewType(item: VisualDownloadCached): Int { + return when (item) { + is VisualDownloadCached.Child -> VIEW_TYPE_CHILD + is VisualDownloadCached.Header -> VIEW_TYPE_HEADER + } + } + + @SuppressLint("NotifyDataSetChanged") + fun setIsMultiDeleteState(value: Boolean) { + if (isMultiDeleteState == value) return + isMultiDeleteState = value + notifyDataSetChanged() // This is shit, but what can you do? + } + + private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { + val isChecked = !checkbox.isChecked + checkbox.isChecked = isChecked + onItemSelectionChanged.invoke(itemId, isChecked) + } + + class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: VisualDownloadCached, + newItem: VisualDownloadCached + ): Boolean { + return oldItem.data.id == newItem.data.id + } + + override fun areContentsTheSame( + oldItem: VisualDownloadCached, + newItem: VisualDownloadCached + ): Boolean { + return oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt index add36f1a5..dae70ebd7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -1,27 +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.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import kotlinx.coroutines.MainScope object DownloadButtonSetup { - fun handleDownloadClick(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 -> @@ -30,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 } } } @@ -57,11 +67,13 @@ object DownloadButtonSetup { } } } + DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause) ) } + DOWNLOAD_ACTION_RESUME_DOWNLOAD -> { activity?.let { ctx -> if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) { @@ -71,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) @@ -80,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 0096ff420..000000000 --- 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 a541171bf..000000000 --- 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 477a18e07..d44ea0020 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt @@ -1,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 +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() -class DownloadChildFragment : Fragment() { 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) - } - }.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() - } - } - - private var downloadDeleteEventListener: ((Int) -> Unit)? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - + override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { - activity?.onBackPressed() // TODO FIX + dispatchBackPressed() return } - 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() + 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() + } + } + setAppBarNoScrollFlagsOnTV() } - val adapter: RecyclerView.Adapter = - DownloadChildAdapter( - ArrayList(), - ) { click -> - handleDownloadClick(activity, click) - } + binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() - downloadDeleteEventListener = { id: Int -> - val list = (download_child_list?.adapter as DownloadChildAdapter?)?.cardList - if (list != null) { - if (list.any { it.data.id == id }) { - updateList(folder) + observe(downloadViewModel.childCards) { cards -> + when (cards) { + is Resource.Success -> { + if (cards.value.isEmpty()) { + dispatchBackPressed() + } + (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) + } + + else -> { + (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) } } } - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } + observe(downloadViewModel.selectedBytes) { + updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) + } - download_child_list.adapter = adapter - download_child_list.layoutManager = GridLayoutManager(context, 1) - updateList(folder) + binding.apply { + btnDelete.setOnClickListener { view -> + downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) + } + + btnCancel.setOnClickListener { + downloadViewModel.cancelSelection() + } + + btnToggleAll.setOnClickListener { + val allSelected = downloadViewModel.isAllChildrenSelected() + if (allSelected) { + downloadViewModel.clearSelectedItems() + } else { + downloadViewModel.selectAllChildren() + } + } + } + + observeNullable(downloadViewModel.selectedItemIds) { selection -> + val isMultiDeleteState = selection != null + val adapter = binding.downloadChildList.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding.downloadDeleteAppbar.isVisible = isMultiDeleteState + binding.downloadChildToolbar.isGone = isMultiDeleteState + + if (selection == null) { + activity?.detachBackPressedCallback("Downloads") + return@observeNullable + } + activity?.attachBackPressedCallback("Downloads") { + downloadViewModel.cancelSelection() + } + + updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) + + binding.btnDelete.isVisible = selection.isNotEmpty() + binding.selectItemsText.isVisible = selection.isEmpty() + + val allSelected = downloadViewModel.isAllChildrenSelected() + if (allSelected) { + binding.btnToggleAll.setText(R.string.deselect_all) + } else binding.btnToggleAll.setText(R.string.select_all) + } + + val adapter = DownloadAdapter( + {}, + { click -> + if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { + context?.let { ctx -> + downloadViewModel.handleSingleDelete(ctx, click.data.id) + } + } else handleDownloadClick(click) + }, + { itemId, isChecked -> + if (isChecked) { + downloadViewModel.addSelected(itemId) + } else downloadViewModel.removeSelected(itemId) + } + ) + + binding.downloadChildList.apply { + setHasFixedSize(true) + setItemViewCacheSize(20) + this.adapter = adapter + setLinearListLayout( + isHorizontal = false, + nextRight = FOCUS_SELF, + nextDown = FOCUS_SELF, + ) + } + } + + private fun updateDeleteButton(count: Int, selectedBytes: Long) { + val formattedSize = formatShortFileSize(context, selectedBytes) + binding?.btnDelete?.text = + getString(R.string.delete_format).format(count, formattedSize) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index f03408455..abc432ef9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -1,53 +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.ui.settings.SettingsFragment.Companion.isTrueTvSettings +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( @@ -58,201 +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(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(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 - ) - } - } - - }, - { 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) - } - } + observe(downloadViewModel.headerCards) { cards -> + when (cards) { + is Resource.Success -> { + (binding.downloadList.adapter as? DownloadAdapter)?.submitList(cards.value) + binding.textNoDownloads.isVisible = cards.value.isEmpty() + binding.downloadLoading.isVisible = false + binding.downloadList.isVisible = true } + + is Resource.Loading -> { + binding.downloadList.isVisible = false + binding.downloadLoading.isVisible = true + } + + is Resource.Failure -> { + binding.downloadList.isVisible = true + binding.downloadLoading.isVisible = false + } + } + } + + observe(downloadViewModel.availableBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.free_storage, + binding.downloadFreeTxt, + binding.downloadFree + ) + } + observe(downloadViewModel.usedBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.used_storage, + binding.downloadUsedTxt, + binding.downloadUsed ) - downloadDeleteEventListener = { id -> - val list = (download_list?.adapter as DownloadHeaderAdapter?)?.cardList - if (list != null) { - if (list.any { it.data.id == id }) { - context?.let { ctx -> - setList(ArrayList()) - downloadsViewModel.updateList(ctx) - } + val hasBytes = it > 0 + if (hasBytes) { + binding.downloadLoadingBytes.stopShimmer() + } else binding.downloadLoadingBytes.startShimmer() + + binding.downloadBytesBar.isVisible = hasBytes + binding.downloadLoadingBytes.isGone = hasBytes + } + observe(downloadViewModel.downloadBytes) { + updateStorageInfo( + binding.root.context, + it, + R.string.app_storage, + binding.downloadAppTxt, + binding.downloadApp + ) + } + observe(downloadQueueViewModel.childCards) { cards -> + val size = cards.currentDownloads.size + cards.queue.size + val context = binding.root.context + val baseText = context.getString(R.string.download_queue) + binding.downloadQueueText.text = if (size > 0) { + "$baseText (${cards.currentDownloads.size}/$size)" + } else { + baseText + } + } + + observe(downloadViewModel.selectedBytes) { + updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) + } + + binding.apply { + btnDelete.setOnClickListener { view -> + downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) + } + + btnCancel.setOnClickListener { + downloadViewModel.cancelSelection() + } + + btnToggleAll.setOnClickListener { + val allSelected = downloadViewModel.isAllHeadersSelected() + if (allSelected) { + downloadViewModel.clearSelectedItems() + } else { + downloadViewModel.selectAllHeaders() } } } - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } + observeNullable(downloadViewModel.selectedItemIds) { selection -> + val isMultiDeleteState = selection != null + val adapter = binding.downloadList.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding.downloadDeleteAppbar.isVisible = isMultiDeleteState + binding.downloadAppbar.isGone = isMultiDeleteState - download_list?.adapter = adapter - download_list?.layoutManager = GridLayoutManager(context, 1) + if (selection == null) { + activity?.detachBackPressedCallback("Downloads") + return@observeNullable + } + activity?.attachBackPressedCallback("Downloads") { + downloadViewModel.cancelSelection() + } + updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) - // Should be visible in emulator layout - download_stream_button?.isGone = isTrueTvSettings() - download_stream_button?.setOnClickListener { - val dialog = - Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom) - dialog.setContentView(R.layout.stream_input) + binding.btnDelete.isVisible = selection.isNotEmpty() + binding.selectItemsText.isVisible = selection.isEmpty() - dialog.show() + 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, + ) + } + + binding.apply { + openLocalVideoButton.apply { + isGone = isLayout(TV) + setOnClickListener { openLocalVideo() } + } + downloadStreamButton.apply { + isGone = isLayout(TV) + setOnClickListener { showStreamInputDialog(it.context) } } - fun activateSwitchOnHls(text: String?) { - dialog.hls_switch?.isChecked = normalSafeApiCall { - URI(text).path?.substringAfterLast(".")?.contains("m3u") - } == true + downloadQueueButton.setOnClickListener { + activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue) } - dialog.stream_referer?.doOnTextChanged { text, _, _, _ -> - if (!preventAutoSwitching) - activateSwitchOnHls(text?.toString()) + downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV) + downloadAppbar.isFocusableInTouchMode = isLayout(TV) + + downloadStreamButtonTv.setOnClickListener { showStreamInputDialog(it.context) } + steamImageviewHolder.isVisible = isLayout(TV) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + binding.downloadList.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> + handleScroll(scrollY - oldScrollY) } + } - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt( - 0 - )?.text?.toString()?.let { copy -> - val fixedText = copy.trim() - dialog.stream_url?.setText(fixedText) - activateSwitchOnHls(fixedText) - } - - 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 { + DOWNLOAD_ACTION_LOAD_RESULT -> { + activity?.loadResult(click.data.url, click.data.apiName, click.data.name) + } + } + } + + private fun updateDeleteButton(count: Int, selectedBytes: Long) { + val formattedSize = formatShortFileSize(context, selectedBytes) + binding?.btnDelete?.text = + getString(R.string.delete_format).format(count, formattedSize) + } + + private fun updateStorageInfo( + context: Context, + bytes: Long, + @StringRes stringRes: Int, + textView: TextView?, + view: View? + ) { + textView?.text = getString(R.string.storage_size_format).format( + getString(stringRes), + formatShortFileSize(context, bytes) + ) + view?.setLayoutWidth(bytes) + } + + private fun openLocalVideo() { + val intent = Intent() + .setAction(Intent.ACTION_GET_CONTENT) + .setType("video/*") + .addCategory(Intent.CATEGORY_OPENABLE) + .addFlags(FLAG_GRANT_READ_URI_PERMISSION) // Request temporary access + safe { + videoResultLauncher.launch( + Intent.createChooser( + intent, + getString(R.string.open_local_video) + ) + ) + } + } + + private fun showStreamInputDialog(context: Context) { + val dialog = Dialog(context, R.style.AlertDialogCustom) + val binding = StreamInputBinding.inflate(dialog.layoutInflater) + dialog.setContentView(binding.root) + dialog.show() + + var preventAutoSwitching = false + binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true } + + binding.streamReferer.doOnTextChanged { text, _, _, _ -> + if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding) + } + + (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( + 0 + )?.text?.toString()?.let { copy -> + val fixedText = copy.trim() + binding.streamUrl.setText(fixedText) + activateSwitchOnHls(fixedText, binding) + } + + binding.applyBtt.setOnClickListener { + val url = binding.streamUrl.text?.toString() + if (url.isNullOrEmpty()) { + showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT) + } else { + val referer = binding.streamReferer.text?.toString() + activity?.navigate( + R.id.global_to_navigation_player, + GeneratorPlayer.newInstance( + LinkGenerator( + listOf(BasicLink(url)), + extract = true, + refererUrl = referer, + id = url.hashCode() + ), 0 + ) + ) dialog.dismissSafe(activity) } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - 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 - } - } - } - 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 29bb303af..000000000 --- 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 3a74a715c..0d35d5670 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -1,122 +1,587 @@ package com.lagradost.cloudstream3.ui.download import android.content.Context +import android.content.DialogInterface import android.os.Environment import android.os.StatFs +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.isMovieType +import com.lagradost.api.Log +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.ConsistentLiveData +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds +import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched +import com.lagradost.cloudstream3.utils.ResourceLiveData +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { - private val _noDownloadsText = MutableLiveData().apply { - value = "" + companion object { + const val TAG = "DownloadViewModel" } - val noDownloadsText: LiveData = _noDownloadsText private val _headerCards = - MutableLiveData>().apply { listOf() } - val headerCards: LiveData> = _headerCards + ResourceLiveData>(Resource.Loading()) + val headerCards: LiveData>> = _headerCards - private val _usedBytes = MutableLiveData() - private val _availableBytes = MutableLiveData() - private val _downloadBytes = MutableLiveData() + private val _childCards = ResourceLiveData>(Resource.Loading()) + val childCards: LiveData>> = _childCards + private val _usedBytes = ConsistentLiveData() val usedBytes: LiveData = _usedBytes + + private val _availableBytes = ConsistentLiveData() val availableBytes: LiveData = _availableBytes + + private val _downloadBytes = ConsistentLiveData() val downloadBytes: LiveData = _downloadBytes - fun updateList(context: Context) = viewModelScope.launchSafe { - val children = withContext(Dispatchers.IO) { - val headers = context.getKeys(DOWNLOAD_EPISODE_CACHE) - headers.mapNotNull { context.getKey(it) } - .distinctBy { it.id } // Remove duplicates + private val _selectedBytes = ConsistentLiveData(0) + val selectedBytes: LiveData = _selectedBytes + + private val _selectedItemIds = ConsistentLiveData?>(null) + val selectedItemIds: LiveData?> = _selectedItemIds + + + fun cancelSelection() { + updateSelectedItems { null } + } + + fun addSelected(itemId: Int) { + updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) } + } + + fun removeSelected(itemId: Int) { + updateSelectedItems { it?.minus(itemId) ?: emptySet() } + } + + fun selectAllHeaders() { + updateSelectedItems { + _headerCards.success.orEmpty() + .map { item -> item.data.id }.toSet() } + } - // parentId : bytes - val totalBytesUsedByChild = HashMap() - // parentId : bytes - val currentBytesUsedByChild = HashMap() - // parentId : downloadsCount - val totalDownloads = HashMap() + fun selectAllChildren() { + updateSelectedItems { + _childCards.success.orEmpty() + .map { item -> item.data.id }.toSet() + } + } + + fun clearSelectedItems() { + // We need this to be done immediately + // so we can't use postValue + updateSelectedItems { emptySet() } + } + + fun isAllChildrenSelected(): Boolean { + val currentSelected = selectedItemIds.value ?: return false + val children = _childCards.success.orEmpty() + return currentSelected.size == children.size && children.all { it.data.id in currentSelected } + } + + fun isAllHeadersSelected(): Boolean { + val currentSelected = selectedItemIds.value ?: return false + val headers = _headerCards.success.orEmpty() + return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected } + } + + private fun updateSelectedItems(action: (Set?) -> Set?) { + val currentSelected = action(selectedItemIds.value) + _selectedItemIds.postValue(currentSelected) + postHeaders() + postChildren() + updateSelectedBytes() + } + + private fun updateSelectedBytes() = viewModelScope.launchSafe { + val selectedItemsList = getSelectedItemsData() ?: return@launchSafe + val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes } + _selectedBytes.postValue(totalSelectedBytes) + } - // Gets all children downloads - withContext(Dispatchers.IO) { - for (c in children) { - val childFile = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, c.id) ?: continue + fun removeRedundantEpisodeKeys(context: Context, keys: List>) { + val settingsManager = context.getSharedPrefs() + ioSafe { + settingsManager.edit { + keys.forEach { (parentId, childId) -> + Log.i(TAG, "Removing download episode key: ${parentId}/${childId}") + val oldPath = getFolderName( + getFolderName( + DOWNLOAD_EPISODE_CACHE, + parentId.toString() + ), + childId.toString() + ) + val newPath = getFolderName( + getFolderName( + DOWNLOAD_EPISODE_CACHE_BACKUP, + parentId.toString() + ), + childId.toString() + ) - if (childFile.fileLength <= 1) continue - val len = childFile.totalBytes - val flen = childFile.fileLength - - totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len - currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen - totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1 + val oldPref = settingsManager.getString(oldPath, null) + // Cowardly future backup solution in case the key removal fails in some edge case. + // This and all backup keys may be removed in a future update if the key removal is proven to be robust. + this.putString(newPath, oldPref) + this.remove(oldPath) + } } } + } - val cached = withContext(Dispatchers.IO) { // wont fetch useless keys - totalDownloads.entries.filter { it.value > 0 }.mapNotNull { - context.getKey( - DOWNLOAD_HEADER_CACHE, - it.key.toString() + fun removeRedundantHeaderKeys( + context: Context, + cached: List, + totalBytesUsedByChild: Map, + totalDownloads: Map + ) { + val settingsManager = context.getSharedPrefs() + ioSafe { + // Do not remove headers used by resume watching + val resumeWatchingIds = + getAllResumeStateIds()?.mapNotNull { id -> + getLastWatched(id)?.parentId + }?.toSet() ?: emptySet() + + settingsManager.edit { + cached.forEach { header -> + val downloads = totalDownloads[header.id] ?: 0 + val bytes = totalBytesUsedByChild[header.id] ?: 0 + + if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) { + Log.i(TAG, "Removing download header key: ${header.id}") + val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString()) + val newPath = + getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString()) + val oldPref = settingsManager.getString(oldPAth, null) + // Cowardly future backup solution in case the key removal fails in some edge case. + // This and all backup keys may be removed in a future update if the key removal is proven to be robust. + this.putString(newPath, oldPref) + this.remove(oldPAth) + } + } + } + } + } + + fun updateHeaderList(context: Context) = viewModelScope.launchSafe { + // Do not push loading as it interrupts the UI + //_headerCards.postValue(Resource.Loading()) + + val visual = ioWork { + val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) + .mapNotNull { context.getKey(it) } + .distinctBy { it.id } // Remove duplicates + + val isCurrentlyDownloading = + DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty() + + val downloadStats = + calculateDownloadStats(context, children) + + val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) + .mapNotNull { context.getKey(it) } + + // Download stats and header keys may change when downloading. + // To prevent the downloader and key removal from colliding, simply do not prune keys when downloading. + if (!isCurrentlyDownloading) { + removeRedundantHeaderKeys( + context, + cached, + downloadStats.totalBytesUsedByChild, + downloadStats.totalDownloads ) } + // calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required + removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads) + + createVisualDownloadList( + context, + cached, + downloadStats.totalBytesUsedByChild, + downloadStats.currentBytesUsedByChild, + downloadStats.totalDownloads + ) } + updateStorageStats(visual) + postHeaders(visual) + } + + fun postHeaders(newValue: List? = null) { + val newValue = newValue ?: _headerCards.success ?: return + val selection = selectedItemIds.value ?: emptySet() + _headerCards.postValue(Resource.Success(newValue.map { + it.copy( + isSelected = selection.contains( + it.data.id + ) + ) + })) + } + + fun postChildren(newValue: List? = null) { + val newValue = newValue ?: _childCards.success ?: return + val selection = selectedItemIds.value ?: emptySet() + _childCards.postValue(Resource.Success(newValue.map { + it.copy( + isSelected = selection.contains( + it.data.id + ) + ) + })) + } + + private data class DownloadStats( + val totalBytesUsedByChild: Map, + val currentBytesUsedByChild: Map, + val totalDownloads: Map, + /** Parent ID to child ID. Keys to be removed. */ + val redundantDownloads: List> + ) + + private fun calculateDownloadStats( + context: Context, + children: List + ): DownloadStats { + // parentId : bytes + val totalBytesUsedByChild = mutableMapOf() + // parentId : bytes + val currentBytesUsedByChild = mutableMapOf() + // parentId : downloadsCount + val totalDownloads = mutableMapOf() + val redundantDownloads = mutableListOf>() + + children.forEach { child -> + val childFile = getDownloadFileInfo(context, child.id) + + if (childFile == null) { + // It may not be a redundant child if something is currently downloading. + // DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader + // leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE + if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) { + redundantDownloads.add(child.parentId to child.id) + } + return@forEach + } + if (childFile.fileLength <= 1) return@forEach + + val len = childFile.totalBytes + val flen = childFile.fileLength + + totalBytesUsedByChild.merge(child.parentId, len, Long::plus) + currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) + totalDownloads.merge(child.parentId, 1, Int::plus) + } + return DownloadStats( + totalBytesUsedByChild, + currentBytesUsedByChild, + totalDownloads, + redundantDownloads + ) + } + + private fun createVisualDownloadList( + context: Context, + cached: List, + totalBytesUsedByChild: Map, + currentBytesUsedByChild: Map, + totalDownloads: Map + ): List { + return cached.mapNotNull { + val downloads = totalDownloads[it.id] ?: 0 + val bytes = totalBytesUsedByChild[it.id] ?: 0 + val currentBytes = currentBytesUsedByChild[it.id] ?: 0 + + if (bytes <= 0 || downloads <= 0) { + return@mapNotNull null + } + + val isSelected = selectedItemIds.value?.contains(it.id) ?: false + val movieEpisode = + if (it.type.isEpisodeBased()) null else context.getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(it.id.toString(), it.id.toString()) + ) + + VisualDownloadCached.Header( + currentBytes = currentBytes, + totalBytes = bytes, + data = it, + child = movieEpisode, + currentOngoingDownloads = 0, + totalDownloads = downloads, + isSelected = isSelected, + ) + // Prevent order being almost completely random, + // making things difficult to find. + }.sortedWith(compareBy { + // Sort by isEpisodeBased() ascending. We put those that + // are episode based at the bottom for UI purposes and to + // make it easier to find by grouping them together. + it.data.type.isEpisodeBased() + }.thenBy { + // Then we sort alphabetically by name (case-insensitive). + // Again, we do this to make things easier to find. + it.data.name.lowercase() + }) + } + + fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { + _childCards.postValue(Resource.Loading()) // always push loading + val visual = withContext(Dispatchers.IO) { - cached.mapNotNull { // TODO FIX - val downloads = totalDownloads[it.id] ?: 0 - val bytes = totalBytesUsedByChild[it.id] ?: 0 - val currentBytes = currentBytesUsedByChild[it.id] ?: 0 - if (bytes <= 0 || downloads <= 0) return@mapNotNull null - val movieEpisode = - if (!it.type.isMovieType()) null - else context.getKey( - DOWNLOAD_EPISODE_CACHE, - getFolderName(it.id.toString(), it.id.toString()) - ) - VisualDownloadHeaderCached( - 0, - downloads, - bytes, - currentBytes, - it, - movieEpisode + context.getKeys(folder).mapNotNull { key -> + context.getKey(key) + }.mapNotNull { + val isSelected = selectedItemIds.value?.contains(it.id) ?: false + val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null + VisualDownloadCached.Child( + currentBytes = info.fileLength, + totalBytes = info.totalBytes, + isSelected = isSelected, + data = it, ) - }.sortedBy { - (it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0) - } // episode sorting by episode, lowest to highest - } + } + }.sortedWith( + compareBy( + // Sort by season first, and then by episode number, + // to ensure sorting is consistent. + { it.data.season ?: 0 }, + { it.data.episode } + )) + + postChildren(visual) + } + + private fun removeItems(idsToRemove: Set) = viewModelScope.launchSafe { + _selectedItemIds.postValue(null) + postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove }) + postChildren(_childCards.success?.filter { it.data.id !in idsToRemove }) + } + + private fun updateStorageStats(visual: List) { try { val stat = StatFs(Environment.getExternalStorageDirectory().path) - - val localBytesAvailable = stat.availableBytes//stat.blockSizeLong * stat.blockCountLong + val localBytesAvailable = stat.availableBytes val localTotalBytes = stat.blockSizeLong * stat.blockCountLong val localDownloadedBytes = visual.sumOf { it.totalBytes } - - _usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes) + val localUsedBytes = localTotalBytes - localBytesAvailable + _usedBytes.postValue(localUsedBytes) _availableBytes.postValue(localBytesAvailable) _downloadBytes.postValue(localDownloadedBytes) - } catch (t : Throwable) { + } catch (t: Throwable) { _downloadBytes.postValue(0) logError(t) } - - _headerCards.postValue(visual) } -} + + fun handleMultiDelete(context: Context) = viewModelScope.launchSafe { + val selectedItemsList = getSelectedItemsData().orEmpty() + val deleteData = processSelectedItems(context, selectedItemsList) + val message = buildDeleteMessage(context, deleteData) + showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) + } + + fun handleSingleDelete( + context: Context, + itemId: Int + ) = viewModelScope.launchSafe { + val itemData = getItemDataFromId(itemId) + val deleteData = processSelectedItems(context, itemData) + val message = buildDeleteMessage(context, deleteData) + showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) + } + + private fun processSelectedItems( + context: Context, + selectedItemsList: List + ): DeleteData { + val names = mutableListOf() + val seriesNames = mutableListOf() + + val ids = mutableSetOf() + val parentIds = mutableSetOf() + + var parentName: String? = null + + selectedItemsList.forEach { item -> + when (item) { + is VisualDownloadCached.Header -> { + if (item.data.type.isEpisodeBased()) { + val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) + .mapNotNull { + context.getKey( + it + ) + } + .filter { it.parentId == item.data.id } + .map { it.id } + ids.addAll(episodes) + parentIds.add(item.data.id) + + val episodeInfo = "${item.data.name} (${item.totalDownloads} ${ + context.resources.getQuantityString( + R.plurals.episodes, + item.totalDownloads + ).lowercase() + })" + seriesNames.add(episodeInfo) + } else { + ids.add(item.data.id) + names.add(item.data.name) + } + } + + is VisualDownloadCached.Child -> { + ids.add(item.data.id) + val parent = context.getKey( + DOWNLOAD_HEADER_CACHE, + item.data.parentId.toString() + ) + parentName = parent?.name + names.add( + context.getNameFull( + item.data.name, + item.data.episode, + item.data.season + ) + ) + } + } + } + + return DeleteData(ids, parentIds, seriesNames, names, parentName) + } + + private fun buildDeleteMessage( + context: Context, + data: DeleteData + ): String { + val formattedNames = data.names.sortedBy { it.lowercase() } + .joinToString(separator = "\n") { "• $it" } + val formattedSeriesNames = data.seriesNames.sortedBy { it.lowercase() } + .joinToString(separator = "\n") { "• $it" } + + return when { + data.seriesNames.isNotEmpty() && data.names.isEmpty() -> { + context.getString(R.string.delete_message_series_only).format(formattedSeriesNames) + } + + data.ids.count() == 1 -> { + context.getString(R.string.delete_message).format( + data.names.firstOrNull() + ) + } + + data.parentName != null && data.names.isNotEmpty() -> { + context.getString(R.string.delete_message_series_episodes) + .format(data.parentName, formattedNames) + } + + data.seriesNames.isNotEmpty() -> { + val seriesSection = context.getString(R.string.delete_message_series_section) + .format(formattedSeriesNames) + context.getString(R.string.delete_message_multiple) + .format(formattedNames) + "\n\n" + seriesSection + } + + else -> context.getString(R.string.delete_message_multiple).format(formattedNames) + } + } + + private fun showDeleteConfirmationDialog( + context: Context, + message: String, + ids: Set, + parentIds: Set + ) { + val builder = AlertDialog.Builder(context) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + viewModelScope.launchSafe { + deleteFilesAndUpdateSettings(context, ids, this) { successfulIds -> + // We always remove parent because if we are deleting from here + // and we have it as non-empty, it was triggered on + // parent header card + removeItems(successfulIds + parentIds) + } + } + } + + DialogInterface.BUTTON_NEGATIVE -> { + // Do nothing on cancel + } + } + } + + try { + val title = if (ids.count() == 1) { + R.string.delete_file + } else R.string.delete_files + builder.setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (e: Exception) { + logError(e) + } + } + + private fun getSelectedItemsData(): List? { + val headers = _headerCards.success.orEmpty() + val children = _childCards.success.orEmpty() + + return selectedItemIds.value?.mapNotNull { id -> + headers.find { it.data.id == id } ?: children.find { it.data.id == id } + } + } + + private fun getItemDataFromId(itemId: Int): List { + return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId } + } + + fun clearChildren() { + _childCards.postValue(Resource.Loading()) + } + + private data class DeleteData( + val ids: Set, + val parentIds: Set, + val seriesNames: List, + val names: List, + val parentName: String? + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/EasyDownloadButton.kt deleted file mode 100644 index 778784326..000000000 --- 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 000000000..382a770cd --- /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 000000000..91c5dd72c --- /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 000000000..f6f8a5ff8 --- /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 000000000..11818a7e9 --- /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 000000000..877fcfea8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt @@ -0,0 +1,274 @@ +package com.lagradost.cloudstream3.ui.download.queue + + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD +import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick +import com.lagradost.cloudstream3.ui.download.DownloadClickEvent +import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE +import com.lagradost.cloudstream3.utils.DataStore.getFolderName +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO + +/** An item in the adapter can either be a separator or a real item. + * isCurrentlyDownloading is used to fully update items as opposed to just moving them. */ +class DownloadAdapterItem(val item: DownloadQueueWrapper?) { + val isSeparator = item == null +} + + +class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter( + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.item?.id == b.item?.id }, + contentSame = { a, b -> + a.item == b.item + }) +) { + var currentDownloads = 0 + + companion object { + val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG" + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = DownloadQueueItemBinding.inflate(inflater, parent, false) + return ViewHolderState(binding) + } + + override fun onBindContent( + holder: ViewHolderState, + item: DownloadAdapterItem, + position: Int + ) { + when (val binding = holder.view) { + is DownloadQueueItemBinding -> { + if (item.item == null) { + holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG + bindSeparator(binding) + } else { + holder.itemView.tag = null + bind(binding, item.item) + } + } + } + } + + fun submitQueue(newQueue: DownloadAdapterQueue) { + val index = newQueue.currentDownloads.size + val current = newQueue.currentDownloads + val queue = newQueue.queue + currentDownloads = current.size + + val newList = + (current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList() + .apply { + // Only add the separator if it actually separates something + if (index < this.size) { + add(index, DownloadAdapterItem(null)) + } + } + submitList(newList) + } + + fun bindSeparator(binding: DownloadQueueItemBinding) { + binding.apply { + separatorHolder.isGone = false + downloadChildEpisodeHolder.isGone = true + } + } + + fun bind( + binding: DownloadQueueItemBinding, + queueWrapper: DownloadQueueWrapper, + ) { + val context = binding.root.context + + binding.apply { + separatorHolder.isGone = true + downloadChildEpisodeHolder.isGone = false + + // Only set the child-text if child and parent are not the same + // This prevents setting movie titles twice + if (queueWrapper.id != queueWrapper.parentId) { + val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName + downloadChildEpisodeTextExtra.text = mainName + } else { + downloadChildEpisodeTextExtra.text = null + } + + downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank() + + val status = VideoDownloadManager.downloadStatus[queueWrapper.id] + + downloadButton.setOnClickListener { view -> + val episodeCached = + getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString()) + ) + + val downloadInfo = context.getKey( + KEY_DOWNLOAD_INFO, + queueWrapper.id.toString() + ) + + val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading() + + val actionList = arrayListOf>() + + if (isCurrentlyDownloading && episodeCached != null) { + // KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything + if (downloadInfo != null) { + actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file)) + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + } + + val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id] + + when (currentStatus) { + VideoDownloadManager.DownloadType.IsDownloading -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_PAUSE_DOWNLOAD, + R.string.popup_pause_download + ) + ) + } + + VideoDownloadManager.DownloadType.IsPaused -> { + actionList.add( + Pair( + DOWNLOAD_ACTION_RESUME_DOWNLOAD, + R.string.popup_resume_download + ) + ) + } + + else -> {} + } + + view.popupMenuNoIcons( + actionList + ) { + handleDownloadClick(DownloadClickEvent(itemId, episodeCached)) + } + } else { + actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) + + view.popupMenuNoIcons( + actionList + ) { + when (itemId) { + DOWNLOAD_ACTION_CANCEL_PENDING -> { + DownloadQueueManager.cancelDownload(queueWrapper.id) + } + } + } + } + } + + downloadButton.resetView() + downloadButton.setStatus(status) + downloadButton.setPersistentId(queueWrapper.id) + + downloadChildEpisodeText.apply { + val name = queueWrapper.downloadItem?.episode?.name + ?: queueWrapper.resumePackage?.item?.ep?.name + val episode = + queueWrapper.downloadItem?.episode?.episode + ?: queueWrapper.resumePackage?.item?.ep?.episode + val season = + queueWrapper.downloadItem?.episode?.season + ?: queueWrapper.resumePackage?.item?.ep?.season + text = context.getNameFull(name, episode, season) + isSelected = true // Needed for text repeating + } + } + } +} + + +class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) : + ItemTouchHelper( + DragAndDropTouchHelperCallback(adapter) + ) + +private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) : + ItemTouchHelper.Callback() { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val item = adapter.getItem(viewHolder.absoluteAdapterPosition) + val isDownloading = item.item?.isCurrentlyDownloading() == true + val dragFlags = if (item.isSeparator || isDownloading) { + 0 + } else { + ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down + } + + val swipeFlags = 0 // Disable swipe functionality + return makeMovementFlags(dragFlags, swipeFlags) + } + + override fun onMove( + recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val fromPosition = source.absoluteAdapterPosition + val toPosition = target.absoluteAdapterPosition + val separatorPosition = adapter.currentDownloads + + val toPositionNoSeparator = + if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition + + if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) { + return false + } else { + adapter.getItem(fromPosition).item?.let { downloadQueueInfo -> + DownloadQueueManager.reorderItem( + downloadQueueInfo, + toPositionNoSeparator - 1 + ) + } + } + + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + + } + + override fun isLongPressDragEnabled(): Boolean { + return true // Enable drag with long press + } + + override fun isItemViewSwipeEnabled(): Boolean { + return false // Disable swipe by default + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt new file mode 100644 index 000000000..071d8913d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt @@ -0,0 +1,79 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isGone +import androidx.fragment.app.activityViewModels +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding +import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.txt + + +class DownloadQueueFragment : + BaseFragment(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) { + private val queueViewModel: DownloadQueueViewModel by activityViewModels() + + override fun onBindingCreated(binding: FragmentDownloadQueueBinding) { + val adapter = DownloadQueueAdapter(this@DownloadQueueFragment) + val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all) + + observe(queueViewModel.childCards) { cards -> + val size = cards.queue.size + cards.currentDownloads.size + val isEmptyQueue = size == 0 + binding.downloadQueueList.isGone = isEmptyQueue + binding.textNoQueue.isGone = !isEmptyQueue + clearQueueItem?.isVisible = !isEmptyQueue + + adapter.submitQueue(cards) + } + + binding.apply { + downloadQueueToolbar.apply { + title = txt(R.string.download_queue).asString(context) + if (isLayout(PHONE or EMULATOR)) { + setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) + setNavigationOnClickListener { + dispatchBackPressed() + } + } + setAppBarNoScrollFlagsOnTV() + clearQueueItem?.setOnMenuItemClickListener { + AlertDialog.Builder(context, R.style.AlertDialogCustom) + .setTitle(R.string.cancel_all) + .setMessage(R.string.cancel_queue_message) + .setPositiveButton(R.string.yes) { _, _ -> + DownloadQueueManager.removeAllFromQueue() + } + .setNegativeButton(R.string.no) { _, _ -> + }.show() + + true + } + } + + downloadQueueList.adapter = adapter + + // Drag and drop + val helper = DragAndDropTouchHelper(adapter) + helper.attachToRecyclerView(downloadQueueList) + } + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt new file mode 100644 index 000000000..fc384cb4e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream3.ui.download.queue + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.launchSafe +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +data class DownloadAdapterQueue( + val currentDownloads: List, + val queue: List, +) + +class DownloadQueueViewModel : ViewModel() { + private val _childCards = MutableLiveData() + val childCards: LiveData = _childCards + private val totalDownloadFlow = + downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> + val current = instances.map { it.downloadQueueWrapper } + DownloadAdapterQueue(current, queue.toList()) + }.combine(VideoDownloadManager.currentDownloads) { total, _ -> + // We want to update the flow when currentDownloads updates, but we do not care about its value + total + } + + init { + viewModelScope.launch { + totalDownloadFlow.collect { queue -> + updateChildList(queue) + } + } + } + + fun updateChildList(downloads: DownloadAdapterQueue) { + _childCards.postValue(downloads) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt index b90a4e43e..43f6d19ff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt @@ -1,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 - - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), - clickCallback, - itemCount, - nextFocusUp, - nextFocusDown, - isHorizontal - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.itemCount = itemCount // i know ugly af - holder.bind(cardList[position], position) +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() } } } - - override fun getItemCount(): Int { - return cardList.size - } - - override fun getItemId(position: Int): Long { - return (cardList[position].id ?: position).toLong() - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - HomeChildDiffCallback(this.cardList, newList) - ) - - cardList.clear() - cardList.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - 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 - } - - (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 - } - } - } - - - 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 - } - //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 +class ResumeItemAdapter( + nextFocusUp: Int? = null, + nextFocusDown: Int? = null, + clickCallback: (SearchClickCallback) -> Unit, + private val removeCallback: (View) -> Unit, +) : HomeChildItemAdapter( + id = "resumeAdapter".hashCode(), + nextFocusUp = nextFocusUp, + nextFocusDown = nextFocusDown, + clickCallback = clickCallback +) { + // As there is no popup on TV we instead use the footer to clear + override val footers = if (isLayout(TV or EMULATOR)) 1 else 0 + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + val expanded = parent.context.isBottomLayout() + val inflater = LayoutInflater.from(parent.context) + val binding = if (expanded) HomeRemoveGridExpandedBinding.inflate( + inflater, + parent, + false + ) else HomeRemoveGridBinding.inflate(inflater, parent, false) + return HomeScrollViewHolderState(binding) + } + + override fun onClearView(holder: ViewHolderState) { + // Clear the image, idk if this saves ram or not, but I guess? + clearImage(holder.view.root.findViewById(R.id.imageView)) + } + + override fun onBindFooter(holder: ViewHolderState) { + this.applyBinding(holder, false) + when (val binding = holder.view) { + is HomeRemoveGridBinding -> { + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) + } + + is HomeRemoveGridExpandedBinding -> { + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) + } + } + holder.itemView.apply { + if (isLayout(TV)) { + isFocusableInTouchMode = true + isFocusable = true + } + nextFocusUp?.let { + nextFocusUpId = it + } + nextFocusDown?.let { + nextFocusDownId = it + } + + setOnClickListener { v -> + removeCallback.invoke(v ?: return@setOnClickListener) + } + } + } +} + +/** Remember to set `updatePosterSize` to cache the poster size, + * otherwise the width and height is unset */ +open class HomeChildItemAdapter( + id: Int, + var nextFocusUp: Int? = null, + var nextFocusDown: Int? = null, + var clickCallback: (SearchClickCallback) -> Unit, ) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].name == newList[newItemPosition].name + BaseAdapter( + id, diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.url == b.url && a.name == b.name + }, + contentSame = { a, b -> + a == b + }) + ) { + var hasNext: Boolean = false + var isHorizontal: Boolean = false + set(value) { + field = value + updateCachedPosterSize() + } - override fun getOldListSize() = oldList.size + private fun updateCachedPosterSize() { + setWidth = if (!isHorizontal) { + minPosterSize + } else { + maxPosterSize + } + setHeight = if (!isHorizontal) { + maxPosterSize + } else { + minPosterSize + } + } - override fun getNewListSize() = newList.size + init { + updateCachedPosterSize() + } - 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 + protected var setWidth = 0 + protected var setHeight = 0 + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val expanded = parent.context.isBottomLayout() + val inflater = LayoutInflater.from(parent.context) + val binding = if (expanded) HomeResultGridExpandedBinding.inflate( + inflater, + parent, + false + ) else HomeResultGridBinding.inflate(inflater, parent, false) + return HomeScrollViewHolderState(binding) + } + + companion object { + // The vast majority of the lag comes from creating the view + // This simply shares the views between all HomeChildItemAdapter + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 20) } + + var minPosterSize: Int = 0 + var maxPosterSize: Int = 0 + + fun updatePosterSize(context: Context, value: Int? = null) { + val scale = value ?: PreferenceManager.getDefaultSharedPreferences(context) + ?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0 + // Scale by +10% per step + val mul = 1.0f + scale * 0.1f + minPosterSize = (114.toPx.toFloat() * mul).toInt() + maxPosterSize = (180.toPx.toFloat() * mul).toInt() + } + + fun updateLayoutParms(layout: FrameLayout, width: Int, height: Int) { + val params = layout.layoutParams + if (params.height == height && params.width == width) return + + params.width = width + params.height = height + + layout.layoutParams = params + } + } + + protected fun applyBinding(holder: ViewHolderState, isFirstItem: Boolean) { + when (val binding = holder.view) { + is HomeResultGridBinding -> { + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) + } + + is HomeResultGridExpandedBinding -> { + updateLayoutParms(binding.backgroundCard, setWidth, setHeight) + + if (isFirstItem) { // to fix tv + binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view + } + } + } + } + + override fun onBindContent( + holder: ViewHolderState, + item: SearchResponse, + position: Int + ) { + applyBinding(holder, position == 0) + + SearchResultBuilder.bind( + clickCallback = { click -> + // ok, so here we hijack the callback to fix the focus + when (click.action) { + SEARCH_ACTION_LOAD -> (holder as? HomeScrollViewHolderState)?.wasFocused = true + } + clickCallback(click) + }, + item, + position, + holder.itemView, + nextFocusUp, + nextFocusDown + ) + + 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 5cf6fc8e3..b68ef5962 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -5,94 +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.core.net.toUri import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.* import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.button.MaterialButton import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipGroup -import com.lagradost.cloudstream3.* +import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent -import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent -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.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.search.* +import com.lagradost.cloudstream3.ui.BaseFragment +import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear +import com.lagradost.cloudstream3.ui.account.AccountViewModel +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD +import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE +import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching -import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult -import com.lagradost.cloudstream3.utils.AppUtils.ownHide -import com.lagradost.cloudstream3.utils.AppUtils.ownShow -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings +import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable +import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide +import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.DataStore.getKey -import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.EmptyEvent import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso +import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount +import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes -import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API -import kotlinx.android.synthetic.main.activity_main_tv.* -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_change_api_loading -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_reload_connection_open_in_browser -import kotlinx.android.synthetic.main.fragment_home.home_reload_connectionerror -import kotlinx.android.synthetic.main.fragment_home.result_error_text -import kotlinx.android.synthetic.main.fragment_home_tv.* -import kotlinx.android.synthetic.main.fragment_result.* -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.* +import com.lagradost.cloudstream3.utils.UIHelper.toPx +private const val TAG = "HomeFragment" -const val HOME_BOOKMARK_VALUE_LIST = "home_bookmarked_last_list" -const val HOME_PREF_HOMEPAGE = "home_pref_homepage" - -class HomeFragment : Fragment() { +class HomeFragment : BaseFragment( + BindingCreator.Bind(FragmentHomeBinding::bind) +) { companion object { - val configEvent = Event() + // Used for configuration changed events to fix any popups that are not attached to a fragment + val configEvent = EmptyEvent() var currentSpan = 1 - val listHomepageItems = mutableListOf() private val errorProfilePics = listOf( R.drawable.monke_benene, @@ -121,26 +118,31 @@ class HomeFragment : Fragment() { //} // returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView + fun Activity.loadHomepageList( expand: HomeViewModel.ExpandableHomepageList, deleteCallback: (() -> Unit)? = null, expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null, - dismissCallback : (() -> Unit), + 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)!! + 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 { @@ -159,10 +161,10 @@ class HomeFragment : Fragment() { // }) //} // } - val delete = bottomSheetDialogBuilder.home_expanded_delete - delete.isGone = deleteCallback == null + //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 = @@ -172,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 @@ -191,26 +194,28 @@ class HomeFragment : Fragment() { } } } - - 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.ownHide() // we hide here because we want to resume it later - //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 @@ -229,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) } } } @@ -237,9 +242,12 @@ 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 @@ -255,7 +263,7 @@ class HomeFragment : Fragment() { return bottomSheetDialogBuilder } - fun getPairList( + private fun getPairList( anime: Chip?, cartoons: Chip?, tvs: Chip?, @@ -263,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) { @@ -301,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) @@ -322,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) { @@ -344,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 -> @@ -355,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 preSelectedTypes = DataStoreHelper.homePreference.toMutableList() - val cancelBtt = dialog.findViewById(R.id.cancel_btt) - val applyBtt = dialog.findViewById(R.id.apply_btt) - - 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 @@ -383,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 } @@ -406,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 -> @@ -422,35 +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) bottomSheetDialog?.ownShow() - val layout = - if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home - return inflater.inflate(layout, container, false) + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { + (activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress") bottomSheetDialog?.ownHide() super.onDestroyView() } - private fun fixGrid() { - activity?.getSpanCount()?.let { - currentSpan = it - } - configEvent.invoke(currentSpan) - } - private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> - homeViewModel.loadAndCancel(api) + homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) } /*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf() @@ -461,279 +589,298 @@ class HomeFragment : Fragment() { }*/ } - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - //(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.notifyDataSetChanged() - fixGrid() - } - - fun bookmarksUpdated(_data : Boolean) { - reloadStored() - } - - override fun onResume() { - super.onResume() - reloadStored() - bookmarksUpdatedEvent += ::bookmarksUpdated - afterPluginsLoadedEvent += ::afterPluginsLoaded - mainPluginsLoadedEvent += ::afterMainPluginsLoaded - } - - override fun onStop() { - bookmarksUpdatedEvent -= ::bookmarksUpdated - afterPluginsLoadedEvent -= ::afterPluginsLoaded - mainPluginsLoadedEvent -= ::afterMainPluginsLoaded - super.onStop() - } - - 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) - } - 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) - } - } - - private fun homeHandleSearch(callback: SearchClickCallback) { - if (callback.action == SEARCH_ACTION_FOCUSED) { - //focusCallback(callback.card) - } else { - handleSearchClickCallback(activity, callback) - } - } - private var currentApiName: String? = null private var toggleRandomButton = false private var bottomSheetDialog: BottomSheetDialog? = null + private var homeMasterAdapter: HomeParentItemAdapterPreview? = null + + var lastSavedHomepage: String? = null + + fun saveHomepageToTV(page: Map) { + // No need to update for phone + if (isLayout(PHONE)) { + return + } + val (name, data) = page.entries.firstOrNull() ?: return + // Modifying homepage is an expensive operation, and therefore we avoid it at all cost + if (name == lastSavedHomepage) { + return + } + Log.i(TAG, "Adding programs $name to TV") + lastSavedHomepage = name + ioSafe { + // empty the channel + deleteAll() + // insert the program from first array + addMovies(data.list.list) + } + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padTop = false, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) + + // Fix grid + configEvent.invoke() + } @SuppressLint("SetTextI18n") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - fixGrid() - - home_change_api_loading?.setOnClickListener(apiChangeClickListener) - home_api_fab?.setOnClickListener(apiChangeClickListener) - home_random?.setOnClickListener { - if (listHomepageItems.isNotEmpty()) { - activity.loadSearchResult(listHomepageItems.random()) + override fun onBindingCreated(binding: FragmentHomeBinding) { + context?.let { HomeChildItemAdapter.updatePosterSize(it) } + (activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { + handleTvBackPress(this) + } + binding.apply { + //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) + //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) + homeApiFab.setOnClickListener(apiChangeClickListener) + homeApiFab.setOnLongClickListener { + if (currentApiName == noneApi.name) return@setOnLongClickListener false + homeViewModel.loadAndCancel(currentApiName, forceReload = true, fromUI = true) + showToast(R.string.action_reload, Toast.LENGTH_SHORT) + true } + homeChangeApi.setOnClickListener(apiChangeClickListener) + homeSwitchAccount.setOnClickListener { + activity?.showAccountSelectLinear() + } + + 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 + } + } + super.onScrolled(recyclerView, dx, dy) + } + }) + } //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 - } - - observe(homeViewModel.preview) { preview -> - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setPreviewData( - preview - ) + 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 - home_api_fab?.text = apiName - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setApiName( - apiName - ) + binding.apply { + homeApiFab.text = apiName + homeChangeApi.text = apiName + homePreviewReloadProvider.isGone = (apiName == noneApi.name) + homePreviewSearchButton.isGone = (apiName == noneApi.name) + } } observe(homeViewModel.page) { data -> - when (data) { - is Resource.Success -> { - home_loading_shimmer?.stopShimmer() - - val d = data.value - val mutableListOfResponse = mutableListOf() - listHomepageItems.clear() - - (home_master_recycler?.adapter as? ParentItemAdapter)?.updateList( - d.values.toMutableList(), - home_master_recycler - ) - - home_loading?.isVisible = false - home_loading_error?.isVisible = false - home_master_recycler?.isVisible = true - //home_loaded?.isVisible = true - if (toggleRandomButton) { - //Flatten list - d.values.forEach { dlist -> - mutableListOfResponse.addAll(dlist.list.list) - } - 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 } - - view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api -> - Pair( - index, - api.name + 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()) ) - }) { - try { - val i = Intent(Intent.ACTION_VIEW) - i.data = Uri.parse(validAPIs[itemId].mainUrl) - startActivity(i) - } catch (e: Exception) { - logError(e) + }) + + 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) } + } + + homeRandom.isVisible = isPhone && hasItems + homeRandom.setOnClickListener(randomClickListener) + homeRandomButtonTv.isVisible = !isPhone && hasItems + homeRandomButtonTv.setOnClickListener(randomClickListener) + } else { + homeRandom.isGone = true + homeRandomButtonTv.isGone = true + } + } + + is Resource.Failure -> { + homeLoadingShimmer.stopShimmer() + 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 = validAPIs[itemId].mainUrl.toUri() + startActivity(i) + } catch (e: Exception) { + logError(e) + } } } + + homeLoading.isVisible = false + homeLoadingError.isVisible = true + homeMasterRecycler.isInvisible = true + + // Based on https://github.com/recloudstream/cloudstream/pull/1438 + val hasNoNetworkConnection = context?.isNetworkAvailable() == false + val isNetworkError = data.isNetworkError + + // Show the downloads button if we have any sort of network shenanigans + homeReloadConnectionGoToDownloads.isVisible = + hasNoNetworkConnection || isNetworkError + + // Only hide the open in browser button if we know this is not network shenanigans related to cs3 + homeReloadConnectionOpenInBrowser.isGone = hasNoNetworkConnection + + resultErrorText.text = if (hasNoNetworkConnection) { + getString(R.string.no_internet_connection) + } else { + data.errorString + } + + homeReloadConnectionGoToDownloads.setOnClickListener { + activity.navigate(R.id.navigation_downloads) + } + + (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { + submitList(null) + clearState() + } } - home_loading?.isVisible = false - home_loading_error?.isVisible = true - home_master_recycler?.isVisible = false - //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_master_recycler?.isVisible = false - //home_loaded?.isVisible = false - } - } - } - - - - observe(homeViewModel.availableWatchStatusTypes) { availableWatchStatusTypes -> - context?.setKey( - HOME_BOOKMARK_VALUE_LIST, - availableWatchStatusTypes.first.map { it.internalId }.toIntArray() - ) - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setAvailableWatchStatusTypes( - availableWatchStatusTypes - ) - } - - observe(homeViewModel.bookmarks) { data -> - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setBookmarkData( - data - ) - } - - observe(homeViewModel.resumeWatching) { resumeWatching -> - (home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setResumeWatchingData( - resumeWatching - ) - if (isTrueTvSettings()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - ioSafe { - activity?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult }) + 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 } } } } - - //context?.fixPaddingStatusbarView(home_statusbar) - //context?.fixPaddingStatusbar(home_padding) - context?.fixPaddingStatusbar(home_loading_statusbar) - - home_master_recycler?.adapter = - HomeParentItemAdapterPreview(mutableListOf(), { callback -> - homeHandleSearch(callback) - }, { item -> - bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = { - homeViewModel.expandAndReturn(it) - }, dismissCallback = { - bottomSheetDialog = null - }) - }, { name -> - homeViewModel.expand(name) - }, { load -> - activity?.loadResult(load.response.url, load.response.apiName, load.action) - }, { - homeViewModel.loadMoreHomeScrollResponses() - }, { - apiChangeClickListener.onClick(it) - }, reloadStored = { - reloadStored() - }, loadStoredData = { - homeViewModel.loadStoredData(it) - }, { (isQuickSearch, text) -> - if (!isQuickSearch) { - QuickSearchFragment.pushSearch( - activity, - text, - currentApiName?.let { arrayOf(it) }) - } - }) - - reloadStored() - loadHomePage(false) - home_master_recycler?.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - 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() - } - } - - super.onScrolled(recyclerView, dx, dy) + observeNullable(homeViewModel.popup) { item -> + if (item == null) { + bottomSheetDialog?.dismissSafe() + bottomSheetDialog = null + return@observeNullable } - }) + + // don't recreate + if (bottomSheetDialog != null) { + return@observeNullable + } + + val (items, delete) = item + + bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = { + homeViewModel.expandAndReturn(it) + }, dismissCallback = { + homeViewModel.popup(null) + bottomSheetDialog = null + }, deleteCallback = delete) + } + + homeViewModel.reloadStored() + homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false) + //loadHomePage(false) // nice profile pic on homepage //home_profile_picture_holder?.isVisible = false // just in case - if (isTvSettings()) { - home_api_fab?.isVisible = false - if (isTrueTvSettings()) { - home_change_api_loading?.isVisible = true - home_change_api_loading?.isFocusable = true - home_change_api_loading?.isFocusableInTouchMode = true - } - // home_bookmark_select?.isFocusable = true - // home_bookmark_select?.isFocusableInTouchMode = true - } else { - home_api_fab?.isVisible = true - home_change_api_loading?.isVisible = false - } + //TODO READD THIS /*for (syncApi in OAuth2Apis) { - val login = syncApi.loginInfo() + val login = SyncAPI2.loginInfo() val pic = login?.profilePicture if (home_profile_picture?.setImage( pic, @@ -745,4 +892,44 @@ class HomeFragment : Fragment() { } }*/ } + + private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) { + // Only apply custom behavior on TV interface + if (!isLayout(TV)) { + helper.runDefault() + return + } + val currentFocus = activity?.currentFocus ?: run { + helper.runDefault() + return + } + // isInsideRecycle is true when focus is inside home_master_recycler + var parent = currentFocus.parent + var isInsideRecycler = false + while (parent != null) { + if (parent is View && parent.id == R.id.home_master_recycler) { + isInsideRecycler = true + break + } + parent = parent.parent + } + when { + // Case 1: Focus is within plugin content -> Move to plugin selector + isInsideRecycler -> { + binding?.homeMasterRecycler?.scrollToPosition(0) + // Defer focus request until after scroll ends + binding?.homeChangeApi?.post { + binding?.homeChangeApi?.requestFocus() + } + } + // Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation + currentFocus.id == R.id.home_change_api || + currentFocus.id == R.id.home_preview_reload_provider || + currentFocus.id == R.id.home_preview_search_button -> { + activity?.findViewById(R.id.navigation_home)?.requestFocus() + } + // Case 3: Any other location -> Use default back behavior + else -> helper.runDefault() + } + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index e6999c9e2..6bdd1bf49 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -1,52 +1,30 @@ package com.lagradost.cloudstream3.ui.home +import android.os.Build +import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView -import androidx.transition.ChangeBounds -import androidx.transition.TransitionManager -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipDrawable -import com.lagradost.cloudstream3.APIHolder.getId -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity -import com.lagradost.cloudstream3.HomePageList +import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.SearchResponse -import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.ui.WatchType -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.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.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SearchClickCallback -import com.lagradost.cloudstream3.ui.search.SearchFragment.Companion.filterSearchResponse -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView -import kotlinx.android.synthetic.main.activity_main_tv.* -import kotlinx.android.synthetic.main.activity_main_tv.view.* -import kotlinx.android.synthetic.main.fragment_home.* -import kotlinx.android.synthetic.main.fragment_home.view.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview_viewpager -import kotlinx.android.synthetic.main.homepage_parent.view.* +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, @@ -56,174 +34,108 @@ class LoadClickCallback( ) open class ParentItemAdapter( - private var items: MutableList, + id: Int, private val clickCallback: (SearchClickCallback) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val expandCallback: ((String) -> Unit)? = null, -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return ParentViewHolder( - LayoutInflater.from(parent.context).inflate( - if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent, - parent, - false - ), - clickCallback, - moreInfoClickCallback, - expandCallback - ) - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ParentViewHolder -> { - holder.bind(items[position]) - } - } - } - - override fun getItemCount(): Int { - return items.size - } - - override fun getItemId(position: Int): Long { - return items[position].list.name.hashCode().toLong() - } - - @JvmName("updateListHomePageList") - fun updateList(newList: List) { - updateList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) } - .toMutableList()) - } - - @JvmName("updateListExpandableHomepageList") - fun updateList( - newList: MutableList, - recyclerView: RecyclerView? = null - ) { - // this - // 1. prevents deep copy that makes this.items == newList - // 2. filters out undesirable results - // 3. moves empty results to the bottom (sortedBy is a stable sort) - val new = - newList.map { it.copy(list = it.list.copy(list = it.list.list.filterSearchResponse())) } - .sortedBy { it.list.list.isEmpty() } - - val diffResult = DiffUtil.calculateDiff( - SearchDiffCallback(items, new) - ) - items.clear() - items.addAll(new) - - //val mAdapter = this - val delta = if (this@ParentItemAdapter is HomeParentItemAdapterPreview) { - headItems - } else { - 0 - } - - diffResult.dispatchUpdatesTo(object : ListUpdateCallback { - override fun onInserted(position: Int, count: Int) { - //notifyItemRangeChanged(position + delta, count) - notifyItemRangeInserted(position + delta, count) - } - - override fun onRemoved(position: Int, count: Int) { - notifyItemRangeRemoved(position + delta, count) - } - - override fun onMoved(fromPosition: Int, toPosition: Int) { - notifyItemMoved(fromPosition + delta, toPosition + delta) - } - - override fun onChanged(_position: Int, count: Int, payload: Any?) { - - val position = _position + delta - - // I know kinda messy, what this does is using the update or bind instead of onCreateViewHolder -> bind - recyclerView?.apply { - // this loops every viewHolder in the recycle view and checks the position to see if it is within the update range - val missingUpdates = (position until (position + count)).toMutableSet() - for (i in 0 until itemCount) { - val child = getChildAt(i) ?: continue - val viewHolder = getChildViewHolder(child) ?: continue - if (viewHolder !is ParentViewHolder) continue - - val absolutePosition = viewHolder.bindingAdapterPosition - if (absolutePosition >= position && absolutePosition < position + count) { - val expand = items.getOrNull(absolutePosition - delta) ?: continue - missingUpdates -= absolutePosition - //println("Updating ${viewHolder.title.text} ($absolutePosition $position) -> ${expand.list.name}") - if (viewHolder.title.text == expand.list.name) { - viewHolder.update(expand) - } else { - viewHolder.bind(expand) - } - } - } - - // just in case some item did not get updated - for (i in missingUpdates) { - notifyItemChanged(i, payload) - } - } ?: run { - // in case we don't have a nice - notifyItemRangeChanged(position, count, payload) - } - } +) : BaseAdapter( + id, + diffCallback = BaseDiffCallback( + itemSame = { a, b -> a.list.name == b.list.name }, + contentSame = { a, b -> + a.list.list == b.list.list }) - - //diffResult.dispatchUpdatesTo(this) +) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 4) } } - class ParentViewHolder - constructor( - 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_child_more_info - val recyclerView: RecyclerView = itemView.home_child_recyclerview + 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) + } - 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 restore(state: Bundle) { + (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( + state.getSafeParcelable("value") + ) + } + } + + override fun submitList( + list: Collection?, + commitCallback: Runnable? + ) { + super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) + } + + override fun onUpdateContent( + holder: ViewHolderState, + item: HomeViewModel.ExpandableHomepageList, + position: Int + ) { + val binding = holder.view + if (binding !is HomepageParentBinding) return + (binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list) + } + + override fun onBindContent( + holder: ViewHolderState, + item: HomeViewModel.ExpandableHomepageList, + position: Int + ) { + val startFocus = R.id.nav_rail_view + val endFocus = FOCUS_SELF + val binding = holder.view + if (binding !is HomepageParentBinding) return + val info = item.list + binding.apply { + val currentAdapter = homeChildRecyclerview.adapter as? HomeChildItemAdapter + if (currentAdapter == null) { + homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool) + homeChildRecyclerview.adapter = HomeChildItemAdapter( + id = id + position + 100, clickCallback = clickCallback, - nextFocusUp = recyclerView.nextFocusUpId, - nextFocusDown = recyclerView.nextFocusDownId, + nextFocusUp = homeChildRecyclerview.nextFocusUpId, + nextFocusDown = homeChildRecyclerview.nextFocusDownId, ).apply { isHorizontal = info.isHorizontalImages + hasNext = 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 @@ -247,27 +159,35 @@ open class ParentItemAdapter( }) //(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged() - if (!isTvSettings()) { - title.setOnClickListener { - moreInfoClickCallback.invoke(expand) + if (isLayout(PHONE)) { + homeChildMoreInfo.setOnClickListener { + moreInfoClickCallback.invoke(item) } } } } + + override fun onCreateContent(parent: ViewGroup): ParentItemHolder { + val layoutResId = when { + isLayout(TV) -> R.layout.homepage_parent_tv + isLayout(EMULATOR) -> R.layout.homepage_parent_emulator + else -> R.layout.homepage_parent + } + + val inflater = LayoutInflater.from(parent.context) + val binding = try { + HomepageParentBinding.bind(inflater.inflate(layoutResId, parent, false)) + } catch (t: Throwable) { + logError(t) + // just in case someone forgot we don't want to crash + HomepageParentBinding.inflate(inflater) + } + + return ParentItemHolder(binding) + } } -class SearchDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].list.name == newList[newItemPosition].list.name - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = - oldList[oldItemPosition] == newList[newItemPosition] -} \ No newline at end of file +@Suppress("DEPRECATION") +inline fun Bundle.getSafeParcelable(key: String): T? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key) + else getParcelable(key, T::class.java) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 94a1a5264..959806e56 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -1,658 +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.FrameLayout +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.ChipDrawable -import com.lagradost.cloudstream3.APIHolder.getId -import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity +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.SettingsFragment.Companion.isTvSettings +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView -import com.lagradost.cloudstream3.utils.UIHelper.setImage -import kotlinx.android.synthetic.main.activity_main.view.* -import kotlinx.android.synthetic.main.fragment_home_head.view.* -import kotlinx.android.synthetic.main.fragment_home_head.view.home_bookmarked_child_recyclerview -import kotlinx.android.synthetic.main.fragment_home_head.view.home_watch_parent_item_title -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_bookmarked_holder -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_none_padding -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_plan_to_watch_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview_viewpager -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_completed_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_dropped_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_on_hold_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_type_watching_btt -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_watch_child_recyclerview -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_watch_holder -import kotlinx.android.synthetic.main.toast.view.* +import com.lagradost.cloudstream3.utils.UIHelper.populateChips +import androidx.core.graphics.toColorInt +import com.lagradost.cloudstream3.ui.setRecycledViewPool class HomeParentItemAdapterPreview( - items: MutableList, - val clickCallback: (SearchClickCallback) -> Unit, - private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, - expandCallback: ((String) -> Unit)? = null, - private val loadCallback: (LoadClickCallback) -> Unit, - private val loadMoreCallback: (() -> Unit), - private val changeHomePageCallback: ((View) -> Unit), - private val reloadStored: (() -> Unit), - private val loadStoredData: ((Set) -> Unit), - private val searchQueryCallback: ((Pair) -> Unit) -) : ParentItemAdapter(items, clickCallback, moreInfoClickCallback, expandCallback) { - private var previewData: Resource>> = Resource.Loading() - private var resumeWatchingData: List = listOf() - private var bookmarkData: Pair> = - false to listOf() - private var apiName: String = "NONE" + 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) - val headItems = 1 + if (binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR)) { + binding.homeBookmarkParentItemMoreInfo.isVisible = true - private var availableWatchStatusTypes: Pair, Set> = - setOf() to setOf() + val marginInDp = 50 + val density = binding.horizontalScrollChips.context.resources.displayMetrics.density + val marginInPixels = (marginInDp * density).toInt() - fun setAvailableWatchStatusTypes(data: Pair, Set>) { - availableWatchStatusTypes = data - holder?.setAvailableWatchStatusTypes(data) - } - - companion object { - private const val VIEW_TYPE_HEADER = 2 - private const val VIEW_TYPE_ITEM = 1 - } - - fun setResumeWatchingData(resumeWatching: List) { - resumeWatchingData = resumeWatching - holder?.updateResume(resumeWatchingData) - } - - fun setPreviewData(preview: Resource>>) { - previewData = preview - holder?.updatePreview(preview) - } - - fun setApiName(name: String) { - apiName = name - holder?.updateApiName(name) - } - - fun setBookmarkData(data: Pair>) { - bookmarkData = data - holder?.updateBookmarks(data) - } - - override fun getItemViewType(position: Int) = when (position) { - 0 -> VIEW_TYPE_HEADER - else -> VIEW_TYPE_ITEM - } - - var holder: HeaderViewHolder? = null - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is HeaderViewHolder -> { - holder.updatePreview(previewData) - holder.updateResume(resumeWatchingData) - holder.updateBookmarks(bookmarkData) - holder.setAvailableWatchStatusTypes(availableWatchStatusTypes) - holder.updateApiName(apiName) - } - else -> super.onBindViewHolder(holder, position - 1) - } - } - - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - println("onCreateViewHolder $viewType") - return when (viewType) { - VIEW_TYPE_HEADER -> HeaderViewHolder( - LayoutInflater.from(parent.context).inflate( - if (isTvSettings()) R.layout.fragment_home_head_tv else R.layout.fragment_home_head, - parent, - false + 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 ), - loadCallback, - loadMoreCallback, - changeHomePageCallback, - clickCallback, - reloadStored, - loadStoredData, - searchQueryCallback, - moreInfoClickCallback - ).also { - this.holder = it - } - VIEW_TYPE_ITEM -> super.onCreateViewHolder(parent, viewType) - else -> error("Unhandled viewType=$viewType") + null + ) } + + return HeaderViewHolder(binding, viewModel, accountViewModel) } - override fun getItemCount(): Int { - return super.getItemCount() + headItems + override fun onBindHeader(holder: ViewHolderState) { + (holder as? HeaderViewHolder)?.bind() } - override fun getItemId(position: Int): Long { - if (position == 0) return previewData.hashCode().toLong() - return super.getItemId(position - headItems) - } - - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { + override fun onViewDetachedFromWindow(holder: ViewHolderState) { when (holder) { is HeaderViewHolder -> { holder.onViewDetachedFromWindow() } - else -> super.onViewDetachedFromWindow(holder) } } - override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) { + override fun onViewAttachedToWindow(holder: ViewHolderState) { when (holder) { is HeaderViewHolder -> { holder.onViewAttachedToWindow() } - else -> super.onViewAttachedToWindow(holder) } } + private class HeaderViewHolder( + val binding: ViewBinding, + val viewModel: HomeViewModel, + accountViewModel: AccountViewModel, + ) : + ViewHolderState(binding) { - class HeaderViewHolder - constructor( - itemView: View, - private val clickCallback: ((LoadClickCallback) -> Unit)?, - private val loadMoreCallback: (() -> Unit), - private val changeHomePageCallback: ((View) -> Unit), - private val searchClickCallback: (SearchClickCallback) -> Unit, - private val reloadStored: () -> Unit, - private val loadStoredData: ((Set) -> Unit), - private val searchQueryCallback: ((Pair) -> Unit), - private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit - ) : RecyclerView.ViewHolder(itemView) { - private var previewAdapter: HomeScrollAdapter? = null - private val previewViewpager: ViewPager2? = itemView.home_preview_viewpager - private val previewHeader: FrameLayout? = itemView.home_preview - private val previewCallback: ViewPager2.OnPageChangeCallback = - object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - // home_search?.isIconified = true - //home_search?.isVisible = true - //home_search?.clearFocus() + override fun save(): Bundle = + Bundle().apply { + putParcelable( + "resumeRecyclerView", + resumeRecyclerView.layoutManager?.onSaveInstanceState() + ) + putParcelable( + "bookmarkRecyclerView", + bookmarkRecyclerView.layoutManager?.onSaveInstanceState() + ) + //putInt("previewViewpager", previewViewpager.currentItem) + } - previewAdapter?.apply { - if (position >= itemCount - 1 && hasMoreItems) { - hasMoreItems = false // dont make two requests - loadMoreCallback() - //homeViewModel.loadMoreHomeScrollResponses() + 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() + } } } - previewAdapter?.getItem(position) - ?.apply { - //itemView.home_preview_title_holder?.let { parent -> - // TransitionManager.beginDelayedTransition( - // parent, - // ChangeBounds() - // ) - //} - itemView.home_preview_description?.isGone = - this.plot.isNullOrBlank() - itemView.home_preview_description?.text = - this.plot ?: "" - itemView.home_preview_text?.text = this.name - itemView.home_preview_tags?.apply { - removeAllViews() - tags?.forEach { tag -> - val chip = Chip(context) - val chipDrawable = - ChipDrawable.createFromAttributes( - context, - null, - 0, - R.style.ChipFilledSemiTransparent - ) - chip.setChipDrawable(chipDrawable) - chip.text = tag - chip.isChecked = false - chip.isCheckable = false - chip.isFocusable = false - chip.isClickable = false - addView(chip) - } - } - itemView.home_preview_tags?.isGone = - tags.isNullOrEmpty() - itemView.home_preview_image?.setImage( - posterUrl, - posterHeaders + } + }) + 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 ) - // itemView.home_preview_title?.text = name + ) + } - itemView.home_preview_play?.setOnClickListener { view -> - clickCallback?.invoke( - LoadClickCallback( - START_ACTION_RESUME_LATEST, - view, - position, - this - ) - ) - } - itemView.home_preview_info?.setOnClickListener { view -> - clickCallback?.invoke( - LoadClickCallback(0, view, position, this) - ) - } + 1 -> { // info + viewModel.click( + SearchClickCallback( + SEARCH_ACTION_LOAD, + callback.view, + -1, + callback.card + ) + ) + } - itemView.home_preview_play_btt?.setOnClickListener { view -> - clickCallback?.invoke( - LoadClickCallback( - START_ACTION_RESUME_LATEST, - view, - position, - this - ) - ) - } + 2 -> { // remove + DataStoreHelper.setResultWatchState( + callback.card.id, + WatchType.NONE.internalId + ) + viewModel.reloadStored() + } + } + } + */ + } - // This makes the hidden next buttons only available when on the info button - // Otherwise you might be able to go to the next item without being at the info button - itemView.home_preview_info_btt?.setOnFocusChangeListener { _, hasFocus -> - itemView.home_preview_hidden_next_focus?.isFocusable = hasFocus - } - itemView.home_preview_play_btt?.setOnFocusChangeListener { _, hasFocus -> - itemView.home_preview_hidden_prev_focus?.isFocusable = hasFocus - } + 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 + ) - itemView.home_preview_info_btt?.setOnClickListener { view -> - clickCallback?.invoke( - LoadClickCallback(0, view, position, this) - ) - } + bindLogo( + url = item.logoUrl, + headers = item.posterHeaders, + titleView = homePreviewText, + logoView = homeBackgroundPosterWatermarkBadgeHolder + ) - itemView.home_preview_hidden_next_focus?.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - previewViewpager?.apply { - setCurrentItem(currentItem + 1, true) - } - itemView.home_preview_info_btt?.requestFocus() - } - } + homePreviewTags.isGone = + item.tags.isNullOrEmpty() - itemView.home_preview_hidden_prev_focus?.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - previewViewpager?.apply { - if (currentItem <= 0) { - nav_rail_view?.menu?.getItem(0)?.actionView?.requestFocus() - } else { - setCurrentItem(currentItem - 1, true) - itemView.home_preview_play_btt?.requestFocus() - } - } - } - } - // very ugly code, but I dont care - val watchType = - DataStoreHelper.getResultWatchState(this.getId()) - itemView.home_preview_bookmark?.setText(watchType.stringRes) - itemView.home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds( + 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( - itemView.home_preview_bookmark.context, - watchType.iconRes + homePreviewBookmark.context, + newValue.iconRes ), null, null ) - itemView.home_preview_bookmark?.setOnClickListener { fab -> - fab.context.getActivity()?.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] - itemView.home_preview_bookmark?.setCompoundDrawablesWithIntrinsicBounds( - null, - ContextCompat.getDrawable( - itemView.home_preview_bookmark.context, - newValue.iconRes - ), - null, - null - ) - itemView.home_preview_bookmark?.setText(newValue.stringRes) - - ResultViewModel2.updateWatchStatus( - this, - newValue - ) - reloadStored() - } - } + homePreviewBookmark.setText(newValue.stringRes) } + } } } + } - private var resumeAdapter: HomeChildItemAdapter? = null - private var resumeHolder: View? = itemView.home_watch_holder - private var resumeRecyclerView: RecyclerView? = itemView.home_watch_child_recyclerview - - private var bookmarkHolder: View? = itemView.home_bookmarked_holder - private var bookmarkAdapter: HomeChildItemAdapter? = null - private var bookmarkRecyclerView: RecyclerView? = - itemView.home_bookmarked_child_recyclerview + 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) + previewViewpager.unregisterOnPageChangeCallback(previewCallback) } - fun onViewAttachedToWindow() { - previewViewpager?.registerOnPageChangeCallback(previewCallback) - } - - private val toggleList = listOf( - Pair(itemView.home_type_watching_btt, WatchType.WATCHING), - Pair(itemView.home_type_completed_btt, WatchType.COMPLETED), - Pair(itemView.home_type_dropped_btt, WatchType.DROPPED), - Pair(itemView.home_type_on_hold_btt, WatchType.ONHOLD), - Pair(itemView.home_plan_to_watch_btt, WatchType.PLANTOWATCH), + 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 { - itemView.home_preview_change_api?.setOnClickListener { view -> - changeHomePageCallback(view) - } - itemView.home_preview_change_api2?.setOnClickListener { view -> - changeHomePageCallback(view) - } + previewViewpager.setPageTransformer(HomeScrollTransformer()) - previewViewpager?.apply { - //if (!isTvSettings()) - setPageTransformer(HomeScrollTransformer()) - //else - // setPageTransformer(null) + previewViewpager.adapter = previewAdapter + resumeRecyclerView.adapter = resumeAdapter + bookmarkRecyclerView.setRecycledViewPool(HomeChildItemAdapter.sharedPool) + bookmarkRecyclerView.adapter = bookmarkAdapter - if (adapter == null) - adapter = HomeScrollAdapter( - if (isTvSettings()) R.layout.home_scroll_view_tv else R.layout.home_scroll_view, - if (isTvSettings()) true else null - ) - } - previewAdapter = previewViewpager?.adapter as? HomeScrollAdapter? - // previewViewpager?.registerOnPageChangeCallback(previewCallback) + resumeRecyclerView.setLinearListLayout( + nextLeft = R.id.nav_rail_view, + nextRight = FOCUS_SELF + ) - if (resumeAdapter == null) { - resumeRecyclerView?.adapter = HomeChildItemAdapter( - ArrayList(), - nextFocusUp = itemView.nextFocusUpId, - nextFocusDown = itemView.nextFocusDownId - ) { callback -> - if (callback.action != SEARCH_ACTION_SHOW_METADATA) { - searchClickCallback(callback) - return@HomeChildItemAdapter - } - callback.view.context?.getActivity()?.showOptionSelectStringRes( - callback.view, - callback.card.posterUrl, - listOf( - R.string.action_open_watching, - R.string.action_remove_watching - ), - listOf( - R.string.action_open_play, - R.string.action_open_watching, - R.string.action_remove_watching - ) - ) { (isTv, actionId) -> - when (actionId + if (isTv) 0 else 1) { - // play - 0 -> { - searchClickCallback.invoke( - SearchClickCallback( - START_ACTION_RESUME_LATEST, - callback.view, - -1, - callback.card - ) - ) - reloadStored() - } - //info - 1 -> { - searchClickCallback( - SearchClickCallback( - SEARCH_ACTION_LOAD, - callback.view, - -1, - callback.card - ) - ) + bookmarkRecyclerView.setLinearListLayout( + nextLeft = R.id.nav_rail_view, + nextRight = FOCUS_SELF + ) - reloadStored() - } - // remove - 2 -> { - val card = callback.card - if (card is DataStoreHelper.ResumeWatchingResult) { - DataStoreHelper.removeLastWatched(card.parentId) - reloadStored() - } - } - } - } - } - } - resumeAdapter = resumeRecyclerView?.adapter as? HomeChildItemAdapter - if (bookmarkAdapter == null) { - bookmarkRecyclerView?.adapter = HomeChildItemAdapter( - ArrayList(), - nextFocusUp = itemView.nextFocusUpId, - nextFocusDown = itemView.nextFocusDownId - ) { callback -> - if (callback.action != SEARCH_ACTION_SHOW_METADATA) { - searchClickCallback(callback) - return@HomeChildItemAdapter - } - 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 -> { - searchClickCallback.invoke( - SearchClickCallback( - START_ACTION_RESUME_LATEST, - callback.view, - -1, - callback.card - ) - ) - reloadStored() - } - 1 -> { // info - searchClickCallback( - SearchClickCallback( - SEARCH_ACTION_LOAD, - callback.view, - -1, - callback.card - ) - ) - - reloadStored() - } - 2 -> { // remove - DataStoreHelper.setResultWatchState( - callback.card.id, - WatchType.NONE.internalId - ) - reloadStored() - } - } - } - } - } - bookmarkAdapter = bookmarkRecyclerView?.adapter as? HomeChildItemAdapter + fixPaddingStatusbarMargin(topPadding) for ((chip, watch) in toggleList) { - chip?.isChecked = false - chip?.setOnCheckedChangeListener { _, isChecked -> + chip.isChecked = false + chip.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { - 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() - ) + viewModel.loadStoredData(setOf(watch)) } // Else if all are unchecked -> Do not load data - else if (toggleList.all { it.first?.isChecked != true }) { - loadStoredData(emptySet()) + else if (toggleList.all { !it.first.isChecked }) { + viewModel.loadStoredData(emptySet()) } } } - itemView.home_search?.context?.fixPaddingStatusbar(itemView.home_search) + headProfilePicCard?.isGone = isLayout(TV or EMULATOR) + alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) - itemView.home_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - searchQueryCallback.invoke(false to query) - //QuickSearchFragment.pushSearch(activity, query, currentApiName?.let { arrayOf(it) } - return true + (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) + } } - override fun onQueryTextChange(newText: String): Boolean { - searchQueryCallback.invoke(true to newText) - //searchViewModel.quickSearch(newText) - return true + 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 + } + }) + } } - fun updateApiName(name: String) { - itemView.home_preview_change_api2?.text = name - itemView.home_preview_change_api?.text = name - } - - fun updatePreview(preview: Resource>>) { - itemView.home_preview_change_api2?.isGone = preview is Resource.Success + private fun updatePreview(preview: Resource>>) { if (preview is Resource.Success) { - itemView.home_none_padding?.apply { + homeNonePadding.apply { val params = layoutParams params.height = 0 layoutParams = params } - } else { - itemView.home_none_padding?.context?.fixPaddingStatusbarView(itemView.home_none_padding) - } + } else fixPaddingStatusbarView(homeNonePadding) + when (preview) { is Resource.Success -> { - if (true != previewAdapter?.setItems( + 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() + previewViewpager.setCurrentItem(0, false) + previewViewpager.beginFakeDrag() + previewViewpager.fakeDragBy(1f) + previewViewpager.endFakeDrag() previewCallback.onPageSelected(0) - previewHeader?.isVisible = true + //previewHeader.isVisible = true + }*/ + + previewViewpager.isVisible = true + previewViewpagerText.isVisible = true + alternativeAccountPadding?.isVisible = false + (binding as? FragmentHomeHeadTvBinding)?.apply { + homePreviewInfoBtt.isVisible = true + } + // Explicitly bind the current item to ensure instant loading + val currentPos = previewViewpager.currentItem + val item = preview.value.second.getOrNull(currentPos) + if (item != null) { + onSelect(item, currentPos) } } + else -> { - previewAdapter?.setItems(listOf(), false) - previewViewpager?.setCurrentItem(0, false) - previewHeader?.isVisible = false + previewAdapter.submitList(listOf()) + previewViewpager.setCurrentItem(0, false) + previewViewpager.isVisible = false + previewViewpagerText.isVisible = false + alternativeAccountPadding?.isVisible = true + (binding as? FragmentHomeHeadTvBinding)?.apply { + homePreviewInfoBtt.isVisible = false + } + //previewHeader.isVisible = false } } - // previewViewpager?.postDelayed({ previewViewpager?.scr(100, 0) }, 1000) - //previewViewpager?.postInvalidate() } - fun updateResume(resumeWatching: List) { - resumeHolder?.isVisible = resumeWatching.isNotEmpty() - resumeAdapter?.updateList(resumeWatching) + private fun updateResume(resumeWatching: List) { + resumeHolder.isVisible = resumeWatching.isNotEmpty() + resumeAdapter.submitList(resumeWatching) - if (!isTvSettings()) { - itemView.home_watch_parent_item_title?.setOnClickListener { - moreInfoClickCallback.invoke( + 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( - itemView.home_watch_parent_item_title?.text.toString(), + title.text.toString(), resumeWatching, false ), 1, false - ) + ), + deleteCallback = { + viewModel.deleteResumeWatching() + } ) } } } - fun updateBookmarks(data: Pair>) { - bookmarkHolder?.isVisible = data.first - bookmarkAdapter?.updateList(data.second) - if (!isTvSettings()) { - itemView.home_bookmark_parent_item_title?.setOnClickListener { - val items = toggleList.mapNotNull { it.first }.filter { it.isChecked } + 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() - moreInfoClickCallback.invoke( + viewModel.popup( HomeViewModel.ExpandableHomepageList( HomePageList( textSum, - data.second, + list, false ), 1, false - ) + ), deleteCallback = { + viewModel.deleteBookmarks(list) + } ) } } } - fun setAvailableWatchStatusTypes(availableWatchStatusTypes: Pair, Set>) { - for ((chip, watch) in toggleList) { - chip?.apply { - isVisible = availableWatchStatusTypes.second.contains(watch) - isChecked = availableWatchStatusTypes.first.contains(watch) + 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() } } } } -} \ No newline at end of file +} 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 f296e53d6..e42e774b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt @@ -1,103 +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.annotation.LayoutRes 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.fragment_home_head_tv.* -import kotlinx.android.synthetic.main.fragment_home_head_tv.view.* -import kotlinx.android.synthetic.main.home_scroll_view.view.* - +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( - @LayoutRes val layout: Int = R.layout.home_scroll_view, - private val forceHorizontalPosters: Boolean? = null -) : RecyclerView.Adapter() { - private var items: MutableList = mutableListOf() + 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) + 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 ViewHolderState(binding) } - fun setItems(newItems: List, hasNext: Boolean): Boolean { - val isSame = newItems.firstOrNull()?.url == items.firstOrNull()?.url - hasMoreItems = hasNext + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is HomeScrollViewBinding -> { + clearImage(binding.homeScrollPreview) + } - val diffResult = DiffUtil.calculateDiff( - HomeScrollDiffCallback(this.items, newItems) - ) - - items.clear() - items.addAll(newItems) - - - diffResult.dispatchUpdatesTo(this) - - return isSame - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), - forceHorizontalPosters - ) - } - - 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, - private val forceHorizontalPosters: Boolean? = null - ) : - RecyclerView.ViewHolder(itemView) { + override fun onBindContent( + holder: ViewHolderState, + item: LoadResponse, + position: Int, + ) { + val binding = holder.view - fun bind(card: LoadResponse) { - card.apply { - val isHorizontal = - (forceHorizontalPosters == true) || ((forceHorizontalPosters != false) && itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) + val posterUrl = item.backgroundPosterUrl ?: item.posterUrl - 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 + 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 + ) + } + + 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 edf580083..8d48f5a68 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -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(apis.withLock { 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,7 @@ 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 @@ -136,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) } @@ -152,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) } @@ -255,25 +316,26 @@ 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.Success(emptyMap())) - _preview.postValue(Resource.Failure(false, null, null, "No homepage")) + _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)) { @@ -285,7 +347,13 @@ class HomeViewModel : ViewModel() { val filteredList = context?.filterHomePageListByFilmQuality(list) ?: list expandable[list.name] = - ExpandableHomepageList(filteredList, 1, home.hasNext) + ExpandableHomepageList( + filteredList.copy( + list = CopyOnWriteArrayList( + filteredList.list + ) + ), 1, home.hasNext + ) } } @@ -300,8 +368,7 @@ class HomeViewModel : ViewModel() { val currentList = items.shuffled().filter { it.list.isNotEmpty() } .flatMap { it.list } - .distinctBy { it.url } - .toList() + .distinctBy { it.url }.toList() if (currentList.isNotEmpty()) { val randomItems = @@ -323,8 +390,6 @@ class HomeViewModel : ViewModel() { _preview.postValue( Resource.Failure( false, - null, - null, "No homepage responses" ) ) @@ -337,42 +402,151 @@ class HomeViewModel : ViewModel() { logError(e) } } + 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 index d7c06c4eb..c5f8fa3d9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -1,50 +1,65 @@ package com.lagradost.cloudstream3.ui.library +import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper -import androidx.fragment.app.Fragment -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup +import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS +import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS import android.view.animation.AlphaAnimation +import android.widget.TextView import androidx.annotation.StringRes import androidx.appcompat.widget.SearchView +import androidx.core.view.allViews +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.allProviders -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName +import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment -import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.txt +import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA -import com.lagradost.cloudstream3.utils.AppUtils.loadResult -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult -import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount -import kotlinx.android.synthetic.main.fragment_library.* +import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs const val LIBRARY_FOLDER = "library_folder" enum class LibraryOpenerType(@StringRes val stringRes: Int) { - Default(R.string.default_subtitles), // TODO FIX AFTER MERGE + Default(R.string.action_default), Provider(R.string.none), Browser(R.string.browser), Search(R.string.search), @@ -61,7 +76,9 @@ data class ProviderLibraryData( val apiName: String ) -class LibraryFragment : Fragment() { +class LibraryFragment : BaseFragment( + BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) +) { companion object { fun newInstance() = LibraryFragment() @@ -73,40 +90,64 @@ class LibraryFragment : Fragment() { private val libraryViewModel: LibraryViewModel by activityViewModels() - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_library, container, false) - } + 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) { - viewpager?.currentItem?.let { currentItem -> + binding?.viewpager?.currentItem?.let { currentItem -> outState.putInt(VIEWPAGER_ITEM_KEY, currentItem) } super.onSaveInstanceState(outState) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(search_status_bar_padding) + 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) - sort_fab?.setOnClickListener { - val methods = libraryViewModel.sortingMethods.map { - txt(it.stringRes).asString(view.context) + binding.libraryRandom.isVisible = isPhone && hasItems + binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems + } + + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = !isLayout(PHONE) + ) + } + + @SuppressLint("ResourceType", "CutPasteId") + override fun onBindingCreated( + binding: FragmentLibraryBinding, + savedInstanceState: Bundle? + ) { + binding.sortFab.setOnClickListener(sortChangeClickListener) + binding.librarySort.setOnClickListener(sortChangeClickListener) + + binding.libraryRoot.findViewById(androidx.appcompat.R.id.search_src_text) + ?.apply { + tag = "tv_no_focus_tag" + // Expand the Appbar when search bar is focused, fixing scroll up issue + setOnFocusChangeListener { _, _ -> + binding.searchBar.setExpanded(true) + } } - activity?.showBottomDialog(methods, - libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), - txt(R.string.sort_by).asString(view.context), - false, - {}, - { - val method = libraryViewModel.sortingMethods[it] - libraryViewModel.sort(method) - }) + val searchCallback = Runnable { + val newText = binding.mainSearch.query.toString() + libraryViewModel.sort(ListSorting.Query, newText) } - main_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { libraryViewModel.sort(ListSorting.Query, query) return true @@ -122,18 +163,24 @@ class LibraryFragment : Fragment() { return true } - libraryViewModel.sort(ListSorting.Query, newText) + binding.mainSearch.removeCallbacks(searchCallback) + + // Delay the execution of the search operation by 1 second (adjust as needed) + // this prevents running search when the user is typing + binding.mainSearch.postDelayed(searchCallback, 1000) + return true } }) libraryViewModel.reloadPages(false) - list_selector?.setOnClickListener { + binding.listSelector.setOnClickListener { val items = libraryViewModel.availableApiNames val currentItem = libraryViewModel.currentApiName.value - activity?.showBottomDialog(items, + activity?.showBottomDialog( + items, items.indexOf(currentItem), txt(R.string.select_library).asString(it.context), false, @@ -143,6 +190,17 @@ class LibraryFragment : Fragment() { } } + //Load value for toggling Random button. Hide at startup + context?.let { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) + toggleRandomButton = + settingsManager.getBoolean( + getString(R.string.random_button_key), + false + ) + binding.libraryRandom.visibility = View.GONE + binding.libraryRandomButtonTv.visibility = View.GONE + } /** * Shows a plugin selection dialogue and saves the response @@ -155,8 +213,9 @@ class LibraryFragment : Fragment() { val availableProviders = allProviders.filter { it.supportedSyncNames.contains(syncId) }.map { it.name } + - // Add the api if it exists - (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } ?: emptyList()) + // Add the api if it exists + (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } + ?: emptyList()) val baseOptions = listOf( LibraryOpenerType.Default, @@ -167,7 +226,7 @@ class LibraryFragment : Fragment() { val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders - val savedSelection = getKey(LIBRARY_FOLDER, key) + val savedSelection = getKey("$currentAccount/$LIBRARY_FOLDER", key) val selectedIndex = when { savedSelection == null -> 0 @@ -202,108 +261,83 @@ class LibraryFragment : Fragment() { } setKey( - LIBRARY_FOLDER, + "$currentAccount/$LIBRARY_FOLDER", key, savedData, ) } } - provider_selector?.setOnClickListener { + binding.providerSelector.setOnClickListener { val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener activity?.showPluginSelectionDialog(syncName.name, syncName) } - viewpager?.setPageTransformer(LibraryScrollTransformer()) - viewpager?.adapter = - viewpager.adapter ?: ViewpagerAdapter(mutableListOf(), { isScrollingDown: Boolean -> + binding.viewpager.setPageTransformer(LibraryScrollTransformer()) + + binding.viewpager.adapter = ViewpagerAdapter( + { isScrollingDown: Boolean -> if (isScrollingDown) { - sort_fab?.shrink() + binding.sortFab.shrink() + binding.libraryRandom.shrink() } else { - sort_fab?.extend() + 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" - }) + // 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 + val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId + val syncName = + libraryViewModel.currentSyncApi?.syncIdName ?: return@callback - when (searchClickCallback.action) { - SEARCH_ACTION_SHOW_METADATA -> { - activity?.showPluginSelectionDialog( + when (searchClickCallback.action) { + SEARCH_ACTION_SHOW_METADATA -> { + (activity as? MainActivity)?.loadPopup( + searchClickCallback.card, + load = false + ) + /*activity?.showPluginSelectionDialog( syncId, syncName, searchClickCallback.card.apiName - ) - } + )*/ + } - SEARCH_ACTION_LOAD -> { - // This basically first selects the individual opener and if that is default then - // selects the whole list opener - val savedListSelection = - getKey(LIBRARY_FOLDER, syncName.name) - val savedSelection = getKey(LIBRARY_FOLDER, syncId).takeIf { - it?.openType != LibraryOpenerType.Default - } ?: savedListSelection - - when (savedSelection?.openType) { - null, LibraryOpenerType.Default -> { - // Prevents opening MAL/AniList as a provider - if (APIHolder.getApiFromNameNull(searchClickCallback.card.apiName) != null) { - activity?.loadSearchResult( - searchClickCallback.card - ) - } else { - // Search when no provider can open - QuickSearchFragment.pushSearch( - activity, - searchClickCallback.card.name - ) - } - } - LibraryOpenerType.None -> {} - LibraryOpenerType.Provider -> - savedSelection.providerData?.apiName?.let { apiName -> - activity?.loadResult( - searchClickCallback.card.url, - apiName, - ) - } - LibraryOpenerType.Browser -> - openBrowser(searchClickCallback.card.url) - LibraryOpenerType.Search -> { - QuickSearchFragment.pushSearch( - activity, - searchClickCallback.card.name - ) - } - } - } + SEARCH_ACTION_LOAD -> { + loadLibraryItem(syncName, syncId, searchClickCallback.card) } } + } - viewpager?.offscreenPageLimit = 2 - viewpager?.reduceDragSensitivity() + binding.apply { + viewpager.offscreenPageLimit = 2 + viewpager.reduceDragSensitivity() + searchBar.setExpanded(true) + } val startLoading = Runnable { - gridview?.numColumns = context?.getSpanCount() ?: 3 - gridview?.adapter = - context?.let { LoadingPosterAdapter(it, 6 * 3) } - library_loading_overlay?.isVisible = true - library_loading_shimmer?.startShimmer() - empty_list_textview?.isVisible = false + 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 { - gridview?.adapter = null - library_loading_overlay?.isVisible = false - library_loading_shimmer?.stopShimmer() + binding.apply { + gridview.adapter = null + libraryLoadingOverlay.isVisible = false + libraryLoadingShimmer.stopShimmer() + } } val handler = Handler(Looper.getMainLooper()) @@ -314,65 +348,121 @@ class LibraryFragment : Fragment() { handler.removeCallbacks(startLoading) val pages = resource.value val showNotice = pages.all { it.items.isEmpty() } - empty_list_textview?.isVisible = showNotice - if (showNotice) { - if (libraryViewModel.availableApiNames.size > 1) { - empty_list_textview?.setText(R.string.empty_library_logged_in_message) - } else { - empty_list_textview?.setText(R.string.empty_library_no_accounts_message) + + 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 + }) } - - (viewpager.adapter as? ViewpagerAdapter)?.pages = pages - // Using notifyItemRangeChanged keeps the animations when sorting - viewpager.adapter?.notifyItemRangeChanged(0, viewpager.adapter?.itemCount ?: 0) - - // 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( - library_tab_layout, - viewpager, - ) { tab, position -> - tab.text = pages.getOrNull(position)?.title?.asStringNull(context) - tab.view.setOnClickListener { - val currentItem = viewpager?.currentItem ?: return@setOnClickListener - val distance = abs(position - currentItem) - hideViewpager(distance) - } - }.attach() } + 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 :( @@ -380,16 +470,102 @@ class LibraryFragment : Fragment() { } } } + + observe(libraryViewModel.currentPage) { position -> + updateRandomVisibility(binding) + val all = binding.viewpager.allViews.toList() + .filterIsInstance() + + all.forEach { view -> + view.isVisible = view.tag == position + view.isFocusable = view.tag == position + + if (view.tag == position) + view.descendantFocusability = FOCUS_AFTER_DESCENDANTS + else + view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS + } + } + } + + private fun loadLibraryItem( + syncName: SyncIdName, + syncId: String, + card: SearchResponse + ) { + // This basically first selects the individual opener and if that is default then + // selects the whole list opener + val savedListSelection = + getKey("$currentAccount/$LIBRARY_FOLDER", syncName.name) + + val savedSelection = getKey( + "$currentAccount/$LIBRARY_FOLDER", + syncId + ).takeIf { + it?.openType != LibraryOpenerType.Default + } ?: savedListSelection + + when (savedSelection?.openType) { + null, LibraryOpenerType.Default -> { + // Prevents opening MAL/AniList as a provider + if (APIHolder.getApiFromNameNull(card.apiName) != null) { + activity?.loadSearchResult( + card + ) + } else { + // Search when no provider can open + QuickSearchFragment.pushSearch( + activity, + card.name + ) + } + } + + LibraryOpenerType.None -> {} + LibraryOpenerType.Provider -> + savedSelection.providerData?.apiName?.let { apiName -> + activity?.loadResult( + card.url, + apiName, + card.name + ) + } + + LibraryOpenerType.Browser -> + openBrowser(card.url) + + LibraryOpenerType.Search -> { + QuickSearchFragment.pushSearch( + activity, + card.name + ) + } + } + } override fun onConfigurationChanged(newConfig: Configuration) { - (viewpager.adapter as? ViewpagerAdapter)?.rebind() super.onConfigurationChanged(newConfig) + val adapter = binding?.viewpager?.adapter ?: return + adapter.notifyItemRangeChanged(0, adapter.itemCount) + } + + private val sortChangeClickListener = View.OnClickListener { view -> + val methods = libraryViewModel.sortingMethods.map { + txt(it.stringRes).asString(view.context) + } + + activity?.showBottomDialog( + methods, + libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), + txt(R.string.sort_by).asString(view.context), + false, + {}, + { + val method = libraryViewModel.sortingMethods[it] + libraryViewModel.sort(method) + }) } } -class MenuSearchView(context: Context) : SearchView(context) { - override fun onActionViewCollapsed() { - super.onActionViewCollapsed() - } -} \ No newline at end of file +class MenuSearchView(context: Context) : SearchView(context) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt index 8aafbdd6f..c3cee1835 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt @@ -2,13 +2,13 @@ package com.lagradost.cloudstream3.ui.library import android.view.View import androidx.viewpager2.widget.ViewPager2 -import kotlinx.android.synthetic.main.library_viewpager_page.view.* +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.page_recyclerview.setPadding( + 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 index 5f64880c7..38f7fcf9d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt @@ -4,14 +4,17 @@ import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +import com.lagradost.cloudstream3.mvvm.throwAbleToResource +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import kotlinx.coroutines.delay +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount enum class ListSorting(@StringRes val stringRes: Int) { Query(R.string.none), @@ -21,11 +24,20 @@ enum class ListSorting(@StringRes val stringRes: Int) { UpdatedOld(R.string.sort_updated_old), AlphabeticalA(R.string.sort_alphabetical_a), AlphabeticalZ(R.string.sort_alphabetical_z), + ReleaseDateNew(R.string.sort_release_date_new), + ReleaseDateOld(R.string.sort_release_date_old), } const val LAST_SYNC_API_KEY = "last_sync_api" class LibraryViewModel : ViewModel() { + fun switchPage(page: Int) { + _currentPage.postValue(page) + } + + private val _currentPage: MutableLiveData = MutableLiveData(0) + val currentPage: LiveData = _currentPage + private val _pages: MutableLiveData>> = MutableLiveData(null) val pages: LiveData>> = _pages @@ -33,15 +45,15 @@ class LibraryViewModel : ViewModel() { val currentApiName: LiveData = _currentApiName private val availableSyncApis - get() = SyncApis.filter { it.hasAccount() } + get() = AccountManager.syncApis.filter { it.isAvailable } var currentSyncApi = availableSyncApis.let { allApis -> - val lastSelection = getKey(LAST_SYNC_API_KEY) + val lastSelection = getKey("$currentAccount/$LAST_SYNC_API_KEY") availableSyncApis.firstOrNull { it.name == lastSelection } ?: allApis.firstOrNull() } private set(value) { field = value - setKey(LAST_SYNC_API_KEY, field?.name) + setKey("$currentAccount/$LAST_SYNC_API_KEY", field?.name) } val availableApiNames: List @@ -59,13 +71,21 @@ class LibraryViewModel : ViewModel() { reloadPages(true) } - fun sort(method: ListSorting, query: String? = null) { - val currentList = pages.value ?: return + fun sort(method: ListSorting, query: String? = null) = ioSafe { + val value = _pages.value ?: return@ioSafe + if (value is Resource.Success) { + sort(method, query, value.value) + } + } + + private fun sort(method: ListSorting, query: String? = null, items: List) { currentSortingMethod = method - (currentList as? Resource.Success)?.value?.forEachIndexed { _, page -> + DataStoreHelper.librarySortingMode = method.ordinal + + items.forEach { page -> page.sort(method, query) } - _pages.postValue(currentList) + _pages.postValue(Resource.Success(items)) } fun reloadPages(forceReload: Boolean) { @@ -78,16 +98,19 @@ class LibraryViewModel : ViewModel() { currentSyncApi?.let { repo -> _currentApiName.postValue(repo.name) _pages.postValue(Resource.Loading()) - val libraryResource = repo.getPersonalLibrary() - if (libraryResource is Resource.Failure) { - _pages.postValue(libraryResource) + val libraryResource = repo.library() + val err = libraryResource.exceptionOrNull() + if (err != null) { + _pages.postValue(throwAbleToResource(err)) + return@let + } + val library = libraryResource.getOrNull() + if (library == null) { + _pages.postValue(Resource.Failure(false, "Unable to fetch library")) return@let } - val library = (libraryResource as? Resource.Success)?.value ?: return@let sortingMethods = library.supportedListSorting.toList() - currentSortingMethod = null - repo.requireLibraryRefresh = false val pages = library.allLibraryLists.map { @@ -97,8 +120,27 @@ class LibraryViewModel : ViewModel() { ) } - _pages.postValue(Resource.Success(pages)) + val desiredSortingMethod = + ListSorting.entries.getOrNull(DataStoreHelper.librarySortingMode) + if (desiredSortingMethod != null && library.supportedListSorting.contains( + desiredSortingMethod + ) + ) { + sort(desiredSortingMethod, null, pages) + } else { + // null query = no sorting + sort(ListSorting.Query, null, pages) + } } } } -} \ No newline at end of file + + init { + MainActivity.reloadLibraryEvent += ::reloadPages + } + + override fun onCleared() { + MainActivity.reloadLibraryEvent -= ::reloadPages + super.onCleared() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt index a637133b5..160fbe2be 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt @@ -5,15 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.ListPopupWindow.MATCH_PARENT -import android.widget.RelativeLayout import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.loading_poster_dynamic.view.* -import kotlin.math.roundToInt -import kotlin.math.sqrt class LoadingPosterAdapter(context: Context, private val itemCount: Int) : BaseAdapter() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt index 2435f8bed..066cf468d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt @@ -1,130 +1,81 @@ package com.lagradost.cloudstream3.ui.library -import android.content.res.ColorStateList -import android.graphics.Color import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.ImageView -import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils import androidx.core.view.isVisible -import androidx.recyclerview.widget.RecyclerView -import com.lagradost.cloudstream3.AcraApplication -import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.AutofitRecyclerView +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder -import com.lagradost.cloudstream3.utils.AppUtils -import com.lagradost.cloudstream3.utils.UIHelper.toPx -import kotlinx.android.synthetic.main.search_result_grid_expanded.view.* import kotlin.math.roundToInt - class PageAdapter( - override val items: MutableList, private val resView: AutofitRecyclerView, val clickCallback: (SearchClickCallback) -> Unit ) : - AppUtils.DiffAdapter(items) { + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + if (a.id != null || b.id != null) { + a.id == b.id + } else { + a.name == b.name && a.url == b.url + } + })) { + private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return LibraryItemViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.search_result_grid_expanded, parent, false) + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchResultGridExpandedBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is LibraryItemViewHolder -> { - holder.bind(items[position], position) + override fun onClearView(holder: ViewHolderState) { + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> { + clearImage(binding.imageView) } } } - private fun isDark(color: Int): Boolean { - return ColorUtils.calculateLuminance(color) < 0.5 - } + override fun onBindContent( + holder: ViewHolderState, + item: SyncAPI.LibraryItem, + position: Int + ) { + val binding = holder.view as? SearchResultGridExpandedBinding ?: return - fun getDifferentColor(color: Int, ratio: Float = 0.7f): Int { - return if (isDark(color)) { - ColorUtils.blendARGB(color, Color.WHITE, ratio) - } else { - ColorUtils.blendARGB(color, Color.BLACK, ratio) + /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ + SearchResultBuilder.bind( + this@PageAdapter.clickCallback, + item, + position, + holder.itemView, + ) + + // See searchAdaptor for this, it basically fixes the height + val params = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + coverHeight + ) + if (params.height != binding.imageView.layoutParams.height || params.width != binding.imageView.layoutParams.width) { + binding.imageView.layoutParams = params } - } - inner class LibraryItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val cardView: ImageView = itemView.imageView - - private val compactView = false//itemView.context.getGridIsCompact() - private val coverHeight: Int = - if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() - - fun bind(item: SyncAPI.LibraryItem, position: Int) { - /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ - - SearchResultBuilder.bind( - this@PageAdapter.clickCallback, - item, - position, - itemView, - colorCallback = { palette -> - AcraApplication.context?.let { ctx -> - val defColor = ContextCompat.getColor(ctx, R.color.ratingColorBg) - var bg = palette.getDarkVibrantColor(defColor) - if (bg == defColor) { - bg = palette.getDarkMutedColor(defColor) - } - if (bg == defColor) { - bg = palette.getVibrantColor(defColor) - } - - val fg = - getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor)) - itemView.text_rating.apply { - setTextColor(ColorStateList.valueOf(fg)) - } - itemView.text_rating_holder?.backgroundTintList = ColorStateList.valueOf(bg) - itemView.watchProgress?.apply { - progressTintList = ColorStateList.valueOf(fg) - progressBackgroundTintList = ColorStateList.valueOf(bg) - } - } - } - ) - - // See searchAdaptor for this, it basically fixes the height - if (!compactView) { - cardView.apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - } - } - - val showProgress = item.episodesCompleted != null && item.episodesTotal != null - itemView.watchProgress.isVisible = showProgress - if (showProgress) { - itemView.watchProgress.max = item.episodesTotal!! - itemView.watchProgress.progress = item.episodesCompleted!! - } - - itemView.imageText.text = item.name - - val showRating = (item.personalRating ?: 0) != 0 - itemView.text_rating_holder.isVisible = showRating - if (showRating) { - // We want to show 8.5 but not 8.0 hence the replace - val rating = ((item.personalRating ?: 0).toDouble() / 10).toString() - .replace(".0", "") - - itemView.text_rating.text = "★ $rating" - } + val showProgress = item.episodesCompleted?.let{ it>0 } ?: false && item.episodesTotal != null + binding.watchProgress.isVisible = showProgress + if (showProgress) { + binding.watchProgress.max = item.episodesTotal + binding.watchProgress.progress = item.episodesCompleted } + + binding.imageText.text = item.name } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt index 33a40386d..68b6eb273 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt @@ -1,90 +1,125 @@ package com.lagradost.cloudstream3.ui.library import android.os.Build +import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup import androidx.core.view.doOnAttach -import androidx.recyclerview.widget.RecyclerView +import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView.OnFlingListener +import com.google.android.material.appbar.AppBarLayout import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.databinding.LibraryViewpagerPageBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.BaseAdapter +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.home.getSafeParcelable import com.lagradost.cloudstream3.ui.search.SearchClickCallback +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount -import kotlinx.android.synthetic.main.library_viewpager_page.view.* + +class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) : + ViewHolderState(binding) { + override fun save(): Bundle = + Bundle().apply { + putParcelable( + "pageRecyclerview", + binding.pageRecyclerview.layoutManager?.onSaveInstanceState() + ) + } + + override fun restore(state: Bundle) { + state.getSafeParcelable("pageRecyclerview")?.let { recycle -> + binding.pageRecyclerview.layoutManager?.onRestoreInstanceState(recycle) + } + } +} class ViewpagerAdapter( - var pages: List, val scrollCallback: (isScrollingDown: Boolean) -> Unit, val clickCallback: (SearchClickCallback) -> Unit -) : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return PageViewHolder( - LayoutInflater.from(parent.context) - .inflate(R.layout.library_viewpager_page, parent, false) +) : BaseAdapter( + id = "ViewpagerAdapter".hashCode(), + diffCallback = BaseDiffCallback( + itemSame = { a, b -> + a.title == b.title + }, + contentSame = { a, b -> + a.items == b.items && a.title == b.title + } + )) { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewpagerAdapterViewHolderState( + LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PageViewHolder -> { - holder.bind(pages[position], unbound.remove(position)) - } - } + override fun onUpdateContent( + holder: ViewHolderState, + item: SyncAPI.Page, + position: Int + ) { + val binding = holder.view + if (binding !is LibraryViewpagerPageBinding) return + (binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items) + binding.pageRecyclerview.scrollToPosition(0) } - private val unbound = mutableSetOf() - /** - * Used to mark all pages for re-binding and forces all items to be refreshed - * Without this the pages will still use the same adapters - **/ - fun rebind() { - unbound.addAll(0..pages.size) - this.notifyItemRangeChanged(0, pages.size) - } + override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { + val binding = holder.view + if (binding !is LibraryViewpagerPageBinding) return - inner class PageViewHolder(private val itemViewTest: View) : - RecyclerView.ViewHolder(itemViewTest) { - fun bind(page: SyncAPI.Page, rebind: Boolean) { - itemView.page_recyclerview?.spanCount = - this@PageViewHolder.itemView.context.getSpanCount() ?: 3 - - if (itemViewTest.page_recyclerview?.adapter == null || rebind) { + 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 - itemViewTest.page_recyclerview?.doOnAttach { - itemViewTest.page_recyclerview?.adapter = PageAdapter( - page.items.toMutableList(), - itemViewTest.page_recyclerview, + doOnAttach { + adapter = PageAdapter( + this, clickCallback - ) + ).apply { + submitList(item.items) + } } } else { - (itemViewTest.page_recyclerview?.adapter as? PageAdapter)?.updateList(page.items) - itemViewTest.page_recyclerview?.scrollToPosition(0) + (adapter as? PageAdapter)?.submitList(item.items) + // scrollToPosition(0) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - itemViewTest.page_recyclerview.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY -> + 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 { - itemViewTest.page_recyclerview.onFlingListener = object : OnFlingListener() { + onFlingListener = object : OnFlingListener() { override fun onFling(velocityX: Int, velocityY: Int): Boolean { scrollCallback.invoke(velocityY > 0) return false } } } - } } - - override fun getItemCount(): Int { - return pages.size - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 21047db3e..e5a460b9a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -1,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() - } - - open fun prevEpisode() { - throw NotImplementedError() - } - - open fun playerPositionChanged(posDur: Pair) { - throw NotImplementedError() - } - - open fun playerDimensionsLoaded(widthHeight: Pair) { - throw NotImplementedError() - } - - open fun subtitlesChanged() { - throw NotImplementedError() - } - - open fun embeddedSubtitlesFetched(subtitles: List) { - throw NotImplementedError() - } - - open fun onTracksInfoChanged() { - throw NotImplementedError() - } - - open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { - - } - - open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { - - } - - open fun exitedPipMode() { - throw NotImplementedError() - } - - private fun keepScreenOn(on: Boolean) { - if (on) { - activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } - - private fun updateIsPlaying(playing: Pair) { - val (wasPlaying, isPlaying) = playing - val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying - val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying - - keepScreenOn(!isPausedRightNow) - - isBuffering = CSPlayerLoading.IsBuffering == isPlaying - if (isBuffering) { - 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) - } + var player: IPlayer + get() = playerHostView?.player ?: _player + set(value) { + _player = value + playerHostView?.player = value } - canEnterPipMode = isPlayingRightNow && hasPipModeSupport - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInPIPMode) { - activity?.let { act -> - PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow) - } - } - } + val subView: SubtitleView? get() = playerHostView?.subView + val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay - 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) - } - } + /** 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 hasNextMirror(): Boolean { + var currentPlayerStatus: CSPlayerLoading + get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering + set(value) { playerHostView?.currentPlayerStatus = value } + + protected var mMediaSession: MediaSession? + get() = playerHostView?.mMediaSession + set(value) { playerHostView?.mMediaSession = value } + + // No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as + // open so subclasses can override only what they need. The ones below throw + // to make it obvious when an implementation is missing. + + override fun nextEpisode() { throw NotImplementedError() } - open fun nextMirror() { + override fun prevEpisode() { throw NotImplementedError() } - private fun requestAudioFocus() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity?.requestLocalAudioFocus(AppUtils.getFocusRequest()) - } + override fun playerPositionChanged(position: Long, duration: Long) { + throw NotImplementedError() } - 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() - } - } + override fun playerDimensionsLoaded(width: Int, height: Int) { + throw NotImplementedError() + } + override fun subtitlesChanged() { + throw NotImplementedError() + } + + override fun embeddedSubtitlesFetched(subtitles: List) { + throw NotImplementedError() + } + + override fun onTracksInfoChanged() { + throw NotImplementedError() + } + + override fun exitedPipMode() { + throw NotImplementedError() + } + + override fun hasNextMirror(): Boolean { + throw NotImplementedError() + } + + override fun nextMirror() { + throw NotImplementedError() + } + + /** Delegates to [PlayerView.playerError] by default; override to customize. */ + override fun playerError(exception: Throwable) { + playerHostView?.playerError(exception) + } + + /** Player fragments don't need system-bar padding adjustment by default. */ + override fun fixLayout(view: View) = Unit + + override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { val ctx = context ?: return - 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() } -} \ No newline at end of file + + open fun resize(resize: PlayerResize, showToast: Boolean) { + playerHostView?.resize(resize, showToast) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 4772a7f13..d7e10c814 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1,62 +1,162 @@ +@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_DRM_UUID +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount +import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList -import com.lagradost.cloudstream3.utils.ExtractorUri -import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName +import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import kotlinx.coroutines.delay +import okhttp3.Interceptor +import org.chromium.net.CronetEngine import java.io.File +import java.security.SecureRandom +import java.util.UUID +import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession +import kotlin.uuid.toJavaUuid const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" -/** 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 +172,26 @@ class CS3IPlayer : IPlayer { private val subtitleHelper = PlayerSubtitleHelper() + /** If we want to play the audio only in the background when the app is not open */ + private var isAudioOnlyBackground = false + /** * This is a way to combine the MediaItem and its duration for the concatenating MediaSource. * @param durationUs does not matter if only one slice is present, since it will not concatenate * */ data class MediaItemSlice( val mediaItem: MediaItem, - val durationUs: Long + val durationUs: Long, + val drm: DrmMetadata? = null + ) + + data class DrmMetadata( + val kid: String? = null, + val key: String? = null, + val uuid: UUID, + val kty: String? = null, + val licenseUrl: String? = null, + val keyRequestParameters: HashMap, ) override fun getDuration(): Long? = exoPlayer?.duration @@ -88,107 +201,73 @@ class CS3IPlayer : IPlayer { /** * Tracks reported to be used by exoplayer, since sometimes it has a mind of it's own when selecting subs. - * String = id + * String = id (without exoplayer track number) * Boolean = if it's active * */ private var playerSelectedSubtitleTracks = listOf>() - - /** isPlaying */ - private var updateIsPlaying: ((Pair) -> Unit)? = null - private var requestAutoFocus: (() -> Unit)? = null - private var playerError: ((Exception) -> Unit)? = null - private var subtitlesUpdates: (() -> Unit)? = null - - /** width x height */ - private var playerDimensionsLoaded: ((Pair) -> Unit)? = null - - /** used for playerPositionChanged */ private var requestedListeningPercentages: List? = null - /** Fired when seeking the player or on requestedListeningPercentages, - * used to make things appear on que - * position, duration */ - private var playerPositionChanged: ((Pair) -> Unit)? = null + private var eventHandler: ((PlayerEvent) -> Unit)? = null - private var nextEpisode: (() -> Unit)? = null - private var prevEpisode: (() -> Unit)? = null + @AnyThread + fun event(event: PlayerEvent) { + // Ensure that all work is done on the main thread. + if (Looper.getMainLooper().isCurrentThread) { + eventHandler?.invoke(event) + } else runOnMainThread { + eventHandler?.invoke(event) + } + } - private var playerUpdated: ((Any?) -> Unit)? = null - private var embeddedSubtitlesFetched: ((List) -> Unit)? = null - private var onTracksInfoChanged: (() -> Unit)? = null - private var onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null - private var onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null + /** + * As initCallbacks and releaseCallbacks must always be done, + * we use this to say that the player is in use. + * */ + @Volatile + var isPlayerActive: Boolean = false override fun releaseCallbacks() { - playerUpdated = null - updateIsPlaying = null - requestAutoFocus = null - playerError = null - playerDimensionsLoaded = null - requestedListeningPercentages = null - playerPositionChanged = null - nextEpisode = null - prevEpisode = null - subtitlesUpdates = null - onTracksInfoChanged = null - onTimestampInvoked = null - requestSubtitleUpdate = null - onTimestampSkipped = null - } - - override fun initCallbacks( - playerUpdated: (Any?) -> Unit, - updateIsPlaying: ((Pair) -> Unit)?, - requestAutoFocus: (() -> Unit)?, - playerError: ((Exception) -> Unit)?, - playerDimensionsLoaded: ((Pair) -> Unit)?, - requestedListeningPercentages: List?, - playerPositionChanged: ((Pair) -> Unit)?, - nextEpisode: (() -> Unit)?, - prevEpisode: (() -> Unit)?, - subtitlesUpdates: (() -> Unit)?, - embeddedSubtitlesFetched: ((List) -> Unit)?, - onTracksInfoChanged: (() -> Unit)?, - onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)?, - onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)?, - ) { - this.playerUpdated = playerUpdated - this.updateIsPlaying = updateIsPlaying - this.requestAutoFocus = requestAutoFocus - this.playerError = playerError - this.playerDimensionsLoaded = playerDimensionsLoaded - this.requestedListeningPercentages = requestedListeningPercentages - this.playerPositionChanged = playerPositionChanged - this.nextEpisode = nextEpisode - this.prevEpisode = prevEpisode - this.subtitlesUpdates = subtitlesUpdates - this.embeddedSubtitlesFetched = embeddedSubtitlesFetched - this.onTracksInfoChanged = onTracksInfoChanged - this.onTimestampInvoked = onTimestampInvoked - this.onTimestampSkipped = onTimestampSkipped - } - - // I know, this is not a perfect solution, however it works for fixing subs - private fun reloadSubs() { - exoPlayer?.applicationLooper?.let { - try { - Handler(it).post { - try { - seekTime(1L) - } catch (e: Exception) { - logError(e) - } - } - } catch (e: Exception) { - logError(e) - } + eventHandler = null + if (isPlayerActive) { + isPlayerActive = false + activePlayers -= 1 + releaseCronetEngine() } } + @AnyThread + override fun initCallbacks( + @MainThread eventHandler: ((PlayerEvent) -> Unit), + requestedListeningPercentages: List?, + ) { + this.requestedListeningPercentages = requestedListeningPercentages + this.eventHandler = eventHandler + if (!isPlayerActive) { + isPlayerActive = true + activePlayers += 1 + } + } + + fun String.stripTrackId(): String { + return this.replace(Regex("""^\d+:"""), "") + } + fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { subtitleHelper.initSubtitles(subView, subHolder, style) } + override fun getPreview(fraction: Float): Bitmap? { + return imageGenerator.getPreviewImage(fraction) + } + + override fun hasPreview(): Boolean { + // No previews on livestreams because the previews get outdated + if (exoPlayer?.isCurrentMediaItemDynamic == true) { + return false + } + return imageGenerator.hasPreview() + } + override fun loadPlayer( context: Context, sameEpisode: Boolean, @@ -197,7 +276,8 @@ class CS3IPlayer : IPlayer { startPosition: Long?, subtitles: Set, subtitle: SubtitleData?, - autoPlay: Boolean? + autoPlay: Boolean?, + preview: Boolean, ) { Log.i(TAG, "loadPlayer") if (sameEpisode) { @@ -216,11 +296,31 @@ class CS3IPlayer : IPlayer { // release the current exoplayer and cache releasePlayer() + if (link != null) { + // only video support atm + (imageGenerator as? PreviewGenerator)?.let { gen -> + if (preview) { + gen.load(link, sameEpisode) + } else { + gen.clear(sameEpisode) + } + } + loadOnlinePlayer(context, link) } else if (data != null) { + (imageGenerator as? PreviewGenerator)?.let { gen -> + if (preview) { + gen.load(context, data, sameEpisode) + } else { + gen.clear(sameEpisode) + } + } loadOfflinePlayer(context, data) + } else { + throw IllegalArgumentException("Requires link or uri") } + } override fun setActiveSubtitles(subtitles: Set) { @@ -228,7 +328,7 @@ class CS3IPlayer : IPlayer { subtitleHelper.setAllSubtitles(subtitles) } - var currentSubtitles: SubtitleData? = null + private var currentSubtitles: SubtitleData? = null private fun List.getTrack(id: String?): Pair? { if (id == null) return null @@ -241,7 +341,11 @@ class CS3IPlayer : IPlayer { return this.firstNotNullOfOrNull { group -> (0 until group.mediaTrackGroup.length).map { group.getTrackFormat(it) to it - }.firstOrNull { it.first.id == id } + }.firstOrNull { + // The format id system is "trackNumber:trackID" + // The track number is not generated by us so we filter it out + it.first.id?.stripTrackId() == id + } ?.let { group.mediaTrackGroup to it.second } } } @@ -274,44 +378,47 @@ class CS3IPlayer : IPlayer { ?: return } - override fun setPreferredAudioTrack(trackLanguage: String?, id: String?) { + override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { preferredAudioTrackLanguage = trackLanguage - - if (id != null) { - val audioTrack = - exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_AUDIO } - ?.getTrack(id) - - if (audioTrack != null) { - exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters - ?.buildUpon() - ?.setOverrideForType( - TrackSelectionOverride( - audioTrack.first, - audioTrack.second + id?.let { trackId -> + val trackFormatIndex = formatIndex ?: 0 + exoPlayer?.currentTracks?.groups + ?.filter { it.type == TRACK_TYPE_AUDIO } + ?.find { group -> + group.getFormats().any { (format, _) -> + format.id == trackId + } + } + ?.let { group -> + exoPlayer?.trackSelectionParameters + ?.buildUpon() + ?.setOverrideForType( + TrackSelectionOverride( + group.mediaTrackGroup, + trackFormatIndex + ) ) - ) - ?.build() - ?: return - return - } + ?.build() + } + ?.let { newParams -> + exoPlayer?.trackSelectionParameters = newParams + return + } } - + // Fallback to language-based selection exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setPreferredAudioLanguage(trackLanguage) - ?.build() - ?: return + ?.build() ?: return } - /** * Gets all supported formats in a list * */ private fun List.getFormats(): List> { - return this.map { + return this.flatMap { it.getFormats() - }.flatten() + } } private fun Tracks.Group.getFormats(): List> { @@ -322,39 +429,66 @@ class CS3IPlayer : IPlayer { } } - private fun Format.toAudioTrack(): AudioTrack { + private fun Format.toAudioTrack(formatIndex: Int?): AudioTrack { return AudioTrack( this.id, this.label, -// isPlaying, - this.language + this.language, + this.sampleMimeType, + this.channelCount, + formatIndex ?: 0, + ) + } + + private fun Format.toSubtitleTrack(): TextTrack { + return TextTrack( + this.id?.stripTrackId(), + this.label, + this.language, + this.sampleMimeType, ) } private fun Format.toVideoTrack(): VideoTrack { return VideoTrack( - this.id, + this.id?.stripTrackId(), this.label, -// isPlaying, this.language, this.width, - this.height + this.height, + this.sampleMimeType ) } override fun getVideoTracks(): CurrentTracks { - val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() - val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } + val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() + val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } .getFormats() .map { it.first.toVideoTrack() } - val audioTracks = allTracks.filter { it.type == TRACK_TYPE_AUDIO }.getFormats() - .map { it.first.toAudioTrack() } - + var currentAudioTrack: AudioTrack? = null + val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO } + .flatMap { group -> + group.getFormats().map { (format, formatIndex) -> + val audioTrack = format.toAudioTrack(formatIndex) + if (group.isTrackSelected(formatIndex)) { + currentAudioTrack = audioTrack + } + audioTrack + } + } + val textTracks = allTrackGroups.filter { it.type == TRACK_TYPE_TEXT } + .getFormats() + .map { it.first.toSubtitleTrack() } + val currentTextTracks = textTracks.filter { track -> + playerSelectedSubtitleTracks.any { it.second && it.first == track.id } + } return CurrentTracks( exoPlayer?.videoFormat?.toVideoTrack(), - exoPlayer?.audioFormat?.toAudioTrack(), + currentAudioTrack, + currentTextTracks, videoTracks, - audioTracks + audioTracks, + textTracks ) } @@ -364,68 +498,65 @@ class CS3IPlayer : IPlayer { override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { Log.i(TAG, "setPreferredSubtitles init $subtitle") currentSubtitles = subtitle + val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false + // Disable subtitles if null + if (subtitle == null) { + trackSelector.setParameters( + trackSelector.buildUponParameters() + .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) + .clearOverridesOfType(TRACK_TYPE_TEXT) + ) + return false + } + // Handle subtitle based on status + when (subtitleHelper.subtitleStatus(subtitle)) { + SubtitleStatus.REQUIRES_RELOAD -> { + Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") + return true + } - fun getTextTrack(id: String) = - exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_TEXT } - ?.getTrack(id) - - return (exoPlayer?.trackSelector as? DefaultTrackSelector?)?.let { trackSelector -> - if (subtitle == null) { - trackSelector.setParameters( - trackSelector.buildUponParameters() - .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 +567,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 +583,42 @@ class CS3IPlayer : IPlayer { exoPlayer?.let { exo -> playbackPosition = exo.currentPosition - currentWindow = exo.currentWindowIndex + currentWindow = exo.currentMediaItemIndex isPlaying = exo.isPlaying } } private fun releasePlayer(saveTime: Boolean = true) { Log.i(TAG, "releasePlayer") - + eventLooperIndex += 1 if (saveTime) updatedTime() - exoPlayer?.release() - //simpleCache?.release() currentTextRenderer = null + currentSubtitleDecoder = null + + exoPlayer?.apply { + playWhenReady = false + + // This may look weird, however on some TV devices the audio does not stop playing + // so this may fix it? + try { + pause() + } catch (t: Throwable) { + // No documented exception, but just to be extra safe + logError(t) + } + playerListener?.let { + removeListener(it) + playerListener = null + } + stop() + release() + } + //simpleCache?.release() exoPlayer = null + event(PlayerAttachedEvent(null)) //simpleCache = null } @@ -469,23 +626,29 @@ class CS3IPlayer : IPlayer { Log.i(TAG, "onStop") saveData() - exoPlayer?.pause() + if (!isAudioOnlyBackground) { + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + } //releasePlayer() } override fun onPause() { Log.i(TAG, "onPause") saveData() - exoPlayer?.pause() + if (!isAudioOnlyBackground) { + handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + } //releasePlayer() } override fun onResume(context: Context) { + isAudioOnlyBackground = false if (exoPlayer == null) reloadPlayer(context) } override fun release() { + imageGenerator.release() releasePlayer() } @@ -495,55 +658,172 @@ class CS3IPlayer : IPlayer { } companion object { + private const val CRONET_TIMEOUT_MS = 15_000 + + /** + * Single shared engine, to minimize the overhead of maintaining many as: + * 1. Cpu time/Startup time + * 2. Mem consumption/GC + * 3. Disk usage, as we simply use the same folder + * */ + private var cronetEngine: CronetEngine? = null + + /** + * How many active sessions we have. + * + * However in reality it should never go negative or be more than 1, + * but this makes more sense architecturally. + * */ + @Volatile + private var activePlayers = 0 + + /** Unique monotonically increasing id to keep track of the last release call */ + @Volatile + private var cronetReleasedId = 0 + + fun releaseCronetEngine() { + if (cronetEngine == null) return + + // Delayed release, as we do not want to restart it when opening trailers ect + val id = ++cronetReleasedId + val posted = Handler(Looper.getMainLooper()).postDelayed({ + // This might get dropped, but that should be very rare + // and should not affect it. + releaseCronetEngineInstantly(id) + }, 60_000) // 1min timeout before release + + // If not posted, then run instantly + if (!posted) { + releaseCronetEngineInstantly(id) + } + } + + private fun releaseCronetEngineInstantly(id: Int) { + // We should release if and only if this was the last call, and + // there is no active players + if (activePlayers == 0 && id == cronetReleasedId) { + try { + cronetEngine?.shutdown() + } catch (t: Throwable) { + logError(t) + } finally { + Log.d(TAG, "CronetEngine shutdown") + // Even if it fails to shutdown, the GC should take care of it + cronetEngine = null + } + } + } + /** * Setting this variable is permanent across app sessions. **/ var preferredAudioTrackLanguage: String? = null get() { - return field ?: getKey(PREFERRED_AUDIO_LANGUAGE_KEY, field)?.also { + return field ?: getKey( + "$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", + field + )?.also { field = it } } set(value) { - setKey(PREFERRED_AUDIO_LANGUAGE_KEY, value) + setKey("$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", value) field = value } private var simpleCache: SimpleCache? = null - var requestSubtitleUpdate: (() -> Unit)? = null + /// Create a small factory for small things, no cache, no cronet + private fun createOnlineSource( + headers: Map?, + interceptor: Interceptor? + ): HttpDataSource.Factory { + val client = if (interceptor == null) { + app.baseClient + } else { + app.baseClient.newBuilder() + .addInterceptor(interceptor) + .build() + } + val source = OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) - private fun createOnlineSource(headers: Map): HttpDataSource.Factory { - val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) - return source.apply { - setDefaultRequestProperties(headers) + if (!headers.isNullOrEmpty()) { + source.setDefaultRequestProperties(headers) + } + return source + } + + fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? { + // Fast case, no need to recreate it + cronetEngine?.let { + return it + } + + // https://gist.github.com/ShivamKumarJha/3c8398b47053ae05112d2a8f8b5de531 + return try { + val cacheDirectory = File(context.cacheDir, "CronetEngine") + cacheDirectory.deleteRecursively() + if (!cacheDirectory.exists()) { + cacheDirectory.mkdirs() + } + CronetEngine.Builder(context) + .enableBrotli(true) + .enableHttp2(true) + .enableQuic(true) + .setStoragePath(cacheDirectory.absolutePath) + .setLibraryLoader(null) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, diskCacheSize) + .build().also { buildEngine -> + Log.d( + TAG, + "Created CronetEngine with cache at ${cacheDirectory.absolutePath}" + ) + cronetEngine = buildEngine + } + } catch (t: Throwable) { + logError(t) + // Something went wrong, so we use the backup okhttp + null } } - private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { - val provider = getApiFromNameNull(link.source) - val interceptor = provider?.getVideoInterceptor(link) + private fun createVideoSource( + link: ExtractorLink, + engine: CronetEngine?, + interceptor: Interceptor?, + ): HttpDataSource.Factory { + val userAgent = link.headers.entries.find { + it.key.equals("User-Agent", ignoreCase = true) + }?.value ?: USER_AGENT val source = if (interceptor == null) { - DefaultHttpDataSource.Factory() //TODO USE app.baseClient - .setUserAgent(USER_AGENT) - .setAllowCrossProtocolRedirects(true) //https://stackoverflow.com/questions/69040127/error-code-io-bad-http-status-exoplayer-android + if (engine == null) { + Log.d(TAG, "Using DefaultHttpDataSource for $link") + OkHttpDataSource.Factory(app.baseClient).setUserAgent(userAgent) + } else { + Log.d(TAG, "Using CronetDataSource for $link") + CronetDataSource.Factory(engine, Executors.newSingleThreadExecutor()) + .setUserAgent(userAgent) + .setConnectionTimeoutMs(CRONET_TIMEOUT_MS) + .setReadTimeoutMs(CRONET_TIMEOUT_MS) + .setResetTimeoutOnRedirects(true) + .setHandleSetCookieRequests(true) + } } else { + Log.d(TAG, "Using OkHttpDataSource for $link") val client = app.baseClient.newBuilder() .addInterceptor(interceptor) .build() - OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) + OkHttpDataSource.Factory(client).setUserAgent(userAgent) } - 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 +831,19 @@ class CS3IPlayer : IPlayer { } private fun Context.createOfflineSource(): DataSource.Factory { - return DefaultDataSourceFactory(this, USER_AGENT) + return DefaultDataSource.Factory( + this, + DefaultHttpDataSource.Factory().setUserAgent(USER_AGENT) + ) } - /*private fun getSubSources( - onlineSourceFactory: DataSource.Factory?, - offlineSourceFactory: DataSource.Factory?, - subHelper: PlayerSubtitleHelper, - ): Pair, List> { - val activeSubtitles = ArrayList() - val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url)) - .setMimeType(sub.mimeType) - .setLanguage("_${sub.name}") - .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) - .build() - when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE -> { - if (offlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(offlineSourceFactory) - .createMediaSource(subConfig, C.TIME_UNSET) - } else { - null - } - } - SubtitleOrigin.URL -> { - if (onlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(onlineSourceFactory) - .createMediaSource(subConfig, C.TIME_UNSET) - } else { - null - } - } - SubtitleOrigin.OPEN_SUBTITLES -> { - // TODO - throw NotImplementedError() - } - } - } - println("SUBSRC: ${subSources.size} activeSubtitles : ${activeSubtitles.size} of ${subHelper.getAllSubtitles().size} ") - return Pair(subSources, activeSubtitles) - }*/ - private fun getCache(context: Context, cacheSize: Long): SimpleCache? { return try { val databaseProvider = StandaloneDatabaseProvider(context) SimpleCache( File( context.cacheDir, "exoplayer" - ).also { it.deleteOnExit() }, // Ensures always fresh file + ).also { deleteFileOnExit(it) }, // Ensures always fresh file LeastRecentlyUsedCacheEvictor(cacheSize), databaseProvider ) @@ -628,12 +870,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,162 +879,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 - ) - ) - // Allows any seeking to be +- 0.3s to allow for faster seeking - .setSeekParameters(SeekParameters(300_000, 300_000)) - .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 @@ -808,33 +979,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_DRM_UUID.toJavaUuid() -> { + // Use headers from DrmMetadata for media requests + val client = dataSourceFactory + ?: throw IllegalArgumentException("Must supply onlineSource") + val drmCallback = + LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray()) + val manager = DefaultDrmSessionManager.Builder() + .setPlayClearSamplesWithoutKeys(true) + .setMultiSession(false) + .setKeyRequestParameters(drm.keyRequestParameters) + .setUuidAndExoMediaDrmProvider( + drm.uuid, + FrameworkMediaDrm.DEFAULT_PROVIDER + ) + .build(drmCallback) + + DashMediaSource.Factory(client) + .setDrmSessionManagerProvider { manager } + .createMediaSource(item.mediaItem) + } + + WIDEVINE_DRM_UUID.toJavaUuid(), + PLAYREADY_DRM_UUID.toJavaUuid() -> { + // Use headers from DrmMetadata for media requests + val client = dataSourceFactory + ?: throw IllegalArgumentException("Must supply onlineSource") + val drmCallback = HttpMediaDrmCallback(drm.licenseUrl, client) + val manager = DefaultDrmSessionManager.Builder() + .setPlayClearSamplesWithoutKeys(true) + .setMultiSession(true) + .setKeyRequestParameters(drm.keyRequestParameters) + .setUuidAndExoMediaDrmProvider( + drm.uuid, + FrameworkMediaDrm.DEFAULT_PROVIDER + ) + .build(drmCallback) + + DashMediaSource.Factory(client) + .setDrmSessionManagerProvider { manager } + .createMediaSource(item.mediaItem) + } + + else -> { + Log.e( + TAG, + "DRM Metadata class is not supported: ${drm::class.simpleName}" + ) + null + } + } + } ?: run { + defaultMediaSourceFactory.createMediaSource(item.mediaItem) + } + } else { + try { + val source = ConcatenatingMediaSource2.Builder() + mediaItemSlices.forEach { item -> + source.add( + // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 + ClippingMediaSource( + defaultMediaSourceFactory.createMediaSource(item.mediaItem), + item.durationUs + ) + ) + } + source.build() + } catch (_: IllegalArgumentException) { + @Suppress("DEPRECATION") + val source = + ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only + mediaItemSlices.forEach { item -> + source.addMediaSource( + // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 + ClippingMediaSource( + defaultMediaSourceFactory.createMediaSource(item.mediaItem), + item.durationUs + ) + ) + } + source + } + } + return exoPlayerBuilder.build().apply { + setPlayWhenReady(playWhenReady) + seekTo(currentWindow, playbackPosition) + // Merge video, subtitles and external audio tracks + val allSources = listOf(videoMediaSource) + subSources + audioSources + setMediaSource( + MergingMediaSource(*allSources.toTypedArray()), + playbackPosition + ) + setHandleAudioBecomingNoisy(true) + setPlaybackSpeed(playBackSpeed) + this.addAnalyticsListener(tracksAnalyticsListener) } } @@ -842,12 +1380,13 @@ class CS3IPlayer : IPlayer { context: Context, mediaSlices: List, subSources: List, - cacheFactory: CacheDataSource.Factory? = null + audioSources: List = emptyList(), + onlineSource: HttpDataSource.Factory? = null, ) { Log.i(TAG, "loadExo") val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val maxVideoHeight = settingsManager.getInt( - context.getString(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 ) @@ -866,34 +1405,56 @@ class CS3IPlayer : IPlayer { cacheSize = cacheSize, videoBufferMs = videoBufferMs, playWhenReady = isPlaying, // this keep the current state of the player - cacheFactory = cacheFactory, subtitleOffset = currentSubtitleOffset, - maxVideoHeight = maxVideoHeight + maxVideoHeight = maxVideoHeight, + audioSources = audioSources, + onlineSource = onlineSource, ) - requestSubtitleUpdate = ::reloadSubs - - playerUpdated?.invoke(exoPlayer) + event(PlayerAttachedEvent(exoPlayer)) exoPlayer?.prepare() + // For offline fragmented MP4s, FLAG_MERGE_FRAGMENTED_SIDX builds the SIDX seek map + // incrementally as data is buffered. The initial seek resolves to the nearest merged + // entry (~first fragment, 3 s). On STATE_READY, re-seek to the actual saved position. + // This may only be reproducible on large and fairly long fragmented MP4 files with + // multiple sidx boxes. + if (onlineSource == null && playbackPosition > (exoPlayer?.duration ?: 0L)) { + exoPlayer?.addListener(object : Player.Listener { + private var seekApplied = false + override fun onPlaybackStateChanged(playbackState: Int) { + if (seekApplied || playbackState != Player.STATE_READY) return + seekApplied = true + exoPlayer?.seekTo(currentWindow, playbackPosition) + exoPlayer?.removeListener(this) + } + }) + } + exoPlayer?.let { exo -> - updateIsPlaying?.invoke( - Pair( - CSPlayerLoading.IsBuffering, - CSPlayerLoading.IsBuffering - ) - ) + event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) isPlaying = exo.isPlaying } + + // we want to avoid an empty exoplayer from sending events + // this is because we need PlayerAttachedEvent to be called to render the UI + // but don't really want the rest like Player.STATE_ENDED calling next episode + if (mediaSlices.isEmpty() && subSources.isEmpty()) { + return + } + + LiveHelper.registerPlayer(exoPlayer) + exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { - normalSafeApiCall { + safe { val textTracks = tracks.groups.filter { it.type == TRACK_TYPE_TEXT } playerSelectedSubtitleTracks = textTracks.map { group -> group.getFormats().mapNotNull { (format, _) -> - (format.id ?: return@mapNotNull null) to group.isSelected + (format.id?.stripTrackId() + ?: return@mapNotNull null) to group.isSelected } }.flatten() @@ -908,28 +1469,37 @@ class CS3IPlayer : IPlayer { return@mapNotNull SubtitleData( // Nicer looking displayed names - fromTwoLettersToLanguage(format.language!!) + fromTagToLanguageName(format.language) ?: format.language!!, + format.label ?: "", // See setPreferredTextLanguage - format.id!!, + format.id!!.stripTrackId(), SubtitleOrigin.EMBEDDED_IN_VIDEO, format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP, - emptyMap() + emptyMap(), + format.language, ) } - embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks) - onTracksInfoChanged?.invoke() - subtitlesUpdates?.invoke() + event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks)) + event(TracksChangedEvent()) + event(SubtitlesUpdatedEvent()) } } + // fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. + @Suppress("OVERRIDE_DEPRECATION") override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> - updateIsPlaying?.invoke( - Pair( - if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, - if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + event( + StatusEvent( + wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, + isPlaying = + when (playbackState) { + Player.STATE_ENDED -> CSPlayerLoading.IsEnded + Player.STATE_BUFFERING -> CSPlayerLoading.IsBuffering + else -> if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + } ) ) isPlaying = exo.isPlaying @@ -939,6 +1509,7 @@ class CS3IPlayer : IPlayer { Player.STATE_READY -> { onRenderFirst() } + else -> {} } @@ -948,23 +1519,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 } } @@ -974,12 +1541,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) @@ -992,7 +1585,7 @@ class CS3IPlayer : IPlayer { override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) if (isPlaying) { - requestAutoFocus?.invoke() + event(RequestAudioFocusEvent()) onRenderFirst() } } @@ -1003,99 +1596,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 (hasUsedFirstRender) { // this insures that we only call this once per player load + return + } + Log.i(TAG, "Rendered first frame") + hasUsedFirstRender = true - 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() + 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() } } } @@ -1108,37 +1696,36 @@ class CS3IPlayer : IPlayer { val mediaItem = getMediaItem(MimeTypes.VIDEO_MP4, data.uri) val offlineSourceFactory = context.createOfflineSource() - val onlineSourceFactory = createOnlineSource(emptyMap()) val (subSources, activeSubtitles) = getSubSources( - onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subtitleHelper, + subHelper = subtitleHelper, + interceptor = null, ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources) - } catch (e: Exception) { - Log.e(TAG, "loadOfflinePlayer error", e) - playerError?.invoke(e) + } catch (t: Throwable) { + Log.e(TAG, "loadOfflinePlayer error", t) + event(ErrorEvent(t)) } } private fun getSubSources( - onlineSourceFactory: HttpDataSource.Factory?, offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, + interceptor: Interceptor?, ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url)) + val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) - .setSelectionFlags(SELECTION_FLAG_DEFAULT) + .setSelectionFlags(0) .build() when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE -> { + SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.EMBEDDED_IN_VIDEO -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) SingleSampleMediaSource.Factory(offlineSourceFactory) @@ -1147,45 +1734,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 @@ -1193,57 +1901,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.toJavaUuid(), + kty = link.kty, + licenseUrl = link.licenseUrl, + keyRequestParameters = link.keyRequestParameters, + ) + ) + ) + } + + else -> listOf( // Single sliced list with unset length MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE) ) } - val onlineSourceFactory = createOnlineSource(link) + // For DASH or HLS single streams (non-playlist), prefer the player's default + // live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick + // the live/default position when no explicit start position was provided. + if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) { + playbackPosition = TIME_UNSET + } + + val provider = getApiFromNameNull(link.source) + val interceptor: Interceptor? = provider?.getVideoInterceptor(link) + + val onlineSourceFactory = + createVideoSource( + link = link, + engine = tryCreateEngine(context, simpleCacheSize), + interceptor = interceptor + ) + val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( - onlineSourceFactory = onlineSourceFactory, offlineSourceFactory = offlineSourceFactory, - subtitleHelper + subHelper = subtitleHelper, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly + ) + + // Create audio sources from ExtractorLink's audioTracks + val audioSources = getAudioSources( + audioTracks = link.audioTracks, + interceptor = interceptor, // Backwards compatibility, needs a new api to work properly ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) - if (simpleCache == null) - simpleCache = getCache(context, simpleCacheSize) - - val cacheFactory = CacheDataSource.Factory().apply { - simpleCache?.let { setCache(it) } - setUpstreamDataSourceFactory(onlineSourceFactory) - } - - loadExo(context, mediaItems, subSources, cacheFactory) - } catch (e: Exception) { - Log.e(TAG, "loadOnlinePlayer error", e) - playerError?.invoke(e) + loadExo( + context = context, + mediaSlices = mediaItems, + subSources = subSources, + audioSources = audioSources, + onlineSource = onlineSourceFactory + ) + } catch (t: Throwable) { + Log.e(TAG, "loadOnlinePlayer error", t) + event(ErrorEvent(t)) } } override fun reloadPlayer(context: Context) { Log.i(TAG, "reloadPlayer") - exoPlayer?.release() + releasePlayer(false) currentLink?.let { loadOnlinePlayer(context, it) } ?: currentDownloadedFile?.let { loadOfflinePlayer(context, it) } } -} \ 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 000000000..c26a4f2df --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/* +* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes. +*/ +package com.lagradost.cloudstream3.ui.player + +import android.text.Html +import android.text.Spanned +import android.text.TextUtils +import androidx.annotation.VisibleForTesting +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Format.CueReplacementBehavior +import androidx.media3.common.text.Cue +import androidx.media3.common.text.Cue.AnchorType +import androidx.media3.common.util.Consumer +import androidx.media3.common.util.Log +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.text.CuesWithTiming +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.text.SubtitleParser.OutputOptions +import com.google.common.base.Preconditions.checkNotNull +import com.google.common.collect.ImmutableList +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** A [SubtitleParser] for SubRip. */ +@UnstableApi +class CustomSubripParser : SubtitleParser { + private val textBuilder: StringBuilder = StringBuilder() + private val tags: ArrayList = ArrayList() + private val parsableByteArray: ParsableByteArray = ParsableByteArray() + + override fun getCueReplacementBehavior(): @CueReplacementBehavior Int { + return CUE_REPLACEMENT_BEHAVIOR + } + + override fun parse( + data: ByteArray, + offset: Int, + length: Int, + outputOptions: OutputOptions, + output: Consumer + ) { + parsableByteArray.reset(data, /* limit= */offset + length) + parsableByteArray.setPosition(offset) + val charset = detectUtfCharset(parsableByteArray) + + val cuesWithTimingBeforeRequestedStartTimeUs: MutableList? = + if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues) + ArrayList() + else + null + var currentLine: String? + while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) { + if (currentLine!!.isEmpty()) { + // Skip blank lines. + continue + } + + // Parse and check the index line. + try { + currentLine.toInt() + } catch (_: NumberFormatException) { + Log.w(TAG, "Skipping invalid index: $currentLine") + continue + } + + // Read and parse the timing line. + currentLine = parsableByteArray.readLine(charset) + if (currentLine == null) { + Log.w(TAG, "Unexpected end") + break + } + + val startTimeUs: Long + val endTimeUs: Long + val matcher = SUBRIP_TIMING_LINE.matcher(currentLine) + if (matcher.matches()) { + startTimeUs = parseTimecode(matcher, /* groupOffset= */1) + endTimeUs = parseTimecode(matcher, /* groupOffset= */6) + } else { + Log.w(TAG, "Skipping invalid timing: $currentLine") + continue + } + + // Read and parse the text and tags. + textBuilder.setLength(0) + tags.clear() + currentLine = parsableByteArray.readLine(charset) + while (!TextUtils.isEmpty(currentLine)) { + if (textBuilder.isNotEmpty()) { + textBuilder.append("
") + } + textBuilder.append(processLine(currentLine!!, tags)) + currentLine = parsableByteArray.readLine(charset) + } + + @Suppress("DEPRECATION") + val text = Html.fromHtml(textBuilder.toString()) + + var alignmentTag: String? = null + for (i in tags.indices) { + val tag = tags[i] + if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) { + alignmentTag = tag + // Subsequent alignment tags should be ignored. + break + } + } + if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) { + output.accept( + CuesWithTiming( + ImmutableList.of(buildCue(text, alignmentTag)), + startTimeUs, /* durationUs= */ + endTimeUs - startTimeUs + ) + ) + } else cuesWithTimingBeforeRequestedStartTimeUs?.add( + CuesWithTiming( + ImmutableList.of(buildCue(text, alignmentTag)), + startTimeUs, /* durationUs= */ + endTimeUs - startTimeUs + ) + ) + } + if (cuesWithTimingBeforeRequestedStartTimeUs != null) { + for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) { + output.accept(cuesWithTiming) + } + } + } + + /** + * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if + * no BOM is found. + */ + private fun detectUtfCharset(data: ParsableByteArray): Charset { + val charset = data.readUtfCharsetFromBom() + return charset ?: StandardCharsets.UTF_8 + } + + /** + * Trims and removes tags from the given line. The removed tags are added to `tags`. + * + * @param line The line to process. + * @param tags A list to which removed tags will be added. + * @return The processed line. + */ + private fun processLine(line: String, tags: ArrayList): String { + var line = line + line = line.trim { it <= ' ' } + + var removedCharacterCount = 0 + val processedLine = StringBuilder(line) + val matcher = SUBRIP_TAG_PATTERN.matcher(line) + while (matcher.find()) { + val tag = matcher.group() + tags.add(tag) + val start = matcher.start() - removedCharacterCount + val tagLength = tag.length + processedLine.replace(start, /* end= */start + tagLength, /* str= */"") + removedCharacterCount += tagLength + } + + return processedLine.toString() + } + + /** + * Build a [Cue] based on the given text and alignment tag. + * + * @param text The text. + * @param alignmentTag The alignment tag, or `null` if no alignment tag is available. + * @return Built cue + */ + private fun buildCue(text: Spanned, alignmentTag: String?): Cue { + val cue = Cue.Builder().setText(text) + if (alignmentTag == null) { + return cue.build() + } + + // Horizontal alignment. + when (alignmentTag) { + ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START) + ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END) + ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + } + + // Vertical alignment. + when (alignmentTag) { + ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END) + ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START) + ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) + else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) + } + + return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor())) + .setLine( + getFractionalPositionForAnchorType(cue.getLineAnchor()), + Cue.LINE_TYPE_FRACTION + ) + .build() + } + + companion object { + /** + * The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this + * implementation. + */ + const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int = + Format.CUE_REPLACEMENT_BEHAVIOR_MERGE + + // Fractional positions for use when alignment tags are present. + private const val START_FRACTION = 0.08f + private const val END_FRACTION = 1 - START_FRACTION + private const val MID_FRACTION = 0.5f + + private const val TAG = "SubripParser" + + // The google devs are useless, this entire class is just to override this + private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?" + private val SUBRIP_TIMING_LINE: Pattern = + Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*") + + // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183]. + private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}") + private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}" + + // Alignment tags for SSA V4+. + private const val ALIGN_BOTTOM_LEFT = "{\\an1}" + private const val ALIGN_BOTTOM_MID = "{\\an2}" + private const val ALIGN_BOTTOM_RIGHT = "{\\an3}" + private const val ALIGN_MID_LEFT = "{\\an4}" + private const val ALIGN_MID_MID = "{\\an5}" + private const val ALIGN_MID_RIGHT = "{\\an6}" + private const val ALIGN_TOP_LEFT = "{\\an7}" + private const val ALIGN_TOP_MID = "{\\an8}" + private const val ALIGN_TOP_RIGHT = "{\\an9}" + + private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long { + val hours = matcher.group(groupOffset + 1) + var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 + timestampMs += checkNotNull(matcher.group(groupOffset + 2)) + .toLong() * 60 * 1000 + timestampMs += checkNotNull(matcher.group(groupOffset + 3)) + .toLong() * 1000 + val millis = matcher.group(groupOffset + 4) + + timestampMs += when (millis?.length) { + null -> 0L + 1 -> millis.toLong() * 100L + 2 -> millis.toLong() * 10L + 3 -> millis.toLong() * 1L + else -> millis.substring(0, 3).toLong() + } + + return timestampMs * 1000 + } + + // TODO(b/289983417): Make package-private again, once it is no longer needed in + // DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed) + @VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE) + fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float { + return when (anchorType) { + Cue.ANCHOR_TYPE_START -> START_FRACTION + Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION + Cue.ANCHOR_TYPE_END -> END_FRACTION + Cue.TYPE_UNSET -> // Should never happen. + throw IllegalArgumentException() + + else -> + throw IllegalArgumentException() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 690d37064..61d6f5564 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -1,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,18 +97,103 @@ class CustomDecoder : SubtitleDecoder { " " ) } + + private fun computeDefaultLineOrPosition(@Cue.AnchorType anchor: Int) = when (anchor) { + Cue.ANCHOR_TYPE_START -> DEFAULT_MARGIN + Cue.ANCHOR_TYPE_MIDDLE -> 0.5f + Cue.ANCHOR_TYPE_END -> 1.0f - DEFAULT_MARGIN + Cue.TYPE_UNSET -> Cue.DIMEN_UNSET + else -> Cue.DIMEN_UNSET + } + + /** + * Fixes alignment for cues with {\anX}, + * this is common for .vtt that should be parsed as .srt + * + * ``` + * WEBVTT + * + * 00:00.000 --> 00:01.000 + * {\an1}Label 1 + * + * 00:01.000 --> 00:02.000 + * {\an2}Label 2 + * + * 00:02.000 --> 00:03.000 + * {\an3}Label 3 + * + * 00:03.000 --> 00:04.000 + * {\an4}Label 4 + * + * 00:04.000 --> 00:05.000 + * {\an5}Label 5 + * + * 00:05.000 --> 00:06.000 + * {\an6}Label 6 + * + * 00:06.000 --> 00:07.000 + * {\an7}Label 7 + * + * 00:07.000 --> 00:08.000 + * {\an8}Label 8 + * + * 00:08.000 --> 00:09.000 + * {\an9}Label 9 + * ``` + */ + fun Cue.Builder.fixSubtitleAlignment(): Cue.Builder { + var trimmed = text?.trim() ?: return this + // https://github.com/androidx/media/blob/main/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaStyle.java + // exoplayer can already parse this, however for eg webvtt it fails + locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment -> + // toLineAnchor + this.setSubtitleAlignment(alignment) + } + + // remove all matches, so we do not display \anx + trimmed = trimmed.replace(locationRegex, "") + setText(trimmed) + return this + } + + fun Cue.Builder.setSubtitleAlignment(alignment: Int?): Cue.Builder { + if (alignment == null) return this + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END + SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE + SSA_ALIGNMENT_TOP_LEFT, SSA_ALIGNMENT_TOP_CENTER, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_START + else -> null + }?.let { anchor -> + setLineAnchor(anchor) + setLine( + computeDefaultLineOrPosition(anchor), Cue.LINE_TYPE_FRACTION + ) + } + // toPositionAnchor + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Cue.ANCHOR_TYPE_START + SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Cue.ANCHOR_TYPE_MIDDLE + SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_END + else -> null + }?.let { anchor -> + setPositionAnchor(anchor) + setPosition(computeDefaultLineOrPosition(anchor)) + } + + // toTextAlignment + when (alignment) { + SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Layout.Alignment.ALIGN_NORMAL + SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Layout.Alignment.ALIGN_CENTER + SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE + else -> null + }?.let { anchor -> + setTextAlignment(anchor) + } + return this + } } - private var realDecoder: SubtitleDecoder? = null - - override fun getName(): String { - return realDecoder?.name ?: this::javaClass.name - } - - override fun dequeueInputBuffer(): SubtitleInputBuffer { - Log.i(TAG, "dequeueInputBuffer") - return realDecoder?.dequeueInputBuffer() ?: SubtitleInputBuffer() - } + private var realDecoder: SubtitleParser? = null private fun getStr(byteArray: ByteArray): Pair { val encoding = try { @@ -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 - private fun SubtitleInputBuffer.setSubtitleText(text: String) { -// println("Set subtitle text -----\n$text\n-----") - this.data = ByteBuffer.wrap(text.toByteArray(charset(UTF_8))) - } + // First we remove all invisible characters at the start, this is an issue in some subtitle files + // Cntrl is control characters: https://en.wikipedia.org/wiki/Unicode_control_characters + // Cf is formatting characters: https://www.compart.com/en/unicode/category/Cf + val controlCharsRegex = Regex("""[\p{Cntrl}\p{Cf}]""") + val trimmedText = + data.trimStart { it.isWhitespace() || controlCharsRegex.matches(it.toString()) } - override fun queueInputBuffer(inputBuffer: SubtitleInputBuffer) { - Log.i(TAG, "queueInputBuffer") - try { - val inputString = getStr(inputBuffer) - if (realDecoder == null && !inputString.isNullOrBlank()) { - var str: String = inputString - // this way we read the subtitle file and decide what decoder to use instead of relying 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() + //https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388 + val subtitleParser = when { + // "WEBVTT" can be hidden behind invisible characters not filtered by trim + trimmedText.substring(0, 10).contains("WEBVTT", ignoreCase = true) -> WebvttParser() + trimmedText.startsWith(" TtmlParser() + (trimmedText.startsWith( + "[Script Info]", + ignoreCase = true + ) || trimmedText.startsWith( + "Title:", + ignoreCase = true + )) -> SsaParser(fallbackFormat?.initializationData) + + trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() + fallbackFormat != null -> { + when (fallbackFormat.sampleMimeType) { + MimeTypes.TEXT_VTT -> WebvttParser() + MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) + MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() + MimeTypes.APPLICATION_TTML -> TtmlParser() + MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser() + MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) + // These decoders are not converted to parsers yet + // TODO +// MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> Cea608Decoder( +// mimeType, +// fallbackFormat.accessibilityChannel, +// Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS +// ) +// MimeTypes.APPLICATION_CEA708 -> Cea708Decoder( +// fallbackFormat.accessibilityChannel, +// fallbackFormat.initializationData +// ) + MimeTypes.APPLICATION_DVBSUBS -> DvbParser(fallbackFormat.initializationData) + MimeTypes.APPLICATION_PGS -> PgsParser() else -> null } - Log.i( - TAG, - "Decoder selected: $realDecoder" - ) - realDecoder?.let { decoder -> - decoder.dequeueInputBuffer()?.let { buff -> - if (decoder !is SsaDecoder) { - if (regexSubtitlesToRemoveCaptions) - captionRegex.forEach { rgx -> - str = str.replace(rgx, "\n") - } - if (regexSubtitlesToRemoveBloat) - bloatRegex.forEach { rgx -> - str = str.replace(rgx, "\n") - } - } - buff.setSubtitleText(str) - decoder.queueInputBuffer(buff) - Log.i( - TAG, - "Decoder queueInputBuffer successfully" - ) - } - CS3IPlayer.requestSubtitleUpdate?.invoke() - } - } else { - Log.i( - TAG, - "Decoder else queueInputBuffer successfully" + } + + else -> null + } + return subtitleParser + } + + val currentSubtitleCues = mutableListOf() + + + override fun parse( + data: ByteArray, + offset: Int, + length: Int, + outputOptions: SubtitleParser.OutputOptions, + output: Consumer + ) { + val currentStyle = style + val customOutput = Consumer { cue -> + val newCue = + CuesWithTiming(cue.cues, cue.startTimeUs, cue.durationUs) + + // Do not apply the offset to the currentSubtitleCues as those are then used for sync subs + currentSubtitleCues.add( + SubtitleCue( + newCue.startTimeUs / 1000, + newCue.durationUs / 1000, + newCue.cues.map { it.text.toString() }) + ) + + // offset timing for the final + val updatedCues = + CuesWithTiming( + newCue.cues, + newCue.startTimeUs - subtitleOffset.times(1000), + newCue.durationUs ) - if (!inputString.isNullOrBlank()) { - var str: String = inputString - if (realDecoder !is SsaDecoder) { - if (regexSubtitlesToRemoveCaptions) - captionRegex.forEach { rgx -> - str = str.replace(rgx, "\n") - } - if (regexSubtitlesToRemoveBloat) + output.accept(updatedCues) + } + Log.i(TAG, "Parse subtitle, current parser: $realDecoder") + try { + val inputString = getStr(data).first + Log.i(TAG, "Subtitle preview: ${inputString.substring(0, 30)}") + if (inputString.isNotBlank()) { + var str: String = trimStr(inputString) + realDecoder = realDecoder ?: getSubtitleParser(inputString) + Log.i( + TAG, + "Parser selected: $realDecoder" + ) + realDecoder?.let { decoder -> + if (decoder !is SsaParser) { + if (currentStyle.removeBloat) bloatRegex.forEach { rgx -> str = str.replace(rgx, "\n") } - if (uppercaseSubtitles) { + if (currentStyle.upperCase) { str = str.uppercase() } } - inputBuffer.setSubtitleText(str) } - - realDecoder?.queueInputBuffer(inputBuffer) + val array = str.toByteArray() + realDecoder?.parse( + array, + minOf(array.size, offset), + minOf(array.size, length), + outputOptions, + customOutput + ) } } catch (e: Exception) { logError(e) } } - override fun dequeueOutputBuffer(): SubtitleOutputBuffer? { - return realDecoder?.dequeueOutputBuffer() + override fun getCueReplacementBehavior(): Int { + // CUE_REPLACEMENT_BEHAVIOR_REPLACE seems most compatible, change if required + return realDecoder?.cueReplacementBehavior ?: Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE } - override fun flush() { - realDecoder?.flush() - } - - override fun release() { - realDecoder?.release() - } - - override fun setPositionUs(positionUs: Long) { - realDecoder?.setPositionUs(positionUs) + override fun reset() { + currentSubtitleCues.clear() + super.reset() } } /** See https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java */ +@OptIn(UnstableApi::class) class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { + override fun supportsFormat(format: Format): Boolean { -// return SubtitleDecoderFactory.DEFAULT.supportsFormat(format) return listOf( MimeTypes.TEXT_VTT, MimeTypes.TEXT_SSA, MimeTypes.APPLICATION_TTML, MimeTypes.APPLICATION_MP4VTT, MimeTypes.APPLICATION_SUBRIP, - //MimeTypes.APPLICATION_TX3G, + MimeTypes.APPLICATION_TX3G, //MimeTypes.APPLICATION_CEA608, //MimeTypes.APPLICATION_MP4CEA608, //MimeTypes.APPLICATION_CEA708, - //MimeTypes.APPLICATION_DVBSUBS, - //MimeTypes.APPLICATION_PGS, + MimeTypes.APPLICATION_DVBSUBS, + MimeTypes.APPLICATION_PGS, //MimeTypes.TEXT_EXOPLAYER_CUES ).contains(format.sampleMimeType) } - override fun createDecoder(format: Format): SubtitleDecoder { - return CustomDecoder() - //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() - //} + private var latestDecoder: WeakReference? = null + + fun getSubtitleCues(): List? { + return latestDecoder?.get()?.currentSubtitleCues } -} \ No newline at end of file + + /** + * Decoders created here persists across reset() + * Do not save state in the decoder which you want to reset (e.g subtitle offset) + */ + override fun createDecoder(format: Format): SubtitleDecoder { + val parser = CustomDecoder(format) + // Allow garbage collection if player releases the decoder + latestDecoder = WeakReference(parser) + + return DelegatingSubtitleDecoder( + parser::class.simpleName + "Decoder", parser + ) + } +} + +/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ +@OptIn(UnstableApi::class) +class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) : + SimpleSubtitleDecoder(name) { + + override fun decode(data: ByteArray, length: Int, reset: Boolean): Subtitle { + if (reset) { + parser.reset() + } + return parser.parseToLegacySubtitle(data, 0, length); + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt deleted file mode 100644 index d3f4171a8..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.lagradost.cloudstream3.ui.player - -import android.os.Looper -import 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 baf7ed52b..35f8dcfd8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt @@ -1,95 +1,76 @@ package com.lagradost.cloudstream3.ui.player -import com.lagradost.cloudstream3.AcraApplication.Companion.context +import android.net.Uri +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlin.math.max -import kotlin.math.min +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF +import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName +import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo class DownloadFileGenerator( - private val episodes: List, - private var currentIndex: Int = 0 -) : IGenerator { + episodes: List +) : VideoGenerator(episodes) { override val hasCache = false + override val canSkipLoading = false - override fun hasNext(): Boolean { - return currentIndex < episodes.size - 1 - } - - override fun hasPrev(): Boolean { - return currentIndex > 0 - } - - override fun next() { - if (hasNext()) - currentIndex++ - } - - override fun prev() { - if (hasPrev()) - currentIndex-- - } - - override fun goto(index: Int) { - // clamps value - currentIndex = min(episodes.size - 1, max(0, index)) - } - - override fun getCurrentId(): Int? { - return episodes[currentIndex].id - } - - override fun getCurrent(offset: Int): Any? { - return episodes.getOrNull(currentIndex + offset) - } - - override fun getAll(): List? { - return null - } + override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, + isCasting: Boolean ): Boolean { - val meta = episodes[currentIndex + offset] - callback(Pair(null, meta)) + val meta = videos.getOrNull(offset) ?: return false - context?.let { ctx -> - val relative = meta.relativePath - val display = meta.displayName - - if (display == null || relative == null) { - return@let - } - VideoDownloadManager.getFolder(ctx, relative, meta.basePath) - ?.forEach { file -> - val name = display.removeSuffix(".mp4") - if (file.first != meta.displayName && file.first.startsWith(name)) { - val realName = file.first.removePrefix(name) - .removeSuffix(".vtt") - .removeSuffix(".srt") - .removeSuffix(".txt") - .trim() - .removePrefix("(") - .removeSuffix(")") - - subtitleCallback( - SubtitleData( - realName.ifBlank { ctx.getString(R.string.default_subtitles) }, - file.second.toString(), - SubtitleOrigin.DOWNLOADED_FILE, - name.toSubtitleMimeType(), - emptyMap() - ) - ) - } + if (meta.uri == Uri.EMPTY) { + // We do this here so that we only load it when + // we actually need it as it can be more expensive. + val info = meta.id?.let { id -> + activity?.let { act -> + getDownloadFileInfo(act, id) } + } + + if (info != null) { + val newMeta = meta.copy(uri = info.path) + callback(null to newMeta) + } else callback(null to meta) + } else callback(null to meta) + + val ctx = context ?: return true + val relative = meta.relativePath ?: return true + val display = meta.displayName ?: return true + + val cleanDisplay = cleanDisplayName(display) + + getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) -> + if (isMatchingSubtitle(name, display, cleanDisplay)) { + val cleanName = cleanDisplayName(name) + val lastNum = Regex(" ([0-9]+)$") + val nameSuffix = lastNum.find(cleanName)?.groupValues?.get(1) ?: "" + val originalName = cleanName.removePrefix(cleanDisplay).replace(lastNum, "").trim() + + subtitleCallback( + SubtitleData( + originalName.ifBlank { ctx.getString(R.string.default_subtitles) }, + nameSuffix, + uri.toString(), + SubtitleOrigin.DOWNLOADED_FILE, + name.toSubtitleMimeType(), + emptyMap(), + fromLanguageToTagIETF(originalName, true) + ) + ) + } } return true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index dc1bbba3c..a086cc16f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt @@ -1,109 +1,111 @@ package com.lagradost.cloudstream3.ui.player import android.content.Intent -import android.net.Uri import android.os.Bundle import android.util.Log import android.view.KeyEvent import androidx.appcompat.app.AppCompatActivity -import com.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() + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Ignore same intent so the player doesnt totally + // reload if you are playing the same thing. + if (isSameIntent(intent)) return + setIntent(intent) + Log.i(TAG, "onNewIntent") + handleIntent(intent) } - private fun playLink(url: String) { - this.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - LinkGenerator( - listOf( - url - ) - ) - ) - ) - } - - 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) + /** + * Use moveTaskToBack instead of finish() so there is always exactly one task + * entry in recents, always reflecting the current file. + * + * finish() destroys the Activity but may leave the task in recents. Each new file + * open can create a new task entry, so recents accumulates stale entries for old + * files. The user then taps a stale entry and gets the wrong file. + * + * moveTaskToBack keeps the Activity alive in the background. There is only ever + * one task entry in recents. New files opened from the file manager arrive via + * onNewIntent on the live instance, updating the player immediately. The single + * recents entry always reflects the current state, ensuring we load the + * correct file. + */ + attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) } + } + + private fun handleIntent(intent: Intent) { val data = intent.data + if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) { + return + } - if (intent?.action == Intent.ACTION_SEND) { - val extraText = try { // I dont trust android - intent.getStringExtra(Intent.EXTRA_TEXT) - } catch (e: Exception) { - null - } + if ( + intent.action == Intent.ACTION_SEND || + intent.action == Intent.ACTION_OPEN_DOCUMENT || + intent.action == Intent.ACTION_VIEW + ) { + val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) } val cd = intent.clipData val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null val url = item?.text?.toString() - - // idk what I am doing, just hope any of these work - if (item?.uri != null) - playUri(item.uri) - else if (url != null) - playLink(url) - else if (data != null) - playUri(data) - else if (extraText != null) - playLink(extraText) - else { - finish() - return + when { + item?.uri != null -> playUri(this, item.uri) + url != null -> playLink(this, url) + data != null -> playUri(this, data) + extraText != null -> playLink(this, extraText) + else -> finishAndRemoveTask() } } else if (data?.scheme == "content") { - playUri(data) - } else { - finish() - return - } + playUri(this, data) + } else finishAndRemoveTask() } -} \ No newline at end of file + + override fun onResume() { + super.onResume() + CommonActivity.setActivityInstance(this) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt index 7c19e97d3..85db33fc0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt @@ -1,50 +1,25 @@ package com.lagradost.cloudstream3.ui.player import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.ExtractorLinkType class ExtractorLinkGenerator( private val links: List, private val subtitles: List, -) : IGenerator { - override val hasCache = false - - override fun getCurrentId(): Int? { - return null - } - - override fun hasNext(): Boolean { - return false - } - - override fun getAll(): List? { - return null - } - - override fun hasPrev(): Boolean { - return false - } - - override fun getCurrent(offset: Int): Any? { - return null - } - - override fun goto(index: Int) {} - - override fun next() {} - - override fun prev() {} - +) : NoVideoGenerator(null) { override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int + offset: Int, + isCasting: Boolean ): Boolean { subtitles.forEach(subtitleCallback) links.forEach { - callback.invoke(it to null) + if(sourceTypes.contains(it.type)) { + callback.invoke(it to null) + } } return true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt new file mode 100644 index 000000000..025267cc9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.ui.player + +import android.content.Context +import android.os.Looper +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.text.TextOutput +import androidx.media3.exoplayer.text.TextRenderer +import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory + +@UnstableApi +class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { + /** Somehow the nextlib authors decided that we need a text renderer that causes + * "ERROR_CODE_FAILED_RUNTIME_CHECK". + * + * Core issue: https://github.com/anilbeesetti/nextlib/pull/158 + * Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718 + * */ + override fun buildTextRenderers( + context: Context, + output: TextOutput, + outputLooper: Looper, + extensionRendererMode: Int, + out: ArrayList + ) { + out.add(TextRenderer(output, outputLooper)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 8d28fd9d8..4ba933e13 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -2,122 +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.* +import com.lagradost.cloudstream3.utils.setText +import com.lagradost.cloudstream3.utils.txt +import kotlin.math.roundToInt -const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking -const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage -const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage -const val VERTICAL_MULTIPLIER = 2.0f -const val HORIZONTAL_MULTIPLIER = 2.0f -const val DOUBLE_TAB_MAXIMUM_HOLD_TIME = 200L -const val DOUBLE_TAB_MINIMUM_TIME_BETWEEN = 200L // this also affects the UI show response time -const val DOUBLE_TAB_PAUSE_PERCENTAGE = 0.15 // in both directions +private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" // All the UI Logic for the player -open class FullScreenPlayer : AbstractPlayerFragment() { +@OptIn(UnstableApi::class) +open class FullScreenPlayer : AbstractPlayerFragment( + BindingCreator.Bind(FragmentPlayerBinding::bind) +) { + override fun pickLayout(): Int = R.layout.fragment_player protected open var lockRotation = true - protected open var isFullScreenPlayer = true - protected open var isTv = false + protected var playerBinding: PlayerCustomLayoutBinding? = null // state of player UI protected var isShowing = false protected var isLocked = false - - //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 androidTVInterfaceOffSeekTime = 10000L; - protected var androidTVInterfaceOnSeekTime = 30000L; - 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) @@ -131,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 - protected val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics + override fun fixLayout(view: View) = Unit - // screenWidth and screenHeight does always - // refer to the screen while in landscape mode - protected val screenWidth: Int - get() { - return max(displayMetrics.widthPixels, displayMetrics.heightPixels) + /** + * Wet code but this can not be made into a function as it is a setter. + * + * The reason for this setter is to fix a bug with the titlecard popup, as we want it to autohide + * when pressing back. + * + * Note that we move the call to autoHide after field assignment with prevField to avoid inf recursion. */ + protected var selectSourceDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } } - protected val screenHeight: Int - get() { - return min(displayMetrics.widthPixels, displayMetrics.heightPixels) + protected var selectTrackDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSpeedDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } + } + protected var selectSubtitlesDialog: Dialog? = null + set(value) { + val prevField = field + field = value + if (value == null && prevField != null) { + autoHide() + } } - private var statusBarHeight: Int? = null - private var navigationBarHeight: Int? = null + /** Checks if any top level dialog is open and showing */ + fun isDialogOpen() = + selectSourceDialog?.isShowing == true + || selectTrackDialog?.isShowing == true + || selectSpeedDialog?.isShowing == true + || selectSubtitlesDialog?.isShowing == true + || isShowingEpisodeOverlay - private val brightnessIcons = listOf( - R.drawable.sun_1, - R.drawable.sun_2, - R.drawable.sun_3, - R.drawable.sun_4, - R.drawable.sun_5, - R.drawable.sun_6, - //R.drawable.sun_7, - // R.drawable.ic_baseline_brightness_1_24, - // R.drawable.ic_baseline_brightness_2_24, - // R.drawable.ic_baseline_brightness_3_24, - // R.drawable.ic_baseline_brightness_4_24, - // R.drawable.ic_baseline_brightness_5_24, - // R.drawable.ic_baseline_brightness_6_24, - // R.drawable.ic_baseline_brightness_7_24, - ) + private fun scheduleMetadataVisibility() { + val metadataScrim = playerBinding?.playerMetadataScrim ?: return + val ctx = metadataScrim.context ?: return - private val volumeIcons = listOf( - R.drawable.ic_baseline_volume_mute_24, - R.drawable.ic_baseline_volume_down_24, - R.drawable.ic_baseline_volume_up_24, - ) + if (!ctx.shouldShowPlayerMetadata()) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return + } + + if (isLayout(PHONE)) { + metadataScrim.isVisible = false + metadataVisibilityToken++ + return + } + + val isPaused = currentPlayerStatus == CSPlayerLoading.IsPaused + val token = ++metadataVisibilityToken + + if (isPaused) { + metadataScrim.postDelayed({ + /** Make sure the user has not interacted with anything */ + if (token != metadataVisibilityToken) return@postDelayed + /** If already visible, then do not rerun the animation */ + if (metadataScrim.isVisible) return@postDelayed + /** Failsafe, as this should only be shown when paused */ + if (currentPlayerStatus != CSPlayerLoading.IsPaused) return@postDelayed + /** We do not want to show the logo in the background when the user is within another screen */ + if (isDialogOpen()) return@postDelayed + + metadataScrim.alpha = 0f + metadataScrim.isVisible = true + metadataScrim.animate() + .alpha(1f) + .setDuration(500L) + .setInterpolator(DecelerateInterpolator()) + .start() + hidePlayerUI() + }, 8000L) + } else { + if (metadataScrim.isVisible) { + metadataScrim.animate() + .alpha(0f) + .setDuration(300L) + .setInterpolator(AccelerateDecelerateInterpolator()) + .withEndAction { + metadataScrim.alpha = 0f // force final state + metadataScrim.isVisible = false + } + .start() + } + } + } + + override fun onDestroyView() { + playerHostView?.releaseOverlayLayoutListener() + playerBinding = null + super.onDestroyView() + } open fun showMirrorsDialogue() { throw NotImplementedError() @@ -184,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() } } - player_video_title_rez?.let { + playerBinding?.playerVideoTitleRez?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() } } + playerBinding?.playerVideoInfo?.let { + ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { + duration = 200 + start() + } + } + playerBinding?.playerMetadataScrim?.let { + ObjectAnimator.ofFloat(it, "translationY", 1f).apply { + duration = 200 + start() + } + } + val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() - 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) } - - - 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) - - /*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) - } - - bottom_player_bar?.startAnimation(fadeAnimation) - player_open_source?.startAnimation(fadeAnimation) - player_top_holder?.startAnimation(fadeAnimation) } override fun subtitlesChanged() { - player_subtitle_offset_btt?.isGone = player.getCurrentPreferredSubtitle() == null + 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() } - 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 restoreOrientationWithSensor(activity: Activity) { + val currentOrientation = activity.resources.configuration.orientation + val orientation = when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + Configuration.ORIENTATION_PORTRAIT -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + else -> playerHostView?.dynamicOrientation() ?: return + } + activity.requestedOrientation = orientation + } + + private fun toggleOrientationWithSensor(activity: Activity) { + val currentOrientation = activity.resources.configuration.orientation + val orientation: Int = when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + Configuration.ORIENTATION_PORTRAIT -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + else -> playerHostView?.dynamicOrientation() ?: return + } + activity.requestedOrientation = orientation + } + + private fun lockOrientation(activity: Activity) { + val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + @Suppress("DEPRECATION") + (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay + else activity.display!! + val rotation = display.rotation + val currentOrientation = activity.resources.configuration.orientation + val orientation: Int + when (currentOrientation) { + Configuration.ORIENTATION_LANDSCAPE -> + orientation = + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + else + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + + Configuration.ORIENTATION_PORTRAIT -> + orientation = + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + + else -> orientation = playerHostView?.dynamicOrientation() ?: return + } + activity.requestedOrientation = orientation + } + + private fun updateOrientation(ignoreDynamicOrientation: Boolean = false) { + activity?.apply { + if (lockRotation) { + if (isLocked) { + 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 + 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 - // 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 + 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) { @@ -345,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 ctx = context ?: return + // Pause player because the subtitles cannot be continuously updated to follow playback. + player.handleEvent( + CSPlayerEvent.Pause, + PlayerEventSource.UI + ) - val beforeOffset = subtitleDelay + val binding = SubtitleOffsetBinding.inflate(LayoutInflater.from(ctx), null, false) - 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)!! + // 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() - input.doOnTextChanged { text, _, _, _ -> - text?.toString()?.toLongOrNull()?.let { - subtitleDelay = it - when { - it > 0L -> { - context?.getString(R.string.subtitle_offset_extra_hint_later_format) - ?.format(it) + 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_before_format) - ?.format(-it) - } - 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) + } + cancelBtt.setOnClickListener { + selectSubtitlesDialog = null dialog.dismissSafe(activity) } } } - private fun showSpeedDialog() { - val speedsText = - listOf( - "0.5x", - "0.75x", - "0.85x", - "1x", - "1.15x", - "1.25x", - "1.4x", - "1.5x", - "1.75x", - "2x" - ) - val speedsNumbers = - listOf(0.5f, 0.75f, 0.85f, 1f, 1.15f, 1.25f, 1.4f, 1.5f, 1.75f, 2f) - val speedIndex = speedsNumbers.indexOf(player.getPlaybackSpeed()) + @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 + } - 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]) + private fun showSpeedDialog() { + val act = activity ?: return + val isPlaying = player.getIsPlaying() + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) + + val binding: SpeedDialogBinding = SpeedDialogBinding.inflate( + LayoutInflater.from(act) + ) + + updateSpeedDialogBinding(binding) + for ((view, speed) in arrayOf( + binding.speed25 to 0.25f, + binding.speed100 to 1.0f, + binding.speed125 to 1.25f, + binding.speed150 to 1.5f, + binding.speed200 to 2.0f, + )) { + view.setOnClickListener { + setPlayBackSpeed(speed) + updateSpeedDialogBinding(binding) } } - } - fun resetRewindText() { - 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) - } - - 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?) {} - - override fun onAnimationRepeat(animation: Animation?) {} - - 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) + binding.speedMinus.setOnClickListener { + setPlayBackSpeed(maxOf((player.getPlaybackSpeed() - 0.1f), 0.1f)) + 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) - - 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?) { - 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) + binding.speedPlus.setOnClickListener { + setPlayBackSpeed(minOf((player.getPlaybackSpeed() + 0.1f), 2.0f)) + updateSpeedDialogBinding(binding) } + + binding.speedBar.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + setPlayBackSpeed(value) + updateSpeedDialogBinding(binding) + } + } + + val dismiss = DialogInterface.OnDismissListener { + activity?.hideSystemUI() + if (isPlaying) { + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) + } + selectSpeedDialog = null + } + + // if (isLayout(PHONE)) { + // val builder = + // BottomSheetDialog(act, R.style.AlertDialogCustom) + // builder.setContentView(binding.root) + // builder.setOnDismissListener(dismiss) + // builder.show() + //} else { + val builder = + AlertDialog.Builder(act, R.style.AlertDialogCustom) + .setView(binding.root) + builder.setOnDismissListener(dismiss) + val dialog = builder.create() + this.selectSpeedDialog = dialog + dialog.show() + //} } private fun onClickChange() { isShowing = !isShowing - if (isShowing) { - 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() { @@ -547,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() } @@ -556,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 { @@ -599,777 +762,606 @@ 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 + /** PlayerView.Callbacks touch overrides */ - // requested volume and brightness is used to make swiping smoother - // to make it not jump between values, - // this value is within the range [0,1] - private var currentRequestedVolume: Float = 0.0f - private var currentRequestedBrightness: Float = 1.0f + override fun isUIShowing(): Boolean = isShowing - enum class TouchAction { - Brightness, - Volume, - Time, + override fun onSingleTap() { + onClickChange() } - companion object { - private fun forceLetters(inp: Long, letters: Int = 2): String { - val added: Int = letters - inp.toString().length - return if (added > 0) { - "0".repeat(added) + inp.toString() - } else { - inp.toString() - } - } - - private fun convertTimeToString(sec: Long): String { - val rsec = sec % 60L - val min = ceil((sec - rsec) / 60.0).toInt() - val rmin = min % 60L - val h = ceil((min - rmin) / 60.0).toLong() - //int rh = h;// h % 24; - return (if (h > 0) forceLetters(h) + ":" else "") + (if (rmin >= 0 || h >= 0) forceLetters( - rmin - ) + ":" else "") + forceLetters( - rsec - ) - } - } - - private fun calculateNewTime( - startTime: Long?, - touchStart: Vector2?, - touchEnd: Vector2? - ): Long? { - if (touchStart == null || touchEnd == null || startTime == null) return null - val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidth.toFloat() - val duration = player.getDuration() ?: return null - return max( - min( - startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), - duration - ), 0 - ) - } - - private fun getBrightness(): Float? { - return if (useTrueSystemBrightness) { - try { - Settings.System.getInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS - ) / 255f - } catch (e: Exception) { - // because true system brightness requires - // permission, this is a lazy way to check - // as it will throw an error if we do not have it - useTrueSystemBrightness = false - return getBrightness() - } - } else { - try { - activity?.window?.attributes?.screenBrightness - } catch (e: Exception) { - logError(e) - null - } - } - } - - private fun setBrightness(brightness: Float) { - if (useTrueSystemBrightness) { - try { - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS_MODE, - Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL - ) - - Settings.System.putInt( - context?.contentResolver, - Settings.System.SCREEN_BRIGHTNESS, (brightness * 255).toInt() - ) - } catch (e: Exception) { - useTrueSystemBrightness = false - setBrightness(brightness) - } - } else { - try { - val lp = activity?.window?.attributes - lp?.screenBrightness = brightness - activity?.window?.attributes = lp - } catch (e: Exception) { - logError(e) - } - } + override fun onTouchDown() { + if (isShowingEpisodeOverlay) toggleEpisodesOverlay(show = false) } @SuppressLint("SetTextI18n") - private fun handleMotionEvent(view: View?, event: MotionEvent?): Boolean { - if (event == null || view == null) return false - val currentTouch = Vector2(event.x, event.y) - val startTouch = currentTouchStart - 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() + override fun onSeekPreviewText(text: String?) { + playerBinding?.playerTimeText?.apply { + isVisible = text != null + if (text != null) this.text = text + } + } - getBrightness()?.let { - currentRequestedBrightness = it - } - (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> - val currentVolume = - audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val maxVolume = - audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + override fun onHidePlayerUI() { + hidePlayerUI() + } - currentRequestedVolume = currentVolume.toFloat() / maxVolume.toFloat() - } - } - } - MotionEvent.ACTION_UP -> { - if (isCurrentTouchValid && !isLocked && isFullScreenPlayer) { - // seek time - if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time) { - val startTime = currentTouchStartPlayerTime - if (startTime != null) { - calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> - if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { - player.seekTo(seekTo) - } - } - } - } - } + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + animateLayoutChanges() + } + autoHide() + } - // 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++ + override fun playerStatusChanged() { + super.playerStatusChanged() + scheduleMetadataVisibility() + } - if (currentClickCount >= 1) { // have double clicked - currentDoubleTapIndex++ - if (doubleTapPauseEnabled && isFullScreenPlayer) { // you can pause if your tap is in the middle of the screen - when { - currentTouch.x < screenWidth / 2 - (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { - if (doubleTapEnabled) - rewind() - } - currentTouch.x > screenWidth / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidth) -> { - if (doubleTapEnabled) - fastForward() - } - else -> { - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } - } - } else if (doubleTapEnabled && isFullScreenPlayer) { - if (currentTouch.x < screenWidth / 2) { - rewind() - } else { - fastForward() - } - } - } - } else { - // is a valid click but not fast enough for seek - currentClickCount = 0 - toggleShowDelayed() - //onClickChange() - } - } else { - currentClickCount = 0 - } + // 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() + } - // call auto hide as it wont hide when you have your finger down - autoHide() + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) - // 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 - } - } - } - - // 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 - } - } - } + // If we rotate the device we need to recalculate the zoom + val gh = playerHostView?.gestureHelper ?: return + val matrix = gh.zoomMatrix + val animation = gh.matrixAnimation + if ((animation == null || !animation.isRunning) && matrix != null) { + // Ignore if we have no zoom or mid-animation + playerView?.post { + gh.applyZoomMatrix(matrix, true) + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() } } - currentTouchLast = currentTouch + } + + override fun resize(resize: PlayerResize, showToast: Boolean) { + super.resize(resize, showToast) + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + } + + private fun handleKeyDownEvent(keyCode: Int): Boolean? { + // adb shell input keyevent [INT] + when (keyCode) { + KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + player.handleEvent(CSPlayerEvent.SeekForward) + } + + KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { + player.handleEvent(CSPlayerEvent.SeekBack) + } + + KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { + player.handleEvent(CSPlayerEvent.PrevEpisode) + } + + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + player.handleEvent(CSPlayerEvent.Pause) + } + + KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { + player.handleEvent(CSPlayerEvent.Play) + } + + KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { + toggleLock() + } + + KeyEvent.KEYCODE_H -> { + onClickChange() + } + + KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { + player.handleEvent(CSPlayerEvent.ToggleMute) + } + + KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { + showMirrorsDialogue() + } + // OpenSubtitles shortcut + KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { + val context = context + if (subsProvidersIsActive && context != null) { + openOnlineSubPicker(context, null) {} + } + } + + KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { + showSpeedDialog() + } + + KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { + nextResize() + } + + KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { + skipOp() + } + + KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } + + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER -> { // space is not captured due to navigation + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + + // KEYCODE_DPAD_CENTER and KEYCODE_ENTER both act as a "select/confirm" button. + // Some remotes (e.g. LG Magic Remote) send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER. + // When the player UI or a dialog is visible, we let the event pass through (return null) + // so the focused button/item can handle the click normally, rather than always toggling + // play/pause. Only when the UI is hidden do we treat it as a play/pause toggle. + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER -> { + if (isShowing || isDialogOpen()) { + return null + } + // If UI is not shown make click instantly skip to next chapter even if locked + if (timestampShowState) { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } else if (!isLocked) { + player.handleEvent(CSPlayerEvent.PlayPauseToggle) + } + onClickChange() + } + + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_UP -> { + if (isShowing || isShowingEpisodeOverlay) { + return null + } + onClickChange() + } + + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { + player.seekTime(-androidTVInterfaceOffSeekTime) + return true + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(-androidTVInterfaceOnSeekTime) + return true + } else { + return null + } + } + + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { + player.seekTime(androidTVInterfaceOffSeekTime) + } else if (playerBinding?.playerPausePlay?.isFocused == true) { + player.seekTime(androidTVInterfaceOnSeekTime) + } else { + return null + } + } + + KeyEvent.KEYCODE_VOLUME_DOWN, + KeyEvent.KEYCODE_VOLUME_UP -> { + // Handled entirely by PlayerView.handleVolumeKey (checks PHONE/EMULATOR). + if (playerHostView?.handleVolumeKey(keyCode) != true) { + return null + } + } + + KeyEvent.KEYCODE_MENU, + KeyEvent.KEYCODE_SETTINGS -> { + if (isLocked || !isThereEpisodes()) { + return null + } + toggleEpisodesOverlay(true) + } + else -> return null // Avoid capturing all input + } return true } private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() - } else { - event.keyCode.let { keyCode -> - when (event.action) { - KeyEvent.ACTION_DOWN -> { - when (keyCode) { - KeyEvent.KEYCODE_DPAD_CENTER -> { - if (!isShowing) { - if (!isLocked) player.handleEvent(CSPlayerEvent.PlayPauseToggle) - onClickChange() - return true - } - } - KeyEvent.KEYCODE_DPAD_UP -> { - if (!isShowing) { - onClickChange() - return true - } - } - KeyEvent.KEYCODE_DPAD_LEFT -> { - if (!isShowing && !isLocked) { - player.seekTime(-androidTVInterfaceOffSeekTime) - return true - } else if (player_pause_play?.isFocused == true) { - player.seekTime(-androidTVInterfaceOnSeekTime) - return true - } - } - KeyEvent.KEYCODE_DPAD_RIGHT -> { - if (!isShowing && !isLocked) { - player.seekTime(androidTVInterfaceOffSeekTime) - return true - } else if (player_pause_play?.isFocused == true) { - player.seekTime(androidTVInterfaceOnSeekTime) - return true - } - } - } - } - } + return false + } + val keyCode = event.keyCode - when (keyCode) { - // don't allow dpad move when hidden + if (event.action == KeyEvent.ACTION_DOWN) { + val value = handleKeyDownEvent(keyCode) + if (value != null) { + return value + } + } - 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() - } - } + when (keyCode) { + // don't allow dpad move when hidden - // netflix capture back and hide ~monke - KeyEvent.KEYCODE_BACK -> { - if (isShowing && isTv) { - onClickChange() - return true - } - } + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN_LEFT, + KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, + KeyEvent.KEYCODE_DPAD_UP_LEFT, + KeyEvent.KEYCODE_DPAD_UP_RIGHT -> { + if (!isShowing) { + return true + } else { + autoHide() } } + + // netflix capture back and hide ~monke + // This is removed due to inconsistent behavior on A36 vs A22, see https://github.com/recloudstream/cloudstream/issues/1804 + /*KeyEvent.KEYCODE_BACK -> { + if (isShowing && isLayout(TV or EMULATOR)) { + onClickChange() + return true + } + }*/ } return false } protected fun uiReset() { + metadataVisibilityToken++ + playerBinding?.playerMetadataScrim?.let { + it.animate().cancel() + it.alpha = 0f + it.isVisible = false + } isShowing = false - + toggleEpisodesOverlay(false) // if nothing has loaded these buttons should not be visible - 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)) + + 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 } // handle tv controls directly based on player state - keyEventListener = { eventNav -> - // Don't hook player keys if player isn't active - if (player.isActive()) { - val (event, hasNavigated) = eventNav - if (event != null) - handleKeyEvent(event, hasNavigated) - else false - } else false - } - - //player_episodes_button?.setOnClickListener { - // player_episodes_button?.isGone = true - // player_episode_list?.isVisible = true - //} -// - //player_episode_list?.adapter = PlayerEpisodeAdapter { click -> -// - //} + setupKeyEventListener() try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) - fastForwardTime = - settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10) - .toLong() * 1000L - androidTVInterfaceOffSeekTime = - settingsManager.getInt(ctx.getString(R.string.android_tv_interface_off_seek_key), 10) + settingsManager.getInt( + ctx.getString(R.string.android_tv_interface_off_seek_key), + 10 + ) .toLong() * 1000L androidTVInterfaceOnSeekTime = - settingsManager.getInt(ctx.getString(R.string.android_tv_interface_on_seek_key), 10) + settingsManager.getInt( + ctx.getString(R.string.android_tv_interface_on_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 - ) playBackSpeedEnabled = settingsManager.getBoolean( ctx.getString(R.string.playback_speed_enabled_key), false ) + playerRotateEnabled = settingsManager.getBoolean( + ctx.getString(R.string.rotate_video_key), + false + ) playerResizeEnabled = settingsManager.getBoolean( ctx.getString(R.string.player_resize_enabled_key), true ) - doubleTapEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_enabled_key), - false - ) - - doubleTapPauseEnabled = - settingsManager.getBoolean( - ctx.getString(R.string.double_tap_pause_enabled_key), - false - ) - - currentPrefQuality = settingsManager.getInt( - ctx.getString(R.string.quality_pref_key), - currentPrefQuality + hideControlsNames = settingsManager.getBoolean( + ctx.getString(R.string.hide_player_control_names_key), + false ) - // useSystemBrightness = - // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) - } - player_speed_btt?.isVisible = playBackSpeedEnabled - player_resize_btt?.isVisible = playerResizeEnabled + val profiles = QualityDataHelper.getProfiles() + val type = if (ctx.isUsingMobileData()) + QualityDataHelper.QualityProfileType.Data + else QualityDataHelper.QualityProfileType.WiFi + + 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() + } + } } catch (e: Exception) { logError(e) } - player_pause_play?.setOnClickListener { - autoHide() - player.handleEvent(CSPlayerEvent.PlayPauseToggle) - } - - skip_chapter_button?.setOnClickListener { - player.handleEvent(CSPlayerEvent.SkipCurrentChapter) - } - - // init clicks - player_resize_btt?.setOnClickListener { - autoHide() - nextResize() - } - - player_speed_btt?.setOnClickListener { - autoHide() - showSpeedDialog() - } - - player_skip_op?.setOnClickListener { - autoHide() - skipOp() - } - - player_skip_episode?.setOnClickListener { - autoHide() - player.handleEvent(CSPlayerEvent.NextEpisode) - } - - player_lock?.setOnClickListener { - autoHide() - toggleLock() - } - - player_subtitle_offset_btt?.setOnClickListener { - showSubtitleOffsetDialog() - } - - exo_rew?.setOnClickListener { - autoHide() - rewind() - } - - exo_ffwd?.setOnClickListener { - autoHide() - fastForward() - } - - player_go_back?.setOnClickListener { - activity?.popCurrentPage() - } - - player_sources_btt?.setOnClickListener { - showMirrorsDialogue() - } - - player_tracks_btt?.setOnClickListener { - showTracksDialogue() - } - - // 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) - } - - exo_progress?.setOnTouchListener { _, event -> - // this makes the bar not disappear when sliding - when (event.action) { - MotionEvent.ACTION_DOWN -> { - currentTapIndex++ + 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 + } } - MotionEvent.ACTION_MOVE -> { - currentTapIndex++ - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { + } + + skipChapterButton.setOnClickListener { + player.handleEvent(CSPlayerEvent.SkipCurrentChapter) + } + + playerRotateBtt.setOnClickListener { + autoHide() + toggleRotate() + } + + // init clicks + playerResizeBtt.setOnClickListener { + autoHide() + nextResize() + } + + playerSpeedBtt.setOnClickListener { + autoHide() + showSpeedDialog() + } + + playerSkipOp.setOnClickListener { + autoHide() + skipOp() + } + + playerSkipEpisode.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + playerGoForward.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + playerRestart.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.Restart) + } + + playerLock.setOnClickListener { + autoHide() + toggleLock() + } + + playerSubtitleOffsetBtt.setOnClickListener { + showSubtitleOffsetDialog() + } + + playerGoBack.setOnClickListener { + activity?.popCurrentPage("FullScreenPlayer") + } + + playerSourcesBtt.setOnClickListener { + showMirrorsDialogue() + } + + playerTracksBtt.setOnClickListener { + showTracksDialogue() + } + + 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 67f58195f..17bef3ec0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -3,109 +3,213 @@ package com.lagradost.cloudstream3.ui.player import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Dialog +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.os.Build import android.os.Bundle +import android.text.Spanned import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.* +import android.widget.AbsListView +import android.widget.ArrayAdapter +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.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.mvvm.* +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding +import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding +import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding +import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding +import com.lagradost.cloudstream3.isAnimeOp +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.cloudstream3.isLiveStream +import com.lagradost.cloudstream3.isMovieType +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.observe +import com.lagradost.cloudstream3.mvvm.observeNullable +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.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.result.* -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1 -import com.lagradost.cloudstream3.utils.* +import com.lagradost.cloudstream3.ui.player.source_priority.LinkSource +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog +import com.lagradost.cloudstream3.ui.result.ACTION_CLICK_DEFAULT +import com.lagradost.cloudstream3.ui.result.EpisodeAdapter +import com.lagradost.cloudstream3.ui.result.FOCUS_SELF +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.ui.result.ResultFragment +import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo +import com.lagradost.cloudstream3.ui.result.ResultViewModel2 +import com.lagradost.cloudstream3.ui.result.SyncViewModel +import com.lagradost.cloudstream3.ui.result.setLinearListLayout +import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.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 java.util.* -import kotlin.collections.ArrayList -import kotlin.collections.HashMap +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() + } - private fun setSubtitles(sub: SubtitleData?): Boolean { - currentSelectedSubtitles = sub - //Log.i(TAG, "setSubtitles = $sub") - return player.setPreferredSubtitles(sub) + if (subtitleLanguageTagIETF != null) { + Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") + setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) + preferredAutoSelectSubtitles = subtitleLanguageTagIETF + } + } + + currentSelectedSubtitles = subtitle + //Log.i(TAG, "setSubtitles = $subtitle") + return player.setPreferredSubtitles(subtitle) } override fun embeddedSubtitlesFetched(subtitles: List) { @@ -114,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 @@ -136,7 +248,7 @@ class GeneratorPlayer : FullScreenPlayer() { return durPos.position } - var currentVerifyLink: Job? = null + private var currentVerifyLink: Job? = null private fun loadExtractorJob(extractorLink: ExtractorLink?) { currentVerifyLink?.cancel() @@ -150,17 +262,254 @@ class GeneratorPlayer : FullScreenPlayer() { } } - private fun loadLink(link: Pair?, sameEpisode: Boolean) { - if (link == null) return + // https://github.com/androidx/media/blob/main/libraries/ui/src/main/java/androidx/media3/ui/PlayerNotificationManager.java#L1517 + private fun createBroadcastIntent( + action: String, + context: Context, + instanceId: Int + ): PendingIntent { + val intent: Intent = Intent(action).setPackage(context.packageName) + intent.putExtra(EXTRA_INSTANCE_ID, instanceId) + val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else PendingIntent.FLAG_UPDATE_CURRENT + return PendingIntent.getBroadcast(context, instanceId, intent, pendingFlags) + } + + private var cachedPlayerNotificationManager: PlayerNotificationManager? = null + + private fun getMediaNotification(context: Context): PlayerNotificationManager { + val cache = cachedPlayerNotificationManager + if (cache != null) return cache + return PlayerNotificationManager.Builder( + context, + NOTIFICATION_ID, + CHANNEL_ID.toString() + ) + .setChannelNameResourceId(R.string.player_notification_channel_name) + .setChannelDescriptionResourceId(R.string.player_notification_channel_description) + .setMediaDescriptionAdapter(object : MediaDescriptionAdapter { + override fun getCurrentContentTitle(player: Player): CharSequence { + return when (val meta = currentMeta) { + is ResultEpisode -> { + meta.headerName + } + + is ExtractorUri -> { + meta.headerName ?: meta.name + } + + else -> null + } ?: "Unknown" + } + + override fun createCurrentContentIntent(player: Player): PendingIntent? { + // Open the app without creating a new task to resume playback seamlessly + return PendingIntentCompat.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + 0, + false + ) + } + + override fun getCurrentContentText(player: Player): CharSequence? { + return when (val meta = currentMeta) { + is ResultEpisode -> { + meta.name + } + + is ExtractorUri -> { + if (meta.headerName == null) { + null + } else { + meta.name + } + } + + else -> null + } + } + + override fun getCurrentLargeIcon( + player: Player, + callback: PlayerNotificationManager.BitmapCallback + ): Bitmap? { + ioSafe { + val url = when (val meta = currentMeta) { + is ResultEpisode -> { + meta.poster + } + + else -> null + } + // if we have a poster url try with it first + if (url != null) { + val urlBitmap = context.getImageBitmapFromUrl(url) + if (urlBitmap != null) { + callback.onBitmap(urlBitmap) + return@ioSafe + } + } + + // retry several times with a preview in case the preview generator is slow + repeat(10) { + val preview = this@GeneratorPlayer.player.getPreview(0.5f) + if (preview != null) { + callback.onBitmap(preview) + return@repeat + } + delay(1000L) + } + } + + // return null as we want to use the callback + return null + } + }).setCustomActionReceiver(object : PlayerNotificationManager.CustomActionReceiver { + // we have to use a custom action for stop if we want to exit the player instead of just stopping playback + override fun createCustomActions( + context: Context, + instanceId: Int + ): MutableMap { + return mutableMapOf( + STOP_ACTION to NotificationCompat.Action( + R.drawable.baseline_stop_24, + @SuppressLint("PrivateResource") + context.getString(androidx.media3.ui.R.string.exo_controls_stop_description), + createBroadcastIntent(STOP_ACTION, context, instanceId) + ) + ) + } + + override fun getCustomActions(player: Player): MutableList { + return mutableListOf(STOP_ACTION) + } + + override fun onCustomAction(player: Player, action: String, intent: Intent) { + when (action) { + STOP_ACTION -> { + exitPlayer() + } + } + } + }) + .setPlayActionIconResourceId(R.drawable.ic_baseline_play_arrow_24) + .setPauseActionIconResourceId(R.drawable.netflix_pause) + .setSmallIconResourceId(R.drawable.baseline_headphones_24) + .setStopActionIconResourceId(R.drawable.baseline_stop_24) + .setRewindActionIconResourceId(R.drawable.go_back_30) + .setFastForwardActionIconResourceId(R.drawable.go_forward_30) + .setNextActionIconResourceId(R.drawable.ic_baseline_skip_next_24) + .setPreviousActionIconResourceId(R.drawable.baseline_skip_previous_24) + .build().apply { + setColorized(true) // Color + setUseChronometer(true) // Seekbar + + // Don't show the prev episode button + setUsePreviousAction(false) + setUsePreviousActionInCompactView(false) + + // Don't show the next episode button + setUseNextAction(false) + setUseNextActionInCompactView(false) + + // Show the skip 30s in both modes + setUseFastForwardAction(true) + setUseFastForwardActionInCompactView(true) + + // Only show rewind in expanded + setUseRewindAction(true) + setUseFastForwardActionInCompactView(false) + + // Use custom stop action + setUseStopAction(false) + } + .also { cachedPlayerNotificationManager = it } + } + + override fun playerUpdated(player: Any?) { + super.playerUpdated(player) + + // Cancel the notification when released + if (player == null) { + cachedPlayerNotificationManager?.setPlayer(null) + cachedPlayerNotificationManager = null + return + } + + // setup the notification when starting the player + if (player is ExoPlayer) { + val ctx = context ?: return + getMediaNotification(ctx).apply { + setPlayer(player) + mMediaSession?.platformToken?.let { + setMediaSessionToken(it) + } + } + } + } + + override fun onDownload(event: DownloadEvent) { + super.onDownload(event) + showDownloadProgress(event) + } + + private fun showDownloadProgress(event: DownloadEvent) { + activity?.runOnUiThread { + playerBinding?.downloadedProgress?.apply { + val indeterminate = event.totalBytes <= 0 || event.downloadedBytes <= 0 + isIndeterminate = indeterminate + if (!indeterminate) { + max = (event.totalBytes / 1000).toInt() + progress = (event.downloadedBytes / 1000).toInt() + } + } + playerBinding?.downloadedProgressText.setText( + txt( + R.string.download_size_format, + android.text.format.Formatter.formatShortFileSize( + context, + event.downloadedBytes + ), + android.text.format.Formatter.formatShortFileSize(context, event.totalBytes) + ) + ) + val downloadSpeed = + android.text.format.Formatter.formatShortFileSize(context, event.downloadSpeed) + playerBinding?.downloadedProgressSpeedText?.text = + // todo string fmt + event.connections?.let { connections -> + "%s/s - %d Connections".format(downloadSpeed, connections) + } ?: downloadSpeed + + // don't display when done + playerBinding?.downloadedProgressSpeedText?.isGone = + event.downloadedBytes != 0L && event.downloadedBytes - 1024 >= event.totalBytes + } + } + + private fun loadLink(link: VideoLink?, sameEpisode: Boolean) { + if (link == null) return + isPlayerActive.set(true) // manage UI - 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) @@ -170,6 +519,7 @@ class GeneratorPlayer : FullScreenPlayer() { // load player context?.let { ctx -> val (url, uri) = link + val subtitles = viewModel.state.subtitles player.loadPlayer( ctx, sameEpisode, @@ -178,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) } } @@ -207,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 { @@ -220,6 +561,7 @@ class GeneratorPlayer : FullScreenPlayer() { } meta.name = newMeta.headerName } + is ExtractorUri -> { if (newMeta.tvType?.isMovieType() == false) { meta.episode = newMeta.episode @@ -231,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) { @@ -288,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) @@ -299,41 +645,41 @@ 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 - dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true) - dialog.year_btt.text = it?.toString() ?: txt(R.string.none).asString(context) + binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) + binding.yearBtt.text = it?.toString() ?: txt(R.string.none).asString(context) } - dialog.year_btt?.setOnClickListener { + binding.yearBtt.setOnClickListener { val none = txt(R.string.none).asString(context) val currentYear = Calendar.getInstance().get(Calendar.YEAR) val earliestYear = 1900 @@ -361,27 +707,41 @@ class GeneratorPlayer : FullScreenPlayer() { ) } - dialog.subtitles_search.setOnQueryTextListener(object : + 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( + SubtitleSearch( query = query ?: return@ioSafe, - imdb = imdbId, + imdbId = loadResponse?.getImdbId(), + tmdbId = loadResponse?.getTMDbId()?.toInt(), + malId = loadResponse?.getMalId()?.toInt(), + aniListId = loadResponse?.getAniListId()?.toInt(), epNumber = currentTempMeta.episode, seasonNumber = currentTempMeta.season, - lang = currentLanguageTwoLetters.ifBlank { null }, + lang = currentLanguageTagIETF.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) + + // TODO Make ui a lot better, like search with tabs val results = providers.amap { - try { - it.search(search) - } catch (e: Exception) { - null + when (val response = Resource.fromResult(it.search(search))) { + is Resource.Success -> { + response.value + } + + is Resource.Loading -> { + emptyList() + } + + is Resource.Failure -> { + showToast(response.errorString) + emptyList() + } } - }.filterNotNull() + } val max = results.maxOfOrNull { it.size } ?: return@ioSafe // very ugly @@ -396,7 +756,7 @@ class GeneratorPlayer : FullScreenPlayer() { // ugly ik activity?.runOnUiThread { setSubtitlesList(items) - dialog.search_loading_bar?.hide() + binding.searchLoadingBar.hide() } } @@ -408,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 + } } } } @@ -447,7 +838,7 @@ 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) } @@ -472,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 ) } @@ -499,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: Dialog? = null -// var selectTracksDialog: AlertDialog? = null + /** Will toast both when an error is found and when a subtitle is selected, + * so only use from a user click and not a background process */ + private fun addFirstSub(query: SubtitleSearch) = + viewModel.viewModelScope.launch { + // async should not have a race condition if they are on the same group + var hasSelectASubtitle = false + + // first come first served with these subtitles + // we might want to change it to prefer different sources when used multiple times, + // however caching might make this random after the first click too + subsProviders.toList().amap { provider -> + val success = when (val result = Resource.fromResult( + provider.search( + query = query + ) + )) { + is Resource.Failure -> { + // scope might cancel, so we do an extra check + if (this.isActive) { + showToast("${provider.idPrefix}${result.errorString}") + } + return@amap + } + + is Resource.Loading -> { + // unreachable + return@amap + } + + is Resource.Success -> { + result.value + } + } + + // try to add every subtitle until we have added a new subtitle file + for (subtitleEntry in success) { + if (hasSelectASubtitle || !this.isActive) { + break + } + + val subtitleResources = provider.resource(subtitleEntry).getOrNull() ?: continue + + val subtitles = subtitleResources.getSubtitles().map { resource -> + SubtitleData( + originalName = resource.name ?: getName(subtitleEntry, true), + nameSuffix = "", + url = resource.url, + origin = resource.origin, + mimeType = resource.url.toSubtitleMimeType(), + headers = subtitleEntry.headers, + languageCode = subtitleEntry.lang, + ) + } + + // checks for both a race condition and if any of the subs generated is new + if (this.isActive && !viewModel.state.subtitles.containsAll(subtitles) && !hasSelectASubtitle) { + hasSelectASubtitle = true + runOnMainThread { + addAndSelectSubtitles(*subtitles.toTypedArray()) + } + break + } + } + } + // maybe better error here? + if (!hasSelectASubtitle && this.isActive) { + showToast(R.string.no_subtitles) + } + } + override fun showMirrorsDialogue() { try { @@ -535,17 +1000,21 @@ class GeneratorPlayer : FullScreenPlayer() { //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") context?.let { ctx -> val isPlaying = player.getIsPlaying() - player.handleEvent(CSPlayerEvent.Pause) - val currentSubtitles = sortSubs(currentSubs) + player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) + val currentSubtitles = sortSubs(viewModel.state.subtitles) - val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) - sourceDialog.setContentView(R.layout.player_select_source_and_subs) + 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 @@ -558,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) @@ -566,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 @@ -576,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. @@ -642,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 { - sourceDialog.dismissSafe(activity) + 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) + } } - sourceDialog.subtitles_encoding_format?.apply { + 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() + } + + binding.subtitlesEncodingFormat.apply { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) @@ -664,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) @@ -676,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 } } @@ -713,6 +1354,7 @@ class GeneratorPlayer : FullScreenPlayer() { } } sourceDialog.dismissSafe(activity) + selectSourceDialog = null } } } catch (e: Exception) { @@ -733,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) + fixSystemBarsPadding(binding.root) - val tracksDialog = trackBuilder.create() + // selectTracksDialog = tracksDialog -// selectTracksDialog = tracksDialog + val videosList = binding.videoTracksList + val audioList = binding.autoTracksList - tracksDialog.show() - val videosList = tracksDialog.video_tracks_list - val audioList = tracksDialog.auto_tracks_list - - 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) { @@ -781,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 @@ -811,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) @@ -827,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) { @@ -836,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 @@ -899,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 @@ -921,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) { @@ -975,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() @@ -994,33 +1731,28 @@ class GeneratorPlayer : FullScreenPlayer() { subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null - val lang = fromTwoLettersToLanguage(langCode) ?: return null if (downloads) { - return subtitles.firstOrNull { sub -> - (sub.origin == SubtitleOrigin.DOWNLOADED_FILE && sub.name == context?.getString( - R.string.default_subtitles - )) - } + sortSubs(subtitles).firstOrNull { + it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode( + langCode + ) + }?.let { return it } } - sortSubs(subtitles).firstOrNull { sub -> - val t = sub.name.replace(Regex("[^A-Za-z]"), " ").trim() - (settings) && t == lang || t.startsWith(lang) || t == langCode - }?.let { sub -> - return sub - } + if (!settings) return null - return null + return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } } private fun autoSelectFromSettings(): Boolean { - // auto select subtitle based of settings + // auto select subtitle based on settings val langCode = preferredAutoSelectSubtitles val current = player.getCurrentPreferredSubtitle() Log.i(TAG, "autoSelectFromSettings = $current") context?.let { ctx -> - if (current != null) { - if (setSubtitles(current)) { + // Only use the player preferred subtitle if it matches the available language + if (current != null && (langCode == null || current.matchesLanguageCode(langCode))) { + if (setSubtitles(current, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) @@ -1028,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) @@ -1042,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 @@ -1082,6 +1822,7 @@ class GeneratorPlayer : FullScreenPlayer() { season = meta.season tvType = meta.tvType } + is ExtractorUri -> { headerName = meta.headerName subName = meta.name @@ -1112,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 @@ -1130,61 +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" - val title = when (titleRez) { - 0 -> "" - 1 -> extra - 2 -> source - 3 -> "$source - $extra" + private fun videoCodecName(mime: String?): String? { + val m = mime?.lowercase() ?: return null + return when { + m.contains("avc") || m.contains("h264") -> "AVC" + m.contains("hevc") || m.contains("h265") -> "HEVC" + m.contains("av1") -> "AV1" + m.contains("vp9") -> "VP9" + m.contains("vp8") -> "VP8" + "/" in m -> m.substringAfter("/").uppercase() + else -> m.uppercase() + } + } + + private fun audioCodecName(mime: String?): String { + val m = mime?.lowercase()?.trim().orEmpty() + if (m.isBlank()) return "" + return when { + m.contains("eac3-joc") -> "Dolby Atmos" + m.contains("truehd") -> "TrueHD" + m.contains("eac3") -> "E-AC3" + m.contains("ac-3") || m.contains("ac3") -> "AC3" + m.contains("aac") || m.contains("mp4a") -> "AAC" + m.contains("opus") -> "Opus" + m.contains("vorbis") -> "Vorbis" + m.contains("mp3") -> "MP3" + m.contains("flac") -> "FLAC" + m.contains("dts") -> "DTS" + m.contains("pcm") -> "PCM" + m.contains("alac") -> "ALAC" + m.contains("amr") -> "AMR" + m.contains("/") -> m.substringAfter("/").uppercase().takeIf { it.isNotBlank() } ?: "" else -> "" } - player_video_title_rez?.text = title - player_video_title_rez?.isVisible = title.isNotBlank() } - 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 @@ -1192,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)) { @@ -1206,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 @@ -1214,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 @@ -1228,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) @@ -1246,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() } } @@ -1274,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) { @@ -1305,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 - 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 - } + 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})" } - 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() + safe { + if (!isPlayerActive.get() && viewModel.state.links.any { link -> + getLinkPriority(currentQualityProfile, link.first) >= + QualityDataHelper.AUTO_SKIP_PRIORITY + } + ) { + startPlayer() + } + } + + 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 a1287e6aa..3ab46ce21 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt @@ -1,27 +1,51 @@ package com.lagradost.cloudstream3.ui.player import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.ExtractorLinkType -interface IGenerator { - val hasCache: Boolean +val LOADTYPE_INAPP = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8, + ExtractorLinkType.TORRENT, + ExtractorLinkType.MAGNET +) - fun hasNext(): Boolean - fun hasPrev(): Boolean - fun next() - fun prev() - fun goto(index: Int) +val LOADTYPE_INAPP_DOWNLOAD = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.M3U8 +) - fun getCurrentId(): Int? // this is used to save data or read data about this id - fun getCurrent(offset : Int = 0): Any? // this is used to get metadata about the current playing, can return null - fun getAll() : List? // this us used to get the metadata about all entries, not needed +val LOADTYPE_CHROMECAST = setOf( + ExtractorLinkType.VIDEO, + ExtractorLinkType.DASH, + ExtractorLinkType.M3U8 +) - /* not safe, must use try catch */ - suspend fun generateLinks( +val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() + + +abstract class NoVideoGenerator(val id : Int?) : VideoGenerator(emptyList()) { + override val hasCache = false + override val canSkipLoading = false + override fun getId(index: Int): Int? = id +} + +abstract class VideoGenerator(val videos: List) { + abstract val hasCache: Boolean + abstract val canSkipLoading: Boolean + abstract fun getId(index : Int) : Int? + + fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex + fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 + + @Throws + abstract suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset : Int = 0, + offset: Int, + isCasting: Boolean ): Boolean -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index ba5a4a85f..034237266 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -1,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 1f2424819..db06e26e9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt @@ -1,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 + +data class ExtractorUri( + val uri: Uri, + val name: String, + + val basePath: String? = null, + val relativePath: String? = null, + val displayName: String? = null, + + val id: Int? = null, + val parentId: Int? = null, + val episode: Int? = null, + val season: Int? = null, + val headerName: String? = null, + val tvType: TvType? = null, +) + +/** + * Used to open the player more easily with the LinkGenerator + **/ +data class BasicLink( + val url: String, + val name: String? = null, +) class LinkGenerator( - private val links: List, + private val links: List, private val extract: Boolean = true, - private val referer: String? = null, - private val isM3u8: Boolean? = null -) : IGenerator { - override val hasCache = false - - override fun getCurrentId(): Int? { - return null - } - - override fun hasNext(): Boolean { - return false - } - - override fun getAll(): List? { - return null - } - - override fun hasPrev(): Boolean { - return false - } - - override fun getCurrent(offset: Int): Any? { - return null - } - - override fun goto(index: Int) {} - - override fun next() {} - - override fun prev() {} - + private val refererUrl: String? = null, + id: Int? +) : NoVideoGenerator(id) { override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, - offset: Int + offset: Int, + isCasting: Boolean ): Boolean { links.amap { link -> - if (!extract || !loadExtractor(link, 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 3b47b27a6..000000000 --- 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 000000000..dcf976612 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt @@ -0,0 +1,95 @@ +package com.lagradost.cloudstream3.ui.player + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.core.content.ContextCompat.getString +import androidx.navigation.NavOptions +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.safefile.SafeFile + +object OfflinePlaybackHelper { + /** + * Pop any existing player off the nav back stack before pushing the new one, + * keeping the stack flat (at most one player at a time). This prevents an + * OOM when many files are opened in sequence via DownloadedPlayerActivity. + */ + private val replacePlayerNavOptions = NavOptions.Builder() + .setPopUpTo(R.id.navigation_player, inclusive = true, saveState = false) + .build() + + fun playLink(activity: Activity, url: String) { + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + LinkGenerator( + listOf( + BasicLink(url) + ), id = url.hashCode() + ), 0 + ), + replacePlayerNavOptions + ) + } + + // See CloudStreamPackage + fun playIntent(activity: Activity, intent: Intent?): Boolean { + if (intent == null) return false + val links = intent.getStringArrayExtra(CloudStreamPackage.LINKS_EXTRA) + ?.mapNotNull { tryParseJson(it) } ?: emptyList() + if (links.isEmpty()) return false + val subs = intent.getStringArrayExtra(CloudStreamPackage.SUBTITLE_EXTRA) + ?.mapNotNull { tryParseJson(it) } ?: emptyList() + + val id = intent.getIntExtra(CloudStreamPackage.ID_EXTRA, -1) + //val title = intent.getStringExtra(CloudStreamPackage.TITLE_EXTRA) // unused + val pos = intent.getLongExtra(CloudStreamPackage.POSITION_EXTRA, -1L) + val dur = intent.getLongExtra(CloudStreamPackage.DURATION_EXTRA, -1L) + + if (id != -1 && pos != -1L) { + val duration = if (dur != -1L) { + dur + } else DataStoreHelper.getViewPos(id)?.duration ?: pos + DataStoreHelper.setViewPos(id, pos, duration) + } + + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + MinimalLinkGenerator( + links, + subs, + if (id != -1) id else null, + ), 0 + ), + replacePlayerNavOptions + ) + return true + } + + fun playUri(activity: Activity, uri: Uri) { + if (uri.scheme == "magnet") { + playLink(activity, uri.toString()) + return + } + val name = SafeFile.fromUri(activity, uri)?.name() + activity.navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + DownloadFileGenerator( + listOf( + ExtractorUri( + uri = uri, + name = name ?: getString(activity, R.string.downloaded_file), + // well not the same as a normal id, but we take it as users may want to + // play downloaded files and save the location + id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode() + ) + ) + ), 0 + ), + replacePlayerNavOptions + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt new file mode 100644 index 000000000..f011ef37b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt @@ -0,0 +1,10 @@ +package com.lagradost.cloudstream3.ui.player + +import android.text.TextPaint +import android.text.style.CharacterStyle +import androidx.annotation.Px + +// source: https://github.com/androidx/media/pull/1840 +class OutlineSpan(@Px val outlineWidth : Float) : CharacterStyle() { + override fun updateDrawState(tp: TextPaint?) { tp?.strokeWidth = outlineWidth } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerEpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerEpisodeAdapter.kt deleted file mode 100644 index cfe27a304..000000000 --- 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 7faf0cf58..e3c390d50 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -5,178 +5,395 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.videoskip.SkipAPI +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.jetbrains.annotations.Contract +import java.util.concurrent.ConcurrentHashMap + +typealias VideoLink = Pair + +data class GeneratorState( + val meta: Any?, + val nextMeta: Any?, + val allMeta: List<*>?, + val response: LoadResponse?, + val index: Int, + val id: Int?, +) + +/** Immutable state of all current links relevant to displaying the video */ +// @MustUseReturnValues +// @Immutable +data class VideoState( + val subtitles: PersistentSet = persistentSetOf(), + val links: PersistentSet = persistentSetOf(), + val stamps: PersistentList = persistentListOf(), + val loading: Resource = Resource.Loading(), + val generatorState: GeneratorState? = null, + val instance: Int, +) { + /** + * This acts as a local cache for sorted links that are not copied over by the copy constructor. + * + * sortedBy is not exactly expensive, but each hasNextMirror does it again, so this alleviates unnecessary recomputation + * */ + private val sortedLinks: ConcurrentHashMap> = ConcurrentHashMap() + + fun clearSortedLinksCache() = sortedLinks.clear() + + // Modifying sortedLinks is not considered a "visible" side effect, and rerunning it does not change the result + // It is by all standards, idempotent and by extension also pure as it has no "visible" side effect + /** Returns .links in the sorted order according to the qualityProfile. + * Use .links if order is not needed */ + @Contract(pure = true) + fun sortLinks(qualityProfile: Int): List { + return sortedLinks[qualityProfile] ?: links.sortedBy { link -> + // negative because we want to sort highest quality first + -getLinkPriority(qualityProfile, link.first) + }.also { value -> sortedLinks[qualityProfile] = value } + } + + @Contract(pure = true) + fun add(item: SubtitleData): VideoState = copy(subtitles = subtitles.add(item)) + + @Contract(pure = true) + fun add(item: VideoLink): VideoState = copy(links = links.add(item)) + + @Contract(pure = true) + fun add(item: VideoSkipStamp): VideoState = copy(stamps = stamps.add(item)) + + @JvmName("addSubtitleData") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(subtitles = subtitles.addAll(items)) + + @JvmName("addVideoLink") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(links = links.addAll(items)) + + @JvmName("addVideoSkipStamp") + @Contract(pure = true) + fun add(items: Collection): VideoState = copy(stamps = stamps.addAll(items)) + + @Contract(pure = true) + fun set(item: SubtitleData): VideoState = copy(subtitles = persistentSetOf(item)) + + @Contract(pure = true) + fun set(item: VideoLink): VideoState = copy(links = persistentSetOf(item)) + + @Contract(pure = true) + fun set(item: VideoSkipStamp): VideoState = copy(stamps = persistentListOf(item)) + + @JvmName("setSubtitleData") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(subtitles = items.toPersistentSet()) + + @JvmName("setVideoLink") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(links = items.toPersistentSet()) + + @JvmName("setVideoSkipStamp") + @Contract(pure = true) + fun set(items: Collection): VideoState = copy(stamps = items.toPersistentList()) +} + +data class VideoLive( + val value: T, + val instance: Int, +) class PlayerGeneratorViewModel : ViewModel() { companion object { - val TAG = "PlayViewGen" + const val TAG = "PlayViewGen" } - private var generator: IGenerator? = null + @Volatile + var generator: VideoGenerator<*>? = null - private val _currentLinks = MutableLiveData>>(setOf()) - val currentLinks: LiveData>> = _currentLinks + @Volatile + var episodeIndex: Int = 0 - private val _currentSubs = MutableLiveData>(setOf()) - val currentSubs: LiveData> = _currentSubs + /** + * The state of the video player, only modify it by modifyState to make sure observe is called, + * and avoid concurrency issues. + * + * This value can be used without Synchronized or locking when reading, as all fields are immutable. + * */ + @Volatile + var state = VideoState(instance = 0) + private set - private val _loadingLinks = MutableLiveData>() - val loadingLinks: LiveData> = _loadingLinks + private val _currentLinks = + MutableLiveData>>>(null) + val currentLinks: LiveData>>> = _currentLinks - private val _currentStamps = MutableLiveData>(emptyList()) - val currentStamps: LiveData> = _currentStamps + private val _currentSubtitles = MutableLiveData>>(null) + val currentSubtitles: LiveData>> = _currentSubtitles + + private val _loadingLinks = MutableLiveData>>() + val loadingLinks: LiveData>> = _loadingLinks + + private val _currentStamps = MutableLiveData>>(null) + val currentStamps: LiveData>> = _currentStamps + + /** + * Modifies the `state` variable safely, and with the correct observe behavior. + * + * Synchronized to avoid concurrency issues, and make this operation atomic. + * Otherwise, one update may be lost if they are done in parallel. + * */ + @Synchronized + fun modifyState(op: VideoState.() -> VideoState) { + val oldState = state + state = op.invoke(oldState) + + /** New instance, always push state */ + if (state.instance != oldState.instance) { + _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + _currentStamps.postValue(VideoLive(state.stamps, state.instance)) + _currentLinks.postValue(VideoLive(state.links, state.instance)) + _loadingLinks.postValue(VideoLive(state.loading, state.instance)) + return + } + + /** + * Only post the changed values, this makes sure we do not invoke the "observe" + * + * We do this by "Referential equality" https://kotlinlang.org/docs/equality.html#referential-equality + * to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged. + * */ + if (state.links !== oldState.links) + _currentLinks.postValue(VideoLive(state.links, state.instance)) + if (state.stamps !== oldState.stamps) + _currentStamps.postValue(VideoLive(state.stamps, state.instance)) + if (state.subtitles !== oldState.subtitles) + _currentSubtitles.postValue(VideoLive(state.subtitles, state.instance)) + + /** Normal equality here as it is not a collection */ + if (state.loading != oldState.loading) + _loadingLinks.postValue(VideoLive(state.loading, state.instance)) + } private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear + /** + * Save the Episode ID to prevent starting multiple link loading Jobs when preloading links. + */ + private var currentLoadingEpisodeId: Int? = null + + var forceClearCache = false + fun setSubtitleYear(year: Int?) { _currentSubtitleYear.postValue(year) } - fun getId(): Int? { - return generator?.getCurrentId() - } - - fun loadLinks(episode: Int) { - generator?.goto(episode) - loadLinks() - } - fun loadLinksPrev() { Log.i(TAG, "loadLinksPrev") - if (generator?.hasPrev() == true) { - generator?.prev() + if (generator?.hasPrev(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun loadLinksNext() { Log.i(TAG, "loadLinksNext") - if (generator?.hasNext() == true) { - generator?.next() + if (generator?.hasNext(episodeIndex) == true) { + episodeIndex += 1 loadLinks() } } fun hasNextEpisode(): Boolean? { - return generator?.hasNext() + return generator?.hasNext(episodeIndex) + } + + fun hasPrevEpisode(): Boolean? { + return generator?.hasPrev(episodeIndex) } fun preLoadNextLinks() { + val id = generator?.getId(episodeIndex) + // Do not preload if already loading + if (id == currentLoadingEpisodeId) return + Log.i(TAG, "preLoadNextLinks") currentJob?.cancel() - currentJob = viewModelScope.launchSafe { - if (generator?.hasCache == true && generator?.hasNext() == true) { - safeApiCall { - generator?.generateLinks( - clearCache = false, - isCasting = false, - {}, - {}, - offset = 1 - ) + currentLoadingEpisodeId = id + + currentJob = viewModelScope.launch { + try { + if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) { + safeApiCall { + generator?.generateLinks( + sourceTypes = LOADTYPE_INAPP, + clearCache = false, + isCasting = false, + callback = {}, + subtitleCallback = {}, + offset = episodeIndex + 1 + ) + } + } + } catch (t: Throwable) { + logError(t) + } finally { + if (currentLoadingEpisodeId == id) { + currentLoadingEpisodeId = null } } } } - fun getMeta(): Any? { - return normalSafeApiCall { generator?.getCurrent() } + fun loadThisEpisode(index: Int) { + episodeIndex = index + loadLinks() } - fun getAllMeta(): List? { - return normalSafeApiCall { generator?.getAll() } - } - - fun getNextMeta(): Any? { - return normalSafeApiCall { - if (generator?.hasNext() == false) return@normalSafeApiCall null - generator?.getCurrent(offset = 1) - } - } - - fun attachGenerator(newGenerator: IGenerator?) { - if (generator == null) { - generator = newGenerator - } + fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) { + Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index") + generator = newGenerator + episodeIndex = index } /** * If duplicate nothing will happen * */ fun addSubtitles(file: Set) { - val currentSubs = _currentSubs.value ?: emptySet() - // Prevent duplicates - val allSubs = (currentSubs + file).distinct().toSet() - // Do not post if there's nothing new - // Posting will refresh subtitles which will in turn - // make the subs to english if previously unselected - if (allSubs != currentSubs) { - _currentSubs.postValue(allSubs) - } + val validFile = file.filter(::isValidSubtitle) + if (validFile.isNotEmpty()) + modifyState { + add(validFile) + } } private var currentJob: Job? = null private var currentStampJob: Job? = null fun loadStamps(duration: Long) { - //currentStampJob?.cancel() currentStampJob = ioSafe { - val meta = generator?.getCurrent() - val page = (generator as? RepoLinkGenerator?)?.page - if (page != null && meta is ResultEpisode) { - _currentStamps.postValue(listOf()) - _currentStamps.postValue( - EpisodeSkip.getStamps( - page, - meta, - duration, - hasNextEpisode() ?: false - ) - ) + val genState = state.generatorState ?: return@ioSafe + val meta = genState.meta + val page = genState.response + val id = genState.id + if (page == null || meta !is ResultEpisode) { + return@ioSafe + } + val stamps = SkipAPI.videoStamps( + page, + meta, + duration, + hasNextEpisode() ?: false + ) + + /** Avoid adding stamps to the wrong video */ + modifyState { + if (id != this.generatorState?.id) { + this + } else { + set(stamps) + } } } } - fun loadLinks(clearCache: Boolean = false, isCasting: Boolean = false) { - Log.i(TAG, "loadLinks") + var langFilterList = listOf() + var filterSubByLang = false + + fun isValidSubtitle(subtitle: SubtitleData): Boolean { + if (langFilterList.isEmpty() || !filterSubByLang) { + return true + } + + /** Only filter out subtitles fetched online */ + if (subtitle.origin != SubtitleOrigin.URL) { + return true + } + + return langFilterList.any { lang -> + subtitle.originalName.contains(lang, ignoreCase = true) + } + } + + fun loadLinks(sourceTypes: Set = LOADTYPE_INAPP) { + Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex") currentJob?.cancel() + val index = episodeIndex - currentJob = viewModelScope.launchSafe { - val currentLinks = mutableSetOf>() - val currentSubs = mutableSetOf() - - // clear old data - _currentSubs.postValue(currentSubs) - _currentLinks.postValue(currentLinks) - - // 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 - }) - } - - _loadingLinks.postValue(loadingState) - _currentLinks.postValue(currentLinks) - _currentSubs.postValue( - currentSubs.union(_currentSubs.value ?: emptySet()) + // Clear old data and reset the state + modifyState { + VideoState( + loading = Resource.Loading(), + generatorState = generator?.let { gen -> + GeneratorState( + meta = gen.videos.getOrNull(index), + nextMeta = gen.videos.getOrNull(index + 1), + id = gen.getId(index), + response = (gen as? RepoLinkGenerator)?.page, + index = index, + allMeta = gen.videos + ) + }, + instance = instance + 1 ) } + currentJob = viewModelScope.launchSafe { + // Load more data + val loadingState = safeApiCall { + generator?.generateLinks( + sourceTypes = sourceTypes, + clearCache = forceClearCache, + callback = { link -> + if (isActive) + modifyState { + add(link) + } + }, + isCasting = false, + offset = index, + subtitleCallback = { link -> + if (isActive && isValidSubtitle(link)) + modifyState { + add(link) + } + }) + Unit + } + + if (!isActive) { + return@launchSafe + } + + /** Only mark as success if we have not skipped loading */ + modifyState { + if (!isActive) { + this + } else { + when (loading) { + is Resource.Loading -> copy(loading = loadingState) + else -> this + } + } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt new file mode 100644 index 000000000..1c7086d12 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGestureHelper.kt @@ -0,0 +1,1220 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Matrix +import android.media.AudioManager +import android.media.audiofx.LoudnessEnhancer +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.keyEventListener +import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation +import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight +import com.lagradost.cloudstream3.utils.Vector2 +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.roundToInt + +/** + * Handles all gesture, volume, brightness, speed-up, zoom, and hardware-key-event input for a + * [PlayerView]. Keeps these separate from the player-view setup and lifecycle + * code in [PlayerView] itself. + * + * Instantiated and owned by [PlayerView]; accessed from host fragments via the delegate + * properties [PlayerView] exposes. + */ +@OptIn(UnstableApi::class) +class PlayerGestureHelper(private val playerView: PlayerView) { + + companion object { + /** Swipe-seek constants */ + const val MINIMUM_SEEK_TIME = 7000L + const val MINIMUM_VERTICAL_SWIPE = 2.0f // % of screen height + const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // % of screen height + const val VERTICAL_MULTIPLIER = 2.0f + const val HORIZONTAL_MULTIPLIER = 2.0f + + /** Double-tap constants */ + /** Maximum finger-hold time (ms) for a tap to qualify as a double-tap seek. */ + const val DOUBLE_TAP_MAXIMUM_HOLD_TIME = 200L + /** Time window (ms) between taps to count as a double-tap. + * Also determines how long a single-tap is delayed before firing. */ + const val DOUBLE_TAP_MINIMUM_TIME_BETWEEN = 200L + /** Fraction of view width on each side that counts as "left" / "right" seek zone. */ + const val DOUBLE_TAP_PAUSE_PERCENTAGE = 0.15 + + /** Zoom constants */ + /** Minimum zoom; allows zooming out past 100% but snaps back. */ + const val MINIMUM_ZOOM = 0.95f + /** Sensitivity for the auto-snap to 100% at the minimum zoom boundary. */ + const val ZOOM_SNAP_SENSITIVITY = 0.07f + /** Maximum zoom to prevent the user from getting lost. */ + const val MAXIMUM_ZOOM = 4.0f + + /** Extracts translation and uniform scale from a matrix with no rotation. */ + fun matrixToTranslationAndScale(matrix: Matrix): Triple { + val points = floatArrayOf(0f, 0f, 1f, 1f) + matrix.mapPoints(points) + val translationX = points[0] + val translationY = points[1] + val scale = points[2] - translationX + return Triple(translationX, translationY, scale) + } + } + + private val context: Context get() = playerView.context + + /** Set true by the host when the player occupies the full screen. + * Controls whether hardware volume-key overrides are active (phones/emulators only). */ + var isFullScreen: Boolean = false + + /** Volume state */ + var currentRequestedVolume: Float = 0.0f + var isVolumeLocked: Boolean = false + var hasShownVolumeToast: Boolean = false + private var loudnessEnhancer: LoudnessEnhancer? = null + private var progressBarLeftHideRunnable: Runnable? = null + + /** Brightness state */ + var currentRequestedBrightness: Float = 1.0f + var currentExtraBrightness: Float = 0.0f + var isBrightnessLocked: Boolean = false + var hasShownBrightnessToast: Boolean = false + /** When true, read/write system brightness via [Settings.System.SCREEN_BRIGHTNESS]. + * Automatically falls back to window-attribute brightness if the permission is missing. */ + var useTrueSystemBrightness: Boolean = true + /** White overlay inflated into exo_content_frame; alpha encodes extra brightness (0–1). */ + var brightnessOverlay: View? = null + private var progressBarRightHideRunnable: Runnable? = null + + /** Gesture settings (read from prefs in initialize) */ + var swipeVerticalEnabled: Boolean = true + var swipeHorizontalEnabled: Boolean = false + var extraBrightnessEnabled: Boolean = false + var speedupEnabled: Boolean = false + + /** Hold / speed-up */ + val holdHandler = Handler(Looper.getMainLooper()) + var hasTriggeredSpeedUp = false + val holdRunnable = Runnable { + playerView.player.setPlaybackSpeed(2.0f) + showOrHideSpeedUp(true) + playerView.callbacks?.onHoldSpeedUp(true) + hasTriggeredSpeedUp = true + } + + enum class TouchAction { Brightness, Volume, Time } + + /** Mirrors the host's lock state; suppresses gesture interactions when true. */ + var isLocked: Boolean = false + + /** Touch tracking */ + var isCurrentTouchValid = false + private set + private var currentTouchStart: Vector2? = null + private var currentTouchLast: Vector2? = null + /** Current in-progress swipe action, null when no swipe is active. */ + var currentTouchAction: TouchAction? = null + /** Action from the previous touch sequence; guards against mis-detected double-taps after swipes. */ + var currentLastTouchAction: TouchAction? = null + /** The time in the player when you first click. */ + private var currentTouchStartPlayerTime: Long? = null + /** The system time when you first click. */ + private var currentTouchStartTime: Long? = null + /** Whether the player UI was visible when the current swipe gesture began. */ + var uiShowingBeforeGesture: Boolean = false + + /** Icons */ + private val brightnessIcons = listOf( + R.drawable.sun_1, R.drawable.sun_2, R.drawable.sun_3, + R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, R.drawable.sun_7, + ) + private val volumeIcons = listOf( + R.drawable.ic_baseline_volume_mute_24, + R.drawable.ic_baseline_volume_down_24, + R.drawable.ic_baseline_volume_up_24, + ) + + /** Double-tap / tap state */ + + /** Whether double-tapping left/right seeks backward/forward. */ + var doubleTapEnabled: Boolean = false + + /** Whether double-tapping the center of the screen pauses (left/right still seeks if [doubleTapEnabled]). */ + var doubleTapPauseEnabled: Boolean = false + + /** Seek distance (ms) for each double-tap seek. Read from prefs in [initialize]. */ + var fastForwardTime: Long = 10_000L + + /** Monotonically-incremented token; cancels any pending single-tap runnable when a double-tap arrives. */ + private var doubleTapToken = 0 + + /** Number of consecutive taps in the current double-tap window. */ + private var tapCount = 0 + + /** System time of the most-recent touch end. Updated by callers at the end of every ACTION_UP. */ + var lastTouchEndTime: Long = 0L + + /** Zoom state */ + + /** Optional view for showing the snap-hint outline during zoom (set by FullScreenPlayer). */ + var videoOutline: View? = null + + /** Current zoom+pan matrix, or null when no zoom is active. */ + var zoomMatrix: Matrix? = null + + /** The matrix the zoom will animate to after the user lifts fingers. */ + var desiredMatrix: Matrix? = null + + /** Running snap-back animation, or null. */ + var matrixAnimation: ValueAnimator? = null + + private var scaleGestureDetector: ScaleGestureDetector? = null + + /** Midpoint of the two-finger pan, null when no pan is active. */ + var lastPan: Vector2? = null + + private var overlayLayoutListener: View.OnLayoutChangeListener? = null + + /** Called from [PlayerView.initialize] after views are bound. */ + fun initialize() { + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + swipeVerticalEnabled = sm.getBoolean(context.getString(R.string.swipe_vertical_enabled_key), true) + swipeHorizontalEnabled = sm.getBoolean(context.getString(R.string.swipe_enabled_key), true) + extraBrightnessEnabled = sm.getBoolean(context.getString(R.string.extra_brightness_key), false) + speedupEnabled = sm.getBoolean(context.getString(R.string.speedup_key), false) + doubleTapEnabled = sm.getBoolean(context.getString(R.string.double_tap_enabled_key), false) + doubleTapPauseEnabled = sm.getBoolean(context.getString(R.string.double_tap_pause_enabled_key), false) + fastForwardTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10).toLong() * 1000L + } catch (_: Exception) { + } + + // Inject the brightness overlay into the ExoPlayer content frame so it sits + // directly on top of the video surface. Alpha is set by handleBrightnessAdjustment. + safe { + val pkg = context.packageName + @SuppressLint("DiscouragedApi") + val contentId = context.resources.getIdentifier("exo_content_frame", "id", pkg) + val contentFrame = playerView.exoPlayerView?.findViewById(contentId) + if (contentFrame != null) { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + brightnessOverlay = LayoutInflater.from(context) + .inflate(R.layout.extra_brightness_overlay, contentFrame, false) + contentFrame.addView(brightnessOverlay) + } + } + + setupTouchGestures() + } + + /** Called from [PlayerView.release]. */ + fun release() { + safe { + brightnessOverlay?.let { + (it.parent as? ViewGroup)?.removeView(it) + } + } + brightnessOverlay = null + loudnessEnhancer?.release() + loudnessEnhancer = null + holdHandler.removeCallbacksAndMessages(null) + clearZoomState() + releaseOverlayLayoutListener() + } + + /** Key-event listener */ + + /** + * Registers the basic volume-key listener on [keyEventListener]. + * Called from [PlayerView.initialize] and from the host fragment's onResume. + */ + fun setupKeyEventListener() { + keyEventListener = { (event, _) -> + if (event != null && event.action == KeyEvent.ACTION_DOWN) + handleVolumeKey(event.keyCode) + else false + } + } + + /** Nulls [keyEventListener]. Called from the host fragment's onPause. */ + fun releaseKeyEventListener() { + keyEventListener = null + } + + /** Speed-up */ + + fun showOrHideSpeedUp(show: Boolean) { + playerView.playerSpeedupButton?.let { btn -> + btn.clearAnimation() + btn.alpha = if (show) 0f else 1f + btn.isVisible = show + btn.animate() + .alpha(if (show) 1f else 0f) + .setDuration(200L) + .withEndAction { if (!show) btn.isVisible = false } + .start() + } + } + + /** Volume helpers */ + + /** + * Syncs [currentRequestedVolume] with the current system stream volume. + * + * This is here to make returning to the player less jarring, if we change the volume outside + * the app. Note that this will make it a bit wierd when using loudness in PiP, then returning + * however that is the cost of correctness. + */ + fun verifyVolume() { + ((context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { am -> + val cur = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (cur < max || currentRequestedVolume <= 1.0f) { + currentRequestedVolume = cur.toFloat() / max.toFloat() + loudnessEnhancer?.release() + loudnessEnhancer = null + } + } + } + + /** + * Handles a hardware volume key press. + * Only active on phones/emulators when [isFullScreen] is true. + * + * @return true if the key was consumed (suppresses the system volume UI). + */ + fun handleVolumeKey(keyCode: Int): Boolean { + /** + * Some TVs do not support volume boosting, and overriding + * the volume buttons can be inconvenient for TV users. + * Since boosting volume is mainly useful on phones and emulators, + * we limit this feature to those devices. + */ + if (!isLayout(PHONE or EMULATOR) || !isFullScreen) return false + if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return false + verifyVolume() + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isVolumeLocked = currentRequestedVolume < 1.0f + // +- 5% + handleVolumeAdjustment(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 0.05f else -0.05f, fromButton = true) + return true + } + + fun handleVolumeAdjustment(delta: Float, fromButton: Boolean) { + val am = (context as? Activity)?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return + val curStep = am.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxStep = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + + val cur = currentRequestedVolume + val locked = isVolumeLocked + val next = (cur + delta).coerceIn(0.0f, if (locked) 1.0f else 2.0f) + val nextStep = (next * maxStep.toFloat()).roundToInt().coerceIn(0, maxStep) + + // Show toast + if (fromButton) { + // For button related request we only show a toast when we exceeded the volume. + if (cur <= 1.0f && next > 1.0f && !hasShownVolumeToast) { + showToast(R.string.volume_exceeded_100) + hasShownVolumeToast = true + } + } else { + val raw = cur + delta + // For swipes, we show toast that we need to swipe again. + if (raw > 1.0 && locked && !hasShownVolumeToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownVolumeToast = true + } + } + + // Set the current volume step. + if (nextStep != curStep) am.setStreamVolume(AudioManager.STREAM_MUSIC, nextStep, 0) + + var hasBoostError = false + // Apply loudness enhancer for volumes > 100%, removes it if less. + if (next > 1.0f) { + val boost = ((next - 1.0f) * 1000).toInt() + val existing = loudnessEnhancer + if (existing != null) { + existing.setTargetGain(boost) + } else { + val sessionId = (playerView.exoPlayerView?.player as? ExoPlayer)?.audioSessionId + if (sessionId != null && sessionId != AudioManager.ERROR) { + try { + loudnessEnhancer = LoudnessEnhancer(sessionId).apply { + setTargetGain(boost); enabled = true + } + } catch (t: Throwable) { logError(t); hasBoostError = true } + } + } + } else { + loudnessEnhancer?.release(); loudnessEnhancer = null + } + + currentRequestedVolume = next + + val leftHolder = playerView.playerProgressbarLeftHolder ?: return + val level1 = playerView.playerProgressbarLeftLevel1 ?: return + val level2 = playerView.playerProgressbarLeftLevel2 ?: return + val icon = playerView.playerProgressbarLeftIcon ?: return + + if (next > 1.0f) { + // Change color to show that LoudnessEnhancer broke + // this is not a real fix, but solves the crash issue. + level2.progressTintList = ColorStateList.valueOf( + ContextCompat.getColor(context, if (hasBoostError) R.color.colorPrimaryRed else R.color.colorPrimaryOrange) + ) + } + // Max is set high to make it smooth. + level1.max = 100_000 + level1.progress = (next * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.max = 100_000 + level2.progress = if (next > 1.0f) ((next - 1.0) * 100_000f).toInt().coerceIn(2_000, 100_000) else 0 + level2.isVisible = next > 1.0f + // Calculate the clamped index for the volume icon based on the requested volume. + val iconIdx = (next * volumeIcons.lastIndex).roundToInt().coerceIn(0, volumeIcons.lastIndex) + icon.setImageResource(volumeIcons[iconIdx]) + + if (!leftHolder.isVisible || leftHolder.alpha < 1f) { + leftHolder.animate().cancel(); leftHolder.alpha = 1f; leftHolder.isVisible = true + } + progressBarLeftHideRunnable?.let { leftHolder.removeCallbacks(it) } + progressBarLeftHideRunnable = Runnable { + leftHolder.animate().cancel() + leftHolder.animate().alpha(0f).setDuration(300).withEndAction { leftHolder.isVisible = false }.start() + } + // Show the progress bar for 1.5 seconds. + leftHolder.postDelayed(progressBarLeftHideRunnable, 1500) + } + + /** Brightness helpers */ + + /** + * Reads from [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun getBrightness(): Float? { + return if (useTrueSystemBrightness) { + try { + Settings.System.getInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS + ) / 255f + } catch (_: Exception) { + // Because true system brightness requires + // permission, this is a lazy way to check + // as it will throw an error if we do not have it. + useTrueSystemBrightness = false + getBrightness() + } + } else { + try { + (context as? Activity)?.window?.attributes?.screenBrightness?.takeIf { it >= 0f } + } catch (e: Exception) { + logError(e) + null + } + } + } + + /** + * Sets [Settings.System.SCREEN_BRIGHTNESS], falling back to the window + * attribute if the permission is absent. + */ + fun setBrightness(brightness: Float) { + if (useTrueSystemBrightness) { + try { + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS_MODE, + Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL + ) + Settings.System.putInt( + (context as? Activity)?.contentResolver, + Settings.System.SCREEN_BRIGHTNESS, + min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) + ) + } catch (_: Exception) { + useTrueSystemBrightness = false + setBrightness(brightness) + } + } else { + try { + val lp = (context as? Activity)?.window?.attributes ?: return + // Use 0.004f instead of 0: on some devices a value too close to 0 causes the + // system to override with its own brightness, making fine-tuning impossible. + lp.screenBrightness = brightness.coerceIn(0.004f, 1.0f) + (context as? Activity)?.window?.attributes = lp + } catch (e: Exception) { + logError(e) + } + } + } + + fun handleBrightnessAdjustment(verticalAddition: Float) { + val lastBrightness = currentRequestedBrightness + val raw = currentRequestedBrightness + verticalAddition + val next = raw.coerceIn(0.0f, if (extraBrightnessEnabled && !isBrightnessLocked) 2.0f else 1.0f) + + if (extraBrightnessEnabled && isBrightnessLocked && raw > 1.0f && !hasShownBrightnessToast) { + showToast(R.string.slide_up_again_to_exceed_100) + hasShownBrightnessToast = true + } + + currentRequestedBrightness = next + if (lastBrightness != currentRequestedBrightness) setBrightness(currentRequestedBrightness) + + currentExtraBrightness = if (extraBrightnessEnabled && next > 1.0f) min(2.0f, next) - 1.0f else 0.0f + brightnessOverlay?.alpha = currentExtraBrightness + playerView.callbacks?.onBrightnessExtra(currentExtraBrightness) + + val rightHolder = playerView.playerProgressbarRightHolder ?: return + val level1 = playerView.playerProgressbarRightLevel1 ?: return + val level2 = playerView.playerProgressbarRightLevel2 ?: return + val icon = playerView.playerProgressbarRightIcon ?: return + + level1.max = 100_000 + level1.progress = max(2_000, (min(1.0f, next) * 100_000f).toInt()) + + if (extraBrightnessEnabled) { + level2.max = 100_000 + level2.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) + level2.isVisible = next > 1.0f + } + + icon.setImageResource( + // Clamp the value in case of extra brightness. + brightnessIcons[min(brightnessIcons.lastIndex, max(0, round(next * brightnessIcons.lastIndex).toInt()))] + ) + + if (!rightHolder.isVisible || rightHolder.alpha < 1f) { + rightHolder.animate().cancel(); rightHolder.alpha = 1f; rightHolder.isVisible = true + } + progressBarRightHideRunnable?.let { rightHolder.removeCallbacks(it) } + progressBarRightHideRunnable = Runnable { + rightHolder.animate().cancel() + rightHolder.animate().alpha(0f).setDuration(300).withEndAction { rightHolder.isVisible = false }.start() + } + rightHolder.postDelayed(progressBarRightHideRunnable, 1500) + } + + /** Zoom helpers */ + + /** + * Returns the current zoom matrix, accounting for RESIZE_MODE_ZOOM which already has + * an implicit zoom applied. + * + * This is different from `zoomMatrix ?: Matrix()` + * because it allows used to start zooming at different resizeModes. + * + * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM + * 100% will make the zoom snap to less zoomed in then you already are. + */ + fun currentZoomMatrix(): Matrix { + val current = zoomMatrix + if (current != null) return current + + val exoView = playerView.exoPlayerView + val videoView = exoView?.videoSurfaceView + + if (exoView == null || videoView == null || + exoView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { + return Matrix() + } + + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f) { + return Matrix() + } + + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = max(initAspect, 1f / initAspect) + return Matrix().apply { postScale(aspect, aspect) } + } + + /** + * Applies [newMatrix] (scale + translation only) to the video surface view. + * + * @param newMatrix The new zoom matrix + * @param animation If this zoom is part of an animation, as then it will not auto zoom after we are done. + */ + fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { + val exoView = playerView.exoPlayerView ?: return + if (!animation) { + matrixAnimation?.cancel() + matrixAnimation = null + } + val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) + + if (exoView.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { + exoView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + + val videoView = exoView.videoSurfaceView ?: return + val videoWidth = videoView.width.toFloat() + val videoHeight = videoView.height.toFloat() + val playerWidth = screenWidthWithOrientation.toFloat() + val playerHeight = screenHeightWithOrientation.toFloat() + + // Sanity check + if (videoWidth <= 1f || videoHeight <= 1f || playerWidth <= 1f || playerHeight <= 1f || scale <= 0.01f) return + + // Calculate the scaled aspect ratio as the view height is not real, check the debugger + // and you will see videoView.height > screen.height. + val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) + val aspect = min(initAspect, 1f / initAspect) + val scaledAspect = scale * aspect + + // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight. + val maxTransX = max(0f, videoWidth * scaledAspect - playerWidth) * 0.5f + val maxTransY = max(0f, videoHeight * scaledAspect - playerHeight) * 0.5f + + // Correct the translation to clamp within the viewing area. + val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) + val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) + + // Set the transform to the correct x and y. + newMatrix.postTranslate( + expectedTranslationX - translationX, + expectedTranslationY - translationY + ) + zoomMatrix = newMatrix + + if (!animation) { + // If we are not in an animation, set up the values for the animation. + if ((scaledAspect - 1f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { + // We are within the correct scaling, so center and fit it. + videoOutline?.isVisible = true + val desired = Matrix() + desired.setScale(1f / aspect, 1f / aspect) + desiredMatrix = desired + } else if (scale < 1f) { + // We have zoomed too far, zoom to 100%. + videoOutline?.isVisible = false + desiredMatrix = Matrix() + } else { + // Keep the same scaling after zoom. + videoOutline?.isVisible = false + desiredMatrix = null + } + } + + // Finally set the actual scale + translation. + videoView.scaleX = scaledAspect + videoView.scaleY = scaledAspect + videoView.translationX = expectedTranslationX + videoView.translationY = expectedTranslationY + updateBrightnessOverlayBounds() + } + + /** + * Clears all zoom state and resets the video surface view to 1:1 scale. + * Does NOT change the ExoPlayer resize mode - call [PlayerView.resize] separately. + */ + fun clearZoomState() { + matrixAnimation?.cancel() + matrixAnimation = null + zoomMatrix = null + desiredMatrix = null + scaleGestureDetector = null + lastPan = null + playerView.exoPlayerView?.videoSurfaceView?.apply { + scaleX = 1f + scaleY = 1f + translationX = 0f + translationY = 0f + } + } + + /** + * Resets zoom to fit mode if any zoom is currently active. + * Calls [PlayerView.resize] to update the ExoPlayer resize mode. + */ + fun resetZoomToDefault() { + if (zoomMatrix != null) { + clearZoomState() + playerView.resize(PlayerResize.Fit, false) + } + } + + private fun createScaleGestureDetector(ctx: Context) { + scaleGestureDetector = ScaleGestureDetector( + ctx, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val matrix = currentZoomMatrix() + val (_, _, scale) = matrixToTranslationAndScale(matrix) + // Clamp scale of the zoom, do it here as it is easier than doing it within applyZoomMatrix. + val newScale = (scale * detector.scaleFactor).coerceIn(MINIMUM_ZOOM, MAXIMUM_ZOOM) + // This is how much we should scale it with to prevent infinite scaling. + val actualScaleFactor = newScale / scale + // Scale around the focus point, this is more natural than just zoom. + val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f + val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f + matrix.postScale(actualScaleFactor, actualScaleFactor, pivotX, pivotY) + applyZoomMatrix(matrix, false) + return true + } + } + ) + } + + /** + * Processes a two-finger zoom/pan gesture event. + * Handles scale detection, panning, and the snap-back animation after finger lift. + * + * @param event The motion event (should have pointerCount >= 2 or [lastPan] != null). + * @param ctx Context used to create the [ScaleGestureDetector] on first call. + * @param onFirstPointerDown Called on [MotionEvent.ACTION_POINTER_DOWN] (e.g. hide player UI). + * @param onGestureEnd Called when the gesture ends (e.g. reset caller touch state). + * @return Always true (event consumed). + */ + fun handleZoomPanGesture( + event: MotionEvent, + ctx: Context, + onFirstPointerDown: () -> Unit, + onGestureEnd: () -> Unit + ): Boolean { + if (scaleGestureDetector == null) createScaleGestureDetector(ctx) + scaleGestureDetector?.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + onFirstPointerDown() + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount >= 2) { + val newPan = Vector2( + (event.getX(0) + event.getX(1)) / 2f, + (event.getY(0) + event.getY(1)) / 2f + ) + val oldPan = lastPan + if (oldPan != null) { + val matrix = currentZoomMatrix() + matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) + applyZoomMatrix(matrix, false) + } + lastPan = newPan + } + } + + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP -> { + lastPan = null + videoOutline?.isVisible = false + matrixAnimation?.cancel() + matrixAnimation = null + + // Snap to desired matrix after zoom gesture ends + matrixAnimation = ValueAnimator.ofFloat(0f, 1f).apply { + startDelay = 0 + duration = 200 + val startMatrix = currentZoomMatrix() + val endMatrix = desiredMatrix ?: return@apply + val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) + val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) + addUpdateListener { anim -> + val v = anim.animatedValue as Float + val vInv = 1f - v + val m = Matrix() + m.setScale(startScale * vInv + endScale * v, startScale * vInv + endScale * v) + m.postTranslate(startX * vInv + endX * v, startY * vInv + endY * v) + applyZoomMatrix(m, true) + } + start() + } + + onGestureEnd() + } + } + return true + } + + /** + * Resizes and repositions [brightnessOverlay] to exactly match the visible video surface, + * accounting for zoom scale and translation. + */ + fun updateBrightnessOverlayBounds() { + val overlay = brightnessOverlay ?: return + val pv = playerView.exoPlayerView ?: return + val video = pv.videoSurfaceView ?: return + + // Compute accurate transformed bounding box of the video view after scale+translation. + val vw = video.width.toFloat() + val vh = video.height.toFloat() + val sx = video.scaleX + val sy = video.scaleY + if (vw <= 0f || vh <= 0f) return + + // Pivot defaults to center if not set. + val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f + val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f + // Use view position (includes translation) as base; avoid double-counting translation. + val tx = video.x + val ty = video.y + + // Transform function for a local point (lx,ly). + fun transform(lx: Float, ly: Float): Pair { + val gx = tx + pivotX + (lx - pivotX) * sx + val gy = ty + pivotY + (ly - pivotY) * sy + return Pair(gx, gy) + } + + val p0 = transform(0f, 0f); val p1 = transform(vw, 0f) + val p2 = transform(0f, vh); val p3 = transform(vw, vh) + + val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) + val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) + val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) + val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) + + val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) + val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) + + val lp = overlay.layoutParams + if (lp == null) { + overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) + } else if (lp.width != newW || lp.height != newH) { + lp.width = newW; lp.height = newH + overlay.layoutParams = lp + } + + overlay.scaleX = 1f; overlay.scaleY = 1f + overlay.x = minX; overlay.y = minY + } + + /** + * Attaches a persistent layout-change listener to the ExoPlayer view so + * [updateBrightnessOverlayBounds] is called on every layout pass (orientation change, + * aspect-ratio change, zoom, PiP transition, etc.). + */ + fun requestUpdateBrightnessOverlayOnNextLayout() { + val exoView = playerView.exoPlayerView ?: return + overlayLayoutListener?.let { exoView.removeOnLayoutChangeListener(it) } + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + safe { updateBrightnessOverlayBounds() } + } + overlayLayoutListener = listener + exoView.addOnLayoutChangeListener(listener) + } + + /** Removes the overlay layout listener registered by [requestUpdateBrightnessOverlayOnNextLayout]. */ + fun releaseOverlayLayoutListener() { + overlayLayoutListener?.let { playerView.exoPlayerView?.removeOnLayoutChangeListener(it) } + overlayLayoutListener = null + } + + /** Rewind / fast-forward animations */ + + /** Resets the rewind button label to the standard "–Xs" format. */ + fun resetRewindText() { + playerView.exoRewText?.text = context.getString(R.string.rew_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** Resets the fast-forward button label to the standard "+Xs" format. */ + fun resetFastForwardText() { + playerView.exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format) + .format(fastForwardTime / 1000) + } + + /** + * Fades playerRewHolder, playerFfwdHolder, and playerPausePlay to [fadeTo] (0f or 1f). + * Always resets the holder alphas to 1f first so any stale fillAfter state is cleared. + * Called from host fragments' show/hide control animations so both GeneratorPlayer and trailer share + * the same fade logic. + */ + fun animateCenterControls(fadeTo: Float) { + val from = if (fadeTo > 0.5f) 0f else 1f + fun makeAnim() = AlphaAnimation(from, fadeTo).apply { duration = 100; fillAfter = true } + // Each view needs its own Animation instance; sharing one causes fillAfter to + // not hold reliably across all views once any of them restarts the animation. + playerView.playerRewHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerFfwdHolder?.let { it.alpha = 1f; it.startAnimation(makeAnim()) } + playerView.playerPausePlay?.startAnimation(makeAnim()) + } + + /** Plays the rewind animation and seeks back by [fastForwardTime]. */ + fun rewind() { + try { + val rewHolder = playerView.playerRewHolder ?: return + val rew = playerView.playerRew + val rewText = playerView.exoRewText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + // Only expose the parent chain when controls are currently hidden. + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + rewHolder.alpha = 1f + + rew?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_left)) + val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) + goLeft.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + rewText?.post { + resetRewindText() + // Restore parent chain only if we changed it and controls are still hidden. + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + rewHolder.alpha = 0f + } + } + } + }) + rewText?.startAnimation(goLeft) + rewText?.text = context.getString(R.string.rew_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(-fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Plays the fast-forward animation and seeks forward by [fastForwardTime]. */ + fun fastForward() { + try { + val ffwdHolder = playerView.playerFfwdHolder ?: return + val ffwd = playerView.playerFfwd + val ffwdText = playerView.exoFfwdText + val wasShowing = playerView.callbacks?.isUIShowing() ?: false + + val prevCenterMenuGone = playerView.playerCenterMenu?.isGone ?: false + val prevVideoHolderVisible = playerView.playerVideoHolder?.isVisible ?: true + if (!wasShowing) { + playerView.playerCenterMenu?.isGone = false + playerView.playerVideoHolder?.isVisible = true + } + // Always clear any stale fillAfter alpha so the button is visible during animation. + ffwdHolder.alpha = 1f + + ffwd?.startAnimation(AnimationUtils.loadAnimation(context, R.anim.rotate_right)) + val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) + goRight.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation?) {} + override fun onAnimationRepeat(animation: Animation?) {} + override fun onAnimationEnd(animation: Animation?) { + ffwdText?.post { + resetFastForwardText() + if (!wasShowing && !(playerView.callbacks?.isUIShowing() ?: false)) { + playerView.playerCenterMenu?.isGone = prevCenterMenuGone + playerView.playerVideoHolder?.isVisible = prevVideoHolderVisible + ffwdHolder.alpha = 0f + } + } + } + }) + ffwdText?.startAnimation(goRight) + ffwdText?.text = context.getString(R.string.ffw_text_format).format(fastForwardTime / 1000) + playerView.player.seekTime(fastForwardTime) + } catch (e: Exception) { logError(e) } + } + + /** Double-tap detection */ + + /** + * Call when a valid tap is detected (short hold, minimal movement, valid touch area). + * Routes to double-tap seeking/pausing or schedules a delayed single-tap callback. + * + * Updates [lastTouchEndTime] when a confirmed tap (single or double) is recorded. + * + * @param x X coordinate of the tap in the view's coordinate space. + * @param viewWidth Width of the view (used to compute left/center/right zones). + * @param isLocked Whether player controls are locked (suppresses double-tap seek). + * @param onSingleTap Invoked when it is determined to be a single tap; may be deferred. + * @return true if a double-tap action was performed. + */ + fun onTapDetected(x: Float, viewWidth: Int, isLocked: Boolean, onSingleTap: () -> Unit): Boolean { + val anyDoubleTap = doubleTapEnabled || doubleTapPauseEnabled + if (!anyDoubleTap) { + onSingleTap() + return false + } + + val timeSinceLast = System.currentTimeMillis() - lastTouchEndTime + return if (!isLocked && timeSinceLast < DOUBLE_TAP_MINIMUM_TIME_BETWEEN) { + /** Double-tap */ + tapCount++ + doubleTapToken++ // cancel any pending single-tap runnable + if (doubleTapPauseEnabled) { + when { + x < viewWidth / 2f - (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) rewind() + } + x > viewWidth / 2f + (DOUBLE_TAP_PAUSE_PERCENTAGE * viewWidth) -> { + if (doubleTapEnabled) fastForward() + } + else -> { + playerView.player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + } else if (doubleTapEnabled) { + if (x < viewWidth / 2f) rewind() else fastForward() + } + true + } else { + /** Single tap (first tap, or too slow for double-tap) */ + tapCount = 0 + val token = ++doubleTapToken + playerView.playerHolder?.postDelayed({ + if (token == doubleTapToken) { + onSingleTap() + } + }, DOUBLE_TAP_MINIMUM_TIME_BETWEEN) + false + } + } + + /** Seek time helpers */ + + private fun calculateNewTime(startTime: Long?, touchStart: Vector2?, touchEnd: Vector2?): Long? { + if (touchStart == null || touchEnd == null || startTime == null) return null + val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() + val duration = playerView.player.getDuration() ?: return null + return max(min(startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), duration), 0) + } + + private fun forceLetters(inp: Long, letters: Int = 2): String { + val added = letters - inp.toString().length + return if (added > 0) "0".repeat(added) + inp.toString() else inp.toString() + } + + private fun convertTimeToString(sec: Long): String { + val rsec = sec % 60L + val min = ceil((sec - rsec) / 60.0).toInt() + val rmin = min % 60L + val h = ceil((min - rmin) / 60.0).toLong() + // int rh = h;// h % 24; + return (if (h > 0) forceLetters(h) + ":" else "") + + (if (rmin >= 0 || h >= 0) forceLetters(rmin) + ":" else "") + + forceLetters(rsec) + } + + /** Touch gestures */ + + fun setupTouchGestures() { + val holder = playerView.playerHolder ?: return + @SuppressLint("ClickableViewAccessibility") + holder.setOnTouchListener(::handleGesture) + } + + private fun isValidTouch(rawX: Float, rawY: Float): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val holder = playerView.playerHolder ?: return true + val insets = holder.rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + val validHeight = rawY > insets.top && rawY < screenHeightWithOrientation - insets.bottom + val validWidth = rawX > insets.left && rawX < screenWidthWithOrientation - insets.right + return validHeight && validWidth + } + + return rawY > context.getStatusBarHeight() && rawX < screenWidthWithOrientation + } + + private fun handleGesture(view: View, event: MotionEvent): Boolean { + val currentTouch = Vector2(event.x, event.y) + val startTouch = currentTouchStart + + /** Two-finger zoom/pan (fullscreen, unlocked) */ + if ((event.pointerCount >= 2 || lastPan != null) && isFullScreen && !isLocked + && !hasTriggeredSpeedUp && currentTouchAction == null) { + holdHandler.removeCallbacks(holdRunnable) // Remove 2x speed. + isCurrentTouchValid = false // Prevent other touches + return handleZoomPanGesture( + event = event, + ctx = view.context, + onFirstPointerDown = { + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + }, + onGestureEnd = { + currentTouchStart = null + currentLastTouchAction = null + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + } + ) + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + isCurrentTouchValid = isValidTouch(event.rawX, event.rawY) + if (isCurrentTouchValid) { + playerView.callbacks?.onTouchDown() + hasTriggeredSpeedUp = false + if (speedupEnabled && playerView.player.getIsPlaying() && !isLocked) { + holdHandler.postDelayed(holdRunnable, 500) + } + isVolumeLocked = currentRequestedVolume < 1.0f + if (currentRequestedVolume <= 1.0f) hasShownVolumeToast = false + isBrightnessLocked = currentRequestedBrightness < 1.0f + if (currentRequestedBrightness <= 1.0f) hasShownBrightnessToast = false + currentTouchStartTime = System.currentTimeMillis() + currentTouchStart = currentTouch + currentTouchLast = currentTouch + currentTouchStartPlayerTime = playerView.player.getPosition() + getBrightness()?.let { currentRequestedBrightness = it + currentExtraBrightness } + verifyVolume() + } + return true + } + + MotionEvent.ACTION_MOVE -> { + if (hasTriggeredSpeedUp) return true + if (!isCurrentTouchValid) return true + + if (currentTouchAction == null && startTouch != null) { + val diffFromStart = startTouch - currentTouch + if (swipeVerticalEnabled) { + if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + uiShowingBeforeGesture = playerView.callbacks?.isUIShowing() ?: false + playerView.callbacks?.onHidePlayerUI() + currentTouchAction = if ((startTouch.x) >= view.width / 2f) + TouchAction.Volume else TouchAction.Brightness + } + } + if (swipeHorizontalEnabled && !isLocked) { + if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { + holdHandler.removeCallbacks(holdRunnable) + currentTouchAction = TouchAction.Time + } + } + } + + val lastTouch = currentTouchLast + if (lastTouch != null) { + val diffFromLast = lastTouch - currentTouch + val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / view.height.toFloat() + when (currentTouchAction) { + TouchAction.Time -> { + // This simply updates UI as the seek logic happens on release + // startTime is rounded to make the UI sync in a nice way. + val startTime = currentTouchStartPlayerTime?.div(1000L)?.times(1000L) + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { newMs -> + val skipMs = newMs - startTime + playerView.callbacks?.onSeekPreviewText( + "${convertTimeToString(newMs / 1000)} [${ + if (abs(skipMs) < 1000) "" else if (skipMs > 0) "+" else "-" + }${convertTimeToString(abs(skipMs / 1000))}]" + ) + } + } + } + TouchAction.Brightness -> if (!isLocked) handleBrightnessAdjustment(verticalAddition) + TouchAction.Volume -> if (!isLocked) handleVolumeAdjustment(verticalAddition, false) + null -> Unit + } + if (currentTouchAction != TouchAction.Time) { + playerView.callbacks?.onSeekPreviewText(null) + } + } + currentTouchLast = currentTouch + return true + } + + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + holdHandler.removeCallbacks(holdRunnable) + if (hasTriggeredSpeedUp) { + playerView.player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) + showOrHideSpeedUp(false) + playerView.callbacks?.onHoldSpeedUp(false) + hasTriggeredSpeedUp = false + } + + if (isCurrentTouchValid) { + // Horizontal seek on release + if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time && !isLocked) { + val startTime = currentTouchStartPlayerTime + if (startTime != null) { + calculateNewTime(startTime, startTouch, currentTouch)?.let { seekTo -> + if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { + playerView.player.seekTo(seekTo, PlayerEventSource.UI) + } + } + } + } + // Tap detection: only fire if the finger was held briefly (not a long-press). + val holdTime = currentTouchStartTime?.let { System.currentTimeMillis() - it } + if (currentTouchAction == null && currentLastTouchAction == null + && !hasTriggeredSpeedUp + && (holdTime == null || holdTime < DOUBLE_TAP_MAXIMUM_HOLD_TIME)) { + onTapDetected( + x = currentTouch.x, + viewWidth = view.width, + isLocked = isLocked, + onSingleTap = { playerView.callbacks?.onSingleTap() } + ) + } + } + + playerView.callbacks?.onSeekPreviewText(null) + val hadSwipe = currentTouchAction != null || currentLastTouchAction != null + playerView.callbacks?.onGestureEnd(hadSwipe, uiShowingBeforeGesture) + + // Reset touch + lastTouchEndTime = System.currentTimeMillis() + isCurrentTouchValid = false + currentTouchStart = null + currentLastTouchAction = currentTouchAction + currentTouchAction = null + currentTouchStartPlayerTime = null + currentTouchLast = null + currentTouchStartTime = null + uiShowingBeforeGesture = false + return true + } + } + return false + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt index 0fbc22f69..0db06499e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt @@ -1,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 + } + } + + /** Is pip enabled in app settings? */ + private fun Context.hasPIPEnabled(): Boolean { + return try { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + settingsManager?.getBoolean( + getString(R.string.pip_enabled_key), + true + ) ?: true + } catch (e: Exception) { + logError(e) + false + } + } + + + /** + * Is pip supported by the OS? + * + * Source: + * https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission + * https://developer.android.com/guide/topics/ui/picture-in-picture + * */ + private fun Context.hasPIPFeature(): Boolean = + // OS Support + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + // Might have the feature, but OS blocked due to power drain + this.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && + // Might have been disabled by the user + this.hasPIPPermission() + + /** Is pip enabled in the OS settings? */ + private fun Context.hasPIPPermission(): Boolean { + val appOps = + getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appOps.checkOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + packageName + ) == AppOpsManager.MODE_ALLOWED + } else true + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun getPen(activity: Activity, code: Int): PendingIntent { + return PendingIntent.getBroadcast( + activity, + code, + Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code), + PendingIntent.FLAG_IMMUTABLE + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun getRemoteAction( + activity: Activity, + id: Int, + @StringRes title: Int, + event: CSPlayerEvent + ): RemoteAction { + val text = activity.getString(title) + return RemoteAction( + Icon.createWithResource(activity, id), + text, + text, + getPen(activity, event.value) + ) + } + + fun updatePIPModeActions( + activity: Activity?, + status: CSPlayerLoading, + pipEnabled: Boolean, + aspectRatio: Rational? + ) { + // Is it even desired to enter pip mode right now if we ignore all settings? + // This does not check for isPIPPossible as that is deferred to later + val isPipDesired = when (status) { + CSPlayerLoading.IsBuffering, CSPlayerLoading.IsPlaying -> pipEnabled + else -> false } - @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) + // 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 + ) + )*/ - @RequiresApi(Build.VERSION_CODES.O) - fun updatePIPModeActions(activity: Activity, isPlaying: Boolean) { - val actions: ArrayList = ArrayList() + 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 8d85f1769..ee6170aa5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt @@ -4,14 +4,14 @@ import android.util.Log import android.util.TypedValue import android.view.ViewGroup import android.widget.FrameLayout -import 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 000000000..0e6f1a367 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerView.kt @@ -0,0 +1,842 @@ +package com.lagradost.cloudstream3.ui.player + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ActivityInfo +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimatedVectorDrawable +import android.media.metrics.PlaybackErrorEvent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.text.format.DateUtils +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.RelativeLayout +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.annotation.OptIn +import androidx.core.view.isGone +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar +import androidx.preference.PreferenceManager +import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat +import com.github.rubensousa.previewseekbar.PreviewBar +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import com.lagradost.cloudstream3.CommonActivity.isInPIPMode +import com.lagradost.cloudstream3.CommonActivity.screenWidth +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.AppContextUtils +import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI +import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage +import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI +import com.lagradost.cloudstream3.utils.UserPreferenceDelegate +import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp +import java.net.SocketTimeoutException + +/** + * Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event + * dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper] + * ([PlayerGestureHelper]), which is exposed via delegate properties for easier access. + */ +@OptIn(UnstableApi::class) +class PlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + + companion object { + private const val TAG = "PlayerView" + } + + /** All gesture, volume, brightness and key-event logic lives here. */ + val gestureHelper = PlayerGestureHelper(this) + + /** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */ + var isFullScreen: Boolean + get() = gestureHelper.isFullScreen + set(value) { gestureHelper.isFullScreen = value } + + var isLocked: Boolean + get() = gestureHelper.isLocked + set(value) { gestureHelper.isLocked = value } + + var videoOutline: View? + get() = gestureHelper.videoOutline + set(value) { gestureHelper.videoOutline = value } + + /** Delegate methods */ + fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode) + fun verifyVolume() = gestureHelper.verifyVolume() + fun setupKeyEventListener() = gestureHelper.setupKeyEventListener() + fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener() + fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout() + fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener() + + /** Callbacks */ + + /** Host-fragment-level callbacks invoked by [mainCallback]. */ + interface Callbacks { + fun nextEpisode() {} + fun prevEpisode() {} + fun playerPositionChanged(position: Long, duration: Long) {} + fun playerStatusChanged() {} + fun playerDimensionsLoaded(width: Int, height: Int) {} + fun subtitlesChanged() {} + fun embeddedSubtitlesFetched(subtitles: List) {} + fun onTracksInfoChanged() {} + fun onTimestamp(timestamp: VideoSkipStamp?) {} + fun onTimestampSkipped(timestamp: VideoSkipStamp) {} + fun exitedPipMode() {} + fun hasNextMirror(): Boolean = false + fun nextMirror() {} + fun onDownload(event: DownloadEvent) {} + fun playerError(exception: Throwable) {} + /** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */ + fun playerUpdated(player: Any?) {} + /** Called on a short single-tap on empty player area (no swipe, no double-tap). */ + fun onSingleTap() {} + /** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */ + fun onHoldSpeedUp(show: Boolean) {} + /** Called during brightness swipe with the current extra-brightness alpha (0–1). */ + fun onBrightnessExtra(alpha: Float) {} + + /** Touch event callbacks */ + + /** Returns whether the player UI (controls overlay) is currently visible. */ + fun isUIShowing(): Boolean = false + /** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */ + fun onTouchDown() {} + /** Called with seek-preview text during a horizontal-swipe, or null to clear it. */ + fun onSeekPreviewText(text: String?) {} + /** Called when a swipe gesture begins; hide the player UI if desired. */ + fun onHidePlayerUI() {} + /** + * Called at the end of each touch sequence. + * @param hadSwipe true if a swipe (brightness/volume/time) was in progress. + * @param wasUiShowing true if the UI was visible when the swipe began. + */ + fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {} + /** + * Called when the auto-hide timer fires: UI is showing, no touch is active. + * Implement to hide the player controls. + */ + fun onAutoHideUI() {} + } + + var callbacks: Callbacks? = null + + /** Player state */ + + var player: IPlayer = CS3IPlayer() + var resizeMode: Int = 0 + var hasPipModeSupport: Boolean = true + var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering + var mMediaSession: MediaSession? = null + private var pipReceiver: BroadcastReceiver? = null + + /** Auto-hide */ + private var autoHideToken = 0 + private val autoHideHandler = Handler(Looper.getMainLooper()) + + /** View references (populated by bindViews) */ + + var subView: SubtitleView? = null + var playerPausePlayHolderHolder: FrameLayout? = null + var playerPausePlay: ImageView? = null + var playerBuffering: ProgressBar? = null + /** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */ + var exoPlayerView: androidx.media3.ui.PlayerView? = null + var piphide: FrameLayout? = null + var subtitleHolder: FrameLayout? = null + internal var playerRew: View? = null + internal var playerFfwd: View? = null + internal var exoRewText: TextView? = null + internal var exoFfwdText: TextView? = null + internal var playerCenterMenu: View? = null + internal var playerRewHolder: View? = null + internal var playerFfwdHolder: View? = null + internal var playerVideoHolder: View? = null + var playerProgressbarLeftHolder: RelativeLayout? = null + var playerProgressbarLeftIcon: ImageView? = null + var playerProgressbarLeftLevel1: ProgressBar? = null + var playerProgressbarLeftLevel2: ProgressBar? = null + var playerProgressbarRightHolder: RelativeLayout? = null + var playerProgressbarRightIcon: ImageView? = null + var playerProgressbarRightLevel1: ProgressBar? = null + var playerProgressbarRightLevel2: ProgressBar? = null + /** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */ + internal var playerSpeedupButton: View? = null + var playerHolder: FrameLayout? = null + private var exoDuration: TextView? = null + private var timeLeft: TextView? = null + private var exoPosition: TextView? = null + private var timeLive: View? = null + private var exoProgress: LivePreviewTimeBar? = null + + /** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */ + var seekTime: Long = 10_000L + + /** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */ + var isVerticalOrientation: Boolean = false + + /** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */ + var autoPlayerRotateEnabled: Boolean = false + + var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + + // Kept so SubtitlesFragment can unsubscribe the exact same reference. + private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged + + /** View discovery */ + + /** + * Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply + * remain null, all usage is null-safe. + */ + fun bindViews(root: View) { + exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration) + exoFfwdText = root.findViewById(R.id.exo_ffwd_text) + exoPlayerView = root.findViewById(R.id.player_view) + exoPosition = root.findViewById(R.id.exo_position) + exoRewText = root.findViewById(R.id.exo_rew_text) + piphide = root.findViewById(R.id.piphide) + playerBuffering = root.findViewById(R.id.player_buffering) + playerCenterMenu = root.findViewById(R.id.player_center_menu) + playerFfwd = root.findViewById(R.id.player_ffwd) + playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder) + playerHolder = root.findViewById(R.id.player_holder) + playerPausePlay = root.findViewById(R.id.player_pause_play) + playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) + playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder) + playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon) + playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1) + playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2) + playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder) + playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon) + playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1) + playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2) + playerRew = root.findViewById(R.id.player_rew) + playerRewHolder = root.findViewById(R.id.player_rew_holder) + playerSpeedupButton = root.findViewById(R.id.player_speedup_button) + playerVideoHolder = root.findViewById(R.id.player_video_holder) + subtitleHolder = root.findViewById(R.id.subtitle_holder) + timeLeft = root.findViewById(R.id.time_left) + timeLive = root.findViewById(R.id.time_live) + } + + /** + * Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener, + * player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper]. + */ + fun initialize() { + resizeMode = DataStoreHelper.resizeMode + resize(resizeMode, false) + + player.releaseCallbacks() + player.initCallbacks( + eventHandler = ::mainCallback, + requestedListeningPercentages = listOf( + SKIP_OP_VIDEO_PERCENTAGE, + PRELOAD_NEXT_EPISODE_PERCENTAGE, + NEXT_WATCH_EPISODE_PERCENTAGE, + UPDATE_SYNC_PROGRESS_PERCENTAGE, + ), + ) + + if (player is CS3IPlayer) { + // Preview bar + val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress) + exoProgress = progressBar as? LivePreviewTimeBar + val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView) + val previewFrameLayout: FrameLayout? = + exoPlayerView?.findViewById(R.id.previewFrameLayout) + + /** Hide the previewFrameLayout on TV to make the skip op button not float, + * as previewFrameLayout is normally invisible */ + if(isLayout(TV)) { + previewFrameLayout?.isVisible = false + } + + if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) { + var resume = false + progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { + override fun onScrubStart(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + val hasPreview = cs3.hasPreview() + progressBar.isPreviewEnabled = hasPreview + resume = cs3.getIsPlaying() + if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) + // No clashing UI + if (hasPreview) subView?.isVisible = false + } + + override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {} + + override fun onScrubStop(previewBar: PreviewBar?) { + val cs3 = player as? CS3IPlayer ?: return + if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) + // Delay to prevent the small flicker of subtitle before seeking. + subView?.postDelayed({ + // If we are not scrubbing then show subtitles again. + if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { + subView?.isVisible = true + } + }, 200) + } + }) + progressBar.attachPreviewView(previewFrameLayout) + progressBar.setPreviewLoader { currentPosition, max -> + val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader + val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat())) + previewImageView.isGone = bitmap == null + previewImageView.setImageBitmap(bitmap) + } + } + + subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) + (player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style) + (player as? CS3IPlayer)?.let { + (it.imageGenerator as? PreviewGenerator)?.params = + ImageParams.new16by9(screenWidth) + } + + /** + * This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player + * and once by the UI even if it should only be registered once by the UI. + */ + exoPlayerView?.findViewById(R.id.exo_progress) + ?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + if (canceled) return + val playerDuration = player.getDuration() ?: return + val playerPosition = player.getPosition() ?: return + mainCallback( + PositionEvent( + source = PlayerEventSource.UI, + durationMs = playerDuration, + fromMs = playerPosition, + toMs = position + ) + ) + } + }) + + // Read seek time and rotation settings. + try { + val sm = PreferenceManager.getDefaultSharedPreferences(context) + seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10) + .toLong() * 1000L + autoPlayerRotateEnabled = sm.getBoolean( + context.getString(R.string.auto_rotate_video_key), true + ) + } catch (_: Exception) { + } + + val seekSecs = (seekTime / 1000).toInt() + exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs) + exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs) + + playerPausePlay?.setOnClickListener { + scheduleAutoHide() + if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI) + } else { + player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + } + } + playerRew?.setOnClickListener { + scheduleAutoHide() + gestureHelper.rewind() + } + playerFfwd?.setOnClickListener { + scheduleAutoHide() + gestureHelper.fastForward() + } + + SubtitlesFragment.applyStyleEvent += subStyleListener + + try { + val ctx = context + val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) + val cs3 = player as? CS3IPlayer ?: return + cs3.cacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L + cs3.simpleCacheSize = + settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L + cs3.videoBufferMs = + settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L + } catch (e: Exception) { + logError(e) + } + + // Duration toggle click listeners + exoDuration?.setOnClickListener { setRemainingTimeCounter(true) } + timeLeft?.setOnClickListener { setRemainingTimeCounter(false) } + // Keep remaining-time text in sync with playback position + exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } + + // Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener) + gestureHelper.initialize() + setupKeyEventListener() + + // Apply duration-mode display (remaining time vs elapsed); TV always shows remaining + setRemainingTimeCounter(durationMode || isLayout(TV)) + } + } + + /** Lifecycle delegation */ + + var fullscreenNotch: Boolean = true // TODO SETTING + + fun enterFullscreen(updateOrientation: () -> Unit = {}) { + val activity = context as? Activity + if (isFullScreen) { + activity?.hideSystemUI() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { + val params = activity?.window?.attributes + params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + activity?.window?.attributes = params + } + } + updateOrientation() + } + + fun exitFullscreen() { + val activity = context as? Activity + gestureHelper.resetZoomToDefault() + activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + // Simply resets brightness and notch settings that might have been overridden. + val lp = activity?.window?.attributes + lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT + } + activity?.window?.attributes = lp + activity?.showSystemUI() + } + + fun onStop() { + player.onStop() + } + + fun onResume(ctx: Context) { + player.onResume(ctx) + } + + /** Releases all player resources. */ + fun release() { + player.release() + player.releaseCallbacks() + player = CS3IPlayer() + + // keyEventListener is deregistered in onPause so that the incoming player's + // onResume can register its own listener without racing against release(). + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + CSPlayerLoading.IsPaused, + false, + null + ) + + mMediaSession?.release() + mMediaSession = null + exoPlayerView?.player = null + + SubtitlesFragment.applyStyleEvent -= subStyleListener + + gestureHelper.release() + autoHideHandler.removeCallbacksAndMessages(null) + + keepScreenOn(false) + } + + fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + activity: Activity? + ) { + try { + isInPIPMode = isInPictureInPictureMode + if (isInPictureInPictureMode) { + // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. + piphide?.isVisible = false + pipReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (ACTION_MEDIA_CONTROL != intent.action) return + player.handleEvent( + CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)], + source = PlayerEventSource.UI + ) + } + } + val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + activity?.registerReceiver(pipReceiver, filter) + } + val isPlaying = player.getIsPlaying() + val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused + updateIsPlaying(status, status) + } else { + // Restore the full-screen UI. + piphide?.isVisible = true + callbacks?.exitedPipMode() + pipReceiver?.let { + // Prevents java.lang.IllegalArgumentException: Receiver not registered + safe { activity?.unregisterReceiver(it) } + } + activity?.hideSystemUI() + hideKeyboard(this) + } + } catch (e: Exception) { + logError(e) + } + } + + /** Player UI helpers */ + + private fun keepScreenOn(on: Boolean) { + val window = (context as? Activity)?.window ?: return + if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) { + val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying + val isBuffering = CSPlayerLoading.IsBuffering == isPlaying + currentPlayerStatus = isPlaying + + keepScreenOn(isPlayingRightNow || isBuffering) + + if (isBuffering) { + playerPausePlayHolderHolder?.isVisible = false + playerBuffering?.isVisible = true + } else { + playerPausePlayHolderHolder?.isVisible = true + playerBuffering?.isVisible = false + + if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) { + playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) + } else if (wasPlaying != isPlaying) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play + ) + val drawable = playerPausePlay?.drawable + var startedAnimation = false + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true } + } + if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true } + if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true } + // Somehow the phone is wacked + if (!startedAnimation) { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } else { + playerPausePlay?.setImageResource( + if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play + ) + } + } + + PlayerPipHelper.updatePIPModeActions( + context as? Activity, + isPlaying, + hasPipModeSupport, + player.getAspectRatio() + ) + } + + private fun requestAudioFocus() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + (context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) + } + } + + private fun playerUpdated(player: Any?) { + if (player is ExoPlayer) { + mMediaSession?.release() + mMediaSession = MediaSession.Builder(context, player) + // Ensure unique ID for concurrent players. + .setId(System.currentTimeMillis().toString()) + .build() + + // Necessary for multiple combined videos. + @Suppress("DEPRECATION") + exoPlayerView?.setShowMultiWindowTimeBar(true) + exoPlayerView?.player = player + exoPlayerView?.performClick() + } + callbacks?.playerUpdated(player) + } + + private fun onSubStyleChanged(style: SaveCaptionStyle) { + player.updateSubtitleStyle(style) + // Forcefully update the subtitle encoding in case the edge size is changed. + player.seekTime(-1) + } + + /** Error handling */ + + @MainThread + fun playerError(exception: Throwable) { + fun showErrorToast(message: String) { + if (callbacks?.hasNextMirror() == true) { + showToast(message, Toast.LENGTH_SHORT) + callbacks?.nextMirror() + } else { + showToast( + context.getString(R.string.no_links_found_toast) + "\n" + message, + Toast.LENGTH_LONG + ) + (context as? FragmentActivity)?.popCurrentPage() + } + } + + when (exception) { + is PlaybackException -> { + val msg = exception.message ?: "" + val errorName = exception.errorCodeName + when (val code = exception.errorCode) { + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, + PlaybackException.ERROR_CODE_IO_NO_PERMISSION, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> + showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_REMOTE_ERROR, + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_TIMEOUT, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> + showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg") + + PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, + PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> + showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> + showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg") + + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> + showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg") + + else -> + showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg") + } + } + + is SocketTimeoutException -> + showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}") + + is ErrorLoadingException -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + + else -> + exception.message?.let { showErrorToast(it) } + ?: showErrorToast(exception.toString()) + } + } + + /** Resize */ + + fun nextResize() { + resizeMode = (resizeMode + 1) % PlayerResize.entries.size + resize(resizeMode, true) + } + + fun resize(resize: Int, showToast: Boolean) { + // Clear all zoom state before applying the new resize mode + gestureHelper.clearZoomState() + resize(PlayerResize.entries[resize], showToast) + } + + fun resize(resize: PlayerResize, showToast: Boolean) { + DataStoreHelper.resizeMode = resize.ordinal + val type = when (resize) { + PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL + PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT + PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + exoPlayerView?.resizeMode = type + if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT) + } + + /** Orientation */ + + /** + * Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation] + * and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape. + * Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation. + */ + fun dynamicOrientation(): Int { + if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + return if (autoPlayerRotateEnabled && isVerticalOrientation) + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + + /** Event dispatch */ + + /** + * This receives the events from the player, if you want to append functionality + * you do it here, do note that this only receives events for UI changes, + * and returning early WON'T stop it from changing in e.g. the player time + * or pause status. + */ + @MainThread + fun mainCallback(event: PlayerEvent) { + // We don't want to spam DownloadEvent. + if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event") + when (event) { + is DownloadEvent -> callbacks?.onDownload(event) + is ResizedEvent -> { + // Skip 0x0 dimensions that the player emits when going to STATE_IDLE + // to avoid incorrectly resetting the auto-detected orientation. + if (event.width > 0 && event.height > 0) { + // TV never rotates; otherwise track whether the video is portrait. + isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width + } + callbacks?.playerDimensionsLoaded(event.width, event.height) + } + is PlayerAttachedEvent -> playerUpdated(event.player) + is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged() + is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp) + is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp) + is TracksChangedEvent -> callbacks?.onTracksInfoChanged() + is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks) + is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error) + is RequestAudioFocusEvent -> requestAudioFocus() + is EpisodeSeekEvent -> when (event.offset) { + -1 -> callbacks?.prevEpisode() + 1 -> callbacks?.nextEpisode() + } + is StatusEvent -> { + updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) + scheduleAutoHide() + callbacks?.playerStatusChanged() + } + is PositionEvent -> callbacks?.playerPositionChanged( + position = event.toMs, + duration = event.durationMs + ) + is VideoEndedEvent -> { + // Only play next episode if autoplay is on (default). + val ctx = context + if (PreferenceManager.getDefaultSharedPreferences(ctx) + ?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true + ) { + player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player) + } + } + is PauseEvent -> Unit + is PlayEvent -> Unit + } + } + + /** Duration display */ + + fun setRemainingTimeCounter(showRemaining: Boolean) { + durationMode = showRemaining + exoDuration?.isInvisible = showRemaining + timeLeft?.isVisible = showRemaining + if (showRemaining) updateRemainingTime() + } + + fun updateRemainingTime() { + val duration = player.getDuration() + val position = player.getPosition() + + if (exoProgress?.isAtLiveEdge() == true) { + timeLeft?.alpha = 0f + exoDuration?.alpha = 0f + timeLive?.isVisible = true + } else { + timeLeft?.alpha = 1f + exoDuration?.alpha = 1f + timeLive?.isVisible = false + } + + if (duration != null && duration > 1 && position != null) { + val remainingTimeSeconds = (duration - position + 500) / 1000 + @SuppressLint("SetTextI18n") + timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" + } + } + + /** Auto-hide */ + + /** + * Schedules a delayed auto-hide of the player UI after [delayMs] ms. + * Any previously pending hide is canceled first. + * The hide fires only when no touch is active and [Callbacks.isUIShowing] is true; + * the actual hide action is delegated to [Callbacks.onAutoHideUI]. + */ + fun scheduleAutoHide(delayMs: Long = 3000L) { + val token = ++autoHideToken + autoHideHandler.removeCallbacksAndMessages(null) + autoHideHandler.postDelayed({ + if (token != autoHideToken) return@postDelayed + if (gestureHelper.isCurrentTouchValid) return@postDelayed + if (callbacks?.isUIShowing() != true) return@postDelayed + callbacks?.onAutoHideUI() + }, delayMs) + } + + /** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */ + fun cancelAutoHide() { + autoHideToken++ + autoHideHandler.removeCallbacksAndMessages(null) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt new file mode 100644 index 000000000..2893bcc47 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt @@ -0,0 +1,546 @@ +package com.lagradost.cloudstream3.ui.player + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.WorkerThread +import androidx.core.graphics.scale +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.M3u8Helper +import com.lagradost.cloudstream3.utils.M3u8Helper2 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.log2 + +const val MAX_LOD = 6 +const val MIN_LOD = 3 + +data class ImageParams( + val width: Int, + val height: Int, +) { + companion object { + val DEFAULT = ImageParams(200, 320) + fun new16by9(width: Int): ImageParams { + if (width < 100) { + return DEFAULT + } + return ImageParams( + width / 4, + (width * 9) / (4 * 16) + ) + } + } + + init { + assert(width > 0 && height > 0) + } +} + +interface IPreviewGenerator { + fun hasPreview(): Boolean + fun getPreviewImage(fraction: Float): Bitmap? + fun release() + + var params: ImageParams + + var durationMs: Long + var loadedImages: Int + + companion object { + fun new(): IPreviewGenerator { + val userDisabled = CloudStreamApp.context?.let { ctx -> + PreferenceManager.getDefaultSharedPreferences(ctx)?.getBoolean( + ctx.getString(R.string.preview_seekbar_key), true + ) == false + } ?: false + /** because TV has low ram + not show we disable this for now */ + return if (isLayout(TV) || userDisabled) { + empty() + } else { + PreviewGenerator() + } + } + + fun empty(): IPreviewGenerator { + return NoPreviewGenerator() + } + } +} + +private fun rescale(image: Bitmap, params: ImageParams): Bitmap { + if (image.width <= params.width && image.height <= params.height) return image + val new = image.scale(params.width, params.height) + // throw away the old image + if (new != image) { + image.recycle() + } + return new +} + +/** rescale to not take up as much memory */ +private fun MediaMetadataRetriever.image(timeUs: Long, params: ImageParams): Bitmap? { + /*if (timeUs <= 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + val primary = this.primaryImage + if (primary != null) { + return rescale(primary, params) + } + } catch (t: Throwable) { + logError(t) + } + }*/ + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + this.getScaledFrameAtTime( + timeUs, + MediaMetadataRetriever.OPTION_CLOSEST_SYNC, + params.width, + params.height + ) + } else { + return rescale(this.getFrameAtTime(timeUs) ?: return null, params) + } +} + +/** PreviewGenerator that hides the implementation details of the sub generators that is used, used for source switch cache */ +class PreviewGenerator : IPreviewGenerator { + + /** the most up to date generator, will always mirror the actual source in the player */ + private var currentGenerator: IPreviewGenerator = NoPreviewGenerator() + + /** the longest generated preview of the same episode */ + private var lastGenerator: IPreviewGenerator = NoPreviewGenerator() + + /** always NoPreviewGenerator, used as a cache for nothing */ + private val dummy: IPreviewGenerator = NoPreviewGenerator() + + /** if the current generator is the same as the last by checking time */ + private fun isSameLength(): Boolean = + currentGenerator.durationMs.minus(lastGenerator.durationMs).absoluteValue < 10_000L + + /** use the backup if the current generator is init or if they have the same length */ + private val backupGenerator: IPreviewGenerator + get() { + if (currentGenerator.durationMs == 0L || isSameLength()) { + return lastGenerator + } + return dummy + } + + override fun hasPreview(): Boolean { + return currentGenerator.hasPreview() || backupGenerator.hasPreview() + } + + override fun getPreviewImage(fraction: Float): Bitmap? { + return try { + currentGenerator.getPreviewImage(fraction) ?: backupGenerator.getPreviewImage(fraction) + } catch (t: Throwable) { + logError(t) + null + } + } + + override fun release() { + lastGenerator.release() + currentGenerator.release() + lastGenerator = NoPreviewGenerator() + currentGenerator = NoPreviewGenerator() + } + + override var params: ImageParams = ImageParams.DEFAULT + set(value) { + field = value + lastGenerator.params = value + backupGenerator.params = value + currentGenerator.params = value + } + + override var durationMs: Long + get() = currentGenerator.durationMs + set(_) {} + override var loadedImages: Int + get() = currentGenerator.loadedImages + set(_) {} + + fun clear(keepCache: Boolean) { + if (keepCache) { + if (!isSameLength() || currentGenerator.loadedImages >= lastGenerator.loadedImages || lastGenerator.durationMs == 0L) { + // the current generator is better than the last generator, therefore keep the current + // or the lengths are not the same, therefore favoring the more recent selection + + // if they are the same we favor the current generator + lastGenerator.release() + lastGenerator = currentGenerator + } else { + // otherwise just keep the last generator and throw away the current generator + currentGenerator.release() + } + } else { + // we switched the episode, therefore keep nothing + lastGenerator.release() + lastGenerator = NoPreviewGenerator() + currentGenerator.release() + // we assume that we set currentGenerator right after this, so currentGenerator != NoPreviewGenerator + } + } + + fun load(link: ExtractorLink, keepCache: Boolean) { + clear(keepCache) + + when (link.type) { + ExtractorLinkType.M3U8 -> { + currentGenerator = M3u8PreviewGenerator(params).apply { + load(url = link.url, headers = link.getAllHeaders()) + } + } + + ExtractorLinkType.VIDEO -> { + currentGenerator = Mp4PreviewGenerator(params).apply { + load(url = link.url, headers = link.getAllHeaders()) + } + } + + else -> { + Log.i("PreviewImg", "unsupported format for $link") + } + } + } + + fun load(context: Context, link: ExtractorUri, keepCache: Boolean) { + clear(keepCache) + currentGenerator = Mp4PreviewGenerator(params).apply { + load(keepCache = keepCache, context = context, uri = link.uri) + } + } +} + +@Suppress("UNUSED_PARAMETER") +private class NoPreviewGenerator : IPreviewGenerator { + override fun hasPreview(): Boolean = false + override fun getPreviewImage(fraction: Float): Bitmap? = null + override fun release() = Unit + override var params: ImageParams + get() = ImageParams(0, 0) + set(value) {} + override var durationMs: Long = 0L + override var loadedImages: Int = 0 +} + +private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewGenerator { + // generated images 1:1 to idx of hsl + private var images: Array = arrayOf() + + companion object { + private const val TAG = "PreviewImgM3u8" + } + + + // prefixSum[i] = sum(hsl.ts[0..i].time) + // where [0] = 0, [1] = hsl.ts[0].time aka time at start of segment, do [b] - [a] for range a,b + private var prefixSum: Array = arrayOf() + + // how many images has been generated + override var loadedImages: Int = 0 + + // how many images we can generate in total, == hsl.size ?: 0 + private var totalImages: Int = 0 + + override fun hasPreview(): Boolean { + return totalImages > 0 && loadedImages >= minOf(totalImages, 4) + } + + override fun getPreviewImage(fraction: Float): Bitmap? { + var bestIdx = -1 + var bestDiff = Double.MAX_VALUE + synchronized(images) { + // just find the best one in a for loop, we don't care about bin searching rn + for (i in images.indices) { + val diff = prefixSum[i].minus(fraction).absoluteValue + if (diff > bestDiff) { + break + } + if (images[i] != null) { + bestIdx = i + bestDiff = diff + } + } + return images.getOrNull(bestIdx) + } + /* + val targetIndex = prefixSum.binarySearch(target) + var ret = images[targetIndex] + if (ret != null) { + return ret + } + for (i in 0..images.size) { + ret = images.getOrNull(i+targetIndex) ?: + }*/ + } + + private fun clear() { + synchronized(images) { + currentJob?.cancel() + // for (i in images.indices) { + // images[i]?.recycle() + // } + images = arrayOf() + prefixSum = arrayOf() + loadedImages = 0 + totalImages = 0 + } + } + + override fun release() { + clear() + images = arrayOf() + } + + override var durationMs: Long = 0L + + private var currentJob: Job? = null + fun load(url: String, headers: Map) { + clear() + currentJob?.cancel() + currentJob = ioSafe { + withContext(Dispatchers.IO) { + Log.i(TAG, "Loading with url = $url headers = $headers") + //tmpFile = + // File.createTempFile("video", ".ts", context.cacheDir).apply { + // deleteOnExit() + // } + val retriever = MediaMetadataRetriever() + val hsl = M3u8Helper2.hslLazy( + M3u8Helper.M3u8Stream( + streamUrl = url, + headers = headers + ), + selectBest = false, + requireAudio = false, + ) + + // no support for encryption atm + if (hsl.isEncrypted) { + Log.i(TAG, "m3u8 is encrypted") + totalImages = 0 + return@withContext + } + + // total duration of the entire m3u8 in seconds + val duration = hsl.allTsLinks.sumOf { it.time ?: 0.0 } + durationMs = (duration * 1000.0).toLong() + val durationInv = 1.0 / duration + + // if the total duration is less then 10s then something is very wrong or + // too short playback to matter + if (duration <= 10.0) { + totalImages = 0 + return@withContext + } + + totalImages = hsl.allTsLinks.size + + // we cant init directly as it is no guarantee of in order + prefixSum = Array(hsl.allTsLinks.size + 1) { 0.0 } + var runningSum = 0.0 + for (i in hsl.allTsLinks.indices) { + runningSum += (hsl.allTsLinks[i].time ?: 0.0) + prefixSum[i + 1] = runningSum * durationInv + } + synchronized(images) { + images = Array(hsl.size) { null } + loadedImages = 0 + } + + val maxLod = ceil(log2(duration)).toInt().coerceIn(MIN_LOD, MAX_LOD) + val count = hsl.allTsLinks.size + for (l in 1..maxLod) { + val items = (1 shl (l - 1)) + for (i in 0 until items) { + val index = (count.div(1 shl l) + (i * count) / items).coerceIn(0, hsl.size) + if (synchronized(images) { images[index] } != null) { + continue + } + Log.i(TAG, "Generating preview for $index") + + val ts = hsl.allTsLinks[index] + try { + retriever.setDataSource(ts.url, hsl.headers) + if (!isActive) { + return@withContext + } + val img = retriever.image(0, params) + if (!isActive) { + return@withContext + } + if (img == null || img.width <= 1 || img.height <= 1) continue + synchronized(images) { + images[index] = img + loadedImages += 1 + } + } catch (t: Throwable) { + logError(t) + continue + } + } + } + + } + } + } +} + +private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGenerator { + // lod = level of detail where the number indicates how many ones there is + // 2^(lod-1) = images + private var loadedLod = 0 + override var loadedImages = 0 + private var images = Array((1 shl MAX_LOD) - 1) { + null + } + + companion object { + private const val TAG = "PreviewImgMp4" + } + + override fun hasPreview(): Boolean { + synchronized(images) { + return loadedLod >= MIN_LOD + } + } + + override fun getPreviewImage(fraction: Float): Bitmap? { + synchronized(images) { + if (loadedLod < MIN_LOD) { + Log.i(TAG, "Requesting preview for $fraction but $loadedLod < $MIN_LOD") + return null + } + Log.i(TAG, "Requesting preview for $fraction") + + var bestIdx = 0 + var bestDiff = 0.5f.minus(fraction).absoluteValue + + // this should be done mathematically, but for now we just loop all images + for (l in 1..loadedLod + 1) { + val items = (1 shl (l - 1)) + for (i in 0 until items) { + val idx = items - 1 + i + if (idx > loadedImages) { + break + } + if (images[idx] == null) { + continue + } + val currentFraction = + (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) + val diff = currentFraction.minus(fraction).absoluteValue + if (diff < bestDiff) { + bestDiff = diff + bestIdx = idx + } + } + } + Log.i(TAG, "Best diff found at ${bestDiff * 100}% diff (${bestIdx})") + return images[bestIdx] + } + } + + // also check out https://github.com/wseemann/FFmpegMediaMetadataRetriever + private val retriever: MediaMetadataRetriever = MediaMetadataRetriever() + + private fun clear(keepCache: Boolean) { + if (keepCache) return + synchronized(images) { + loadedLod = 0 + loadedImages = 0 + // for (i in images.indices) { + // images[i]?.recycle() + // images[i] = null + //} + images.fill(null) + } + } + + private var currentJob: Job? = null + fun load(url: String, headers: Map) { + currentJob?.cancel() + currentJob = ioSafe { + Log.i(TAG, "Loading with url = $url headers = $headers") + clear(true) + retriever.setDataSource(url, headers) + start(this) + } + } + + fun load(keepCache: Boolean, context: Context, uri: Uri) { + currentJob?.cancel() + currentJob = ioSafe { + Log.i(TAG, "Loading with uri = $uri") + clear(keepCache) + retriever.setDataSource(context, uri) + start(this) + } + } + + override fun release() { + currentJob?.cancel() + clear(false) + } + + override var durationMs: Long = 0L + + @Throws + @WorkerThread + private fun start(scope: CoroutineScope) { + Log.i(TAG, "Started loading preview") + + val durationMs = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() + ?: throw IllegalArgumentException("Bad video duration") + this.durationMs = durationMs + val durationUs = (durationMs * 1000L).toFloat() + //val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: throw IllegalArgumentException("Bad video width") + //val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: throw IllegalArgumentException("Bad video height") + + // log2 # 10s durations in the video ~= how many segments we have + val maxLod = ceil(log2((durationMs / 10_000).toFloat())).toInt().coerceIn(MIN_LOD, MAX_LOD) + + for (l in 1..maxLod) { + val items = (1 shl (l - 1)) + for (i in 0 until items) { + val idx = items - 1 + i // as sum(prev) = cur-1 + // frame = 100 / 2^lod + i * 100 / 2^(lod-1) = duration % where lod is one indexed + val fraction = (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) + Log.i(TAG, "Generating preview for ${fraction * 100}%") + val frame = durationUs * fraction + val img = retriever.image(frame.toLong(), params) + if (!scope.isActive) return + if (img == null || img.width <= 1 || img.height <= 1) continue + synchronized(images) { + images[idx] = img + loadedImages = maxOf(loadedImages, idx) + } + } + + synchronized(images) { + loadedLod = maxOf(loadedLod, l) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt index 2ce53ea5d..0668a194b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt @@ -2,149 +2,159 @@ package com.lagradost.cloudstream3.ui.player import android.util.Log import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.ExtractorUri -import kotlin.math.max -import kotlin.math.min +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +data class Cache( + val linkCache: MutableSet, + val subtitleCache: MutableSet, + /** When it was last updated */ + var lastCachedTimestamp: Long = unixTime, + /** If it has fully loaded */ + var saturated: Boolean, +) class RepoLinkGenerator( - private val episodes: List, - private var currentIndex: Int = 0, + episodes: List, val page: LoadResponse? = null, -) : IGenerator { +) : VideoGenerator(episodes) { companion object { const val TAG = "RepoLink" - val cache: HashMap, Pair, MutableSet>> = + val cache: HashMap, Cache> = hashMapOf() } override val hasCache = true - - override fun hasNext(): Boolean { - return currentIndex < episodes.size - 1 - } - - override fun hasPrev(): Boolean { - return currentIndex > 0 - } - - override fun next() { - Log.i(TAG, "next") - if (hasNext()) - currentIndex++ - } - - override fun prev() { - Log.i(TAG, "prev") - if (hasPrev()) - currentIndex-- - } - - override fun goto(index: Int) { - Log.i(TAG, "goto $index") - // clamps value - currentIndex = min(episodes.size - 1, max(0, index)) - } - - override fun getCurrentId(): Int { - return episodes[currentIndex].id - } - - override fun getCurrent(offset: Int): Any? { - return episodes.getOrNull(currentIndex + offset) - } - - override fun getAll(): List { - return episodes - } + override val canSkipLoading = true + override fun getId(index: Int): Int? = videos.getOrNull(index)?.id // this is a simple array that is used to instantly load links if they are already loaded //var linkCache = Array>(size = episodes.size, init = { setOf() }) //var subsCache = Array>(size = episodes.size, init = { setOf() }) + @Throws override suspend fun generateLinks( clearCache: Boolean, - isCasting: Boolean, + sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, + isCasting: Boolean, ): Boolean { - val index = currentIndex - val current = episodes.getOrNull(index + offset) ?: return false + val current = videos.getOrNull(offset) ?: return false - val (currentLinkCache, currentSubsCache) = if (clearCache) { - Pair(mutableSetOf(), mutableSetOf()) - } else { - cache[Pair(current.apiName, current.id)] ?: Pair(mutableSetOf(), mutableSetOf()) + val currentCache = synchronized(cache) { + cache[current.apiName to current.id] ?: Cache( + mutableSetOf(), + mutableSetOf(), + unixTime, + false + ).also { + cache[current.apiName to current.id] = it + } } - //val currentLinkCache = if (clearCache) mutableSetOf() else linkCache[index].toMutableSet() - //val currentSubsCache = if (clearCache) mutableSetOf() else subsCache[index].toMutableSet() + // These act as a general filter to prevent duplication of links or names + // Avoid any possible ConcurrentModificationException + val currentLinksUrls = ConcurrentHashMap.newKeySet() + val currentSubsUrls = ConcurrentHashMap.newKeySet() + // Use atomics as otherwise we get race conditions when incrementing, while rare it did actually happen! + val lastCountedSuffix = ConcurrentHashMap() - val currentLinks = mutableSetOf() // makes all urls unique - val currentSubsUrls = mutableSetOf() // makes all subs urls unique - val currentSubsNames = mutableSetOf() // makes all subs names unique + synchronized(currentCache) { + val outdatedCache = + unixTime - currentCache.lastCachedTimestamp > 60 * 20 // 20 minutes - currentLinkCache.forEach { link -> - currentLinks.add(link.url) - callback(Pair(link, null)) - } + if (outdatedCache || clearCache) { + currentCache.linkCache.clear() + currentCache.subtitleCache.clear() + currentCache.saturated = false + } else if (currentCache.linkCache.isNotEmpty()) { + Log.d( + TAG, + "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago" + ) + } - currentSubsCache.forEach { sub -> - currentSubsUrls.add(sub.url) - currentSubsNames.add(sub.name) - subtitleCallback(sub) - } + // call all callbacks + currentCache.linkCache.forEach { link -> + currentLinksUrls.add(link.url) + if (sourceTypes.contains(link.type)) { + callback(link to null) + } + } - // this stops all execution if links are cached - // no extra get requests - if (currentLinkCache.size > 0) { - return true + currentCache.subtitleCache.forEach { sub -> + currentSubsUrls.add(sub.url) + lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet() + subtitleCallback(sub) + } + + // this stops all execution if links are cached + // no extra get requests + if (currentCache.saturated) { + return true + } } val result = APIRepository( getApiFromNameNull(current.apiName) ?: throw Exception("This provider does not exist") - ).loadLinks(current.data, - isCasting, - { file -> + ).loadLinks( + current.data, + isCasting = isCasting, + subtitleCallback = { file -> + Log.d(TAG, "Loaded SubtitleFile: $file") val correctFile = PlayerSubtitleHelper.getSubtitleData(file) - if (!currentSubsUrls.contains(correctFile.url)) { - currentSubsUrls.add(correctFile.url) + if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) { + return@loadLinks + } - // this part makes sure that all names are unique for UX - var name = correctFile.name - var count = 0 - while (currentSubsNames.contains(name)) { - count++ - name = "${correctFile.name} $count" - } + // this part makes sure that all names are unique for UX + val nameDecoded = correctFile.originalName.html().toString() + .trim() // `%3Ch1%3Esub%20name…` → `

sub name…` → `sub name…` + val suffixCount = + lastCountedSuffix.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet() - currentSubsNames.add(name) - val updatedFile = correctFile.copy(name = name) + val updatedFile = + correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") - if (!currentSubsCache.contains(updatedFile)) { + synchronized(currentCache) { + if (currentCache.subtitleCache.add(updatedFile)) { subtitleCallback(updatedFile) - currentSubsCache.add(updatedFile) - //subsCache[index] = currentSubsCache + currentCache.lastCachedTimestamp = unixTime } } }, - { link -> + callback = { link -> Log.d(TAG, "Loaded ExtractorLink: $link") - if (!currentLinks.contains(link.url)) { - if (!currentLinkCache.contains(link)) { - currentLinks.add(link.url) - callback(Pair(link, null)) - currentLinkCache.add(link) - //linkCache[index] = currentLinkCache + if (link.url.isBlank() || !currentLinksUrls.add(link.url)) { + return@loadLinks + } + + synchronized(currentCache) { + if (currentCache.linkCache.add(link)) { + if (sourceTypes.contains(link.type)) { + callback(Pair(link, null)) + } + + currentCache.linkCache.add(link) + currentCache.lastCachedTimestamp = unixTime } } } ) - cache[Pair(current.apiName, current.id)] = Pair(currentLinkCache, currentSubsCache) + + synchronized(currentCache) { + currentCache.saturated = currentCache.linkCache.isNotEmpty() + currentCache.lastCachedTimestamp = unixTime + } return result } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt new file mode 100644 index 000000000..824b5d1a2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt @@ -0,0 +1,114 @@ +package com.lagradost.cloudstream3.ui.player + +/** + * Inspired by https://medium.com/@Semper_Viventem/simple-implementation-of-rounded-background-for-text-in-android-60a7706c0419 + * however the connecting triangles cant be rendered on a transparent bg, also does not support alignment. + * + * This current implementation may be expanded to only draw the drawRoundRect with rounded corners iff + * it is on an edge for a nice look: + * + * /----------\ + * | large | + * \----------/ + * | | <- this instead of / and \ + * | small | + * \-------/ + * + * Also note that the background may be drawn wildly different from where exoplayer places it + * because exoplayer has their own custom drawing. This is only an attempt to correlate it. + * + * Additionally, not tested on RTL +*/ + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.os.Build +import android.text.Layout.Alignment +import android.text.StaticLayout +import android.text.TextPaint +import android.text.style.LineBackgroundSpan + +class RoundedBackgroundColorSpan( + private val backgroundColor: Int, + private val alignment: Alignment, + private val padding: Float, + private val radius: Float +) : LineBackgroundSpan { + private val paint = Paint().apply { + color = backgroundColor + isAntiAlias = true + } + + override fun drawBackground( + c: Canvas, + p: Paint, + left: Int, + right: Int, + top: Int, + baseline: Int, + bottom: Int, + text: CharSequence, + start: Int, + end: Int, + lineNumber: Int + ) { + + // https://github.com/androidx/media/blob/main/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java + if (Color.alpha(backgroundColor) <= 0) { + return + } + + val width = p.measureText(text, start, end) + val textLayout: StaticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + StaticLayout.Builder + .obtain(text, 0, text.length, TextPaint(p), width.toInt()) + .setAlignment(alignment) + .setLineSpacing(0.0f, 1.0f) + .setIncludePad(true) + .build() + } else { + @Suppress("DEPRECATION") + StaticLayout( + text, + TextPaint(p), + width.toInt(), + alignment, + 1.0f, + 0.0f, + true + ) + } + + val center = (left + right).toFloat() * 0.5f + + // I know this is not how you actually do it, but fuck it. + // You have to override the subtitle painter to get all the correct value + val textLeft = when (alignment) { + Alignment.ALIGN_NORMAL -> { + 0.0f + } + + Alignment.ALIGN_OPPOSITE -> { + right - width + } + + Alignment.ALIGN_CENTER -> { + center - width * 0.5f + } + } + + val textTop = textLayout.getLineTop(lineNumber).toFloat() + val textBottom = textLayout.getLineBottom(lineNumber).toFloat() + + c.drawRoundRect( + textLeft - padding, + textTop, + textLeft + width + padding, + textBottom, + radius, + radius, + paint + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt new file mode 100644 index 000000000..fa65c322e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt @@ -0,0 +1,104 @@ +package com.lagradost.cloudstream3.ui.player + +import android.animation.ObjectAnimator +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import androidx.core.view.isInvisible +import com.lagradost.cloudstream3.databinding.SubtitleOffsetItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import kotlin.math.roundToInt + +data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: List) { + val endTimeMs = startTimeMs + durationMs +} + +class SubtitleOffsetItemAdapter( + private var currentTimeMs: Long, + val clickCallback: (SubtitleCue) -> Unit +) : + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.startTimeMs == b.startTimeMs + })) { + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val binding = SubtitleOffsetItemBinding.inflate(inflater, parent, false) + return ViewHolderState(binding) + } + + override fun onBindContent(holder: ViewHolderState, item: SubtitleCue, position: Int) { + val binding = holder.view as? SubtitleOffsetItemBinding ?: return + + binding.root.setOnClickListener { + clickCallback.invoke(item) + } + + binding.subtitleText.text = item.text.joinToString("\n") + + val timeMs = currentTimeMs + val startTime = item.startTimeMs + val endTime = item.endTimeMs + + val newAlpha = if (timeMs >= startTime) 1f else 0.5f + ObjectAnimator.ofFloat( + binding.subtitleText, + "alpha", + binding.subtitleText.alpha, + newAlpha + ).apply { + interpolator = DecelerateInterpolator() + }.start() + + val showProgress = timeMs in startTime..= it.value.startTimeMs + }?.index ?: 0 + } + + fun updateTime(timeMs: Long) { + val previousTime = currentTimeMs + currentTimeMs = timeMs + + val earlyTime = minOf(previousTime, timeMs) + val lateTime = maxOf(previousTime, timeMs) + + // TODO Add binary search and notifyItemRangeChanged + val affectedItems = immutableCurrentList.withIndex().filter { cue -> + // Padding is required in the range because changes can be done within one single subtitle range, + // and that subtitle needs to be updated + cue.value.startTimeMs in (earlyTime - cue.value.durationMs)..(lateTime + cue.value.durationMs) + } + + affectedItems.forEach { item -> + // This could likely be a range + this.notifyItemChanged(item.index) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/Torrent.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/Torrent.kt new file mode 100644 index 000000000..2e554f75e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/Torrent.kt @@ -0,0 +1,392 @@ +package com.lagradost.cloudstream3.ui.player + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.api.Log +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.newExtractorLink +import torrServer.TorrServer +import java.io.File +import java.net.ConnectException +import java.net.URLEncoder + +object Torrent { + var hasAcceptedTorrentForThisSession: Boolean? = null + private const val TORRENT_SERVER_PATH: String = "torrent_tmp" + private const val TIMEOUT: Long = 3 + private const val TAG: String = "Torrent" + + /** Cleans up both old aria2c files and newer go server, (even if the new is also self cleaning) */ + @Throws + fun deleteAllFiles(): Boolean { + val act = CommonActivity.activity ?: return false + val defaultDirectory = "${act.cacheDir.path}/$TORRENT_SERVER_PATH" + return File(defaultDirectory).deleteRecursively() + } + + private var TORRENT_SERVER_URL = "" // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/main/server.go#L23 + + /** Returns true if the server is up */ + private suspend fun echo(): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return false + } + return try { + app.get( + "$TORRENT_SERVER_URL/echo", + ).text.isNotEmpty() + } catch (e: ConnectException) { + // `Failed to connect to /127.0.0.1:8090` if the server is down + false + } catch (t: Throwable) { + logError(t) + false + } + } + + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/shutdown.go#L22 + /** Gracefully shutdown the server. + * should not be used because I am unable to start it again, and the stopTorrentServer() crashes the app */ + suspend fun shutdown(): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return false + } + return try { + app.get( + "$TORRENT_SERVER_URL/shutdown", + ).isSuccessful + } catch (t: Throwable) { + logError(t) + false + } + } + + /** Lists all torrents by the server */ + @Throws + private suspend fun list(): Array { + if(TORRENT_SERVER_URL.isEmpty()) { + throw ErrorLoadingException("Not initialized") + } + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "list", + ), + timeout = TIMEOUT, + headers = emptyMap() + ).parsed>() + } + + /** Drops a single torrent, (I think) this means closing the stream. Returns returns if it is successful */ + private suspend fun drop(hash: String): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return false + } + return try { + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "drop", + hash = hash + ), + timeout = TIMEOUT, + headers = emptyMap() + ).isSuccessful + } catch (t: Throwable) { + logError(t) + false + } + } + + /** Removes a single torrent from the server registry */ + private suspend fun rem(hash: String): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return false + } + return try { + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "rem", + hash = hash + ), + timeout = TIMEOUT, + headers = emptyMap() + ).isSuccessful + } catch (t: Throwable) { + logError(t) + false + } + } + + + /** Removes all torrents from the server, and returns if it is successful */ + suspend fun clearAll(): Boolean { + if(TORRENT_SERVER_URL.isEmpty()) { + return true + } + return try { + val items = list() + var allSuccess = true + for (item in items) { + val hash = item.hash + if (hash == null) { + Log.i(TAG, "No hash on ${item.name}") + allSuccess = false + continue + } + if (drop(hash)) { + Log.i(TAG, "Successfully dropped ${item.name}") + } else { + Log.i(TAG, "Failed to drop ${item.name}") + allSuccess = false + continue + } + if (rem(hash)) { + Log.i(TAG, "Successfully removed ${item.name}") + } else { + Log.i(TAG, "Failed to remove ${item.name}") + allSuccess = false + continue + } + } + allSuccess + } catch (t: Throwable) { + logError(t) + false + } + } + + /** Gets all the metadata of a torrent, will throw if that hash does not exists + * https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/torrents.go#L126 */ + @Throws + suspend fun get( + hash: String, + ): TorrentStatus { + if(TORRENT_SERVER_URL.isEmpty()) { + throw ErrorLoadingException("Not initialized") + } + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "get", + hash = hash, + ), + timeout = TIMEOUT, + headers = emptyMap() + ).parsed() + } + + /** Adds a torrent to the server, this is needed for us to get the hash for further modification, as well as start streaming it*/ + @Throws + private suspend fun add(url: String): TorrentStatus { + if(TORRENT_SERVER_URL.isEmpty()) { + throw ErrorLoadingException("Not initialized") + } + return app.post( + "$TORRENT_SERVER_URL/torrents", + json = TorrentRequest( + action = "add", + link = url, + ), + headers = emptyMap() + ).parsed() + } + + /** Spins up the torrent server. */ + private suspend fun setup(dir: String): Boolean { + go.Seq.load() + if (echo()) { + return true + } + val port = TorrServer.startTorrentServer(dir, 0) + if(port < 0) { + return false + } + TORRENT_SERVER_URL = "http://127.0.0.1:$port" + TorrServer.addTrackers(trackers.joinToString(separator = ",\n")) + return echo() + } + + /** Transforms a torrent link into a streamable link via the server */ + @Throws + suspend fun transformLink(link: ExtractorLink): Pair { + val act = CommonActivity.activity ?: throw IllegalArgumentException("No activity") + val defaultDirectory = "${act.cacheDir.path}/$TORRENT_SERVER_PATH" + File(defaultDirectory).mkdir() + if (!setup(defaultDirectory)) { + throw ErrorLoadingException("Unable to setup the torrent server") + } + val status = add(link.url) + + return newExtractorLink( + source = link.source, + name = link.name, + url = status.streamUrl(link.url), + type = ExtractorLinkType.VIDEO + ) { + this.referer = "" + this.quality = link.quality + } to status + } + + private val trackers = listOf( + "udp://tracker.opentrackr.org:1337/announce", + "https://tracker2.ctix.cn/announce", + "https://tracker1.520.jp:443/announce", + "udp://opentracker.i2p.rocks:6969/announce", + "udp://open.tracker.cl:1337/announce", + "udp://open.demonii.com:1337/announce", + "http://tracker.openbittorrent.com:80/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://open.stealth.si:80/announce", + "udp://exodus.desync.com:6969/announce", + "udp://tracker-udp.gbitt.info:80/announce", + "udp://explodie.org:6969/announce", + "https://tracker.gbitt.info:443/announce", + "http://tracker.gbitt.info:80/announce", + "udp://uploads.gamecoast.net:6969/announce", + "udp://tracker1.bt.moack.co.kr:80/announce", + "udp://tracker.tiny-vps.com:6969/announce", + "udp://tracker.theoks.net:6969/announce", + "udp://tracker.dump.cl:6969/announce", + "udp://tracker.bittor.pw:1337/announce", + "https://tracker1.520.jp:443/announce", + "udp://opentracker.i2p.rocks:6969/announce", + "udp://open.tracker.cl:1337/announce", + "udp://open.demonii.com:1337/announce", + "http://tracker.openbittorrent.com:80/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://open.stealth.si:80/announce", + "udp://exodus.desync.com:6969/announce", + "udp://tracker-udp.gbitt.info:80/announce", + "udp://explodie.org:6969/announce", + "https://tracker.gbitt.info:443/announce", + "http://tracker.gbitt.info:80/announce", + "udp://uploads.gamecoast.net:6969/announce", + "udp://tracker1.bt.moack.co.kr:80/announce", + "udp://tracker.tiny-vps.com:6969/announce", + "udp://tracker.theoks.net:6969/announce", + "udp://tracker.dump.cl:6969/announce", + "udp://tracker.bittor.pw:1337/announce" + ) + + + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/torrents.go#L18 + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/main/web/api/route.go#L7 + data class TorrentRequest( + @JsonProperty("action") + val action: String, + @JsonProperty("hash") + val hash: String = "", + @JsonProperty("link") + val link: String = "", + @JsonProperty("title") + val title: String = "", + @JsonProperty("poster") + val poster: String = "", + @JsonProperty("data") + val data: String = "", + @JsonProperty("save_to_db") + val saveToDB: Boolean = false, + ) + + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/torr/state/state.go#L33 + // omitempty = nullable + data class TorrentStatus( + @JsonProperty("title") + var title: String, + @JsonProperty("poster") + var poster: String, + @JsonProperty("data") + var data: String?, + @JsonProperty("timestamp") + var timestamp: Long, + @JsonProperty("name") + var name: String?, + @JsonProperty("hash") + var hash: String?, + @JsonProperty("stat") + var stat: Int, + @JsonProperty("stat_string") + var statString: String, + @JsonProperty("loaded_size") + var loadedSize: Long?, + @JsonProperty("torrent_size") + var torrentSize: Long?, + @JsonProperty("preloaded_bytes") + var preloadedBytes: Long?, + @JsonProperty("preload_size") + var preloadSize: Long?, + @JsonProperty("download_speed") + var downloadSpeed: Double?, + @JsonProperty("upload_speed") + var uploadSpeed: Double?, + @JsonProperty("total_peers") + var totalPeers: Int?, + @JsonProperty("pending_peers") + var pendingPeers: Int?, + @JsonProperty("active_peers") + var activePeers: Int?, + @JsonProperty("connected_seeders") + var connectedSeeders: Int?, + @JsonProperty("half_open_peers") + var halfOpenPeers: Int?, + @JsonProperty("bytes_written") + var bytesWritten: Long?, + @JsonProperty("bytes_written_data") + var bytesWrittenData: Long?, + @JsonProperty("bytes_read") + var bytesRead: Long?, + @JsonProperty("bytes_read_data") + var bytesReadData: Long?, + @JsonProperty("bytes_read_useful_data") + var bytesReadUsefulData: Long?, + @JsonProperty("chunks_written") + var chunksWritten: Long?, + @JsonProperty("chunks_read") + var chunksRead: Long?, + @JsonProperty("chunks_read_useful") + var chunksReadUseful: Long?, + @JsonProperty("chunks_read_wasted") + var chunksReadWasted: Long?, + @JsonProperty("pieces_dirtied_good") + var piecesDirtiedGood: Long?, + @JsonProperty("pieces_dirtied_bad") + var piecesDirtiedBad: Long?, + @JsonProperty("duration_seconds") + var durationSeconds: Double?, + @JsonProperty("bit_rate") + var bitRate: String?, + @JsonProperty("file_stats") + var fileStats: List?, + @JsonProperty("trackers") + var trackers: List?, + ) { + fun streamUrl(url: String): String { + val fileName = + this.fileStats?.first { !it.path.isNullOrBlank() }?.path + ?: throw ErrorLoadingException("Null path") + + val index = url.substringAfter("index=").substringBefore("&").toIntOrNull() ?: 0 + + // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/stream.go#L18 + return "$TORRENT_SERVER_URL/stream/${ + URLEncoder.encode(fileName, "utf-8") + }?link=${this.hash}&index=$index&play" + } + } + + data class TorrentFileStat( + @JsonProperty("id") + val id: Int?, + @JsonProperty("path") + val path: String?, + @JsonProperty("length") + val length: Long?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt new file mode 100644 index 000000000..b3873bd32 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt @@ -0,0 +1,678 @@ +@file:Suppress( + "ALL", + "DEPRECATION", + "RedundantVisibilityModifier", + "RemoveRedundantQualifierName", + "UNCHECKED_CAST", + "UNUSED", + "UNUSED_PARAMETER", + "UNUSED_VARIABLE" +) + +package com.lagradost.cloudstream3.ui.player + +import android.net.Uri +import androidx.annotation.GuardedBy +import androidx.media3.common.C +import androidx.media3.common.FileTypes +import androidx.media3.common.Format +import androidx.media3.common.util.TimestampAdjuster +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.amr.AmrExtractor +import androidx.media3.extractor.avi.AviExtractor +import androidx.media3.extractor.avif.AvifExtractor +import androidx.media3.extractor.bmp.BmpExtractor +import androidx.media3.extractor.flac.FlacExtractor +import androidx.media3.extractor.flv.FlvExtractor +import androidx.media3.extractor.heif.HeifExtractor +import androidx.media3.extractor.jpeg.JpegExtractor +import androidx.media3.extractor.mkv.UpdatedMatroskaExtractor +import androidx.media3.extractor.mp3.Mp3Extractor +import androidx.media3.extractor.mp4.FragmentedMp4Extractor +import androidx.media3.extractor.mp4.Mp4Extractor +import androidx.media3.extractor.ogg.OggExtractor +import androidx.media3.extractor.png.PngExtractor +import androidx.media3.extractor.text.DefaultSubtitleParserFactory +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.ts.Ac3Extractor +import androidx.media3.extractor.ts.Ac4Extractor +import androidx.media3.extractor.ts.AdtsExtractor +import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory +import androidx.media3.extractor.ts.PsExtractor +import androidx.media3.extractor.ts.TsExtractor +import androidx.media3.extractor.wav.WavExtractor +import androidx.media3.extractor.webp.WebpExtractor +import com.google.common.collect.ImmutableList +import java.lang.reflect.Constructor +import java.lang.reflect.InvocationTargetException +import java.util.concurrent.atomic.AtomicBoolean + +/** + * An [ExtractorsFactory] that provides an array of extractors for the following formats: + * + * + * * MP4, including M4A ([Mp4Extractor]) + * * fMP4 ([FragmentedMp4Extractor]) + * * Matroska and WebM ([UpdatedMatroskaExtractor]) + * * Ogg Vorbis/FLAC ([OggExtractor] + * * MP3 ([Mp3Extractor]) + * * AAC ([AdtsExtractor]) + * * MPEG TS ([TsExtractor]) + * * MPEG PS ([PsExtractor]) + * * FLV ([FlvExtractor]) + * * WAV ([WavExtractor]) + * * AC3 ([Ac3Extractor]) + * * AC4 ([Ac4Extractor]) + * * AMR ([AmrExtractor]) + * * FLAC + * + * * If available, the FLAC extension's `androidx.media3.decoder.flac.FlacExtractor` + * is used. + * * Otherwise, the core [FlacExtractor] is used. Note that Android devices do not + * generally include a FLAC decoder before API 27. This can be worked around by using + * the FLAC extension or the FFmpeg extension. + * + * * JPEG ([JpegExtractor]) + * * PNG ([PngExtractor]) + * * WEBP ([WebpExtractor]) + * * BMP ([BmpExtractor]) + * * HEIF ([HeifExtractor]) + * * AVIF ([AvifExtractor]) + * * MIDI, if available, the MIDI extension's `androidx.media3.decoder.midi.MidiExtractor` + * is used. + * + */ +@UnstableApi +class UpdatedDefaultExtractorsFactory : ExtractorsFactory { + private var constantBitrateSeekingEnabled = false + private var constantBitrateSeekingAlwaysEnabled = false + private var adtsFlags: @AdtsExtractor.Flags Int = 0 + private var amrFlags: @AmrExtractor.Flags Int = 0 + private var flacFlags: @FlacExtractor.Flags Int = 0 + private var matroskaFlags: @UpdatedMatroskaExtractor.Flags Int = 0 + private var mp4Flags: @Mp4Extractor.Flags Int = 0 + private var fragmentedMp4Flags: @FragmentedMp4Extractor.Flags Int = 0 + private var mp3Flags: @Mp3Extractor.Flags Int = 0 + private var tsMode: @TsExtractor.Mode Int + private var tsFlags: @DefaultTsPayloadReaderFactory.Flags Int = 0 + + // TODO (b/261183220): Initialize tsSubtitleFormats in constructor once shrinking bug is fixed. + private var tsSubtitleFormats: ImmutableList? = null + private var tsTimestampSearchBytes: Int + private var textTrackTranscodingEnabled: Boolean + private var subtitleParserFactory: SubtitleParser.Factory + private var codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int + private var jpegFlags: @JpegExtractor.Flags Int = 0 + private var heifFlags: @HeifExtractor.Flags Int = 0 + + init { + tsMode = TsExtractor.MODE_SINGLE_PMT + tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES + subtitleParserFactory = DefaultSubtitleParserFactory() + textTrackTranscodingEnabled = true + codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 or C.VIDEO_CODEC_FLAG_H265 + } + + /** + * Convenience method to set whether approximate seeking using constant bitrate assumptions should + * be enabled for all extractors that support it. If set to true, the flags required to enable + * this functionality will be OR'd with those passed to the setters when creating extractor + * instances. If set to false then the flags passed to the setters will be used without + * modification. + * + * @param constantBitrateSeekingEnabled Whether approximate seeking using a constant bitrate + * assumption should be enabled for all extractors that support it. + * @return The factory, for convenience. + */ + @Synchronized + fun setConstantBitrateSeekingEnabled( + constantBitrateSeekingEnabled: Boolean + ): UpdatedDefaultExtractorsFactory { + this.constantBitrateSeekingEnabled = constantBitrateSeekingEnabled + return this + } + + /** + * Convenience method to set whether approximate seeking using constant bitrate assumptions should + * be enabled for all extractors that support it, and if it should be enabled even if the content + * length (and hence the duration of the media) is unknown. If set to true, the flags required to + * enable this functionality will be OR'd with those passed to the setters when creating extractor + * instances. If set to false then the flags passed to the setters will be used without + * modification. + * + * + * When seeking into content where the length is unknown, application code should ensure that + * requested seek positions are valid, or should be ready to handle playback failures reported + * through [Player.Listener.onPlayerError] with [PlaybackException.errorCode] set to + * [PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE]. + * + * @param constantBitrateSeekingAlwaysEnabled Whether approximate seeking using a constant bitrate + * assumption should be enabled for all extractors that support it, including when the content + * duration is unknown. + * @return The factory, for convenience. + */ + @Synchronized + fun setConstantBitrateSeekingAlwaysEnabled( + constantBitrateSeekingAlwaysEnabled: Boolean + ): UpdatedDefaultExtractorsFactory { + this.constantBitrateSeekingAlwaysEnabled = constantBitrateSeekingAlwaysEnabled + return this + } + + /** + * Sets flags for [AdtsExtractor] instances created by the factory. + * + * @see AdtsExtractor.AdtsExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setAdtsExtractorFlags( + flags: @AdtsExtractor.Flags Int + ): UpdatedDefaultExtractorsFactory { + this.adtsFlags = flags + return this + } + + /** + * Sets flags for [AmrExtractor] instances created by the factory. + * + * @see AmrExtractor.AmrExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setAmrExtractorFlags(flags: @AmrExtractor.Flags Int): UpdatedDefaultExtractorsFactory { + this.amrFlags = flags + return this + } + + /** + * Sets flags for [FlacExtractor] instances created by the factory. The flags are also used + * by `androidx.media3.decoder.flac.FlacExtractor` instances if the FLAC extension is being + * used. + * + * @see FlacExtractor.FlacExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setFlacExtractorFlags( + flags: @FlacExtractor.Flags Int + ): UpdatedDefaultExtractorsFactory { + this.flacFlags = flags + return this + } + + /** + * Sets flags for [UpdatedMatroskaExtractor] instances created by the factory. + * + * @see UpdatedMatroskaExtractor.MatroskaExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setMatroskaExtractorFlags( + flags: @UpdatedMatroskaExtractor.Flags Int + ): UpdatedDefaultExtractorsFactory { + this.matroskaFlags = flags + return this + } + + /** + * Sets flags for [Mp4Extractor] instances created by the factory. + * + * @see Mp4Extractor.Mp4Extractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setMp4ExtractorFlags(flags: @Mp4Extractor.Flags Int): UpdatedDefaultExtractorsFactory { + this.mp4Flags = flags + return this + } + + /** + * Sets flags for [FragmentedMp4Extractor] instances created by the factory. + * + * @see FragmentedMp4Extractor.FragmentedMp4Extractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setFragmentedMp4ExtractorFlags( + flags: @FragmentedMp4Extractor.Flags Int + ): UpdatedDefaultExtractorsFactory { + this.fragmentedMp4Flags = flags + return this + } + + /** + * Sets flags for [Mp3Extractor] instances created by the factory. + * + * @see Mp3Extractor.Mp3Extractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setMp3ExtractorFlags(flags: @Mp3Extractor.Flags Int): UpdatedDefaultExtractorsFactory { + mp3Flags = flags + return this + } + + /** + * Sets the mode for [TsExtractor] instances created by the factory. + * + * @see TsExtractor.TsExtractor + * @param mode The mode to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setTsExtractorMode(mode: @TsExtractor.Mode Int): UpdatedDefaultExtractorsFactory { + tsMode = mode + return this + } + + /** + * Sets flags for [DefaultTsPayloadReaderFactory]s used by [TsExtractor] instances + * created by the factory. + * + * @see TsExtractor.TsExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setTsExtractorFlags( + flags: @DefaultTsPayloadReaderFactory.Flags Int + ): UpdatedDefaultExtractorsFactory { + tsFlags = flags + return this + } + + /** + * Sets a list of subtitle formats to pass to the [DefaultTsPayloadReaderFactory] used by + * [TsExtractor] instances created by the factory. + * + * @see DefaultTsPayloadReaderFactory.DefaultTsPayloadReaderFactory + * @param subtitleFormats The subtitle formats. + * @return The factory, for convenience. + */ + @Synchronized + fun setTsSubtitleFormats(subtitleFormats: List?): UpdatedDefaultExtractorsFactory { + tsSubtitleFormats = subtitleFormats?.let { ImmutableList.copyOf(it) } + return this + } + + /** + * Sets the number of bytes searched to find a timestamp for [TsExtractor] instances created + * by the factory. + * + * @see TsExtractor.TsExtractor + * @param timestampSearchBytes The number of search bytes to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setTsExtractorTimestampSearchBytes( + timestampSearchBytes: Int + ): UpdatedDefaultExtractorsFactory { + tsTimestampSearchBytes = timestampSearchBytes + return this + } + + @Deprecated( + """This method (and all support for 'legacy' subtitle decoding during rendering) will + be removed in a future release.""" + ) + @Synchronized + fun setTextTrackTranscodingEnabled( + textTrackTranscodingEnabled: Boolean + ): UpdatedDefaultExtractorsFactory { + return experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled) + } + + @Deprecated("") + @Synchronized + override fun experimentalSetTextTrackTranscodingEnabled( + textTrackTranscodingEnabled: Boolean + ): UpdatedDefaultExtractorsFactory { + this.textTrackTranscodingEnabled = textTrackTranscodingEnabled + return this + } + + @Synchronized + override fun setSubtitleParserFactory( + subtitleParserFactory: SubtitleParser.Factory + ): UpdatedDefaultExtractorsFactory { + this.subtitleParserFactory = subtitleParserFactory + return this + } + + @Synchronized + override fun experimentalSetCodecsToParseWithinGopSampleDependencies( + codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int + ): UpdatedDefaultExtractorsFactory { + this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies + return this + } + + /** + * Sets flags for [JpegExtractor] instances created by the factory. + * + * @see JpegExtractor.JpegExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setJpegExtractorFlags( + flags: @JpegExtractor.Flags Int + ): UpdatedDefaultExtractorsFactory { + this.jpegFlags = flags + return this + } + + /** + * Sets flags for [HeifExtractor] instances created by the factory. + * + * @see HeifExtractor.HeifExtractor + * @param flags The flags to use. + * @return The factory, for convenience. + */ + @Synchronized + fun setHeifExtractorFlags( + flags: @HeifExtractor.Flags Int + ): UpdatedDefaultExtractorsFactory { + this.heifFlags = flags + return this + } + + @Synchronized + override fun createExtractors(): Array { + return createExtractors(Uri.EMPTY, HashMap()) + } + + @Synchronized + override fun createExtractors( + uri: Uri, responseHeaders: Map> + ): Array { + val extractors: MutableList = + ArrayList( /* initialCapacity= */DEFAULT_EXTRACTOR_ORDER.size) + + val responseHeadersInferredFileType: @FileTypes.Type Int = + FileTypes.inferFileTypeFromResponseHeaders(responseHeaders) + if (responseHeadersInferredFileType != FileTypes.UNKNOWN) { + addExtractorsForFileType(responseHeadersInferredFileType, extractors) + } + + val uriInferredFileType: @FileTypes.Type Int = FileTypes.inferFileTypeFromUri(uri) + if (uriInferredFileType != FileTypes.UNKNOWN + && uriInferredFileType != responseHeadersInferredFileType + ) { + addExtractorsForFileType(uriInferredFileType, extractors) + } + + for (fileType in DEFAULT_EXTRACTOR_ORDER) { + if (fileType != responseHeadersInferredFileType && fileType != uriInferredFileType) { + addExtractorsForFileType(fileType, extractors) + } + } + return extractors.toTypedArray() + } + + private fun addExtractorsForFileType( + fileType: @FileTypes.Type Int, + extractors: MutableList + ) { + when (fileType) { + FileTypes.AC3 -> extractors.add(Ac3Extractor()) + FileTypes.AC4 -> extractors.add(Ac4Extractor()) + FileTypes.ADTS -> extractors.add( + AdtsExtractor( + (adtsFlags + or (if (constantBitrateSeekingEnabled) + AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + else + 0) + or (if (constantBitrateSeekingAlwaysEnabled) + AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS + else + 0)) + ) + ) + + FileTypes.AMR -> extractors.add( + AmrExtractor( + (amrFlags + or (if (constantBitrateSeekingEnabled) + AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + else + 0) + or (if (constantBitrateSeekingAlwaysEnabled) + AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS + else + 0)) + ) + ) + + FileTypes.FLAC -> { + val flacExtractor: Extractor? = FLAC_EXTENSION_LOADER.getExtractor(flacFlags) + if (flacExtractor != null) { + extractors.add(flacExtractor) + } else { + extractors.add(FlacExtractor(flacFlags)) + } + } + + FileTypes.FLV -> extractors.add(FlvExtractor()) + FileTypes.MATROSKA -> extractors.add( + UpdatedMatroskaExtractor( + subtitleParserFactory, + matroskaFlags + or (if (textTrackTranscodingEnabled) + 0 + else + UpdatedMatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA) + ) + ) + + FileTypes.MP3 -> extractors.add( + Mp3Extractor( + (mp3Flags + or (if (constantBitrateSeekingEnabled) + Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + else + 0) + or (if (constantBitrateSeekingAlwaysEnabled) + Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS + else + 0)) + ) + ) + + FileTypes.MP4 -> { + extractors.add( + FragmentedMp4Extractor( + subtitleParserFactory, + fragmentedMp4Flags or + FragmentedMp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA + ) + ) + + extractors.add( + Mp4Extractor( + subtitleParserFactory, + mp4Flags or + Mp4Extractor + .codecsToParseWithinGopSampleDependenciesAsFlags( + codecsToParseWithinGopSampleDependencies + ) or + if (textTrackTranscodingEnabled) 0 + else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA + ) + ) + } + + FileTypes.OGG -> extractors.add(OggExtractor()) + FileTypes.PS -> extractors.add(PsExtractor()) + FileTypes.TS -> { + if (tsSubtitleFormats == null) { + tsSubtitleFormats = ImmutableList.of() + } + extractors.add( + TsExtractor( + tsMode, + (if (textTrackTranscodingEnabled) 0 else TsExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA), + subtitleParserFactory, + TimestampAdjuster(0), + DefaultTsPayloadReaderFactory(tsFlags, tsSubtitleFormats!!), + tsTimestampSearchBytes + ) + ) + } + + FileTypes.WAV -> extractors.add(WavExtractor()) + FileTypes.JPEG -> extractors.add(JpegExtractor(jpegFlags)) + FileTypes.MIDI -> { + val midiExtractor: Extractor? = MIDI_EXTENSION_LOADER.getExtractor() + if (midiExtractor != null) { + extractors.add(midiExtractor) + } + } + + FileTypes.AVI -> extractors.add( + AviExtractor( + (if (textTrackTranscodingEnabled) 0 else AviExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA), + subtitleParserFactory + ) + ) + + FileTypes.PNG -> extractors.add(PngExtractor()) + FileTypes.WEBP -> extractors.add(WebpExtractor()) + FileTypes.BMP -> extractors.add(BmpExtractor()) + FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags)) + FileTypes.AVIF -> extractors.add(AvifExtractor()) + FileTypes.WEBVTT, FileTypes.UNKNOWN -> {} + else -> {} + } + } + + private class ExtensionLoader(private val constructorSupplier: ConstructorSupplier) { + interface ConstructorSupplier { + @get:Throws( + InvocationTargetException::class, + IllegalAccessException::class, + NoSuchMethodException::class, + ClassNotFoundException::class + ) + val constructor: Constructor? + } + + private val extensionLoaded = AtomicBoolean(false) + + @GuardedBy("extensionLoaded") + private val extractorConstructor: Constructor? = null + + fun getExtractor(vararg constructorParams: Any?): Extractor? { + val extractorConstructor: Constructor = maybeLoadExtractorConstructor() + ?: return null + try { + return extractorConstructor.newInstance(*constructorParams) + } catch (e: Exception) { + throw IllegalStateException("Unexpected error creating extractor", e) + } + } + + fun maybeLoadExtractorConstructor(): Constructor? { + synchronized(extensionLoaded) { + if (extensionLoaded.get()) { + return extractorConstructor + } + try { + return constructorSupplier.constructor + } catch (e: ClassNotFoundException) { + // Expected if the app was built without the extension. + } catch (e: Exception) { + // The extension is present, but instantiation failed. + throw RuntimeException("Error instantiating extension", e) + } + extensionLoaded.set(true) + return extractorConstructor + } + } + } + + companion object { + // Extractors order is optimized according to + // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. + // The JPEG extractor appears after audio/video extractors because we expect audio/video input to + // be more common. + private val DEFAULT_EXTRACTOR_ORDER = intArrayOf( + FileTypes.FLV, + FileTypes.FLAC, + FileTypes.WAV, + FileTypes.MP4, + FileTypes.AMR, + FileTypes.PS, + FileTypes.OGG, + FileTypes.TS, + FileTypes.MATROSKA, + FileTypes.ADTS, + FileTypes.AC3, + FileTypes.AC4, + FileTypes.MP3, // The following extractors are not part of the optimized ordering, and were appended + // without further analysis. + FileTypes.AVI, + FileTypes.MIDI, + FileTypes.JPEG, + FileTypes.PNG, + FileTypes.WEBP, + FileTypes.BMP, + FileTypes.HEIF, + FileTypes.AVIF + ) + + private val FLAC_EXTENSION_LOADER = + ExtensionLoader(object : ExtensionLoader.ConstructorSupplier { + override val constructor get() = flacExtractorConstructor + }) + private val MIDI_EXTENSION_LOADER = + ExtensionLoader(object : ExtensionLoader.ConstructorSupplier { + override val constructor get() = midiExtractorConstructor + }) + + @get:Throws( + ClassNotFoundException::class, + NoSuchMethodException::class + ) + private val midiExtractorConstructor: Constructor + get() = Class.forName("androidx.media3.decoder.midi.MidiExtractor") + .asSubclass(Extractor::class.java) + .getConstructor() + + @get:Throws( + ClassNotFoundException::class, + NoSuchMethodException::class, + InvocationTargetException::class, + IllegalAccessException::class + ) + private val flacExtractorConstructor: Constructor? + get() { + val isFlacNativeLibraryAvailable = + java.lang.Boolean.TRUE == Class.forName("androidx.media3.decoder.flac.FlacLibrary") + .getMethod("isAvailable") + .invoke( /* obj= */null) + if (isFlacNativeLibraryAvailable) { + return Class.forName("androidx.media3.decoder.flac.FlacExtractor") + .asSubclass(Extractor::class.java) + .getConstructor(Int::class.javaPrimitiveType) + } + return null + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt new file mode 100644 index 000000000..5937b1973 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt @@ -0,0 +1,3242 @@ +@file:Suppress( + "ALL", + "DEPRECATION", + "RedundantVisibilityModifier", + "RemoveRedundantQualifierName", + "UNCHECKED_CAST", + "UNUSED", + "UNUSED_PARAMETER", + "UNUSED_VARIABLE" +) + +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.extractor.mkv // we cant change the pkg as EbmlReader is private + +import android.util.Pair +import android.util.SparseArray +import androidx.annotation.CallSuper +import androidx.annotation.IntDef +import androidx.media3.common.C +import androidx.media3.common.C.BufferFlags +import androidx.media3.common.C.ColorRange +import androidx.media3.common.C.ColorTransfer +import androidx.media3.common.C.PcmEncoding +import androidx.media3.common.C.SelectionFlags +import androidx.media3.common.C.StereoMode +import androidx.media3.common.ColorInfo +import androidx.media3.common.DrmInitData +import androidx.media3.common.DrmInitData.SchemeData +import androidx.media3.common.Format +import androidx.media3.common.Metadata +import androidx.media3.common.MimeTypes +import androidx.media3.common.ParserException +import androidx.media3.common.util.Log +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.container.DolbyVisionConfig +import androidx.media3.container.NalUnitUtil +import androidx.media3.extractor.AacUtil +import androidx.media3.extractor.AvcConfig +import androidx.media3.extractor.ChunkIndex +import androidx.media3.extractor.ChunkIndexProvider +import androidx.media3.extractor.DtsUtil +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.HevcConfig +import androidx.media3.extractor.MpegAudioUtil +import androidx.media3.extractor.PositionHolder +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.SeekMap.SeekPoints +import androidx.media3.extractor.SeekPoint +import androidx.media3.extractor.TrackAwareSeekMap +import androidx.media3.extractor.TrackOutput +import androidx.media3.extractor.TrackOutput.CryptoData +import androidx.media3.extractor.TrueHdSampleRechunker +import androidx.media3.extractor.metadata.ThumbnailMetadata +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput +import com.google.common.base.Preconditions.checkArgument +import com.google.common.base.Preconditions.checkNotNull +import com.google.common.base.Preconditions.checkState +import com.google.common.collect.ImmutableList +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.Arrays +import java.util.Collections +import java.util.Locale +import java.util.Objects +import java.util.UUID +import kotlin.math.max +import kotlin.math.min + +/** Extracts data from the Matroska and WebM container formats. */ +@UnstableApi +class UpdatedMatroskaExtractor private constructor( + private val reader: EbmlReader, + flags: @Flags Int, + subtitleParserFactory: SubtitleParser.Factory +) : + Extractor { + /** + * Flags controlling the behavior of the extractor. Possible flag values are [ ][.FLAG_DISABLE_SEEK_FOR_CUES] and {#FLAG_EMIT_RAW_SUBTITLE_DATA}. + */ + @MustBeDocumented + @Retention(AnnotationRetention.SOURCE) + @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE, AnnotationTarget.TYPE_PARAMETER) + @IntDef(flag = true, value = [FLAG_DISABLE_SEEK_FOR_CUES, FLAG_EMIT_RAW_SUBTITLE_DATA]) + annotation class Flags + + private val varintReader: VarintReader + private val tracks: SparseArray + private val seekForCuesEnabled: Boolean + private val parseSubtitlesDuringExtraction: Boolean + private val subtitleParserFactory: SubtitleParser.Factory + + // Temporary arrays. + private val nalStartCode: ParsableByteArray + private val nalLength: ParsableByteArray + private val scratch: ParsableByteArray + private val vorbisNumPageSamples: ParsableByteArray + private val seekEntryIdBytes: ParsableByteArray + private val sampleStrippedBytes: ParsableByteArray + private val subtitleSample: ParsableByteArray + private val encryptionInitializationVector: ParsableByteArray + private val encryptionSubsampleData: ParsableByteArray + private val supplementalData: ParsableByteArray + private var encryptionSubsampleDataBuffer: ByteBuffer? = null + + private var segmentContentSize: Long = 0 + private var segmentContentPosition = C.INDEX_UNSET.toLong() + private var timecodeScale = C.TIME_UNSET + private var durationTimecode = C.TIME_UNSET + private var durationUs = C.TIME_UNSET + private var isWebm: Boolean = false + private var pendingEndTracks: Boolean + + // The track corresponding to the current TrackEntry element, or null. + private var currentTrack: Track? = null + + // Whether a seek map has been sent to the output. + private var sentSeekMap = false + + // Master seek entry related elements. + private var seekEntryId = 0 + private var seekEntryPosition: Long = 0 + + // Cue related elements. + private val perTrackCues: SparseArray> + private var inCuesElement = false + private var currentCueTimeUs: Long = C.TIME_UNSET + private var currentCueTrackNumber: Int = C.INDEX_UNSET + private var currentCueClusterPosition: Long = C.INDEX_UNSET.toLong() + private var currentCueRelativePosition: Long = C.INDEX_UNSET.toLong() + private var primarySeekTrackNumber: Int = C.INDEX_UNSET + private var seekForCues = false + private var seekForSeekContent = false + private var visitedSeekHeads: HashSet = HashSet() + private var pendingSeekHeads: ArrayList = ArrayList() + private var seekPositionAfterSeekingForHead = C.INDEX_UNSET.toLong() + private var cuesContentPosition = C.INDEX_UNSET.toLong() + private var seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() + private var clusterTimecodeUs = C.TIME_UNSET + + // Reading state. + private var haveOutputSample = false + + // Block reading state. + private var blockState = 0 + private var blockTimeUs: Long = 0 + private var blockDurationUs: Long = 0 + private var blockSampleIndex = 0 + private var blockSampleCount = 0 + private var blockSampleSizes: IntArray + private var blockTrackNumber = 0 + private var blockTrackNumberLength = 0 + private var blockFlags: @BufferFlags Int = 0 + private var blockAdditionalId = 0 + private var blockHasReferenceBlock = false + private var blockGroupDiscardPaddingNs: Long = 0 + + // Sample writing state. + private var sampleBytesRead = 0 + private var sampleBytesWritten = 0 + private var sampleCurrentNalBytesRemaining = 0 + private var sampleEncodingHandled = false + private var sampleSignalByteRead = false + private var samplePartitionCountRead = false + private var samplePartitionCount = 0 + private var sampleSignalByte: Byte = 0 + private var sampleInitializationVectorRead = false + + // Extractor outputs. + private var extractorOutput: ExtractorOutput? = + null + + @Deprecated("Use {@link #MatroskaExtractor(SubtitleParser.Factory)} instead.") + constructor() : this( + DefaultEbmlReader(), + FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED + ) + + @Deprecated("Use {@link #MatroskaExtractor(SubtitleParser.Factory, int)} instead.") + constructor(flags: @Flags Int) : this( + DefaultEbmlReader(), + flags or FLAG_EMIT_RAW_SUBTITLE_DATA, + SubtitleParser.Factory.UNSUPPORTED + ) + + /** + * Constructs an instance. + * + * @param subtitleParserFactory The [SubtitleParser.Factory] for parsing subtitles during + * extraction. + */ + constructor(subtitleParserFactory: SubtitleParser.Factory) : this( + DefaultEbmlReader(), /* flags= */ + 0, + subtitleParserFactory + ) + + /** + * Constructs an instance. + * + * @param subtitleParserFactory The [SubtitleParser.Factory] for parsing subtitles during + * extraction. + * @param flags Flags that control the extractor's behavior. + */ + constructor(subtitleParserFactory: SubtitleParser.Factory, flags: @Flags Int) : this( + DefaultEbmlReader(), + flags, + subtitleParserFactory + ) + + /* package */ + init { + reader.init(InnerEbmlProcessor()) + this.subtitleParserFactory = subtitleParserFactory + this.perTrackCues = SparseArray() + seekForCuesEnabled = (flags and FLAG_DISABLE_SEEK_FOR_CUES) == 0 + parseSubtitlesDuringExtraction = (flags and FLAG_EMIT_RAW_SUBTITLE_DATA) == 0 + varintReader = VarintReader() + tracks = SparseArray() + scratch = ParsableByteArray(4) + vorbisNumPageSamples = ParsableByteArray(ByteBuffer.allocate(4).putInt(-1).array()) + seekEntryIdBytes = ParsableByteArray(4) + nalStartCode = ParsableByteArray(NalUnitUtil.NAL_START_CODE) + nalLength = ParsableByteArray(4) + sampleStrippedBytes = ParsableByteArray() + subtitleSample = ParsableByteArray() + encryptionInitializationVector = ParsableByteArray(ENCRYPTION_IV_SIZE) + encryptionSubsampleData = ParsableByteArray() + supplementalData = ParsableByteArray() + blockSampleSizes = IntArray(1) + pendingEndTracks = true + } + + @Throws(IOException::class) + override fun sniff(input: ExtractorInput): Boolean { + return Sniffer().sniff(input) + } + + override fun init(output: ExtractorOutput) { + extractorOutput = + if (parseSubtitlesDuringExtraction) + SubtitleTranscodingExtractorOutput(output, subtitleParserFactory) + else + output + } + + @CallSuper + override fun seek(position: Long, timeUs: Long) { + clusterTimecodeUs = C.TIME_UNSET + blockState = BLOCK_STATE_START + reader.reset() + varintReader.reset() + resetWriteSampleData() + inCuesElement = false + currentCueTimeUs = C.TIME_UNSET + currentCueTrackNumber = C.INDEX_UNSET + currentCueClusterPosition = C.INDEX_UNSET.toLong() + currentCueRelativePosition = C.INDEX_UNSET.toLong() + // To prevent creating duplicate cue points on a re-parse, clear any existing cue data if the + // seek map has not yet been sent. Once sent, the cue data is considered final, and subsequent + // Cues elements will be ignored by the parsing logic. + if (!sentSeekMap) { + perTrackCues.clear() + } + for (i in 0.. EbmlProcessor.ELEMENT_TYPE_MASTER + + ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_CUE_RELATIVE_POSITION, ID_CUE_TRACK, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT + + ID_DOC_TYPE, ID_NAME, ID_CODEC_ID, ID_LANGUAGE -> EbmlProcessor.ELEMENT_TYPE_STRING + ID_SEEK_ID, ID_BLOCK_ADD_ID_EXTRA_DATA, ID_CONTENT_COMPRESSION_SETTINGS, ID_CONTENT_ENCRYPTION_KEY_ID, ID_SIMPLE_BLOCK, ID_BLOCK, ID_CODEC_PRIVATE, ID_PROJECTION_PRIVATE, ID_BLOCK_ADDITIONAL -> EbmlProcessor.ELEMENT_TYPE_BINARY + ID_DURATION, ID_SAMPLING_FREQUENCY, ID_PRIMARY_R_CHROMATICITY_X, ID_PRIMARY_R_CHROMATICITY_Y, ID_PRIMARY_G_CHROMATICITY_X, ID_PRIMARY_G_CHROMATICITY_Y, ID_PRIMARY_B_CHROMATICITY_X, ID_PRIMARY_B_CHROMATICITY_Y, ID_WHITE_POINT_CHROMATICITY_X, ID_WHITE_POINT_CHROMATICITY_Y, ID_LUMNINANCE_MAX, ID_LUMNINANCE_MIN, ID_PROJECTION_POSE_YAW, ID_PROJECTION_POSE_PITCH, ID_PROJECTION_POSE_ROLL -> EbmlProcessor.ELEMENT_TYPE_FLOAT + + else -> EbmlProcessor.ELEMENT_TYPE_UNKNOWN + } + } + + /** + * Checks if the given id is that of a level 1 element. + * + * @see EbmlProcessor.isLevel1Element + */ + @CallSuper + protected fun isLevel1Element(id: Int): Boolean { + return id == ID_SEGMENT_INFO || id == ID_CLUSTER || id == ID_CUES || id == ID_TRACKS + } + + /** + * Called when the start of a master element is encountered. + * + * @see EbmlProcessor.startMasterElement + */ + @CallSuper + @Throws(ParserException::class) + protected fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { + assertInitialized() + when (id) { + ID_SEGMENT -> { + if (segmentContentPosition != C.INDEX_UNSET.toLong() && segmentContentPosition != contentPosition) { + throw ParserException.createForMalformedContainer( + "Multiple Segment elements not supported", /* cause= */null + ) + } + segmentContentPosition = contentPosition + segmentContentSize = contentSize + } + + ID_SEEK -> { + seekEntryId = UNSET_ENTRY_ID + seekEntryPosition = C.INDEX_UNSET.toLong() + } + + ID_CUES -> { + if (!sentSeekMap) { + inCuesElement = true + } + } + + ID_CUE_POINT -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTimeUs = C.TIME_UNSET + } + } + + ID_CUE_TRACK_POSITIONS -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTrackNumber = C.INDEX_UNSET + currentCueClusterPosition = C.INDEX_UNSET.toLong() + currentCueRelativePosition = C.INDEX_UNSET.toLong() + } + } + + ID_CLUSTER -> if (!sentSeekMap) { + // We need to build cues before parsing the cluster. + if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET.toLong()) { + // We know where the Cues element is located. Seek to request it. + seekForCues = true + } else if (seekForCuesEnabled && pendingSeekHeads.isNotEmpty()) { + // We do not know where the cues are located, however we have seek-heads + // we have not yet visited + seekForSeekContent = true + } else { + // We don't know where the Cues element is located. It's most likely omitted. Allow + // playback, but disable seeking. + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + sentSeekMap = true + } + } + + ID_BLOCK_GROUP -> { + blockHasReferenceBlock = false + blockGroupDiscardPaddingNs = 0L + } + + ID_CONTENT_ENCODING -> {} + ID_CONTENT_ENCRYPTION -> getCurrentTrack(id).hasContentEncryption = true + ID_TRACK_ENTRY -> { + currentTrack = Track() + currentTrack!!.isWebm = isWebm + } + ID_MASTERING_METADATA -> getCurrentTrack(id).hasColorInfo = true + else -> {} + } + } + + /** + * Called when the end of a master element is encountered. + * + * @see EbmlProcessor.endMasterElement + */ + @CallSuper + @Throws(ParserException::class) + protected fun endMasterElement(id: Int) { + assertInitialized() + when (id) { + ID_SEGMENT_INFO -> { + if (timecodeScale == C.TIME_UNSET) { + // timecodeScale was omitted. Use the default value. + timecodeScale = 1000000 + } + if (durationTimecode != C.TIME_UNSET) { + durationUs = scaleTimecodeToUs(durationTimecode) + } + } + + ID_SEGMENT -> { + // We only care if we have not already sent the seek map + if (!sentSeekMap) { + // We have reached the end of the segment, however we can still decide how to handle + // pending seek heads. + // + // This is treated as the end as "Multiple Segment elements not supported" + if (pendingSeekHeads.isNotEmpty() && seekForCuesEnabled) { + // We seek to the next seek point if we can seek and there is seek heads + seekForSeekContent = true + } else { + // Otherwise, if we not found any cues nor any more seek heads then we mark + // this as unseekable. + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + sentSeekMap = true + } + } + } + + ID_SEEK -> { + if (seekEntryId == UNSET_ENTRY_ID || seekEntryPosition == C.INDEX_UNSET.toLong()) { + throw ParserException.createForMalformedContainer( + "Mandatory element SeekID or SeekPosition not found", /* cause= */null + ) + } else if (seekEntryId == ID_SEEK_HEAD) { + // We have a set here to prevent inf recursion, only if this seek head is non + // visited we add it. VLC limits this to 10, but this should work equally as well. + if (visitedSeekHeads.add(seekEntryPosition)) { + pendingSeekHeads.add(seekEntryPosition) + } + } else if (seekEntryId == ID_CUES) { + cuesContentPosition = seekEntryPosition + // We are currently seeking from the seek-head, so we seek again to get to the cues + // instead of waiting for the cluster + if (seekForCuesEnabled && seekPositionAfterSeekingForHead != C.INDEX_UNSET.toLong()) { + seekForCues = true + } + } + } + + ID_CUES -> { + if (!sentSeekMap) { + var hasAnyCues = false + for (i in 0 until perTrackCues.size()) { + if (perTrackCues.valueAt(i).isNotEmpty()) { + hasAnyCues = true + break + } + } + + if (!hasAnyCues || durationUs == C.TIME_UNSET) { + // Cues are missing, empty, or duration is unknown. + extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) + } else { + for (i in 0 until perTrackCues.size()) { + perTrackCues.valueAt(i).sort() + } + + val seekMap = MatroskaSeekMap( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize + ) + extractorOutput!!.seekMap(seekMap) + } + sentSeekMap = true + inCuesElement = false + for (i in 0 until tracks.size()) { + val track: Track = tracks.valueAt(i) + track.maybeAddThumbnailMetadata(perTrackCues, durationUs, segmentContentPosition, segmentContentSize) + if (!track.waitingForDtsAnalysis) { + track.assertOutputInitialized() + track.output!!.format(requireNotNull(track.format)) + } + } + maybeEndTracks() + } + } + + ID_CUE_TRACK_POSITIONS -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueTimeUs != C.TIME_UNSET + && currentCueTrackNumber != C.INDEX_UNSET + && currentCueClusterPosition != C.INDEX_UNSET.toLong() + ) { + var trackCues = perTrackCues[currentCueTrackNumber] + if (trackCues == null) { + trackCues = ArrayList() + perTrackCues.put(currentCueTrackNumber, trackCues) + } + + trackCues.add( + MatroskaSeekMap.CuePointData( + currentCueTimeUs, + /* clusterPosition= */ segmentContentPosition + currentCueClusterPosition, + /* relativePosition= */ currentCueRelativePosition + ) + ) + } + } + } + + ID_BLOCK_GROUP -> { + if (blockState != BLOCK_STATE_DATA) { + // We've skipped this block (due to incompatible track number). + return + } + val track = tracks[blockTrackNumber] + track.assertOutputInitialized() + if (blockGroupDiscardPaddingNs > 0L && CODEC_ID_OPUS == track.codecId) { + // For Opus, attach DiscardPadding to the block group samples as supplemental data. + supplementalData.reset( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putLong(blockGroupDiscardPaddingNs) + .array() + ) + } + + // Commit sample metadata. + var sampleOffset = 0 + run { + var i = 0 + while (i < blockSampleCount) { + sampleOffset += blockSampleSizes[i] + i++ + } + } + var i = 0 + while (i < blockSampleCount) { + val sampleTimeUs = blockTimeUs + (i * track.defaultSampleDurationNs) / 1000 + var sampleFlags = blockFlags + if (i == 0 && !blockHasReferenceBlock) { + // If the ReferenceBlock element was not found in this block, then the first frame is a + // keyframe. + sampleFlags = sampleFlags or C.BUFFER_FLAG_KEY_FRAME + } + val sampleSize = blockSampleSizes[i] + sampleOffset -= sampleSize // The offset is to the end of the sample. + commitSampleToOutput(track, sampleTimeUs, sampleFlags, sampleSize, sampleOffset) + i++ + } + blockState = BLOCK_STATE_START + } + + ID_CONTENT_ENCODING -> { + assertInTrackEntry(id) + if (currentTrack!!.hasContentEncryption) { + if (currentTrack!!.cryptoData == null) { + throw ParserException.createForMalformedContainer( + "Encrypted Track found but ContentEncKeyID was not found", /* cause= */ + null + ) + } + currentTrack!!.drmInitData = + DrmInitData( + SchemeData( + C.UUID_NIL, + MimeTypes.VIDEO_WEBM, + currentTrack!!.cryptoData!!.encryptionKey + ) + ) + } + } + + ID_CONTENT_ENCODINGS -> { + assertInTrackEntry(id) + if (currentTrack!!.hasContentEncryption && currentTrack!!.sampleStrippedBytes != null) { + throw ParserException.createForMalformedContainer( + "Combining encryption and compression is not supported", /* cause= */null + ) + } + } + + ID_TRACK_ENTRY -> { + val currentTrack = checkNotNull(this.currentTrack) + if (currentTrack.codecId == null) { + throw ParserException.createForMalformedContainer( + "CodecId is missing in TrackEntry element", /* cause= */null + ) + } else { + if (isCodecSupported(currentTrack.codecId!!)) { + currentTrack.initializeFormat(currentTrack.number); + currentTrack.output = extractorOutput!!.track(currentTrack.number, currentTrack.type); + tracks.put(currentTrack.number, currentTrack) + } + } + this.currentTrack = null + } + + ID_TRACKS -> { + if (tracks.size() == 0) { + throw ParserException.createForMalformedContainer( + "No valid tracks were found", /* cause= */ null + ) + } + + // Determine the track to use for default seeking. + var defaultVideoTrackNumber: Int = C.INDEX_UNSET + var firstVideoTrackNumber: Int = C.INDEX_UNSET + var defaultAudioTrackNumber: Int = C.INDEX_UNSET + var firstAudioTrackNumber: Int = C.INDEX_UNSET + + // If we're not going to seek for cues, output the formats immediately. + val mayBeSendFormatsEarly = !seekForCuesEnabled || cuesContentPosition == C.INDEX_UNSET.toLong(); + + for (i in 0 until tracks.size()) { + val trackItem: Track = tracks.valueAt(i) + + val trackType: @C.TrackType Int = trackItem.type + when (trackType) { + C.TRACK_TYPE_VIDEO -> { + if (trackItem.flagDefault) { + defaultVideoTrackNumber = trackItem.number + } + if (firstVideoTrackNumber == C.INDEX_UNSET) { + firstVideoTrackNumber = trackItem.number + } + } + + C.TRACK_TYPE_AUDIO -> { + if (trackItem.flagDefault) { + defaultAudioTrackNumber = trackItem.number + } + if (firstAudioTrackNumber == C.INDEX_UNSET) { + firstAudioTrackNumber = trackItem.number + } + } + } + + if (mayBeSendFormatsEarly) { + trackItem.assertOutputInitialized() + if (!trackItem.waitingForDtsAnalysis) { + trackItem.output!!.format(checkNotNull(trackItem.format)) + } + } + } + + primarySeekTrackNumber = when { + defaultVideoTrackNumber != C.INDEX_UNSET -> defaultVideoTrackNumber + firstVideoTrackNumber != C.INDEX_UNSET -> firstVideoTrackNumber + defaultAudioTrackNumber != C.INDEX_UNSET -> defaultAudioTrackNumber + firstAudioTrackNumber != C.INDEX_UNSET -> firstAudioTrackNumber + tracks.size() > 0 -> tracks.valueAt(0).number + else -> C.INDEX_UNSET + } + + if (mayBeSendFormatsEarly) { + maybeEndTracks() + } + } + + else -> {} + } + } + + /** + * Called when an integer element is encountered. + * + * @see EbmlProcessor.integerElement + */ + @CallSuper + @Throws(ParserException::class) + protected fun integerElement(id: Int, value: Long) { + when (id) { + ID_EBML_READ_VERSION -> // Validate that EBMLReadVersion is supported. This extractor only supports v1. + if (value != 1L) { + throw ParserException.createForMalformedContainer( + "EBMLReadVersion $value not supported", /* cause= */null + ) + } + + ID_DOC_TYPE_READ_VERSION -> // Validate that DocTypeReadVersion is supported. This extractor only supports up to v2. + if (value < 1 || value > 2) { + throw ParserException.createForMalformedContainer( + "DocTypeReadVersion $value not supported", /* cause= */null + ) + } + + ID_SEEK_POSITION -> // Seek Position is the relative offset beginning from the Segment. So to get absolute + // offset from the beginning of the file, we need to add segmentContentPosition to it. + seekEntryPosition = value + segmentContentPosition + + ID_TIMECODE_SCALE -> timecodeScale = value + ID_PIXEL_WIDTH -> getCurrentTrack(id).width = value.toInt() + ID_PIXEL_HEIGHT -> getCurrentTrack(id).height = value.toInt() + ID_DISPLAY_WIDTH -> getCurrentTrack(id).displayWidth = value.toInt() + ID_DISPLAY_HEIGHT -> getCurrentTrack(id).displayHeight = value.toInt() + ID_DISPLAY_UNIT -> getCurrentTrack(id).displayUnit = value.toInt() + ID_TRACK_NUMBER -> getCurrentTrack(id).number = value.toInt() + ID_FLAG_DEFAULT -> getCurrentTrack(id).flagDefault = value == 1L + ID_FLAG_FORCED -> getCurrentTrack(id).flagForced = value == 1L + ID_TRACK_TYPE -> { + val matroskaTrackType = value.toInt() + getCurrentTrack(id).type = when (matroskaTrackType) { + 1 -> C.TRACK_TYPE_VIDEO // Matroska video + 2 -> C.TRACK_TYPE_AUDIO // Matroska audio + 17 -> C.TRACK_TYPE_TEXT // Matroska subtitle + 33 -> C.TRACK_TYPE_METADATA // Matroska metadata + else -> C.TRACK_TYPE_UNKNOWN + } + } + ID_DEFAULT_DURATION -> getCurrentTrack(id).defaultSampleDurationNs = value.toInt() + ID_MAX_BLOCK_ADDITION_ID -> getCurrentTrack(id).maxBlockAdditionId = value.toInt() + ID_BLOCK_ADD_ID_TYPE -> getCurrentTrack(id).blockAddIdType = value.toInt() + ID_CODEC_DELAY -> getCurrentTrack(id).codecDelayNs = value + ID_SEEK_PRE_ROLL -> getCurrentTrack(id).seekPreRollNs = value + ID_DISCARD_PADDING -> blockGroupDiscardPaddingNs = value + ID_CHANNELS -> getCurrentTrack(id).channelCount = value.toInt() + ID_AUDIO_BIT_DEPTH -> getCurrentTrack(id).audioBitDepth = value.toInt() + ID_REFERENCE_BLOCK -> blockHasReferenceBlock = true + ID_CONTENT_ENCODING_ORDER -> // This extractor only supports one ContentEncoding element and hence the order has to be 0. + if (value != 0L) { + throw ParserException.createForMalformedContainer( + "ContentEncodingOrder $value not supported", /* cause= */null + ) + } + + ID_CONTENT_ENCODING_SCOPE -> // This extractor only supports the scope of all frames. + if (value != 1L) { + throw ParserException.createForMalformedContainer( + "ContentEncodingScope $value not supported", /* cause= */null + ) + } + + ID_CONTENT_COMPRESSION_ALGORITHM -> // This extractor only supports header stripping. + if (value != 3L) { + throw ParserException.createForMalformedContainer( + "ContentCompAlgo $value not supported", /* cause= */null + ) + } + + ID_CONTENT_ENCRYPTION_ALGORITHM -> // Only the value 5 (AES) is allowed according to the WebM specification. + if (value != 5L) { + throw ParserException.createForMalformedContainer( + "ContentEncAlgo $value not supported", /* cause= */null + ) + } + + ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE -> // Only the value 1 is allowed according to the WebM specification. + if (value != 1L) { + throw ParserException.createForMalformedContainer( + "AESSettingsCipherMode $value not supported", /* cause= */null + ) + } + + ID_CUE_TIME -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTimeUs = scaleTimecodeToUs(value) + } + } + + ID_CUE_TRACK -> { + if (!sentSeekMap) { + assertInCues(id) + currentCueTrackNumber = value.toInt() + } + } + + ID_CUE_CLUSTER_POSITION -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueClusterPosition == C.INDEX_UNSET.toLong()) { + currentCueClusterPosition = value + } + } + } + + ID_CUE_RELATIVE_POSITION -> { + if (!sentSeekMap) { + assertInCues(id) + if (currentCueRelativePosition == C.INDEX_UNSET.toLong()) { + currentCueRelativePosition = value + } + } + } + + ID_TIME_CODE -> clusterTimecodeUs = scaleTimecodeToUs(value) + ID_BLOCK_DURATION -> blockDurationUs = scaleTimecodeToUs(value) + ID_STEREO_MODE -> { + val layout = value.toInt() + assertInTrackEntry(id) + when (layout) { + 0 -> currentTrack!!.stereoMode = C.STEREO_MODE_MONO + 1 -> currentTrack!!.stereoMode = C.STEREO_MODE_LEFT_RIGHT + 3 -> currentTrack!!.stereoMode = C.STEREO_MODE_TOP_BOTTOM + 15 -> currentTrack!!.stereoMode = C.STEREO_MODE_STEREO_MESH + else -> {} + } + } + + ID_COLOUR_PRIMARIES -> { + assertInTrackEntry(id) + currentTrack!!.hasColorInfo = true + val colorSpace = ColorInfo.isoColorPrimariesToColorSpace(value.toInt()) + if (colorSpace != Format.NO_VALUE) { + currentTrack!!.colorSpace = colorSpace + } + } + + ID_COLOUR_TRANSFER -> { + assertInTrackEntry(id) + val colorTransfer = + ColorInfo.isoTransferCharacteristicsToColorTransfer(value.toInt()) + if (colorTransfer != Format.NO_VALUE) { + currentTrack!!.colorTransfer = colorTransfer + } + } + + ID_COLOUR_BITS_PER_CHANNEL -> { + assertInTrackEntry(id) + currentTrack!!.hasColorInfo = true + currentTrack!!.bitsPerChannel = value.toInt() + } + + ID_COLOUR_RANGE -> { + assertInTrackEntry(id) + when (value.toInt()) { + 1 -> currentTrack!!.colorRange = C.COLOR_RANGE_LIMITED + 2 -> currentTrack!!.colorRange = C.COLOR_RANGE_FULL + else -> {} + } + } + + ID_MAX_CLL -> getCurrentTrack(id).maxContentLuminance = value.toInt() + ID_MAX_FALL -> getCurrentTrack(id).maxFrameAverageLuminance = value.toInt() + ID_PROJECTION_TYPE -> { + assertInTrackEntry(id) + when (value.toInt()) { + 0 -> currentTrack!!.projectionType = C.PROJECTION_RECTANGULAR + 1 -> currentTrack!!.projectionType = C.PROJECTION_EQUIRECTANGULAR + 2 -> currentTrack!!.projectionType = C.PROJECTION_CUBEMAP + 3 -> currentTrack!!.projectionType = C.PROJECTION_MESH + else -> {} + } + } + + ID_BLOCK_ADD_ID -> blockAdditionalId = value.toInt() + else -> {} + } + } + + /** + * Called when a float element is encountered. + * + * @see EbmlProcessor.floatElement + */ + @CallSuper + @Throws(ParserException::class) + protected fun floatElement(id: Int, value: Double) { + when (id) { + ID_DURATION -> durationTimecode = value.toLong() + ID_SAMPLING_FREQUENCY -> getCurrentTrack(id).sampleRate = value.toInt() + ID_PRIMARY_R_CHROMATICITY_X -> getCurrentTrack(id).primaryRChromaticityX = + value.toFloat() + + ID_PRIMARY_R_CHROMATICITY_Y -> getCurrentTrack(id).primaryRChromaticityY = + value.toFloat() + + ID_PRIMARY_G_CHROMATICITY_X -> getCurrentTrack(id).primaryGChromaticityX = + value.toFloat() + + ID_PRIMARY_G_CHROMATICITY_Y -> getCurrentTrack(id).primaryGChromaticityY = + value.toFloat() + + ID_PRIMARY_B_CHROMATICITY_X -> getCurrentTrack(id).primaryBChromaticityX = + value.toFloat() + + ID_PRIMARY_B_CHROMATICITY_Y -> getCurrentTrack(id).primaryBChromaticityY = + value.toFloat() + + ID_WHITE_POINT_CHROMATICITY_X -> getCurrentTrack(id).whitePointChromaticityX = + value.toFloat() + + ID_WHITE_POINT_CHROMATICITY_Y -> getCurrentTrack(id).whitePointChromaticityY = + value.toFloat() + + ID_LUMNINANCE_MAX -> getCurrentTrack(id).maxMasteringLuminance = value.toFloat() + ID_LUMNINANCE_MIN -> getCurrentTrack(id).minMasteringLuminance = value.toFloat() + ID_PROJECTION_POSE_YAW -> getCurrentTrack(id).projectionPoseYaw = value.toFloat() + ID_PROJECTION_POSE_PITCH -> getCurrentTrack(id).projectionPosePitch = value.toFloat() + ID_PROJECTION_POSE_ROLL -> getCurrentTrack(id).projectionPoseRoll = value.toFloat() + else -> {} + } + } + + /** + * Called when a string element is encountered. + * + * @see EbmlProcessor.stringElement + */ + @CallSuper + @Throws(ParserException::class) + protected fun stringElement(id: Int, value: String) { + when (id) { + ID_DOC_TYPE -> // Validate that DocType is supported. + if (DOC_TYPE_WEBM != value && DOC_TYPE_MATROSKA != value) { + throw ParserException.createForMalformedContainer( + "DocType $value not supported", /* cause= */null + ) + } + + ID_NAME -> getCurrentTrack(id).name = value + ID_CODEC_ID -> getCurrentTrack(id).codecId = value + ID_LANGUAGE -> getCurrentTrack(id).language = value + else -> {} + } + } + + /** + * Called when a binary element is encountered. + * + * @see EbmlProcessor.binaryElement + */ + @CallSuper + @Throws(IOException::class) + protected fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) { + when (id) { + ID_SEEK_ID -> { + Arrays.fill(seekEntryIdBytes.data, 0.toByte()) + input.readFully(seekEntryIdBytes.data, 4 - contentSize, contentSize) + seekEntryIdBytes.position = 0 + seekEntryId = seekEntryIdBytes.readUnsignedInt().toInt() + } + + ID_BLOCK_ADD_ID_EXTRA_DATA -> handleBlockAddIDExtraData( + getCurrentTrack(id), + input, + contentSize + ) + + ID_CODEC_PRIVATE -> { + assertInTrackEntry(id) + currentTrack!!.codecPrivate = ByteArray(contentSize) + input.readFully(currentTrack!!.codecPrivate!!, 0, contentSize) + } + + ID_PROJECTION_PRIVATE -> { + assertInTrackEntry(id) + currentTrack!!.projectionData = ByteArray(contentSize) + input.readFully(currentTrack!!.projectionData!!, 0, contentSize) + } + + ID_CONTENT_COMPRESSION_SETTINGS -> { + assertInTrackEntry(id) + // This extractor only supports header stripping, so the payload is the stripped bytes. + currentTrack!!.sampleStrippedBytes = ByteArray(contentSize) + input.readFully(currentTrack!!.sampleStrippedBytes!!, 0, contentSize) + } + + ID_CONTENT_ENCRYPTION_KEY_ID -> { + val encryptionKey = ByteArray(contentSize) + input.readFully(encryptionKey, 0, contentSize) + getCurrentTrack(id).cryptoData = + CryptoData( + C.CRYPTO_MODE_AES_CTR, encryptionKey, 0, 0 + ) // We assume patternless AES-CTR. + } + + ID_SIMPLE_BLOCK, ID_BLOCK -> { + // Please refer to http://www.matroska.org/technical/specs/index.html#simpleblock_structure + // and http://matroska.org/technical/specs/index.html#block_structure + // for info about how data is organized in SimpleBlock and Block elements respectively. They + // differ only in the way flags are specified. + if (blockState == BLOCK_STATE_START) { + blockTrackNumber = + varintReader.readUnsignedVarint(input, false, true, 8).toInt() + blockTrackNumberLength = varintReader.lastLength + blockDurationUs = C.TIME_UNSET + blockState = BLOCK_STATE_HEADER + scratch.reset( /* limit= */0) + } + + val track = tracks[blockTrackNumber] + + // Ignore the block if we don't know about the track to which it belongs. + if (track == null) { + input.skipFully(contentSize - blockTrackNumberLength) + blockState = BLOCK_STATE_START + return + } + + track.assertOutputInitialized() + + if (blockState == BLOCK_STATE_HEADER) { + // Read the relative timecode (2 bytes) and flags (1 byte). + readScratch(input, 3) + val lacing = (scratch.data[2].toInt() and 0x06) shr 1 + if (lacing == LACING_NONE) { + blockSampleCount = 1 + blockSampleSizes = ensureArrayCapacity(blockSampleSizes, 1) + blockSampleSizes[0] = contentSize - blockTrackNumberLength - 3 + } else { + // Read the sample count (1 byte). + readScratch(input, 4) + blockSampleCount = (scratch.data[3].toInt() and 0xFF) + 1 + blockSampleSizes = ensureArrayCapacity(blockSampleSizes, blockSampleCount) + if (lacing == LACING_FIXED_SIZE) { + val blockLacingSampleSize = + (contentSize - blockTrackNumberLength - 4) / blockSampleCount + Arrays.fill( + blockSampleSizes, + 0, + blockSampleCount, + blockLacingSampleSize + ) + } else if (lacing == LACING_XIPH) { + var totalSamplesSize = 0 + var headerSize = 4 + var sampleIndex = 0 + while (sampleIndex < blockSampleCount - 1) { + blockSampleSizes[sampleIndex] = 0 + var byteValue: Int + do { + readScratch(input, ++headerSize) + byteValue = scratch.data[headerSize - 1].toInt() and 0xFF + blockSampleSizes[sampleIndex] += byteValue + } while (byteValue == 0xFF) + totalSamplesSize += blockSampleSizes[sampleIndex] + sampleIndex++ + } + blockSampleSizes[blockSampleCount - 1] = + contentSize - blockTrackNumberLength - headerSize - totalSamplesSize + } else if (lacing == LACING_EBML) { + var totalSamplesSize = 0 + var headerSize = 4 + var sampleIndex = 0 + while (sampleIndex < blockSampleCount - 1) { + blockSampleSizes[sampleIndex] = 0 + readScratch(input, ++headerSize) + if (scratch.data[headerSize - 1].toInt() == 0) { + throw ParserException.createForMalformedContainer( + "No valid varint length mask found", /* cause= */null + ) + } + var readValue: Long = 0 + var i = 0 + while (i < 8) { + val lengthMask = 1 shl (7 - i) + if ((scratch.data[headerSize - 1].toInt() and lengthMask) != 0) { + var readPosition = headerSize - 1 + headerSize += i + readScratch(input, headerSize) + readValue = + ((scratch.data[readPosition++].toInt() and 0xFF) and lengthMask.inv()).toLong() + while (readPosition < headerSize) { + readValue = readValue shl 8 + readValue = + readValue or (scratch.data[readPosition++].toInt() and 0xFF).toLong() + } + // The first read value is the first size. Later values are signed offsets. + if (sampleIndex > 0) { + readValue -= (1L shl (6 + i * 7)) - 1 + } + break + } + i++ + } + if (readValue < Int.MIN_VALUE || readValue > Int.MAX_VALUE) { + throw ParserException.createForMalformedContainer( + "EBML lacing sample size out of range.", /* cause= */null + ) + } + val intReadValue = readValue.toInt() + blockSampleSizes[sampleIndex] = + if (sampleIndex == 0) + intReadValue + else + blockSampleSizes[sampleIndex - 1] + intReadValue + totalSamplesSize += blockSampleSizes[sampleIndex] + sampleIndex++ + } + blockSampleSizes[blockSampleCount - 1] = + contentSize - blockTrackNumberLength - headerSize - totalSamplesSize + } else { + // Lacing is always in the range 0--3. + throw ParserException.createForMalformedContainer( + "Unexpected lacing value: $lacing", /* cause= */null + ) + } + } + + val timecode = + (scratch.data[0].toInt() shl 8) or (scratch.data[1].toInt() and 0xFF) + blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode.toLong()) + val isKeyframe = + track.type == C.TRACK_TYPE_AUDIO + || (id == ID_SIMPLE_BLOCK && (scratch.data[2].toInt() and 0x80) == 0x80) + blockFlags = if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0 + blockState = BLOCK_STATE_DATA + blockSampleIndex = 0 + } + + if (id == ID_SIMPLE_BLOCK) { + // For SimpleBlock, we can write sample data and immediately commit the corresponding + // sample metadata. + while (blockSampleIndex < blockSampleCount) { + val sampleSize = + writeSampleData( + input, + track, + blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ + false + ) + val sampleTimeUs = + blockTimeUs + (blockSampleIndex * track.defaultSampleDurationNs) / 1000 + commitSampleToOutput( + track, + sampleTimeUs, + blockFlags, + sampleSize, /* offset= */ + 0 + ) + blockSampleIndex++ + } + blockState = BLOCK_STATE_START + } else { + // For Block, we need to wait until the end of the BlockGroup element before committing + // sample metadata. This is so that we can handle ReferenceBlock (which can be used to + // infer whether the first sample in the block is a keyframe), and BlockAdditions (which + // can contain additional sample data to append) contained in the block group. Just output + // the sample data, storing the final sample sizes for when we commit the metadata. + while (blockSampleIndex < blockSampleCount) { + blockSampleSizes[blockSampleIndex] = + writeSampleData( + input, + track, + blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ + true + ) + blockSampleIndex++ + } + } + } + + ID_BLOCK_ADDITIONAL -> { + if (blockState != BLOCK_STATE_DATA) { + return + } + handleBlockAdditionalData( + tracks[blockTrackNumber], blockAdditionalId, input, contentSize + ) + } + + else -> throw ParserException.createForMalformedContainer( + "Unexpected id: $id", /* cause= */null + ) + } + } + + @Throws(IOException::class) + protected fun handleBlockAddIDExtraData(track: Track, input: ExtractorInput, contentSize: Int) { + if (track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVVC + || track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVCC + ) { + track.dolbyVisionConfigBytes = ByteArray(contentSize) + input.readFully(track.dolbyVisionConfigBytes!!, 0, contentSize) + } else { + // Unhandled BlockAddIDExtraData. + input.skipFully(contentSize) + } + } + + @Throws(IOException::class) + protected fun handleBlockAdditionalData( + track: Track, blockAdditionalId: Int, input: ExtractorInput, contentSize: Int + ) { + if (blockAdditionalId == BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 + && CODEC_ID_VP9 == track.codecId + ) { + supplementalData.reset(contentSize) + input.readFully(supplementalData.data, 0, contentSize) + } else { + // Unhandled block additional data. + input.skipFully(contentSize) + } + } + + @Throws(ParserException::class) + private fun assertInTrackEntry(id: Int) { + if (currentTrack == null) { + throw ParserException.createForMalformedContainer( + "Element $id must be in a TrackEntry", /* cause= */null + ) + } + } + + @Throws(ParserException::class) + private fun assertInCues(id: Int) { + if (!inCuesElement) { + throw ParserException.createForMalformedContainer( + "Element $id must be in a Cues", /* cause= */null + ) + } + } + + /** + * Returns the track corresponding to the current TrackEntry element. + * + * @throws ParserException if the element id is not in a TrackEntry. + */ + @Throws(ParserException::class) + protected fun getCurrentTrack(currentElementId: Int): Track { + assertInTrackEntry(currentElementId) + return currentTrack!! + } + + private fun commitSampleToOutput( + track: Track, timeUs: Long, flags: @BufferFlags Int, size: Int, offset: Int + ) { + var size = size + if (track.trueHdSampleRechunker != null) { + track.trueHdSampleRechunker!!.sampleMetadata( + track.output!!, timeUs, flags, size, offset, track.cryptoData + ) + } else { + if (CODEC_ID_SUBRIP == track.codecId + || CODEC_ID_ASS == track.codecId + || CODEC_ID_SSA == track.codecId + || CODEC_ID_VTT == track.codecId + ) { + if (blockSampleCount > 1) { + Log.w(TAG, "Skipping subtitle sample in laced block.") + } else if (blockDurationUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping subtitle sample with no duration.") + } else { + setSubtitleEndTime( + track.codecId!!, blockDurationUs, subtitleSample.data + ) + // The Matroska spec doesn't clearly define whether subtitle samples are null-terminated + // or the sample should instead be sized precisely. We truncate the sample at a null-byte + // to gracefully handle null-terminated strings followed by garbage bytes. + for (i in subtitleSample.position.. 1) { + // There were multiple samples in the block. Appending the additional data to the last + // sample doesn't make sense. Skip instead. + supplementalData.reset( /* limit= */0) + } else { + // Append supplemental data. + val supplementalDataSize = supplementalData.limit() + track.output!!.sampleData( + supplementalData, + supplementalDataSize, + TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL + ) + size += supplementalDataSize + } + } + track.output!!.sampleMetadata(timeUs, flags, size, offset, track.cryptoData) + } + haveOutputSample = true + } + + /** + * Ensures [.scratch] contains at least `requiredLength` bytes of data, reading from + * the extractor input if necessary. + */ + @Throws(IOException::class) + private fun readScratch(input: ExtractorInput, requiredLength: Int) { + if (scratch.limit() >= requiredLength) { + return + } + if (scratch.capacity() < requiredLength) { + scratch.ensureCapacity( + max( + (scratch.capacity() * 2).toDouble(), + requiredLength.toDouble() + ).toInt() + ) + } + input.readFully(scratch.data, scratch.limit(), requiredLength - scratch.limit()) + scratch.setLimit(requiredLength) + } + + /** + * Writes data for a single sample to the track output. + * + * @param input The input from which to read sample data. + * @param track The track to output the sample to. + * @param size The size of the sample data on the input side. + * @param isBlockGroup Whether the samples are from a BlockGroup. + * @return The final size of the written sample. + * @throws IOException If an error occurs reading from the input. + */ + @Throws(IOException::class) + private fun writeSampleData( + input: ExtractorInput, + track: Track, + size: Int, + isBlockGroup: Boolean + ): Int { + var size = size + if (CODEC_ID_SUBRIP == track.codecId) { + writeSubtitleSampleData(input, SUBRIP_PREFIX, size) + return finishWriteSampleData() + } else if (CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId) { + writeSubtitleSampleData(input, SSA_PREFIX, size) + return finishWriteSampleData() + } else if (CODEC_ID_VTT == track.codecId) { + writeSubtitleSampleData(input, VTT_PREFIX, size) + return finishWriteSampleData() + } + + if (track.waitingForDtsAnalysis) { + checkNotNull(track.format) + if (DtsUtil.isSampleDtsHd(input, size)) { + track.format = track.format!! + .buildUpon() + .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) + .build() + } + + track.output!!.format(track.format!!) + track.waitingForDtsAnalysis = false + maybeEndTracks() + } + + val output = track.output + if (!sampleEncodingHandled) { + if (track.hasContentEncryption) { + // If the sample is encrypted, read its encryption signal byte and set the IV size. + // Clear the encrypted flag. + blockFlags = blockFlags and C.BUFFER_FLAG_ENCRYPTED.inv() + if (!sampleSignalByteRead) { + input.readFully(scratch.data, 0, 1) + sampleBytesRead++ + if ((scratch.data[0].toInt() and 0x80) == 0x80) { + throw ParserException.createForMalformedContainer( + "Extension bit is set in signal byte", /* cause= */null + ) + } + sampleSignalByte = scratch.data[0] + sampleSignalByteRead = true + } + val isEncrypted = (sampleSignalByte.toInt() and 0x01) == 0x01 + if (isEncrypted) { + val hasSubsampleEncryption = (sampleSignalByte.toInt() and 0x02) == 0x02 + blockFlags = blockFlags or C.BUFFER_FLAG_ENCRYPTED + if (!sampleInitializationVectorRead) { + input.readFully(encryptionInitializationVector.data, 0, ENCRYPTION_IV_SIZE) + sampleBytesRead += ENCRYPTION_IV_SIZE + sampleInitializationVectorRead = true + // Write the signal byte, containing the IV size and the subsample encryption flag. + scratch.data[0] = + (ENCRYPTION_IV_SIZE or (if (hasSubsampleEncryption) 0x80 else 0x00)).toByte() + scratch.position = 0 + output!!.sampleData(scratch, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION) + sampleBytesWritten++ + // Write the IV. + encryptionInitializationVector.position = 0 + output.sampleData( + encryptionInitializationVector, + ENCRYPTION_IV_SIZE, + TrackOutput.SAMPLE_DATA_PART_ENCRYPTION + ) + sampleBytesWritten += ENCRYPTION_IV_SIZE + } + if (hasSubsampleEncryption) { + if (!samplePartitionCountRead) { + input.readFully(scratch.data, 0, 1) + sampleBytesRead++ + scratch.position = 0 + samplePartitionCount = scratch.readUnsignedByte() + samplePartitionCountRead = true + } + val samplePartitionDataSize = samplePartitionCount * 4 + scratch.reset(samplePartitionDataSize) + input.readFully(scratch.data, 0, samplePartitionDataSize) + sampleBytesRead += samplePartitionDataSize + val subsampleCount = (1 + (samplePartitionCount / 2)).toShort() + val subsampleDataSize = 2 + 6 * subsampleCount + if (encryptionSubsampleDataBuffer == null + || encryptionSubsampleDataBuffer!!.capacity() < subsampleDataSize + ) { + encryptionSubsampleDataBuffer = ByteBuffer.allocate(subsampleDataSize) + } + encryptionSubsampleDataBuffer!!.position(0) + encryptionSubsampleDataBuffer!!.putShort(subsampleCount) + // Loop through the partition offsets and write out the data in the way ExoPlayer + // wants it (ISO 23001-7 Part 7): + // 2 bytes - sub sample count. + // for each sub sample: + // 2 bytes - clear data size. + // 4 bytes - encrypted data size. + var partitionOffset = 0 + for (i in 0.. 0) { + sampleStrippedBytes.readBytes(target, offset, pendingStrippedBytes) + } + } + + /** + * Outputs up to `length` bytes of sample data to `output`, consisting of either + * [.sampleStrippedBytes] or data read from `input`. + */ + @Throws(IOException::class) + private fun writeToOutput(input: ExtractorInput, output: TrackOutput, length: Int): Int { + val bytesWritten: Int + val strippedBytesLeft = sampleStrippedBytes.bytesLeft() + if (strippedBytesLeft > 0) { + bytesWritten = min(length.toDouble(), strippedBytesLeft.toDouble()).toInt() + output.sampleData(sampleStrippedBytes, bytesWritten) + } else { + bytesWritten = output.sampleData(input, length, false) + } + return bytesWritten + } + + /** + * Updates the position of the holder to Cues element's position if the extractor configuration + * permits use of master seek entry. After building Cues sets the holder's position back to where + * it was before. + * + * @param seekPosition The holder whose position will be updated. + * @param currentPosition Current position of the input. + * @return Whether the seek position was updated. + */ + private fun maybeSeekForCues(seekPosition: PositionHolder, currentPosition: Long): Boolean { + // This seeks in a lazy manner, unlike VLC that seeks immediately when encountering a seek head + // This minimizes the amount of seeking done, but also does not seek if the cues element is + // already found, even if seek heads exits. This might be nice to change if we need other + // critical information from seek heads. + // + // The nature of each recursive query becomes to consume as much content as possible + // (until cues or end of segment). However this also means that we only need to seek + // back to the top once, instead seeking back in a stack like manner. + if (seekForSeekContent) { + checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") + // The exact order does not really matter, but it is easiest to just do stack (FILO) + val next = pendingSeekHeads.removeAt(pendingSeekHeads.size - 1) + seekPosition.position = next + seekForSeekContent = false + if (seekPositionAfterSeekingForHead == C.INDEX_UNSET.toLong()) { + seekPositionAfterSeekingForHead = currentPosition + } + return true + } + + if (seekForCues) { + seekPositionAfterBuildingCues = currentPosition + seekPosition.position = cuesContentPosition + seekForCues = false + return true + } + + // After parsing Cues, seek back to original position if available. We will not do this unless + // we seeked to get to the Cues in the first place. + if (sentSeekMap && seekPositionAfterBuildingCues != C.INDEX_UNSET.toLong()) { + seekPosition.position = seekPositionAfterBuildingCues + seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() + return true + } + + // After we have seeked back from seekPositionAfterBuildingCues seek back again to the seek head + if (sentSeekMap && seekPositionAfterSeekingForHead != C.INDEX_UNSET.toLong()) { + seekPosition.position = seekPositionAfterSeekingForHead + seekPositionAfterSeekingForHead = C.INDEX_UNSET.toLong() + return true + } + + return false + } + + @Throws(ParserException::class) + private fun scaleTimecodeToUs(unscaledTimecode: Long): Long { + if (timecodeScale == C.TIME_UNSET) { + throw ParserException.createForMalformedContainer( + "Can't scale timecode prior to timecodeScale being set.", /* cause= */null + ) + } + return Util.scaleLargeTimestamp(unscaledTimecode, timecodeScale, 1000) + } + + private fun assertInitialized() { + checkNotNull( + extractorOutput + ) + } + + private fun maybeEndTracks() { + if (!pendingEndTracks) return + + for (i in 0 until tracks.size()) { + if (tracks.valueAt(i).waitingForDtsAnalysis) return + } + + checkNotNull(extractorOutput).endTracks() + pendingEndTracks = false + } + + /** Passes events through to the outer [UpdatedMatroskaExtractor]. */ + private inner class InnerEbmlProcessor : EbmlProcessor { + override fun getElementType(id: Int): @EbmlProcessor.ElementType Int { + return this@UpdatedMatroskaExtractor.getElementType(id) + } + + override fun isLevel1Element(id: Int): Boolean { + return this@UpdatedMatroskaExtractor.isLevel1Element(id) + } + + @Throws(ParserException::class) + override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { + this@UpdatedMatroskaExtractor.startMasterElement(id, contentPosition, contentSize) + } + + @Throws(ParserException::class) + override fun endMasterElement(id: Int) { + this@UpdatedMatroskaExtractor.endMasterElement(id) + } + + @Throws(ParserException::class) + override fun integerElement(id: Int, value: Long) { + this@UpdatedMatroskaExtractor.integerElement(id, value) + } + + @Throws(ParserException::class) + override fun floatElement(id: Int, value: Double) { + this@UpdatedMatroskaExtractor.floatElement(id, value) + } + + @Throws(ParserException::class) + override fun stringElement(id: Int, value: String) { + this@UpdatedMatroskaExtractor.stringElement(id, value) + } + + @Throws(IOException::class) + override fun binaryElement(id: Int, contentsSize: Int, input: ExtractorInput) { + this@UpdatedMatroskaExtractor.binaryElement(id, contentsSize, input) + } + } + + /** Holds data corresponding to a single track. */ + protected class Track { + // Common elements. + var isWebm: Boolean = false + var name: String? = null + var codecId: String? = null + var number: Int = 0 + var type: @C.TrackType Int = 0 + var defaultSampleDurationNs: Int = 0 + var maxBlockAdditionId: Int = 0 + var blockAddIdType: Int = 0 + var hasContentEncryption: Boolean = false + var sampleStrippedBytes: ByteArray? = null + var cryptoData: CryptoData? = + null + var codecPrivate: ByteArray? = null + var drmInitData: DrmInitData? = + null + + // Video elements. + var width: Int = Format.NO_VALUE + var height: Int = Format.NO_VALUE + var bitsPerChannel: Int = Format.NO_VALUE + var displayWidth: Int = Format.NO_VALUE + var displayHeight: Int = Format.NO_VALUE + var displayUnit: Int = DISPLAY_UNIT_PIXELS + var projectionType: @C.Projection Int = Format.NO_VALUE + var projectionPoseYaw: Float = 0f + var projectionPosePitch: Float = 0f + var projectionPoseRoll: Float = 0f + var projectionData: ByteArray? = + null + var stereoMode: @StereoMode Int = Format.NO_VALUE + var hasColorInfo: Boolean = false + var colorSpace: @C.ColorSpace Int = Format.NO_VALUE + var colorTransfer: @ColorTransfer Int = Format.NO_VALUE + var colorRange: @ColorRange Int = Format.NO_VALUE + var maxContentLuminance: Int = DEFAULT_MAX_CLL + var maxFrameAverageLuminance: Int = DEFAULT_MAX_FALL + var primaryRChromaticityX: Float = Format.NO_VALUE.toFloat() + var primaryRChromaticityY: Float = Format.NO_VALUE.toFloat() + var primaryGChromaticityX: Float = Format.NO_VALUE.toFloat() + var primaryGChromaticityY: Float = Format.NO_VALUE.toFloat() + var primaryBChromaticityX: Float = Format.NO_VALUE.toFloat() + var primaryBChromaticityY: Float = Format.NO_VALUE.toFloat() + var whitePointChromaticityX: Float = Format.NO_VALUE.toFloat() + var whitePointChromaticityY: Float = Format.NO_VALUE.toFloat() + var maxMasteringLuminance: Float = Format.NO_VALUE.toFloat() + var minMasteringLuminance: Float = Format.NO_VALUE.toFloat() + var dolbyVisionConfigBytes: ByteArray? = null + + // Audio elements. Initially set to their default values. + var channelCount: Int = 1 + var audioBitDepth: Int = Format.NO_VALUE + var sampleRate: Int = 8000 + var codecDelayNs: Long = 0 + var seekPreRollNs: Long = 0 + var trueHdSampleRechunker: TrueHdSampleRechunker? = null + var waitingForDtsAnalysis: Boolean = false + + // Text elements. + var flagForced: Boolean = false + + // Common track elements. + var flagDefault: Boolean = true + var language: String = "eng" + + // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. + var output: TrackOutput? = null + var format: Format? = null + var nalUnitLengthFieldLength: Int = 0 + + /** Builds the [Format] for the track. */ + @Throws(ParserException::class) + fun initializeFormat(trackId: Int) { + var mimeType: String + var maxInputSize = Format.NO_VALUE + var pcmEncoding: @PcmEncoding Int = Format.NO_VALUE + var initializationData: List? = null + var codecs: String? = null + when (codecId) { + CODEC_ID_VP8 -> mimeType = MimeTypes.VIDEO_VP8 + CODEC_ID_VP9 -> { + mimeType = MimeTypes.VIDEO_VP9 + initializationData = + if (codecPrivate == null) null else ImmutableList.of( + codecPrivate!! + ) + } + CODEC_ID_AV1 -> { + mimeType = MimeTypes.VIDEO_AV1 + initializationData = + if (codecPrivate == null) null else ImmutableList.of( + codecPrivate!! + ) + } + CODEC_ID_MPEG2 -> mimeType = MimeTypes.VIDEO_MPEG2 + CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP -> { + mimeType = MimeTypes.VIDEO_MP4V + initializationData = + if (codecPrivate == null) null else listOf( + codecPrivate!! + ) + } + + CODEC_ID_H264 -> { + mimeType = MimeTypes.VIDEO_H264 + val avcConfig = AvcConfig.parse( + ParsableByteArray( + getCodecPrivate( + codecId!! + ) + ) + ) + initializationData = avcConfig.initializationData + nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength + codecs = avcConfig.codecs + } + + CODEC_ID_H265 -> { + mimeType = MimeTypes.VIDEO_H265 + val hevcConfig = HevcConfig.parse( + ParsableByteArray( + getCodecPrivate( + codecId!! + ) + ) + ) + initializationData = hevcConfig.initializationData + nalUnitLengthFieldLength = hevcConfig.nalUnitLengthFieldLength + codecs = hevcConfig.codecs + } + + CODEC_ID_FOURCC -> { + val pair = + parseFourCcPrivate( + ParsableByteArray( + getCodecPrivate( + codecId!! + ) + ) + ) + mimeType = pair.first + initializationData = pair.second + } + + CODEC_ID_THEORA -> // TODO: This can be set to the real mimeType if/when we work out what initializationData + // should be set to for this case. + mimeType = MimeTypes.VIDEO_UNKNOWN + + CODEC_ID_VORBIS -> { + mimeType = MimeTypes.AUDIO_VORBIS + maxInputSize = VORBIS_MAX_INPUT_SIZE + initializationData = parseVorbisCodecPrivate( + getCodecPrivate( + codecId!! + ) + ) + } + + CODEC_ID_OPUS -> { + mimeType = MimeTypes.AUDIO_OPUS + maxInputSize = OPUS_MAX_INPUT_SIZE + initializationData = ArrayList(3) + initializationData.add(getCodecPrivate(codecId!!)) + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(codecDelayNs) + .array() + ) + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(seekPreRollNs) + .array() + ) + } + + CODEC_ID_AAC -> { + mimeType = MimeTypes.AUDIO_AAC + initializationData = listOf( + getCodecPrivate( + codecId!! + ) + ) + val aacConfig = AacUtil.parseAudioSpecificConfig(codecPrivate!!) + // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, + // which is more reliable. See [Internal: b/10903778]. + sampleRate = aacConfig.sampleRateHz + channelCount = aacConfig.channelCount + codecs = aacConfig.codecs + } + + CODEC_ID_MP2 -> { + mimeType = MimeTypes.AUDIO_MPEG_L2 + maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES + } + + CODEC_ID_MP3 -> { + mimeType = MimeTypes.AUDIO_MPEG + maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES + } + + CODEC_ID_AC3 -> mimeType = MimeTypes.AUDIO_AC3 + CODEC_ID_E_AC3 -> mimeType = MimeTypes.AUDIO_E_AC3 + CODEC_ID_TRUEHD -> { + mimeType = MimeTypes.AUDIO_TRUEHD + trueHdSampleRechunker = TrueHdSampleRechunker() + } + + CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> { + mimeType = MimeTypes.AUDIO_DTS // temporary + waitingForDtsAnalysis = true + } + CODEC_ID_DTS_LOSSLESS -> mimeType = MimeTypes.AUDIO_DTS_HD + CODEC_ID_FLAC -> { + mimeType = MimeTypes.AUDIO_FLAC + initializationData = listOf( + getCodecPrivate( + codecId!! + ) + ) + } + + CODEC_ID_ACM -> { + mimeType = MimeTypes.AUDIO_RAW + if (parseMsAcmCodecPrivate( + ParsableByteArray( + getCodecPrivate( + codecId!! + ) + ) + ) + ) { + pcmEncoding = Util.getPcmEncoding(audioBitDepth) + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE + mimeType = MimeTypes.AUDIO_UNKNOWN + Log.w( + TAG, + ("Unsupported PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType) + ) + } + } else { + mimeType = MimeTypes.AUDIO_UNKNOWN + Log.w( + TAG, + "Non-PCM MS/ACM is unsupported. Setting mimeType to $mimeType" + ) + } + } + + CODEC_ID_PCM_INT_LIT -> { + mimeType = MimeTypes.AUDIO_RAW + pcmEncoding = Util.getPcmEncoding(audioBitDepth) + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE + mimeType = MimeTypes.AUDIO_UNKNOWN + Log.w( + TAG, + ("Unsupported little endian PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType) + ) + } + } + + CODEC_ID_PCM_INT_BIG -> { + mimeType = MimeTypes.AUDIO_RAW + if (audioBitDepth == 8) { + pcmEncoding = C.ENCODING_PCM_8BIT + } else if (audioBitDepth == 16) { + pcmEncoding = C.ENCODING_PCM_16BIT_BIG_ENDIAN + } else if (audioBitDepth == 24) { + pcmEncoding = C.ENCODING_PCM_24BIT_BIG_ENDIAN + } else if (audioBitDepth == 32) { + pcmEncoding = C.ENCODING_PCM_32BIT_BIG_ENDIAN + } else { + pcmEncoding = Format.NO_VALUE + mimeType = MimeTypes.AUDIO_UNKNOWN + Log.w( + TAG, + ("Unsupported big endian PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType) + ) + } + } + + CODEC_ID_PCM_FLOAT -> { + mimeType = MimeTypes.AUDIO_RAW + if (audioBitDepth == 32) { + pcmEncoding = C.ENCODING_PCM_FLOAT + } else { + pcmEncoding = Format.NO_VALUE + mimeType = MimeTypes.AUDIO_UNKNOWN + Log.w( + TAG, + ("Unsupported floating point PCM bit depth: " + + audioBitDepth + + ". Setting mimeType to " + + mimeType) + ) + } + } + + CODEC_ID_SUBRIP -> mimeType = MimeTypes.APPLICATION_SUBRIP + CODEC_ID_ASS, CODEC_ID_SSA -> { + mimeType = MimeTypes.TEXT_SSA + initializationData = ImmutableList.of( + SSA_DIALOGUE_FORMAT, getCodecPrivate( + codecId!! + ) + ) + } + + CODEC_ID_VTT -> mimeType = MimeTypes.TEXT_VTT + CODEC_ID_VOBSUB -> { + mimeType = MimeTypes.APPLICATION_VOBSUB + initializationData = ImmutableList.of( + getCodecPrivate( + codecId!! + ) + ) + } + + CODEC_ID_PGS -> mimeType = MimeTypes.APPLICATION_PGS + CODEC_ID_DVBSUB -> { + mimeType = MimeTypes.APPLICATION_DVBSUBS + // Init data: composition_page (2), ancillary_page (2) + val initializationDataBytes = ByteArray(4) + System.arraycopy(getCodecPrivate(codecId!!), 0, initializationDataBytes, 0, 4) + initializationData = ImmutableList.of(initializationDataBytes) + } + + else -> throw ParserException.createForMalformedContainer( + "Unrecognized codec identifier.", /* cause= */null + ) + } + + if (dolbyVisionConfigBytes != null) { + val dolbyVisionConfig = + DolbyVisionConfig.parse(ParsableByteArray(dolbyVisionConfigBytes!!)) + if (dolbyVisionConfig != null) { + codecs = dolbyVisionConfig.codecs + mimeType = MimeTypes.VIDEO_DOLBY_VISION + } + } + + var selectionFlags: @SelectionFlags Int = 0 + selectionFlags = selectionFlags or if (flagDefault) C.SELECTION_FLAG_DEFAULT else 0 + selectionFlags = selectionFlags or if (flagForced) C.SELECTION_FLAG_FORCED else 0 + + val formatBuilder = Format.Builder() + // TODO: Consider reading the name elements of the tracks and, if present, incorporating them + // into the trackId passed when creating the formats. + if (MimeTypes.isAudio(mimeType)) { + formatBuilder + .setChannelCount(channelCount) + .setSampleRate(sampleRate) + .setPcmEncoding(pcmEncoding) + } else if (MimeTypes.isVideo(mimeType)) { + if (displayUnit == DISPLAY_UNIT_PIXELS) { + displayWidth = if (displayWidth == Format.NO_VALUE) width else displayWidth + displayHeight = if (displayHeight == Format.NO_VALUE) height else displayHeight + } + var pixelWidthHeightRatio = Format.NO_VALUE.toFloat() + if (displayWidth != Format.NO_VALUE && displayHeight != Format.NO_VALUE) { + pixelWidthHeightRatio = + ((height * displayWidth).toFloat()) / (width * displayHeight) + } + var colorInfo: ColorInfo? = null + if (hasColorInfo) { + val hdrStaticInfo = hdrStaticInfo + colorInfo = + ColorInfo.Builder() + .setColorSpace(colorSpace) + .setColorRange(colorRange) + .setColorTransfer(colorTransfer) + .setHdrStaticInfo(hdrStaticInfo) + .setLumaBitdepth(bitsPerChannel) + .setChromaBitdepth(bitsPerChannel) + .build() + } + var rotationDegrees = Format.NO_VALUE + + if (name != null && TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { + rotationDegrees = TRACK_NAME_TO_ROTATION_DEGREES[name]!! + } + if (projectionType == C.PROJECTION_RECTANGULAR && java.lang.Float.compare( + projectionPoseYaw, + 0f + ) == 0 && java.lang.Float.compare(projectionPosePitch, 0f) == 0 + ) { + // The range of projectionPoseRoll is [-180, 180]. + if (java.lang.Float.compare(projectionPoseRoll, 0f) == 0) { + rotationDegrees = 0 + } else if (java.lang.Float.compare(projectionPoseRoll, 90f) == 0) { + rotationDegrees = 90 + } else if (java.lang.Float.compare(projectionPoseRoll, -180f) == 0 + || java.lang.Float.compare(projectionPoseRoll, 180f) == 0 + ) { + rotationDegrees = 180 + } else if (java.lang.Float.compare(projectionPoseRoll, -90f) == 0) { + rotationDegrees = 270 + } + } + formatBuilder + .setWidth(width) + .setHeight(height) + .setPixelWidthHeightRatio(pixelWidthHeightRatio) + .setRotationDegrees(rotationDegrees) + .setProjectionData(projectionData) + .setStereoMode(stereoMode) + .setColorInfo(colorInfo) + } else if (MimeTypes.APPLICATION_SUBRIP == mimeType + || MimeTypes.TEXT_SSA == mimeType + || MimeTypes.TEXT_VTT == mimeType + || MimeTypes.APPLICATION_VOBSUB == mimeType + || MimeTypes.APPLICATION_PGS == mimeType + || MimeTypes.APPLICATION_DVBSUBS == mimeType + ) { + } else { + throw ParserException.createForMalformedContainer( + "Unexpected MIME type.", /* cause= */null + ) + } + + if (name != null && !TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { + formatBuilder.setLabel(name) + } + + format = + formatBuilder + .setId(trackId) + .setContainerMimeType(if (isWebm) MimeTypes.VIDEO_WEBM else MimeTypes.VIDEO_MATROSKA) + .setSampleMimeType(mimeType) + .setMaxInputSize(maxInputSize) + .setLanguage(language) + .setSelectionFlags(selectionFlags) + .setInitializationData(initializationData) + .setCodecs(codecs) + .setDrmInitData(drmInitData) + .build() + } + + /** Forces any pending sample metadata to be flushed to the output. */ + fun outputPendingSampleMetadata() { + if (trueHdSampleRechunker != null) { + trueHdSampleRechunker!!.outputPendingSampleMetadata(output!!, cryptoData) + } + } + + /** Resets any state stored in the track in response to a seek. */ + fun reset() { + if (trueHdSampleRechunker != null) { + trueHdSampleRechunker!!.reset() + } + } + + /** + * Returns true if supplemental data will be attached to the samples. + * + * @param isBlockGroup Whether the samples are from a BlockGroup. + */ + fun samplesHaveSupplementalData(isBlockGroup: Boolean): Boolean { + if (CODEC_ID_OPUS == codecId) { + // At the end of a BlockGroup, a positive DiscardPadding value will be written out as + // supplemental data for Opus codec. Otherwise (i.e. DiscardPadding <= 0) supplemental data + // size will be 0. + return isBlockGroup + } + return maxBlockAdditionId > 0 + } + + private val hdrStaticInfo: ByteArray? + /** Returns the HDR Static Info as defined in CTA-861.3. */ + get() { + // Are all fields present. + if (primaryRChromaticityX == Format.NO_VALUE.toFloat() || primaryRChromaticityY == Format.NO_VALUE.toFloat() || primaryGChromaticityX == Format.NO_VALUE.toFloat() || primaryGChromaticityY == Format.NO_VALUE.toFloat() || primaryBChromaticityX == Format.NO_VALUE.toFloat() || primaryBChromaticityY == Format.NO_VALUE.toFloat() || whitePointChromaticityX == Format.NO_VALUE.toFloat() || whitePointChromaticityY == Format.NO_VALUE.toFloat() || maxMasteringLuminance == Format.NO_VALUE.toFloat() || minMasteringLuminance == Format.NO_VALUE.toFloat()) { + return null + } + + val hdrStaticInfoData = ByteArray(25) + val hdrStaticInfo = + ByteBuffer.wrap(hdrStaticInfoData).order(ByteOrder.LITTLE_ENDIAN) + hdrStaticInfo.put(0.toByte()) // Type. + hdrStaticInfo.putShort( + ((primaryRChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() + ) + hdrStaticInfo.putShort( + ((primaryRChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() + ) + hdrStaticInfo.putShort( + ((primaryGChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() + ) + hdrStaticInfo.putShort( + ((primaryGChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() + ) + hdrStaticInfo.putShort( + ((primaryBChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() + ) + hdrStaticInfo.putShort( + ((primaryBChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() + ) + hdrStaticInfo.putShort( + ((whitePointChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() + ) + hdrStaticInfo.putShort( + ((whitePointChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() + ) + hdrStaticInfo.putShort((maxMasteringLuminance + 0.5f).toInt().toShort()) + hdrStaticInfo.putShort((minMasteringLuminance + 0.5f).toInt().toShort()) + hdrStaticInfo.putShort(maxContentLuminance.toShort()) + hdrStaticInfo.putShort(maxFrameAverageLuminance.toShort()) + return hdrStaticInfoData + } + + /** + * Finds the best thumbnail timestamp from the cue points and adds it to the track's format as + * [ThumbnailMetadata]. + */ + fun maybeAddThumbnailMetadata( + perTrackCues: SparseArray>, + durationUs: Long, + segmentContentPosition: Long, + segmentContentSize: Long + ) { + if (type != C.TRACK_TYPE_VIDEO) return + + val cuePoints = perTrackCues[number] + if (cuePoints.isNullOrEmpty()) return + + val thumbnailTimestampUs = findBestThumbnailPresentationTimeUs( + cuePoints, durationUs, segmentContentPosition, segmentContentSize + ) + + if (thumbnailTimestampUs != C.TIME_UNSET) { + val currentFormat = requireNotNull(format) + val existingMetadata = currentFormat.metadata + val thumbnailMetadata = ThumbnailMetadata(thumbnailTimestampUs) + val newMetadata = if (existingMetadata == null) { + Metadata(thumbnailMetadata) + } else { + existingMetadata.copyWithAppendedEntries(thumbnailMetadata) + } + format = currentFormat.buildUpon().setMetadata(newMetadata).build() + } + } + + /** + * Finds the best thumbnail timestamp from the provided cue points. + * + *

The heuristic seeks to find a visually interesting frame by assuming that a larger chunk + * size corresponds to a more complex and representative frame. It calculates an approximate + * bitrate for each chunk and selects the timestamp of the chunk with the highest bitrate. + */ + private fun findBestThumbnailPresentationTimeUs( + cuePoints: MutableList, + durationUs: Long, + segmentContentPosition: Long, + segmentContentSize: Long + ): Long { + if (cuePoints.isEmpty()) return C.TIME_UNSET + + var maxBitrate = 0.0 + var bestCueIndex = -1 + val scanLimit = min(cuePoints.size, MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL) + + for (i in 0 until scanLimit) { + val cue = cuePoints[i] + + if (cue.timeUs > MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL) break + + val bytesBetweenCues: Long + val durationBetweenCuesUs: Long + + if (i < cuePoints.size - 1) { + val nextCue = cuePoints[i + 1] + bytesBetweenCues = (nextCue.clusterPosition + nextCue.relativePosition) - + (cue.clusterPosition + cue.relativePosition) + durationBetweenCuesUs = nextCue.timeUs - cue.timeUs + } else { + // Last cue point + bytesBetweenCues = (segmentContentPosition + segmentContentSize) - + (cue.clusterPosition + cue.relativePosition) + durationBetweenCuesUs = durationUs - cue.timeUs + } + + if (durationBetweenCuesUs > 0) { + // This is an approximation of the bitrate for thumbnail heuristic. + val bitrate = bytesBetweenCues.toDouble() / durationBetweenCuesUs + if (bitrate > maxBitrate) { + maxBitrate = bitrate + bestCueIndex = i + } + } + } + + return if (bestCueIndex == -1) C.TIME_UNSET else cuePoints[bestCueIndex].timeUs + } + + /** + * Checks that the track has an output. + * + * + * It is unfortunately not possible to mark [UpdatedMatroskaExtractor.tracks] as only + * containing tracks with output with the nullness checker. This method is used to check that + * fact at runtime. + */ + fun assertOutputInitialized() { + checkNotNull( + output + ) + } + + @Throws(ParserException::class) + private fun getCodecPrivate(codecId: String): ByteArray { + if (codecPrivate == null) { + throw ParserException.createForMalformedContainer( + "Missing CodecPrivate for codec $codecId", /* cause= */null + ) + } + return codecPrivate!! + } + + companion object { + private const val DISPLAY_UNIT_PIXELS = 0 + private const val MAX_CHROMATICITY = 50000 // Defined in CTA-861.3. + + /** Default max content light level (CLL) that should be encoded into hdrStaticInfo. */ + private const val DEFAULT_MAX_CLL = 1000 // nits. + + /** Default frame-average light level (FALL) that should be encoded into hdrStaticInfo. */ + private const val DEFAULT_MAX_FALL = 200 // nits. + + /** + * Builds initialization data for a [Format] from FourCC codec private data. + * + * @return The codec MIME type and initialization data. If the compression type is not supported + * then the MIME type is set to [MimeTypes.VIDEO_UNKNOWN] and the initialization data + * is `null`. + * @throws ParserException If the initialization data could not be built. + */ + @Throws(ParserException::class) + private fun parseFourCcPrivate( + buffer: ParsableByteArray + ): Pair> { + try { + buffer.skipBytes(16) // size(4), width(4), height(4), planes(2), bitcount(2). + val compression = buffer.readLittleEndianUnsignedInt() + if (compression == FOURCC_COMPRESSION_DIVX.toLong()) { + return Pair(MimeTypes.VIDEO_DIVX, null) + } else if (compression == FOURCC_COMPRESSION_H263.toLong()) { + return Pair(MimeTypes.VIDEO_H263, null) + } else if (compression == FOURCC_COMPRESSION_VC1.toLong()) { + // Search for the initialization data from the end of the BITMAPINFOHEADER. The last 20 + // bytes of which are: sizeImage(4), xPel/m (4), yPel/m (4), clrUsed(4), clrImportant(4). + val startOffset = buffer.position + 20 + val bufferData = buffer.data + for (offset in startOffset.. { + try { + if (codecPrivate[0].toInt() != 0x02) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */null + ) + } + var offset = 1 + var vorbisInfoLength = 0 + while ((codecPrivate[offset].toInt() and 0xFF) == 0xFF) { + vorbisInfoLength += 0xFF + offset++ + } + vorbisInfoLength += codecPrivate[offset++].toInt() and 0xFF + + var vorbisSkipLength = 0 + while ((codecPrivate[offset].toInt() and 0xFF) == 0xFF) { + vorbisSkipLength += 0xFF + offset++ + } + vorbisSkipLength += codecPrivate[offset++].toInt() and 0xFF + + if (codecPrivate[offset].toInt() != 0x01) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */null + ) + } + val vorbisInfo = ByteArray(vorbisInfoLength) + System.arraycopy(codecPrivate, offset, vorbisInfo, 0, vorbisInfoLength) + offset += vorbisInfoLength + if (codecPrivate[offset].toInt() != 0x03) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */null + ) + } + offset += vorbisSkipLength + if (codecPrivate[offset].toInt() != 0x05) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */null + ) + } + val vorbisBooks = ByteArray(codecPrivate.size - offset) + System.arraycopy( + codecPrivate, + offset, + vorbisBooks, + 0, + codecPrivate.size - offset + ) + val initializationData: MutableList = ArrayList(2) + initializationData.add(vorbisInfo) + initializationData.add(vorbisBooks) + return initializationData + } catch (e: ArrayIndexOutOfBoundsException) { + throw ParserException.createForMalformedContainer( + "Error parsing vorbis codec private", /* cause= */null + ) + } + } + + /** + * Parses an MS/ACM codec private, returning whether it indicates PCM audio. + * + * @return Whether the codec private indicates PCM audio. + * @throws ParserException If a parsing error occurs. + */ + @Throws(ParserException::class) + private fun parseMsAcmCodecPrivate(buffer: ParsableByteArray): Boolean { + try { + val formatTag = buffer.readLittleEndianUnsignedShort() + if (formatTag == WAVE_FORMAT_PCM) { + return true + } else if (formatTag == WAVE_FORMAT_EXTENSIBLE) { + buffer.position = WAVE_FORMAT_SIZE + 6 // unionSamples(2), channelMask(4) + return buffer.readLong() == WAVE_SUBFORMAT_PCM.mostSignificantBits + && buffer.readLong() == WAVE_SUBFORMAT_PCM.leastSignificantBits + } else { + return false + } + } catch (e: ArrayIndexOutOfBoundsException) { + throw ParserException.createForMalformedContainer( + "Error parsing MS/ACM codec private", /* cause= */null + ) + } + } + } + } + + companion object { + /** + * Creates a factory for [UpdatedMatroskaExtractor] instances with the provided [ ]. + */ + fun newFactory(subtitleParserFactory: SubtitleParser.Factory): ExtractorsFactory { + return ExtractorsFactory { + arrayOf( + UpdatedMatroskaExtractor(subtitleParserFactory) + ) + } + } + + /** + * Flag to disable seeking for cues. + * + * + * Normally (i.e. when this flag is not set) the extractor will seek to the cues element if its + * position is specified in the seek head and if it's after the first cluster. Setting this flag + * disables seeking to the cues element. If the cues element is after the first cluster then the + * media is treated as being unseekable. + */ + const val FLAG_DISABLE_SEEK_FOR_CUES: Int = 1 + + /** + * Flag to use the source subtitle formats without modification. If unset, subtitles will be + * transcoded to [MimeTypes.APPLICATION_MEDIA3_CUES] during extraction. + */ + const val FLAG_EMIT_RAW_SUBTITLE_DATA: Int = 1 shl 1 // 2 + + @Deprecated("Use {@link #newFactory(SubtitleParser.Factory)} instead.") + val FACTORY: ExtractorsFactory = ExtractorsFactory { + arrayOf( + UpdatedMatroskaExtractor( + SubtitleParser.Factory.UNSUPPORTED, + FLAG_EMIT_RAW_SUBTITLE_DATA + ) + ) + } + + private const val TAG = "MatroskaExtractor" + + private const val UNSET_ENTRY_ID = -1 + + private const val BLOCK_STATE_START = 0 + private const val BLOCK_STATE_HEADER = 1 + private const val BLOCK_STATE_DATA = 2 + + private const val DOC_TYPE_MATROSKA = "matroska" + private const val DOC_TYPE_WEBM = "webm" + private const val CODEC_ID_VP8 = "V_VP8" + private const val CODEC_ID_VP9 = "V_VP9" + private const val CODEC_ID_AV1 = "V_AV1" + private const val CODEC_ID_MPEG2 = "V_MPEG2" + private const val CODEC_ID_MPEG4_SP = "V_MPEG4/ISO/SP" + private const val CODEC_ID_MPEG4_ASP = "V_MPEG4/ISO/ASP" + private const val CODEC_ID_MPEG4_AP = "V_MPEG4/ISO/AP" + private const val CODEC_ID_H264 = "V_MPEG4/ISO/AVC" + private const val CODEC_ID_H265 = "V_MPEGH/ISO/HEVC" + private const val CODEC_ID_FOURCC = "V_MS/VFW/FOURCC" + private const val CODEC_ID_THEORA = "V_THEORA" + private const val CODEC_ID_VORBIS = "A_VORBIS" + private const val CODEC_ID_OPUS = "A_OPUS" + private const val CODEC_ID_AAC = "A_AAC" + private const val CODEC_ID_MP2 = "A_MPEG/L2" + private const val CODEC_ID_MP3 = "A_MPEG/L3" + private const val CODEC_ID_AC3 = "A_AC3" + private const val CODEC_ID_E_AC3 = "A_EAC3" + private const val CODEC_ID_TRUEHD = "A_TRUEHD" + private const val CODEC_ID_DTS = "A_DTS" + private const val CODEC_ID_DTS_EXPRESS = "A_DTS/EXPRESS" + private const val CODEC_ID_DTS_LOSSLESS = "A_DTS/LOSSLESS" + private const val CODEC_ID_FLAC = "A_FLAC" + private const val CODEC_ID_ACM = "A_MS/ACM" + private const val CODEC_ID_PCM_INT_LIT = "A_PCM/INT/LIT" + private const val CODEC_ID_PCM_INT_BIG = "A_PCM/INT/BIG" + private const val CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE" + private const val CODEC_ID_SUBRIP = "S_TEXT/UTF8" + private const val CODEC_ID_ASS = "S_TEXT/ASS" + private const val CODEC_ID_SSA = "S_TEXT/SSA" + private const val CODEC_ID_VTT = "S_TEXT/WEBVTT" + private const val CODEC_ID_VOBSUB = "S_VOBSUB" + private const val CODEC_ID_PGS = "S_HDMV/PGS" + private const val CODEC_ID_DVBSUB = "S_DVBSUB" + + private const val VORBIS_MAX_INPUT_SIZE = 8192 + private const val OPUS_MAX_INPUT_SIZE = 5760 + private const val ENCRYPTION_IV_SIZE = 8 + private const val TRACK_TYPE_AUDIO = 2 + + private const val ID_EBML = 0x1A45DFA3 + private const val ID_EBML_READ_VERSION = 0x42F7 + private const val ID_DOC_TYPE = 0x4282 + private const val ID_DOC_TYPE_READ_VERSION = 0x4285 + private const val ID_SEGMENT = 0x18538067 + private const val ID_SEGMENT_INFO = 0x1549A966 + private const val ID_SEEK_HEAD = 0x114D9B74 + private const val ID_SEEK = 0x4DBB + private const val ID_SEEK_ID = 0x53AB + private const val ID_SEEK_POSITION = 0x53AC + private const val ID_INFO = 0x1549A966 + private const val ID_TIMECODE_SCALE = 0x2AD7B1 + private const val ID_DURATION = 0x4489 + private const val ID_CLUSTER = 0x1F43B675 + private const val ID_TIME_CODE = 0xE7 + private const val ID_SIMPLE_BLOCK = 0xA3 + private const val ID_BLOCK_GROUP = 0xA0 + private const val ID_BLOCK = 0xA1 + private const val ID_BLOCK_DURATION = 0x9B + private const val ID_BLOCK_ADDITIONS = 0x75A1 + private const val ID_BLOCK_MORE = 0xA6 + private const val ID_BLOCK_ADD_ID = 0xEE + private const val ID_BLOCK_ADDITIONAL = 0xA5 + private const val ID_REFERENCE_BLOCK = 0xFB + private const val ID_TRACKS = 0x1654AE6B + private const val ID_TRACK_ENTRY = 0xAE + private const val ID_TRACK_NUMBER = 0xD7 + private const val ID_TRACK_TYPE = 0x83 + private const val ID_FLAG_DEFAULT = 0x88 + private const val ID_FLAG_FORCED = 0x55AA + private const val ID_DEFAULT_DURATION = 0x23E383 + private const val ID_MAX_BLOCK_ADDITION_ID = 0x55EE + private const val ID_BLOCK_ADDITION_MAPPING = 0x41E4 + private const val ID_BLOCK_ADD_ID_TYPE = 0x41E7 + private const val ID_BLOCK_ADD_ID_EXTRA_DATA = 0x41ED + private const val ID_NAME = 0x536E + private const val ID_CODEC_ID = 0x86 + private const val ID_CODEC_PRIVATE = 0x63A2 + private const val ID_CODEC_DELAY = 0x56AA + private const val ID_SEEK_PRE_ROLL = 0x56BB + private const val ID_DISCARD_PADDING = 0x75A2 + private const val ID_VIDEO = 0xE0 + private const val ID_PIXEL_WIDTH = 0xB0 + private const val ID_PIXEL_HEIGHT = 0xBA + private const val ID_DISPLAY_WIDTH = 0x54B0 + private const val ID_DISPLAY_HEIGHT = 0x54BA + private const val ID_DISPLAY_UNIT = 0x54B2 + private const val ID_AUDIO = 0xE1 + private const val ID_CHANNELS = 0x9F + private const val ID_AUDIO_BIT_DEPTH = 0x6264 + private const val ID_SAMPLING_FREQUENCY = 0xB5 + private const val ID_CONTENT_ENCODINGS = 0x6D80 + private const val ID_CONTENT_ENCODING = 0x6240 + private const val ID_CONTENT_ENCODING_ORDER = 0x5031 + private const val ID_CONTENT_ENCODING_SCOPE = 0x5032 + private const val ID_CONTENT_COMPRESSION = 0x5034 + private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254 + private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255 + private const val ID_CONTENT_ENCRYPTION = 0x5035 + private const val ID_CONTENT_ENCRYPTION_ALGORITHM = 0x47E1 + private const val ID_CONTENT_ENCRYPTION_KEY_ID = 0x47E2 + private const val ID_CONTENT_ENCRYPTION_AES_SETTINGS = 0x47E7 + private const val ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE = 0x47E8 + private const val ID_CUES = 0x1C53BB6B + private const val ID_CUE_POINT = 0xBB + private const val ID_CUE_TIME = 0xB3 + private const val ID_CUE_TRACK = 0xF7 + private const val ID_CUE_TRACK_POSITIONS = 0xB7 + private const val ID_CUE_CLUSTER_POSITION = 0xF1 + private const val ID_CUE_RELATIVE_POSITION = 0xF0 + private const val ID_LANGUAGE = 0x22B59C + private const val ID_PROJECTION = 0x7670 + private const val ID_PROJECTION_TYPE = 0x7671 + private const val ID_PROJECTION_PRIVATE = 0x7672 + private const val ID_PROJECTION_POSE_YAW = 0x7673 + private const val ID_PROJECTION_POSE_PITCH = 0x7674 + private const val ID_PROJECTION_POSE_ROLL = 0x7675 + private const val ID_STEREO_MODE = 0x53B8 + private const val ID_COLOUR = 0x55B0 + private const val ID_COLOUR_RANGE = 0x55B9 + private const val ID_COLOUR_BITS_PER_CHANNEL = 0x55B2 + private const val ID_COLOUR_TRANSFER = 0x55BA + private const val ID_COLOUR_PRIMARIES = 0x55BB + private const val ID_MAX_CLL = 0x55BC + private const val ID_MAX_FALL = 0x55BD + private const val ID_MASTERING_METADATA = 0x55D0 + private const val ID_PRIMARY_R_CHROMATICITY_X = 0x55D1 + private const val ID_PRIMARY_R_CHROMATICITY_Y = 0x55D2 + private const val ID_PRIMARY_G_CHROMATICITY_X = 0x55D3 + private const val ID_PRIMARY_G_CHROMATICITY_Y = 0x55D4 + private const val ID_PRIMARY_B_CHROMATICITY_X = 0x55D5 + private const val ID_PRIMARY_B_CHROMATICITY_Y = 0x55D6 + private const val ID_WHITE_POINT_CHROMATICITY_X = 0x55D7 + private const val ID_WHITE_POINT_CHROMATICITY_Y = 0x55D8 + private const val ID_LUMNINANCE_MAX = 0x55D9 + private const val ID_LUMNINANCE_MIN = 0x55DA + + /** + * BlockAddID value for ITU T.35 metadata in a VP9 track. See also + * https://www.webmproject.org/docs/container/. + */ + private const val BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 = 4 + + /** + * BlockAddIdType value for Dolby Vision configuration with profile <= 7. See also + * https://www.matroska.org/technical/codec_specs.html. + */ + private const val BLOCK_ADD_ID_TYPE_DVCC = 0x64766343 + + /** + * BlockAddIdType value for Dolby Vision configuration with profile > 7. See also + * https://www.matroska.org/technical/codec_specs.html. + */ + private const val BLOCK_ADD_ID_TYPE_DVVC = 0x64767643 + + private const val LACING_NONE = 0 + private const val LACING_XIPH = 1 + private const val LACING_FIXED_SIZE = 2 + private const val LACING_EBML = 3 + + private const val FOURCC_COMPRESSION_DIVX = 0x58564944 + private const val FOURCC_COMPRESSION_H263 = 0x33363248 + private const val FOURCC_COMPRESSION_VC1 = 0x31435657 + + /** The maximum number of chunks to scan when searching for a thumbnail. */ + private const val MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL = 20 + + /** The maximum duration to scan for a thumbnail, in microseconds. */ + private const val MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL = 10_000_000L + + /** + * A template for the prefix that must be added to each subrip sample. + * + * + * The display time of each subtitle is passed as `timeUs` to [ ][TrackOutput.sampleMetadata]. The start and end timecodes in this template are relative to + * `timeUs`. Hence the start timecode is always zero. The 12 byte end timecode starting at + * [.SUBRIP_PREFIX_END_TIMECODE_OFFSET] is set to a placeholder value, and must be replaced + * with the duration of the subtitle. + * + * + * Equivalent to the UTF-8 string: "1\n00:00:00,000 --> 00:00:00,000\n". + */ + private val SUBRIP_PREFIX = byteArrayOf( + 49, + 10, + 48, + 48, + 58, + 48, + 48, + 58, + 48, + 48, + 44, + 48, + 48, + 48, + 32, + 45, + 45, + 62, + 32, + 48, + 48, + 58, + 48, + 48, + 58, + 48, + 48, + 44, + 48, + 48, + 48, + 10 + ) + + /** The byte offset of the end timecode in [.SUBRIP_PREFIX]. */ + private const val SUBRIP_PREFIX_END_TIMECODE_OFFSET = 19 + + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in a subrip timecode (milliseconds). + */ + private const val SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR: Long = 1000 + + /** The format of a subrip timecode. */ + private const val SUBRIP_TIMECODE_FORMAT = "%02d:%02d:%02d,%03d" + + /** Matroska specific format line for SSA subtitles. */ + private val SSA_DIALOGUE_FORMAT = Util.getUtf8Bytes( + "Format: Start, End, " + + "ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text" + ) + + /** + * A template for the prefix that must be added to each SSA sample. + * + * + * The display time of each subtitle is passed as `timeUs` to [ ][TrackOutput.sampleMetadata]. The start and end timecodes in this template are relative to + * `timeUs`. Hence the start timecode is always zero. The 12 byte end timecode starting at + * [.SUBRIP_PREFIX_END_TIMECODE_OFFSET] is set to a placeholder value, and must be replaced + * with the duration of the subtitle. + * + * + * Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". + */ + private val SSA_PREFIX = byteArrayOf( + 68, + 105, + 97, + 108, + 111, + 103, + 117, + 101, + 58, + 32, + 48, + 58, + 48, + 48, + 58, + 48, + 48, + 58, + 48, + 48, + 44, + 48, + 58, + 48, + 48, + 58, + 48, + 48, + 58, + 48, + 48, + 44 + ) + + /** The byte offset of the end timecode in [.SSA_PREFIX]. */ + private const val SSA_PREFIX_END_TIMECODE_OFFSET = 21 + + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in an SSA timecode (1/100ths of a second). + */ + private const val SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR: Long = 10000 + + /** The format of an SSA timecode. */ + private const val SSA_TIMECODE_FORMAT = "%01d:%02d:%02d:%02d" + + /** + * A template for the prefix that must be added to each VTT sample. + * + * + * The display time of each subtitle is passed as `timeUs` to [ ][TrackOutput.sampleMetadata]. The start and end timecodes in this template are relative to + * `timeUs`. Hence the start timecode is always zero. The 12 byte end timecode starting at + * [.VTT_PREFIX_END_TIMECODE_OFFSET] is set to a placeholder value, and must be replaced + * with the duration of the subtitle. + * + * + * Equivalent to the UTF-8 string: "WEBVTT\n\n00:00:00.000 --> 00:00:00.000\n". + */ + private val VTT_PREFIX = byteArrayOf( + 87, + 69, + 66, + 86, + 84, + 84, + 10, + 10, + 48, + 48, + 58, + 48, + 48, + 58, + 48, + 48, + 46, + 48, + 48, + 48, + 32, + 45, + 45, + 62, + 32, + 48, + 48, + 58, + 48, + 48, + 58, + 48, + 48, + 46, + 48, + 48, + 48, + 10 + ) + + /** The byte offset of the end timecode in [.VTT_PREFIX]. */ + private const val VTT_PREFIX_END_TIMECODE_OFFSET = 25 + + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in a VTT timecode (milliseconds). + */ + private const val VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR: Long = 1000 + + /** The format of a VTT timecode. */ + private const val VTT_TIMECODE_FORMAT = "%02d:%02d:%02d.%03d" + + /** The length in bytes of a WAVEFORMATEX structure. */ + private const val WAVE_FORMAT_SIZE = 18 + + /** Format tag indicating a WAVEFORMATEXTENSIBLE structure. */ + private const val WAVE_FORMAT_EXTENSIBLE = 0xFFFE + + /** Format tag for PCM. */ + private const val WAVE_FORMAT_PCM = 1 + + /** Sub format for PCM. */ + private val WAVE_SUBFORMAT_PCM = UUID(0x0100000000001000L, -0x7fffff55ffc7648fL) + + /** Some HTC devices signal rotation in track names. */ + private val TRACK_NAME_TO_ROTATION_DEGREES: Map + + init { + val trackNameToRotationDegrees: MutableMap = HashMap() + trackNameToRotationDegrees["htc_video_rotA-000"] = 0 + trackNameToRotationDegrees["htc_video_rotA-090"] = 90 + trackNameToRotationDegrees["htc_video_rotA-180"] = 180 + trackNameToRotationDegrees["htc_video_rotA-270"] = 270 + TRACK_NAME_TO_ROTATION_DEGREES = Collections.unmodifiableMap(trackNameToRotationDegrees) + } + + /** + * Overwrites the end timecode in `subtitleData` with the correctly formatted time derived + * from `durationUs`. + * + * + * See documentation on [.SSA_DIALOGUE_FORMAT] and [.SUBRIP_PREFIX] for why we use + * the duration as the end timecode. + * + * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS], + * [.CODEC_ID_SSA] or [.CODEC_ID_VTT]. + * @param durationUs The duration of the sample, in microseconds. + * @param subtitleData The subtitle sample in which to overwrite the end timecode (output + * parameter). + */ + private fun setSubtitleEndTime(codecId: String, durationUs: Long, subtitleData: ByteArray) { + val endTimecode: ByteArray + val endTimecodeOffset: Int + when (codecId) { + CODEC_ID_SUBRIP -> { + endTimecode = + formatSubtitleTimecode( + durationUs, + SUBRIP_TIMECODE_FORMAT, + SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR + ) + endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET + } + + CODEC_ID_ASS, CODEC_ID_SSA -> { + endTimecode = + formatSubtitleTimecode( + durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR + ) + endTimecodeOffset = SSA_PREFIX_END_TIMECODE_OFFSET + } + + CODEC_ID_VTT -> { + endTimecode = + formatSubtitleTimecode( + durationUs, VTT_TIMECODE_FORMAT, VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR + ) + endTimecodeOffset = VTT_PREFIX_END_TIMECODE_OFFSET + } + + else -> throw IllegalArgumentException() + } + System.arraycopy(endTimecode, 0, subtitleData, endTimecodeOffset, endTimecode.size) + } + + /** + * Formats `timeUs` using `timecodeFormat`, and sets it as the end timecode in `subtitleSampleData`. + */ + private fun formatSubtitleTimecode( + timeUs: Long, timecodeFormat: String, lastTimecodeValueScalingFactor: Long + ): ByteArray { + var timeUs = timeUs + checkArgument(timeUs != C.TIME_UNSET) + val timeCodeData: ByteArray + val hours = (timeUs / (3600 * C.MICROS_PER_SECOND)).toInt() + timeUs -= (hours * 3600L * C.MICROS_PER_SECOND) + val minutes = (timeUs / (60 * C.MICROS_PER_SECOND)).toInt() + timeUs -= (minutes * 60L * C.MICROS_PER_SECOND) + val seconds = (timeUs / C.MICROS_PER_SECOND).toInt() + timeUs -= (seconds * C.MICROS_PER_SECOND) + val lastValue = (timeUs / lastTimecodeValueScalingFactor).toInt() + timeCodeData = + Util.getUtf8Bytes( + String.format(Locale.US, timecodeFormat, hours, minutes, seconds, lastValue) + ) + return timeCodeData + } + + private fun isCodecSupported(codecId: String): Boolean { + return when (codecId) { + CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_SSA, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true + + else -> false + } + } + + /** + * Returns an array that can store (at least) `length` elements, which will be either a new + * array or `array` if it's not null and large enough. + */ + private fun ensureArrayCapacity(array: IntArray?, length: Int): IntArray { + return if (array == null) { + IntArray(length) + } else if (array.size >= length) { + array + } else { + // Double the size to avoid allocating constantly if the required length increases gradually. + IntArray( + max((array.size * 2).toDouble(), length.toDouble()) + .toInt() + ) + } + } + } + + class MatroskaSeekMap( + private val perTrackCues: SparseArray>, + private val durationUs: Long, + private val primarySeekTrackNumber: Int, + segmentContentPosition: Long, + segmentContentSize: Long + ) : TrackAwareSeekMap, ChunkIndexProvider { + + private val chunkIndex: ChunkIndex? = + buildChunkIndex( + perTrackCues, + durationUs, + primarySeekTrackNumber, + segmentContentPosition, + segmentContentSize + ) + + override fun isSeekable(): Boolean { + // The media is seekable overall only if the primary seek track has cue points. + return isSeekable(primarySeekTrackNumber) + } + + override fun isSeekable(trackId: Int): Boolean { + val cuePoints = perTrackCues[trackId] + return !cuePoints.isNullOrEmpty() + } + + override fun getDurationUs(): Long = durationUs + + override fun getSeekPoints(timeUs: Long): SeekPoints = + chunkIndex?.getSeekPoints(timeUs) + ?: SeekPoints(SeekPoint.START) + + override fun getSeekPoints(timeUs: Long, trackId: Int): SeekPoints { + var cuePoints = perTrackCues[trackId] + + if ((cuePoints.isNullOrEmpty()) && trackId != primarySeekTrackNumber) { + cuePoints = perTrackCues[primarySeekTrackNumber] + } + + if (cuePoints.isNullOrEmpty()) { + return SeekPoints(SeekPoint.START) + } + + val bestIndex = Util.binarySearchFloor( + cuePoints, + CuePointData(timeUs, C.INDEX_UNSET.toLong(), C.INDEX_UNSET.toLong()), + /* inclusive= */ true, + /* stayInBounds= */ false + ) + + return if (bestIndex != -1) { + val bestCue = cuePoints[bestIndex] + val firstPoint = SeekPoint(bestCue.timeUs, bestCue.clusterPosition) + + if (bestCue.timeUs < timeUs && bestIndex + 1 < cuePoints.size) { + val nextCue = cuePoints[bestIndex + 1] + val secondPoint = SeekPoint(nextCue.timeUs, nextCue.clusterPosition) + SeekPoints(firstPoint, secondPoint) + } else { + SeekPoints(firstPoint) + } + } else { + val firstCue = cuePoints[0] + SeekPoints(SeekPoint(firstCue.timeUs, firstCue.clusterPosition)) + } + } + + override fun getChunkIndex(): ChunkIndex? = chunkIndex + + private companion object { + + private fun buildChunkIndex( + perTrackCues: SparseArray>, + durationUs: Long, + primarySeekTrackNumber: Int, + segmentContentPosition: Long, + segmentContentSize: Long + ): ChunkIndex? { + + val primaryTrackCuePoints = + perTrackCues[primarySeekTrackNumber] ?: return null + + if (primaryTrackCuePoints.isEmpty()) { + return null + } + + val cuePointsSize = primaryTrackCuePoints.size + var sizes = IntArray(cuePointsSize) + var offsets = LongArray(cuePointsSize) + var durationsUs = LongArray(cuePointsSize) + var timesUs = LongArray(cuePointsSize) + + for (i in 0 until cuePointsSize) { + val cue = primaryTrackCuePoints[i] + timesUs[i] = cue.timeUs + offsets[i] = cue.clusterPosition + } + + for (i in 0 until cuePointsSize - 1) { + sizes[i] = (offsets[i + 1] - offsets[i]).toInt() + durationsUs[i] = timesUs[i + 1] - timesUs[i] + } + + // Start from the last cue point and move backward until a valid duration is found. + var lastValidIndex = cuePointsSize - 1 + while (lastValidIndex > 0 && timesUs[lastValidIndex] >= durationUs) { + lastValidIndex-- + } + + // Calculate sizes and durations for the last valid index + sizes[lastValidIndex] = + (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() + durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] + + // If trailing cue points were found, truncate the arrays to the last valid index. + if (lastValidIndex < cuePointsSize - 1) { + Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration.") + sizes = sizes.copyOf(lastValidIndex + 1) + offsets = offsets.copyOf(lastValidIndex + 1) + durationsUs = durationsUs.copyOf(lastValidIndex + 1) + timesUs = timesUs.copyOf(lastValidIndex + 1) + } + + return ChunkIndex(sizes, offsets, durationsUs, timesUs) + } + } + + class CuePointData( + /** The timestamp of the cue point, in microseconds. */ + val timeUs: Long, + + /** The absolute byte offset of the start of the cluster containing this cue point. */ + val clusterPosition: Long, + + /** + * The relative byte offset of the cue point's data block within its cluster. + * + *

Note: For seeking, use {@link #clusterPosition} to prevent A/V desync. + */ + val relativePosition: Long + ) : Comparable { + + override fun compareTo(other: CuePointData): Int { + return timeUs.compareTo(other.timeUs) + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other !is CuePointData) { + return false + } + return this.timeUs == other.timeUs && + this.clusterPosition == other.clusterPosition && + this.relativePosition == other.relativePosition + } + + override fun hashCode(): Int { + return Objects.hash(timeUs, clusterPosition, relativePosition) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt new file mode 100644 index 000000000..52cd4361b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import com.lagradost.cloudstream3.mvvm.debugWarning +import java.util.WeakHashMap + +object LiveHelper { + private val liveManagers = WeakHashMap>() + + @OptIn(UnstableApi::class) + fun registerPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper registerPlayer called with null player!" } + return + } + + // Prevent duplicates + if (liveManagers.contains(player)) { + return + } + + val liveManager = LiveManager(player) + val listener = object : Player.Listener { + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + val window = Timeline.Window() + timeline.getWindow(player.currentMediaItemIndex, window) + if (window.isDynamic) { + liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs)) + } + super.onTimelineChanged(timeline, reason) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs) + + // Seek back to the optimal live spot + if (timeAheadOfLive > 100) { + player.seekTo(newPosition.positionMs - timeAheadOfLive) + } + } + } + + synchronized(liveManagers) { + player.addListener(listener) + liveManagers[player] = liveManager to listener + } + } + + fun unregisterPlayer(player: Player?) { + if (player == null) { + debugWarning { "LiveHelper unregisterPlayer called with null player!" } + return + } + + // Prevent duplicates + if (!liveManagers.contains(player)) { + return + } + + synchronized(liveManagers) { + liveManagers[player]?.let { (_, listener) -> + player.removeListener(listener) + } + liveManagers.remove(player) + } + } + + fun getLiveManager(player: Player?) = liveManagers[player]?.first +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt new file mode 100644 index 000000000..8d848d46a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt @@ -0,0 +1,97 @@ +package com.lagradost.cloudstream3.ui.player.live + +import androidx.media3.common.C +import androidx.media3.common.Player +import java.lang.ref.WeakReference + +// How much margin from the live point is still considered "live" +const val LIVE_MARGIN = 6_000L + +// How many ms should we be behind the real live point? +// Too low, and we cannot pre-buffer +// Too high, and we are no longer live +const val PREFERRED_LIVE_OFFSET = 5_000L + +// An extra offset from the optimal calculated timestamp +// This is to account for chunk updates not always being the same size +const val CHUNK_VARIANCE = 3000L + +// A livestream chunk from the player, the time we get it and the duration can be used to calculate +// the expected live timestamp. +class LivestreamChunk( + durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis() +) { + // We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point. + // If we are ahead of the middle point we will reach the end before the new chunk is expected to be released. + val targetPosition = maxOf(0,minOf( + durationMs - PREFERRED_LIVE_OFFSET, + durationMs / 2 - CHUNK_VARIANCE + )) + + fun isPositionLive(position: Long): Boolean { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET + // println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive") + return withinLive + } + + fun getTimeAheadOfLive(position: Long): Long { + val currentTime = System.currentTimeMillis() + val livePosition = targetPosition + (currentTime - receiveTimeMs) + // println("Ahead of live: ${position-livePosition}") + return position - livePosition + } +} + +// There are two types of livestreams we need to manage +// 1. A livestream with no history, a continually sliding window. +// This livestream has no currentLiveOffset, which means we need to calculate +// the real live point based on when we receive the latest update and the size of that update. +// 2. A livestream with history. +// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point. +// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations. +class LiveManager { + private var _currentPlayer: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayer?.get() + + constructor(player: Player?) { + _currentPlayer = WeakReference(player) + } + + private var lastLivestreamChunk: LivestreamChunk? = null + + fun submitLivestreamChunk(chunk: LivestreamChunk) { + lastLivestreamChunk = chunk + } + + /** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */ + fun getTimeAheadOfLive(position: Long): Long { + val player = currentPlayer ?: return 0 + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0 + + // If the currentLiveOffset is wrong we fall back to manual calculations + val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + val relativeOffset = player.currentLiveOffset - player.currentPosition + position + PREFERRED_LIVE_OFFSET - relativeOffset + } else { + lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0 + } + + // Ensure min of 0 + return maxOf(0, ahead) + } + + /** Check if the stream is currently at the expected live edge, with margins */ + fun isAtLiveEdge(): Boolean { + val player = currentPlayer ?: return false + if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false + + // If the currentLiveOffset is wrong we fall back to manual calculations + return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) { + player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET + } else { + lastLivestreamChunk?.isPositionLive(player.currentPosition) == true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt new file mode 100644 index 000000000..3001281fd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt @@ -0,0 +1,38 @@ +package com.lagradost.cloudstream3.ui.player.live + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.OptIn +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView +import androidx.media3.ui.R +import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar +import java.lang.ref.WeakReference + + +@OptIn(UnstableApi::class) +class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) { + + private var _currentPlayerView: WeakReference? = null + val currentPlayer: Player? get() = _currentPlayerView?.get()?.player + + fun registerPlayerView(player: PlayerView?) { + _currentPlayerView = WeakReference(player) + val controller = + _currentPlayerView?.get()?.findViewById(R.id.exo_controller) + + controller?.setProgressUpdateListener { position, bufferedPosition -> + currentPlayer?.let { player -> + if (isAtLiveEdge()) { + setPosition(player.duration) + } + } + } + } + + fun isAtLiveEdge(): Boolean { + return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt new file mode 100644 index 000000000..11dd39105 --- /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 000000000..85c2a85df --- /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 000000000..02470484e --- /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 000000000..6a0f12e9a --- /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 000000000..c8ac96ebb --- /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 ba57d2de5..cf9bc9975 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt @@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.ui.quicksearch import android.app.Activity import android.content.Context -import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -12,39 +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 androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia -import com.lagradost.cloudstream3.APIHolder.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList 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.AppUtils.ownShow -import com.lagradost.cloudstream3.utils.UIHelper -import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar +import com.lagradost.cloudstream3.ui.setRecycledViewPool +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality +import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable +import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount +import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage -import 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, @@ -75,6 +93,15 @@ class QuickSearchFragment : Fragment() { 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?, @@ -85,7 +112,7 @@ class QuickSearchFragment : Fragment() { ) searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] bottomSheetDialog?.ownShow() - return inflater.inflate(R.layout.quick_search, container, false) + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroy() { @@ -107,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() } @@ -135,54 +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 -> - bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { - bottomSheetDialog = null + binding.quickSearchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) + binding.quickSearchMasterRecycler.adapter = + ParentItemAdapter( + id = "quickSearchMasterRecycler".hashCode(), + { callback -> + SearchHelper.handleSearchClickCallback(callback) + //when (callback.action) { + //SEARCH_ACTION_LOAD -> { + // clickCallback?.invoke(callback) + //} + // else -> SearchHelper.handleSearchClickCallback(activity, callback) + //} + }, + { item -> + bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { + bottomSheetDialog = null + }, expandCallback = { searchViewModel.expandAndReturn(it) }) + }, + expandCallback = { name -> + ioSafe { + searchViewModel.expandAndReturn(name) + } }) - }) - quick_search_master_recycler?.layoutManager = GridLayoutManager(context, 1) + 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) @@ -192,19 +248,12 @@ class QuickSearchFragment : Fragment() { } val searchExitIcon = - quick_search?.findViewById(androidx.appcompat.R.id.search_close_btn) + binding.quickSearch.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 - - - 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 } @@ -214,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 92cecc377..056588d0b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt @@ -1,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.* +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() : RecyclerView.Adapter() { - data class ActorMetaData( - var isInverted: Boolean, - val actor: ActorData, - ) +class ActorAdaptor( + private var nextFocusUpId: Int? = null, + private val focusCallback: (View?) -> Unit = {} +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.actor.name == b.actor.name +})) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } + } - private val actors: MutableList = mutableListOf() + // Easier to store it here than to store it in the ActorData + val inverted: HashMap = hashMapOf() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - 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 onBindContent(holder: ViewHolderState, item: ActorData, position: Int) { + when (val binding = holder.view) { + is CastItemBinding -> { + val itemView = binding.root + val isInverted = inverted.getOrDefault(item, false) + + val (mainImg, vaImage) = if (!isInverted || item.voiceActor?.image.isNullOrBlank()) { + Pair(item.actor.image, item.voiceActor?.image) + } else { + Pair(item.voiceActor?.image, item.actor.image) } - } - } - } - override fun getItemCount(): Int { - return actors.size - } + // 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 + } - private fun updateActorList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - ActorDiffCallback(this.actors, newList) - ) + itemView.setOnFocusChangeListener { v, hasFocus -> + if (hasFocus) { + focusCallback(v) + } + } - actors.clear() - actors.addAll(newList) + itemView.setOnClickListener { + inverted[item] = !isInverted + this.onUpdateContent(holder, getItem(position), position) + } - diffResult.dispatchUpdatesTo(this) - } - - fun updateList(newList: List) { - if (actors.size >= newList.size) { - updateActorList(newList.mapIndexed { i, data -> actors[i].copy(actor = data) }) - } else { - updateActorList(newList.mapIndexed { i, data -> - if (i < actors.size) - actors[i].copy(actor = data) - else ActorMetaData(isInverted = false, actor = data) - }) - } - } - - private class CardViewHolder - constructor( - 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) - } - - itemView.setOnClickListener { - callback(position) - } - - actorImage.setImage(mainImg) - - actorName.text = actor.actor.name - actor.role?.let { - actorExtra.context?.getString( - when (it) { - ActorRole.Main -> { - R.string.actor_main - } - ActorRole.Supporting -> { - R.string.actor_supporting - } - ActorRole.Background -> { - R.string.actor_background + 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 0932b0018..5e5504164 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -1,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,285 +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), "") + + return VideoClickActionHolder.uniqueIdToId(playerPref) ?: ACTION_PLAY_EPISODE_IN_PLAYER + } + + val sharedPool = + newSharedPool { + setMaxRecycledViews(HAS_POSTER or CONTENT, 10) + setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) } - } } - var cardList: MutableList = mutableListOf() - - 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) { + 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 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 + ) + ) + } + + HAS_POSTER -> { + ViewHolderState( + ResultEpisodeLargeBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + else -> throw NotImplementedError() } } - 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 onBindContent(holder: ViewHolderState, item: ResultEpisode, position: Int) { + val itemView = holder.itemView + when (val binding = holder.view) { + is ResultEpisodeLargeBinding -> { + val setWidth = + if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT - override fun 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 - //} + binding.apply { + episodeLinHolder.layoutParams.width = setWidth + episodeHolderLarge.layoutParams.width = setWidth + episodeHolder.layoutParams.width = setWidth - holder.reattachDownloadButton() - } - } + if (isLayout(PHONE or EMULATOR) && CommonActivity.appliedTheme == R.style.AmoledMode) { + episodeHolderLarge.radius = 0.0f + episodeHolder.setPadding(0) + } - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - ResultDiffCallback(this.cardList, newList) - ) + 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 + ) + ) + } - cardList.clear() - cardList.addAll(newList) + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_MIRROR, + item + ) + ) + } - diffResult.dispatchUpdatesTo(this) - } + 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) - 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 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 - return EpisodeCardViewHolder( - LayoutInflater.from(parent.context) - .inflate(layout, parent, false), - hasDownloadSupport, - clickCallback, - downloadClickCallback - ) - } + 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() - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is EpisodeCardViewHolder -> { - holder.bind(cardList[position]) - mBoundViewHolders.add(holder) - } - } - } + 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 getItemCount(): Int { - return cardList.size - } + 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 + } - 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() + null // We only care about the runnable + } + } + } + } else { + // Clear the image + episodePoster.dispose() + } + episodePoster.isVisible = posterVisible - var episodeDownloadBar: ContentLoadingProgressBar? = null - var episodeDownloadImage: ImageView? = null - var localCard: ResultEpisode? = null + 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 = "" + } - @SuppressLint("SetTextI18n") - fun bind(card: ResultEpisode) { - localCard = card + episodeRating.isGone = episodeRating.text.isNullOrBlank() - val isTrueTv = isTrueTvSettings() + episodeDescript.apply { + text = item.description.html() + isGone = text.isNullOrBlank() - 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 + 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 + } + } + } - 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 + if (item.airDate != null) { + val isUpcoming = unixTimeMS < item.airDate - episodeDownloadBar = - parentView.result_episode_progress_downloaded - episodeDownloadImage = parentView.result_episode_download + 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 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 formattedAirDate = SimpleDateFormat.getDateInstance( + DateFormat.LONG, + Locale.getDefault() + ).apply { + }.format(Date(item.airDate)) - if (card.videoWatchState == VideoWatchState.Watched) { - // This cannot be done in getDisplayPosition() as when you have not watched something - // the duration and position is 0 - episodeProgress?.max = 1 - episodeProgress?.progress = 1 - episodeProgress?.isVisible = true - } else { - val displayPos = card.getDisplayPosition() - episodeProgress?.max = (card.duration / 1000).toInt() - episodeProgress?.progress = (displayPos / 1000).toInt() - episodeProgress?.isVisible = displayPos > 0L - } + episodeDate.setText(txt(formattedAirDate)) + } + } else { + episodeUpcomingIcon.isVisible = false + episodePlayIcon.isVisible = true + episodeDate.isVisible = false + } - episodePoster?.isVisible = episodePoster?.setImage(card.poster) == true + episodeRuntime.setText( + txt( + item.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } + ) + ) - if (card.rating != null) { - episodeRating?.text = episodeRating?.context?.getString(R.string.rated_format) - ?.format(card.rating.toFloat() / 10f) - } else { - episodeRating?.text = "" - } + if (isLayout(EMULATOR or PHONE)) { + episodePoster.setOnClickListener { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_CLICK_DEFAULT, + item + ) + ) + } - episodeRating?.isGone = episodeRating?.text.isNullOrBlank() - - episodeDescript?.apply { - text = card.description.html() - isGone = text.isNullOrBlank() - setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card)) - } - } - - if (!isTrueTv) { - episodePoster?.setOnClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) + episodePoster.setOnLongClickListener { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_SHOW_TOAST, + item + ) + ) + return@setOnLongClickListener true + } + } } - episodePoster?.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_TOAST, card)) + itemView.setOnClickListener { + clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) + } + + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false + } + + itemView.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) return@setOnLongClickListener true } } - 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 + ) + ) + } - itemView.setOnLongClickListener { - clickCallback.invoke(EpisodeClickEvent(ACTION_SHOW_OPTIONS, card)) - return@setOnLongClickListener true - } + DOWNLOAD_ACTION_LONG_CLICK -> { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_DOWNLOAD_MIRROR, + item + ) + ) + } - episodeDownloadImage?.isVisible = hasDownloadSupport - episodeDownloadBar?.isVisible = hasDownloadSupport - reattachDownloadButton() - } - - 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 - ) - - 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) + else -> { + downloadClickCallback.invoke(it) + } + } } + + val status = VideoDownloadManager.downloadStatus[item.id] + downloadButton.resetView() + downloadButton.setPersistentId(item.id) + downloadButton.setStatus(status) + + val name = + if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" + episodeFiller.isVisible = item.isFiller == true + episodeText.text = + name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name + episodeText.isSelected = true // is needed for text repeating + + if (item.videoWatchState == VideoWatchState.Watched) { + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + val displayPos = item.getDisplayPosition() + val durationSec = (item.duration / 1000).toInt() + val progressSec = (displayPos / 1000).toInt() + + if (displayPos >= item.duration && displayPos > 0) { + episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) + episodeProgress.isVisible = false + } else { + episodePlayIcon.setImageResource(R.drawable.play_button_transparent) + episodeProgress.apply { + max = durationSec + progress = progressSec + isVisible = displayPos > 0L + } + } + } + + itemView.setOnClickListener { + clickCallback.invoke( + EpisodeClickEvent( + position, + ACTION_CLICK_DEFAULT, + item + ) + ) + } + + if (isLayout(TV)) { + itemView.isFocusable = true + itemView.isFocusableInTouchMode = true + //itemView.touchscreenBlocksFocus = false + } + + itemView.setOnLongClickListener { + clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) + return@setOnLongClickListener true + } + + //binding.resultEpisodeDownload.isVisible = hasDownloadSupport + //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport } } } } -} - -class ResultDiffCallback( - private val oldList: List, - private val newList: List -) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition].id == newList[newItemPosition].id - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt index ebd6a658a..54657ed57 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt @@ -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() + NoStateAdapter( + diffCallback = BaseDiffCallback( + itemSame = Int::equals, + contentSame = Int::equals + ) + ) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } + } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return ImageViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false) + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is ImageViewHolder -> { - holder.bind(images[position], clickCallback, nextFocusUp, nextFocusDown) + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? ResultMiniImageBinding ?: return + clearImage(binding.root) + } + + override fun onBindContent(holder: ViewHolderState, item: Int, position: Int) { + val binding = holder.view as? ResultMiniImageBinding ?: return + + binding.root.apply { + loadImage(item) + if (nextFocusDown != null) { + this.nextFocusDownId = nextFocusDown } - } - } - - override fun getItemCount(): Int { - return images.size - } - - override fun getItemId(position: Int): Long { - return images[position].toLong() - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - DiffCallback(this.images, newList) - ) - - images.clear() - images.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - class ImageViewHolder - constructor(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 + 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 59a462647..3a0edba2f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt @@ -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 68dd1c0ec..cbf94fd97 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt @@ -1,108 +1,26 @@ 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_poster -import kotlinx.android.synthetic.main.fragment_result.result_poster_holder -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 @@ -129,7 +47,7 @@ data class ResultEpisode( val index: Int, val position: Long, // time in MS val duration: Long, // duration in MS - val rating: Int?, + val score: Score?, val description: String?, val isFiller: Boolean?, val tvType: TvType, @@ -137,7 +55,12 @@ data class ResultEpisode( /** * Conveys if the episode itself is marked as watched **/ - val videoWatchState: VideoWatchState + val videoWatchState: VideoWatchState, + /** Sum of all previous season episode counts + episode */ + val totalEpisodeIndex: Int? = null, + val airDate: Long? = null, + val runTime: Int? = null, + val seasonData: SeasonData? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -167,33 +90,41 @@ fun buildResultEpisode( apiName: String, id: Int, index: Int, - rating: Int? = null, + rating: Score? = null, description: String? = null, isFiller: Boolean? = null, tvType: TvType, parentId: Int, + totalEpisodeIndex: Int? = null, + airDate: Long? = null, + runTime: Int? = null, + seasonData: SeasonData? = null, ): ResultEpisode { val posDur = getViewPos(id) val videoWatchState = getVideoWatchState(id) ?: VideoWatchState.None return ResultEpisode( - headerName, - name, - poster, - episode, - seasonIndex, - season, - data, - apiName, - id, - index, - posDur?.position ?: 0, - posDur?.duration ?: 0, - rating, - description, - isFiller, - tvType, - parentId, - videoWatchState + headerName = headerName, + name = name, + poster = poster, + episode = episode, + seasonIndex = seasonIndex, + season = season, + data = data, + apiName = apiName, + id = id, + index = index, + position = posDur?.position ?: 0, + duration = posDur?.duration ?: 0, + score = rating, + description = description, + isFiller = isFiller, + tvType = tvType, + parentId = parentId, + videoWatchState = videoWatchState, + totalEpisodeIndex = totalEpisodeIndex, + airDate = airDate, + runTime = runTime, + seasonData = seasonData ) } @@ -202,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" +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) - 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) + 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() - } - - override fun onResume() { - afterPluginsLoadedEvent += ::reloadViewModel - super.onResume() - activity?.let { - it.window?.navigationBarColor = - it.colorFromAttribute(R.attr.primaryBlackBackground) + putBoolean(RESTART_BUNDLE, true) } } - override fun onDestroy() { - afterPluginsLoadedEvent -= ::reloadViewModel - super.onDestroy() - } - - /// 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() - // } - // } - //} - - result_loading?.isVisible = false - result_finish_loading?.isVisible = true - result_loading_error?.isVisible = false - } + 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) } } - open fun setRecommendations(rec: List?, validApiName: String?) { - + fun updateUI(id: Int? = null) { + // updateUIListener?.invoke() + updateUIEvent.invoke(id) } - private fun updateUI() { - syncModel.updateUserData() - viewModel.reloadEpisodes() - } + val updateUIEvent = Event() - 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 - } + //private var updateUIListener: (() -> Unit)? = null - main { - val file = - ioWorkSafe { - context?.let { - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( - it, - ep.id - ) - } - } - 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 - } - } - } + //protected open val resultLayout = R.layout.fragment_result_swipe - 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 + /* override var layout = R.layout.fragment_result_swipe - /* - * 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 onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { - // Do not use this.isTv, that is the player - val isTv = isTvSettings() - val hasEpisodes = - !(result_episodes?.adapter as? EpisodeAdapter?)?.cardList.isNullOrEmpty() + return super.onCreateView(inflater, container, savedInstanceState) + //return inflater.inflate(resultLayout, container, false) + } - if (isTv && hasEpisodes) { - // Make it impossible to focus anywhere else! - temporary_no_focus?.isFocusable = true - temporary_no_focus?.requestFocus() - } + override fun onDestroyView() { + updateUIListener = null + super.onDestroyView() + } - (result_episodes?.adapter as? EpisodeAdapter)?.updateList(episodes.value) + override fun onResume() { + afterPluginsLoadedEvent += ::reloadViewModel + super.onResume() + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + } - 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() - } - } - } - } + override fun onDestroy() { + afterPluginsLoadedEvent -= ::reloadViewModel + super.onDestroy() + } + + + 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() @@ -492,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) @@ -506,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 @@ -528,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 @@ -542,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, @@ -554,508 +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.episodeSynopsis) { description -> - view.context?.let { ctx -> - val builder: AlertDialog.Builder = - AlertDialog.Builder(ctx, R.style.AlertDialogCustom) - builder.setMessage(description.html()) - .setTitle(R.string.synopsis) - .setOnDismissListener { - viewModel.releaseEpisodeSynopsis() - } - .show() - } - } - - 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 -> - if(data == null) return@observe - 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() - } - } - - - 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 { - removeAllViews() - 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 - chip.setTextColor(context.colorFromAttribute(R.attr.textColor)) - 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( @@ -1068,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 b38e17658..38b24b265 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -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.* +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) + } + } -class ResultFragmentPhone : ResultFragment() { - var currentTrailers: List = emptyList() + /** 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 + ) } - } - }) - - - 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() - } - if (!isFullScreenPlayer && player.getIsPlaying()) { - if (scrollY > (player_background?.height ?: scrollY)) { - player.handleEvent(CSPlayerEvent.Pause) - } - } - //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 - ) - } - } - 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) + if (resume.isMovie) { + resultPlayParent.isGone = true + resultResumeSeriesProgressText.isVisible = true + resultResumeSeriesProgressText.setText(progress.progressLeft) } - } - } - } - - observe(viewModel.episodesCountText) { count -> - result_episodes_text.setText(count) - } - - 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) - } + 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 } - is Some.None -> { - popupDialog?.dismissSafe(activity) - popupDialog = null + + resultResumeSeriesButton.setOnClickListener { + resumeAction(storedData, resume) + } + resultNextSeriesButton.setOnClickListener { + resumeAction(storedData, resume) } } } - observe(viewModel.loadedLinks) { load -> - when (load) { - is Some.Success -> { - 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() + 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 + + val drawable = if (isFavorite) { + R.drawable.ic_baseline_favorite_24 + } else { + R.drawable.ic_baseline_favorite_border_24 + } + + binding.resultFavorite.setImageResource(drawable) + } + + observeNullable(viewModel.episodes) { episodes -> + 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() + } } - //builder.setOnCancelListener { - // it?.dismiss() - //} - builder.setCanceledOnTouchOutside(true) - builder.show() - - builder + 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() } - } - is Some.None -> { - loadingDialog?.dismissSafe(activity) - loadingDialog = null } } } - observe(viewModel.selectedSeason) { text -> - result_season_button.setText(text) + observeNullable(viewModel.movie) { data -> + resultBinding?.apply { + resultPlayMovie.isVisible = data is Resource.Success + downloadButton.isVisible = + data is Resource.Success && viewModel.currentRepo?.api?.hasDownloadSupport == true - 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) + (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) + } + } + } + } } - observe(viewModel.selectedDubStatus) { status -> - result_dub_select?.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) + 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.selectedRange) { range -> - result_episode_select.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) + observeNullable(viewModel.episodesCountText) { count -> + resultBinding?.resultEpisodesText.setText(count) + } + + 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) + } + ) + } + } + + 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 Resource.Loading -> { + syncBinding?.resultSyncMaxEpisodes?.text = + syncBinding?.resultSyncMaxEpisodes?.context?.getString(R.string.sync_total_episodes_none) + } + + else -> {} + } + } + + + 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 + } + + 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) + } + + 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) + } + + 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) + } + } + } + } + + + 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})" + } + } + } + + observeNullable(viewModel.selectedSeason) { text -> + resultBinding?.apply { + resultSeasonButton.setText(text) + + 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) + } + } + } + + observeNullable(viewModel.selectedDubStatus) { status -> + resultBinding?.apply { + resultDubSelect.setText(status) + + if (resultDubSelect.isVisible && !resultSeasonButton.isVisible && !resultEpisodeSelect.isVisible && resultResumeParent.isVisible) { + setFocusUpAndDown(resultResumeSeriesButton, resultDubSelect) + } + } + } + observeNullable(viewModel.selectedRange) { range -> + resultBinding?.apply { + resultEpisodeSelect.setText(range) + + selectEpisodeRange = range?.asStringNull(resultEpisodeSelect.context) + // If Season button is invisible then the bookmark button next focus is episode select + if (resultEpisodeSelect.isVisible && !resultSeasonButton.isVisible && resultResumeParent.isVisible) { + setFocusUpAndDown(resultResumeSeriesButton, resultEpisodeSelect) + } + } } // 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() { } } + private fun resumeAction( + storedData: ResultFragment.StoredData, + resume: ResumeWatchingStatus + ) { + viewModel.handleAction( + EpisodeClickEvent( + storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, + resume.result + ) + ) + } + override fun onPause() { + playerHostView?.releaseKeyEventListener() super.onPause() - PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(this) + PanelsChildGestureRegionObserver.Provider.get() + .addGestureRegionsUpdateListener(gestureRegionsListener) } - override fun onGestureRegionsUpdate(gestureRegions: List) { - result_overlapping_panels?.setChildGestureRegions(gestureRegions) - } - - 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 - } - - result_recommendations_btt?.nextFocusDownId = nextFocusDown - result_search?.nextFocusDownId = nextFocusDown - result_open_in_browser?.nextFocusDownId = nextFocusDown - result_share?.nextFocusDownId = nextFocusDown - } - 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]) + + recommendationBinding?.apply { + root.isGone = isInvalid + root.post { + rec?.let { list -> + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { it.apiName == matchAgainst }) } } - } ?: run { - result_recommendations_filter_button?.isVisible = false } - result_recommendations?.post { - rec?.let { list -> - (result_recommendations?.adapter as? SearchAdapter)?.updateList(list.filter { it.apiName == matchAgainst }) + 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 + } + } + resultOverlappingPanels.setEndPanelLockState(if (isInvalid) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) + + 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 2bd8ff0f4..cfbacc5d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -1,33 +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.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.lagradost.cloudstream3.APIHolder.updateHasTrailers +import com.google.android.material.button.MaterialButton +import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent 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.ExtractorLink +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.fixSystemBarsPadding +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.fragment_result_tv.* +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 : ResultFragment() { - override val resultLayout = R.layout.fragment_result_tv +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() @@ -36,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) } @@ -53,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 } @@ -63,178 +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() - } - } - - override fun updateMovie(data: ResourceSome>) { - super.updateMovie(data) - if (data is ResourceSome.Success && hasNoFocus()) { - result_play_movie?.requestFocus() - } - } - - override fun setTrailers(trailers: List?) { - context?.updateHasTrailers() - if (!LoadResponse.isTrailersEnabled) return - - result_play_trailer?.isGone = trailers.isNullOrEmpty() - result_play_trailer?.setOnClickListener { - if (trailers.isNullOrEmpty()) return@setOnClickListener - activity.navigate( - R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - ExtractorLinkGenerator( - trailers, - emptyList() - ) - ) - ) - } - } - - override fun setRecommendations(rec: List?, validApiName: String?) { + private 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()) + 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 - 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 + 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 + } } } 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 + 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 + ) } - //result_episodes?.setMaxViewPoolSize(0, Int.MAX_VALUE) + } - result_season_selection.setAdapter() - result_range_selection.setAdapter() - result_dub_selection.setAdapter() - result_recommendations_filter_selection.setAdapter() + override fun onResume() { + activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) + afterPluginsLoadedEvent += ::reloadViewModel + super.onResume() + } - observe(viewModel.selectPopup) { popup -> - when (popup) { - is Some.Success -> { - popupDialog?.dismissSafe(activity) + override fun onStop() { + afterPluginsLoadedEvent -= ::reloadViewModel + super.onStop() + } - popupDialog = activity?.let { act -> - val pop = popup.value - val options = pop.getOptions(act) - val title = pop.getTitle(act) + private fun View.fade(turnVisible: Boolean) { + if (turnVisible) { + isVisible = true + } - act.showBottomDialogInstant( - options, title, { - popupDialog = null - pop.callback(null) - }, { - popupDialog = null - pop.callback(it) - } + 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() + } + } + + 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 + } + } + } + + 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) + } + } + } + + 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 + ) + } + 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) + } + } + } + } + } + + 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 ) } } - is Some.None -> { - popupDialog?.dismissSafe(activity) - popupDialog = null + } + + binding.resultFavoriteText.apply { + val text = if (isFavorite == true) { + R.string.unfavorite + } else R.string.favorite + setText(text) + } + } + + observeNullable(viewModel.subscribeStatus) { isSubscribed -> + binding.resultSubscribe.isVisible = isSubscribed != null && isLayout(EMULATOR) + binding.resultSubscribeButton.apply { + if (isSubscribed == null) return@observeNullable + + val drawable = if (isSubscribed) { + R.drawable.ic_baseline_notifications_active_24 + } else R.drawable.baseline_notifications_none_24 + setIconResource(drawable) + + setOnClickListener { + viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> + if (newStatus == null) return@toggleSubscriptionStatus + + val message = if (newStatus) { + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else R.string.subscription_deleted + + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data) + .asStringNull(context) ?: "" + CommonActivity.showToast( + txt( + message, + name + ), Toast.LENGTH_SHORT + ) + } + } + + binding.resultSubscribeText.apply { + val text = if (isSubscribed) { + R.string.action_unsubscribe + } else R.string.action_subscribe + setText(text) } } } - observe(viewModel.loadedLinks) { load -> - when (load) { - is Some.Success -> { - 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.setOnCancelListener { - // it?.dismiss() - //} - builder.setCanceledOnTouchOutside(true) + observeNullable(viewModel.movie) { data -> + if (data == null) { + return@observeNullable + } - builder.show() - - builder + 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) } - result_back?.setOnClickListener { - activity?.popCurrentPage() - } - - result_recommendations?.spanCount = 8 - result_recommendations?.adapter = - SearchAdapter( - ArrayList(), - result_recommendations, - ) { callback -> - SearchHelper.handleSearchClickCallback(activity, callback) + if (isLayout(TV)) { + observe(viewModel.episodeSynopsis) { description -> + context?.let { ctx -> + val builder: AlertDialog.Builder = + AlertDialog.Builder(ctx, R.style.AlertDialogCustom) + builder.setMessage(description.html()) + .setTitle(R.string.synopsis) + .setOnDismissListener { + viewModel.releaseEpisodeSynopsis() + } + .show() + } } + } + + // Used to request focus the first time the episodes are loaded. + var hasLoadedEpisodesOnce = false + observeNullable(viewModel.episodes) { episodes -> + 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) + } + } + } + + 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 bf47209a4..3b1471e6a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -3,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 + + // Single-tap on empty player area: toggle controls. + override fun onSingleTap() { + if (introVisible) return + if (isShowing) uiReset() else showControls() + } + + private fun showControls() { + if (introVisible) return + isShowing = true + updateUIVisibility() + playerHostView?.scheduleAutoHide() + } + + override fun isUIShowing(): Boolean = isShowing + + override fun onAutoHideUI() { + if (player.getIsPlaying()) uiReset() + } + + override fun onHidePlayerUI() = uiReset() + + // When the hold-speedup gesture fires, hide controls so the video is unobstructed. + // The speedup button show/hide and speed change are handled by PlayerView. + override fun onHoldSpeedUp(show: Boolean) { + if (show && isShowing) uiReset() + } + + override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) { + if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) { + isShowing = true + showControls() + } else playerHostView?.scheduleAutoHide() + } override fun nextEpisode() {} - override fun prevEpisode() {} - - override fun playerPositionChanged(posDur: Pair) {} - + override fun playerPositionChanged(position: Long, duration: Long) {} override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { @@ -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 } + // Fade center controls in/out; also resets stale fillAfter alpha from seek animations. + playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f) + } + + override fun playerStatusChanged() { + if (introVisible) { + playerBinding?.playerPausePlayHolderHolder?.isVisible = false + } + } + + override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + playerHostView?.videoOutline = playerBinding?.videoOutline + playerHostView?.requestUpdateBrightnessOverlayOnNextLayout() + + playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() - player_intro_play?.setOnClickListener { - player_intro_play?.isGone = true - player.handleEvent(CSPlayerEvent.Play) - updateUIVisibility() + playerBinding?.playerIntroPlay?.setOnClickListener { + playerBinding?.playerIntroPlay?.isGone = true + introVisible = false + player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) fixPlayerSize() + showControls() } } - - override fun onBackPressed(): Boolean { - return if (isFullScreenPlayer) { - updateFullscreen(false) - false - } else { - true - } - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 6817af6a0..c519e0de2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -1,64 +1,152 @@ 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 import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.APIHolder.getId +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime -import com.lagradost.cloudstream3.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.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.startCast import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Coroutines.main +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.DataStore.editor +import com.lagradost.cloudstream3.utils.DataStore.getFolderName +import com.lagradost.cloudstream3.utils.DataStore.setKey +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount +import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions +import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub +import com.lagradost.cloudstream3.utils.DataStoreHelper.getFavoritesData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultEpisode import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultSeason import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState +import com.lagradost.cloudstream3.utils.DataStoreHelper.getSubscribedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.DataStoreHelper.removeFavoritesData +import com.lagradost.cloudstream3.utils.DataStoreHelper.removeSubscribedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.setBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setDub +import com.lagradost.cloudstream3.utils.DataStoreHelper.setFavoritesData import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultEpisode import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultSeason +import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState +import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState +import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData +import com.lagradost.cloudstream3.utils.Editor +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkType +import com.lagradost.cloudstream3.utils.FillerEpisodeCheck +import com.lagradost.cloudstream3.utils.INFER_TYPE +import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.navigate -import kotlinx.coroutines.* -import java.io.File +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects +import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager +import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle +import com.lagradost.cloudstream3.utils.loadExtractor +import com.lagradost.cloudstream3.utils.newExtractorLink +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit - /** This starts at 1 */ data class EpisodeRange( // used to index data @@ -87,11 +175,13 @@ data class ResultData( val title: String, var syncData: Map, - val posterImage: UiImage?, - val posterBackgroundImage: UiImage?, + val posterImage: String?, + val posterBackgroundImage: String?, + val logoUrl: String?, val plotText: UiText, val apiName: UiText, val ratingText: UiText?, + val contentRatingText: UiText?, val vpnText: UiText?, val metaText: UiText?, val durationText: UiText?, @@ -103,8 +193,30 @@ data class ResultData( val nextAiringDate: UiText?, val nextAiringEpisode: UiText?, val plotHeaderText: UiText, + val posterHeaders: Map? = null, ) +data class CheckDuplicateData( + val name: String, + val year: Int?, + val syncData: Map? +) + +enum class LibraryListType { + BOOKMARKS, + FAVORITES, + SUBSCRIPTIONS +} + +enum class EpisodeSortType { + NUMBER_ASC, + NUMBER_DESC, + RATING_HIGH_LOW, + RATING_LOW_HIGH, + DATE_NEWEST, + DATE_OLDEST +} + fun txt(status: DubStatus?): UiText? { return txt( when (status) { @@ -142,18 +254,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 +287,9 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { ), nextAiringDate = nextAiringDate, nextAiringEpisode = nextAiringEpisode, - posterImage = img( - posterUrl, posterHeaders - ) ?: img(R.drawable.default_cover), - posterBackgroundImage = img( - backgroundPosterUrl ?: posterUrl, posterHeaders - ) ?: img(R.drawable.default_cover), + posterImage = posterUrl ?: backgroundPosterUrl, + posterHeaders = posterHeaders, + posterBackgroundImage = backgroundPosterUrl ?: posterUrl, titleText = txt(name), url = url, tags = tags ?: emptyList(), @@ -183,10 +299,11 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { R.string.cast_format, actors?.joinToString { it.actor.name }), plotText = - if (plot.isNullOrBlank()) txt(if (this is TorrentLoadResponse) R.string.torrent_no_plot else R.string.normal_no_plot) else txt( - plot!! - ), + if (plot.isNullOrBlank()) txt(if (this is TorrentLoadResponse) R.string.torrent_no_plot else R.string.normal_no_plot) else txt( + plot!! + ), backgroundPosterUrl = backgroundPosterUrl, + logoUrl = logoUrl, title = name, typeText = txt( when (type) { @@ -202,12 +319,19 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { TvType.Live -> R.string.live_singular TvType.Others -> R.string.other_singular TvType.NSFW -> R.string.nsfw_singular + TvType.Music -> R.string.music_singular + TvType.AudioBook -> R.string.audio_book_singular + TvType.CustomMedia -> R.string.custom_media_singular + TvType.Audio -> R.string.audio_singular + TvType.Podcast -> R.string.podcast_singular + TvType.Video -> R.string.video_singular } ), yearText = txt(year?.toString()), apiName = txt(apiName), - ratingText = rating?.div(1000f) - ?.let { if (it <= 0.1f) null else txt(R.string.rating_format, it) }, + ratingText = score?.toStringNull(0.1, 10, 1, false, '.') + ?.let { txt(R.string.rating_format, it) }, + contentRatingText = txt(contentRating), vpnText = txt( when (repo.vpnStatus) { VPNStatus.None -> null @@ -216,10 +340,9 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { } ), metaText = - if (repo.providerType == ProviderType.MetaProvider) txt(R.string.provider_info_meta) else null, + if (repo.providerType == ProviderType.MetaProvider) txt(R.string.provider_info_meta) else null, durationText = if (dur == null || dur <= 0) null else txt( - R.string.duration_format, - dur + secondsToReadable(dur * 60, "0 mins") ), onGoingText = if (this is EpisodeResponse) { txt( @@ -231,12 +354,29 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData { ) } else null, noEpisodesFoundText = - if ((this is TvSeriesLoadResponse && this.episodes.isEmpty()) || (this is AnimeLoadResponse && !this.episodes.any { it.value.isNotEmpty() })) txt( - R.string.no_episodes_found - ) else null + if ((this is TvSeriesLoadResponse && this.episodes.isEmpty()) || (this is AnimeLoadResponse && !this.episodes.any { it.value.isNotEmpty() })) txt( + R.string.no_episodes_found + ) else null ) } +data class ExtractorSubtitleLink( + val name: String, + override val url: String, + override val referer: String, + override val headers: Map = mapOf() +) : IDownloadableMinimum + +fun LoadResponse.getId(): Int { + // this fixes an issue with outdated api as getLoadResponseIdFromUrl might be fucked + return (if (this is ResultViewModel2.LoadResponseFromSearch) this.id else null) + ?: getLoadResponseIdFromUrl(uniqueUrl, apiName) +} + +private fun getLoadResponseIdFromUrl(url: String, apiName: String): Int { + return url.replace(getApiFromNameNull(apiName)?.mainUrl ?: "", "").replace("/", "") + .hashCode() +} data class LinkProgress( val linksLoaded: Int, @@ -258,6 +398,7 @@ data class ResumeWatchingStatus( data class LinkLoadingResult( val links: List, val subs: List, + val syncData: HashMap ) sealed class SelectPopup { @@ -302,18 +443,19 @@ 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) @@ -332,12 +474,13 @@ class ResultViewModel2 : ViewModel() { private var currentMeta: SyncAPI.SyncResult? = null private var currentSync: Map? = null private var currentIndex: EpisodeIndexer? = null + private var currentSorting: EpisodeSortType? = null private var currentRange: EpisodeRange? = null private var currentShowFillers: Boolean = false - 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 @@ -349,17 +492,17 @@ class ResultViewModel2 : ViewModel() { 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()) @@ -381,16 +524,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) @@ -403,51 +558,58 @@ class ResultViewModel2 : ViewModel() { private val _selectedDubStatusIndex: MutableLiveData = MutableLiveData(-1) val selectedDubStatusIndex: LiveData = _selectedDubStatusIndex - private val _loadedLinks: MutableLiveData> = MutableLiveData(Some.None) - val loadedLinks: LiveData> = _loadedLinks + private val _loadedLinks: MutableLiveData = MutableLiveData(null) + val loadedLinks: LiveData = _loadedLinks - private val _resumeWatching: MutableLiveData> = - MutableLiveData(Some.None) - val resumeWatching: LiveData> = _resumeWatching + private val _resumeWatching: MutableLiveData = + MutableLiveData(null) + val resumeWatching: LiveData = _resumeWatching private val _episodeSynopsis: MutableLiveData = MutableLiveData(null) val episodeSynopsis: LiveData = _episodeSynopsis + private val _subscribeStatus: MutableLiveData = MutableLiveData(null) + val subscribeStatus: LiveData = _subscribeStatus + + private val _favoriteStatus: MutableLiveData = MutableLiveData(null) + val favoriteStatus: LiveData = _favoriteStatus + companion object { const val TAG = "RVM2" - private const val EPISODE_RANGE_SIZE = 20 - 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() + 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, - currentResponse.name, - currentResponse.url, - currentResponse.apiName, - currentResponse.type, - currentResponse.posterUrl, - currentResponse.year + // If displaySeason is null then only show the name! + return if (seasonData?.name != null && seasonData.displaySeason == null) { + txt(seasonData.name) + } else { + val suffix = seasonData?.name?.let { " $it" } ?: "" + txt( + R.string.season_format, + txt(R.string.season), + seasonData?.displaySeason ?: season, + suffix ) - ) + } } + 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 } @@ -461,12 +623,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( @@ -497,7 +663,8 @@ class ResultViewModel2 : ViewModel() { val episodeNumber = episodes[currentIndex].episode if (episodeNumber < currentMin) { currentMin = episodeNumber - } else if (episodeNumber > currentMax) { + } + if (episodeNumber > currentMax) { currentMax = episodeNumber } ++currentIndex @@ -558,249 +725,89 @@ class ResultViewModel2 : ViewModel() { index to list }.toMap() } - - private fun downloadSubtitle( - context: Context?, - link: ExtractorSubtitleLink, - fileName: String, - folder: String - ) { - ioSafe { - VideoDownloadManager.downloadThing( - context ?: return@ioSafe, - link, - "$fileName ${link.name}", - folder, - if (link.url.contains(".srt")) ".srt" else "vtt", - false, - null - ) { - // no notification - } - } - } - - private fun getFolder(currentType: TvType, titleName: String): String { - val sanitizedFileName = VideoDownloadManager.sanitizeFilename(titleName) - return when (currentType) { - TvType.Anime -> "Anime/$sanitizedFileName" - TvType.Movie -> "Movies" - TvType.AnimeMovie -> "Movies" - TvType.TvSeries -> "TVSeries/$sanitizedFileName" - TvType.OVA -> "OVA" - TvType.Cartoon -> "Cartoons/$sanitizedFileName" - TvType.Torrent -> "Torrent" - TvType.Documentary -> "Documentaries" - TvType.AsianDrama -> "AsianDrama" - TvType.Live -> "LiveStreams" - TvType.NSFW -> "NSFW" - TvType.Others -> "Others" - } - } - - private fun downloadSubtitle( - context: Context?, - link: SubtitleData, - meta: VideoDownloadManager.DownloadEpisodeMetadata, - ) { - context?.let { ctx -> - val fileName = VideoDownloadManager.getFileName(ctx, meta) - val folder = getFolder(meta.type ?: return, meta.mainName) - downloadSubtitle( - ctx, - ExtractorSubtitleLink(link.name, link.url, ""), - fileName, - folder - ) - } - } - - fun startDownload( - context: Context?, - episode: ResultEpisode, - currentIsMovie: Boolean, - currentHeaderName: String, - currentType: TvType, - currentPoster: String?, - apiName: String, - parentId: Int, - url: String, - links: List, - subs: List? - ) { - try { - if (context == null) return - - val meta = - getMeta( - episode, - currentHeaderName, - apiName, - currentPoster, - currentIsMovie, - currentType - ) - - val folder = getFolder(currentType, currentHeaderName) - - val src = "$DOWNLOAD_NAVIGATE_TO/$parentId" // url ?: return@let - - // SET VISUAL KEYS - setKey( - DOWNLOAD_HEADER_CACHE, - parentId.toString(), - VideoDownloadHelper.DownloadHeaderCached( - apiName, - url, - currentType, - currentHeaderName, - currentPoster, - parentId, - System.currentTimeMillis(), - ) - ) - - setKey( - DataStore.getFolderName( - DOWNLOAD_EPISODE_CACHE, - parentId.toString() - ), // 3 deep folder for faster acess - episode.id.toString(), - VideoDownloadHelper.DownloadEpisodeCached( - episode.name, - episode.poster, - episode.episode, - episode.season, - episode.id, - parentId, - episode.rating, - episode.description, - System.currentTimeMillis(), - ) - ) - - // DOWNLOAD VIDEO - VideoDownloadManager.downloadEpisodeUsingWorker( - context, - src,//url ?: return, - folder, - meta, - links - ) - - // 1. Checks if the lang should be downloaded - // 2. Makes it into the download format - // 3. Downloads it as a .vtt file - val downloadList = SubtitlesFragment.getDownloadSubsLanguageISO639_1() - subs?.let { subsList -> - subsList.filter { - downloadList.contains( - SubtitleHelper.fromLanguageToTwoLetters( - it.name, - true - ) - ) - } - .map { ExtractorSubtitleLink(it.name, it.url, "") } - .forEach { link -> - val fileName = VideoDownloadManager.getFileName(context, meta) - downloadSubtitle(context, link, fileName, folder) - } - } - } catch (e: Exception) { - logError(e) - } - } - - suspend fun downloadEpisode( - activity: Activity?, - episode: ResultEpisode, - currentIsMovie: Boolean, - currentHeaderName: String, - currentType: TvType, - currentPoster: String?, - apiName: String, - parentId: Int, - url: String, - ) { - ioSafe { - val generator = RepoLinkGenerator(listOf(episode)) - val currentLinks = mutableSetOf() - val currentSubs = mutableSetOf() - generator.generateLinks(clearCache = false, isCasting = false, callback = { - it.first?.let { link -> - currentLinks.add(link) - } - }, subtitleCallback = { sub -> - currentSubs.add(sub) - }) - - if (currentLinks.isEmpty()) { - main { - showToast( - activity, - R.string.no_links_found_toast, - Toast.LENGTH_SHORT - ) - } - return@ioSafe - } else { - main { - showToast( - activity, - R.string.download_started, - Toast.LENGTH_SHORT - ) - } - } - - startDownload( - activity, - episode, - currentIsMovie, - currentHeaderName, - currentType, - currentPoster, - apiName, - parentId, - url, - sortUrls(currentLinks), - sortSubs(currentSubs), - ) - } - } - - private fun getMeta( - episode: ResultEpisode, - titleName: String, - apiName: String, - currentPoster: String?, - currentIsMovie: Boolean, - tvType: TvType, - ): VideoDownloadManager.DownloadEpisodeMetadata { - return VideoDownloadManager.DownloadEpisodeMetadata( - episode.id, - VideoDownloadManager.sanitizeFilename(titleName), - apiName, - episode.poster ?: currentPoster, - episode.name, - if (currentIsMovie) null else episode.season, - if (currentIsMovie) null else episode.episode, - tvType, - ) - } } private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) val watchStatus: LiveData get() = _watchStatus - private val _selectPopup: MutableLiveData> = MutableLiveData(Some.None) - val selectPopup: LiveData> get() = _selectPopup + private val _selectPopup: MutableLiveData = MutableLiveData(null) + val selectPopup: LiveData = _selectPopup + fun updateWatchStatus( + status: WatchType, + context: Context?, + loadResponse: LoadResponse? = null, + statusChangedCallback: ((statusChanged: Boolean) -> Unit)? = null + ) { + val (response, currentId) = loadResponse?.let { load -> + (load to load.getId()) + } ?: ((currentResponse ?: return) to (currentId ?: return)) - fun updateWatchStatus(status: WatchType) { - updateWatchStatus(currentResponse ?: return, status) - _watchStatus.postValue(status) + val currentStatus = getResultWatchState(currentId) + + // If the current status is "NONE" and the new status is not "NONE", + // fetch the bookmarked data to check for duplicates, otherwise set this + // to an empty list, so that we don't show the duplicate warning dialog, + // but we still want to update the current bookmark and refresh the data anyway. + val bookmarkedData = if (currentStatus == WatchType.NONE && status != WatchType.NONE) { + getAllBookmarkedData() + } else emptyList() + + checkAndWarnDuplicates( + context, + LibraryListType.BOOKMARKS, + CheckDuplicateData( + name = response.name, + year = response.year, + syncData = response.syncData, + ), + bookmarkedData + ) { shouldContinue: Boolean, duplicateIds: List -> + if (!shouldContinue) return@checkAndWarnDuplicates + + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + deleteBookmarkedData(duplicateId) + } + } + + setResultWatchState(currentId, status.internalId) + + // We don't need to store if WatchType.NONE. + // The key is removed in setResultWatchState, we don't want to + // re-add it again here if it was just removed. + if (status != WatchType.NONE) { + val current = getBookmarkedData(currentId) + + setBookmarkedData( + currentId, + DataStoreHelper.BookmarkedData( + current?.bookmarkedTime ?: unixTimeMS, + currentId, + unixTimeMS, + response.name, + response.url, + response.apiName, + response.type, + response.posterUrl, + response.year, + response.syncData, + plot = response.plot, + tags = response.tags, + score = response.score + ) + ) + } + + if (currentStatus != status) { + MainActivity.bookmarksUpdatedEvent(true) + MainActivity.reloadLibraryEvent(true) + } + + _watchStatus.postValue(status) + + statusChangedCallback?.invoke(true) + } } private fun startChromecast( @@ -809,11 +816,311 @@ class ResultViewModel2 : ViewModel() { isVisible: Boolean = true ) { if (activity == null) return - loadLinks(result, isVisible = isVisible, isCasting = true) { data -> + loadLinks( + result, + isVisible = isVisible, + sourceTypes = LOADTYPE_CHROMECAST, + isCasting = true + ) { data -> startChromecast(activity, result, data.links, data.subs, 0) } } + /** + * 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 + + // This might be a bit confusing, but even if the loadresponse is not a EpisodeResponse + // _subscribeStatus might be true. + + 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 + } + + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + removeSubscribedData(duplicateId) + } + } + + 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) + } + } + } + + private fun getMeta( + episode: ResultEpisode, + titleName: String, + apiName: String, + currentPoster: String?, + currentIsMovie: Boolean, + tvType: TvType, + ): DownloadObjects.DownloadEpisodeMetadata { + return DownloadObjects.DownloadEpisodeMetadata( + episode.id, + episode.parentId, + sanitizeFilename(titleName), + apiName, + episode.poster ?: currentPoster, + episode.name, + if (currentIsMovie) null else episode.season, + if (currentIsMovie) null else episode.episode, + tvType, + ) + } + + + /** + * Toggles the favorite status of an item. + * + * @param context The context to use. + * @param statusChangedCallback A callback that is invoked when the favorite status changes. + * It provides the new favorite status (true if added to favorites, false if removed, null if action was canceled). + */ + fun toggleFavoriteStatus( + context: Context?, + statusChangedCallback: ((newStatus: Boolean?) -> Unit)? = null + ) { + val isFavorite = _favoriteStatus.value ?: return + val response = currentResponse ?: return + + val currentId = currentId ?: return + + if (isFavorite) { + removeFavoritesData(currentId) + statusChangedCallback?.invoke(false) + _favoriteStatus.postValue(false) + MainActivity.reloadLibraryEvent(true) + } else { + checkAndWarnDuplicates( + context, + LibraryListType.FAVORITES, + CheckDuplicateData( + name = response.name, + year = response.year, + syncData = response.syncData, + ), + getAllFavorites(), + ) { shouldContinue: Boolean, duplicateIds: List -> + if (!shouldContinue) { + statusChangedCallback?.invoke(null) + return@checkAndWarnDuplicates + } + + if (duplicateIds.isNotEmpty()) { + duplicateIds.forEach { duplicateId -> + removeFavoritesData(duplicateId) + } + } + + val current = getFavoritesData(currentId) + + setFavoritesData( + currentId, + DataStoreHelper.FavoritesData( + current?.favoritesTime ?: unixTimeMS, + currentId, + unixTimeMS, + response.name, + response.url, + response.apiName, + response.type, + response.posterUrl, + response.year, + response.syncData, + plot = response.plot, + score = response.score, + tags = response.tags + ) + ) + + _favoriteStatus.postValue(true) + statusChangedCallback?.invoke(true) + MainActivity.reloadLibraryEvent(true) + } + } + } + + @MainThread + private fun checkAndWarnDuplicates( + context: Context?, + listType: LibraryListType, + checkDuplicateData: CheckDuplicateData, + data: List, + checkDuplicatesCallback: (shouldContinue: Boolean, duplicateIds: List) -> Unit + ) { + val whitespaceRegex = "\\s+".toRegex() + fun normalizeString(input: String): String { + /** + * Trim the input string and replace consecutive spaces with a single space. + * This covers some edge-cases where the title does not match exactly across providers, + * and one provider has the title with an extra whitespace. This is minor enough that + * it should still match in this case. + */ + return input.trim().replace(whitespaceRegex, " ") + } + + val syncData = checkDuplicateData.syncData + + val imdbId = getImdbIdFromSyncData(syncData) + val tmdbId = getTMDbIdFromSyncData(syncData) + val malId = syncData?.get(AccountManager.malApi.idPrefix) + val aniListId = syncData?.get(AccountManager.aniListApi.idPrefix) + val normalizedName = normalizeString(checkDuplicateData.name) + val year = checkDuplicateData.year + + val duplicateEntries = data.filter { it: DataStoreHelper.LibrarySearchResponse -> + val librarySyncData = it.syncData + val yearCheck = year == it.year || year == null || it.year == null + + val checks = listOf( + { imdbId != null && getImdbIdFromSyncData(librarySyncData) == imdbId }, + { tmdbId != null && getTMDbIdFromSyncData(librarySyncData) == tmdbId }, + { malId != null && librarySyncData?.get(AccountManager.malApi.idPrefix) == malId }, + { aniListId != null && librarySyncData?.get(AccountManager.aniListApi.idPrefix) == aniListId }, + { normalizedName == normalizeString(it.name) && yearCheck } + ) + + checks.any { it() } + } + + if (duplicateEntries.isEmpty() || context == null) { + checkDuplicatesCallback.invoke(true, emptyList()) + return + } + + val replaceMessage = if (duplicateEntries.size > 1) { + R.string.duplicate_replace_all + } else R.string.duplicate_replace + + val message = if (duplicateEntries.size == 1) { + val list = when (listType) { + LibraryListType.BOOKMARKS -> getResultWatchState( + duplicateEntries[0].id ?: 0 + ).stringRes + + LibraryListType.FAVORITES -> R.string.favorites_list_name + LibraryListType.SUBSCRIPTIONS -> R.string.subscription_list_name + } + + context.getString( + R.string.duplicate_message_single, + "${normalizeString(duplicateEntries[0].name)} (${context.getString(list)}) — ${duplicateEntries[0].apiName}" + ) + } else { + val bulletPoints = duplicateEntries.joinToString("\n") { + val list = when (listType) { + LibraryListType.BOOKMARKS -> getResultWatchState(it.id ?: 0).stringRes + LibraryListType.FAVORITES -> R.string.favorites_list_name + LibraryListType.SUBSCRIPTIONS -> R.string.subscription_list_name + } + + "• ${it.apiName}: ${normalizeString(it.name)} (${context.getString(list)})" + } + + context.getString(R.string.duplicate_message_multiple, bulletPoints) + } + + val builder: AlertDialog.Builder = AlertDialog.Builder(context) + + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + checkDuplicatesCallback.invoke(true, emptyList()) + } + + DialogInterface.BUTTON_NEGATIVE -> { + checkDuplicatesCallback.invoke(false, emptyList()) + } + + DialogInterface.BUTTON_NEUTRAL -> { + checkDuplicatesCallback.invoke(true, duplicateEntries.map { it.id }) + } + } + } + + builder.setTitle(R.string.duplicate_title) + .setMessage(message) + .setPositiveButton(R.string.duplicate_add, dialogClickListener) + .setNegativeButton(R.string.duplicate_cancel, dialogClickListener) + .setNeutralButton(replaceMessage, dialogClickListener) + .show().setDefaultFocus() + } + + private fun getImdbIdFromSyncData(syncData: Map?): String? { + return safe { + val imdbId = readIdFromString( + syncData?.get(AccountManager.simklApi.idPrefix) + )[SimklSyncServices.Imdb] + if (imdbId == "null") null else imdbId + } + } + + private fun getTMDbIdFromSyncData(syncData: Map?): String? { + return safe { + val tmdbId = readIdFromString( + syncData?.get(AccountManager.simklApi.idPrefix) + )[SimklSyncServices.Tmdb] + if (tmdbId == "null") null else tmdbId + } + } + private fun startChromecast( activity: Activity?, result: ResultEpisode, @@ -843,23 +1150,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) } - }) + } ) } @@ -870,49 +1176,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)) } } @@ -920,11 +1244,10 @@ class ResultViewModel2 : ViewModel() { private fun acquireSingleSubtitle( result: ResultEpisode, - isCasting: Boolean, text: UiText, callback: (Pair) -> Unit, ) { - loadLinks(result, isVisible = true, isCasting = isCasting) { links -> + loadLinks(result, isVisible = true) { links -> postPopup( text, links.subs.map { txt(it.name) }) @@ -934,11 +1257,17 @@ class ResultViewModel2 : ViewModel() { } } + fun skipLoading() { + currentLoadLinkJob?.cancelChildren() + currentLoadLinkJob = null + } + private suspend fun CoroutineScope.loadLinks( result: ResultEpisode, isVisible: Boolean, - isCasting: Boolean, + sourceTypes: Set = LOADTYPE_ALL, clearCache: Boolean = false, + isCasting: Boolean = false ): LinkLoadingResult { val tempGenerator = RepoLinkGenerator(listOf(result)) @@ -946,187 +1275,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)) - } - - 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) - } - } - } - } - } - - private fun playInWebVideo( - activity: Activity?, - link: ExtractorLink, - title: String?, - posterUrl: String?, - subtitles: List - ) = launchActivity(activity, WEB_VIDEO) { - setDataAndType(Uri.parse(link.url), "video/*") - - putExtra("subs", subtitles.map { it.url.toUri() }.toTypedArray()) - title?.let { putExtra("title", title) } - posterUrl?.let { putExtra("poster", posterUrl) } - val headers = Bundle().apply { - if (link.referer.isNotBlank()) - putString("Referer", link.referer) - putString("User-Agent", USER_AGENT) - for ((key, value) in link.headers) { - putString(key, value) - } - } - putExtra("android.media.intent.extra.HTTP_HEADERS", headers) - putExtra("secure_uri", true) - } - - private fun playWithMpv( - activity: Activity?, - id: Int, - link: ExtractorLink, - subtitles: List, - resume: Boolean = true, - ) = launchActivity(activity, MPV, id) { - putExtra("subs", subtitles.map { it.url.toUri() }.toTypedArray()) - putExtra("subs.name", subtitles.map { it.name }.toTypedArray()) - putExtra("subs.filename", subtitles.map { it.name }.toTypedArray()) - setDataAndType(Uri.parse(link.url), "video/*") - component = MPV_COMPONENT - putExtra("secure_uri", true) - putExtra("return_result", true) - val position = getViewPos(id)?.position - if (resume && position != null) - putExtra("position", position.toInt()) - } - - // https://wiki.videolan.org/Android_Player_Intents/ - private fun playWithVlc( - activity: Activity?, - data: LinkLoadingResult, - id: Int, - resume: Boolean = true, - // if it is only a single link then resume works correctly - singleFile: Boolean? = null - ) = launchActivity(activity, VLC, id) { act -> - addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) - addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - - val outputDir = act.cacheDir - - if (singleFile ?: (data.links.size == 1)) { - setDataAndType(data.links.first().url.toUri(), "video/*") - } else { - val outputFile = File.createTempFile("mirrorlist", ".m3u8", outputDir) - - var text = "#EXTM3U" - - // With subtitles it doesn't work for no reason :( -// for (sub in data.subs) { -// text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${sub.name}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.name}\",URI=\"${sub.url}\"" -// } - for (link in data.links) { - text += "\n#EXTINF:, ${link.name}\n${link.url}" - } - outputFile.writeText(text) - - setDataAndType( - FileProvider.getUriForFile( - act, - act.applicationContext.packageName + ".provider", - outputFile - ), "video/*" - ) - } - - val position = if (resume) { - getViewPos(id)?.position ?: 0L - } else { - 1L - } - - component = VLC_COMPONENT - - putExtra("from_start", !resume) - putExtra("position", position) - } - - - 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 + return LinkLoadingResult( + sortUrls(links), + sortSubs(subs), + HashMap(currentResponse?.syncData ?: emptyMap()) ) - ) + } + + fun handleAction(click: EpisodeClickEvent) = + viewModelScope.launchSafe { + handleEpisodeClickEvent(click) + } fun releaseEpisodeSynopsis() { _episodeSynopsis.postValue(null) } - private suspend fun handleEpisodeClickEvent(activity: Activity?, click: EpisodeClickEvent) { + private fun markEpisodes( + editor: Editor, + episodeIds: Array, + watchState: VideoWatchState + ) { + val watchStateString = watchState.toJson() + episodeIds.forEach { + if (getVideoWatchState(it.toInt()) != watchState) { + editor.setKeyRaw( + getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), + watchStateString + ) + } + } + } + + private fun getEpisodesIdsBySeason(season: Int): HashMap> { + val result = currentEpisodes.entries + .asSequence() + .filter { it.key.season <= season && it.key.dubStatus == preferDubStatus } + .flatMap { entry -> + entry.value.asSequence().map { entry.key.season to it.id.toString() } + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, ids) -> ids.toTypedArray() } + .toMap(HashMap()) + + if (season != 0) { + result.remove(0) + } + return result + } + + + private suspend fun handleEpisodeClickEvent(click: EpisodeClickEvent) { when (click.action) { ACTION_SHOW_OPTIONS -> { val options = mutableListOf>() + if (activity?.isConnectedToChromecast() == true) { options.addAll( listOf( @@ -1135,23 +1367,10 @@ class ResultViewModel2 : ViewModel() { ) ) } + options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) - - for (app in apps) { - if (activity?.isAppInstalled(app.packageString) == true) { - options.add( - txt( - R.string.episode_action_play_in_format, - txt(app.name) - ) to app.action - ) - } - } - options.addAll( listOf( - txt(R.string.episode_action_play_in_browser) to ACTION_PLAY_EPISODE_IN_BROWSER, - txt(R.string.episode_action_copy_link) to ACTION_COPY_LINK, txt(R.string.episode_action_auto_download) to ACTION_DOWNLOAD_EPISODE, txt(R.string.episode_action_download_mirror) to ACTION_DOWNLOAD_MIRROR, txt(R.string.episode_action_download_subtitle) to ACTION_DOWNLOAD_EPISODE_SUBTITLE_MIRROR, @@ -1159,17 +1378,26 @@ class ResultViewModel2 : ViewModel() { ) ) + options.addAll( + VideoClickActionHolder.makeOptionMap(activity, click.data) + ) + // Do not add mark as watched on movies if (!listOf(TvType.Movie, TvType.AnimeMovie).contains(click.data.tvType)) { val isWatched = - DataStoreHelper.getVideoWatchState(click.data.id) == VideoWatchState.Watched + getVideoWatchState(click.data.id) == VideoWatchState.Watched val watchedText = if (isWatched) R.string.action_remove_from_watched else R.string.action_mark_as_watched - options.add(txt(watchedText) to ACTION_MARK_AS_WATCHED) - } + val markUpToText = + if (isWatched) R.string.action_remove_mark_watched_up_to_this_episode + else R.string.action_mark_watched_up_to_this_episode + options.add(txt(watchedText) to ACTION_MARK_AS_WATCHED) + + options.add(txt(markUpToText) to ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE) + } postPopup( txt( activity?.getNameFull( @@ -1181,27 +1409,26 @@ 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) } @@ -1217,7 +1444,6 @@ class ResultViewModel2 : ViewModel() { acquireSingleSubtitle( click.data, - false, txt(R.string.episode_action_download_subtitle) ) { (links, index) -> downloadSubtitle( @@ -1233,39 +1459,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, @@ -1276,144 +1504,162 @@ 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_PLAY_EPISODE_IN_PLAYER -> { - val data = currentResponse?.syncData?.toList() ?: emptyList() - val list = - HashMap().apply { putAll(data) } - activity?.navigate( - R.id.global_to_navigation_player, - GeneratorPlayer.newInstance( - generator?.also { - it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work - ?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id } - ?.let { index -> - if (index >= 0) - it.goto(index) - } - - } ?: return, list - ) - ) - } ACTION_MARK_AS_WATCHED -> { val isWatched = - DataStoreHelper.getVideoWatchState(click.data.id) == VideoWatchState.Watched - + getVideoWatchState(click.data.id) == VideoWatchState.Watched if (isWatched) { - DataStoreHelper.setVideoWatchState(click.data.id, VideoWatchState.None) + setVideoWatchState(click.data.id, VideoWatchState.None) } else { - DataStoreHelper.setVideoWatchState(click.data.id, VideoWatchState.Watched) + setVideoWatchState(click.data.id, VideoWatchState.Watched) } - // Kinda dirty to reload all episodes :( reloadEpisodes() } + + ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE -> ioSafe { + val editor = context?.let { it1 -> editor(it1, false) } + + if (editor != null) { + val (clickSeason, clickEpisode) = click.data.let { + (it.season ?: 0) to it.episode + } + val watchState = + if (getVideoWatchState(click.data.id) == VideoWatchState.Watched) VideoWatchState.None else VideoWatchState.Watched + val seasons = getEpisodesIdsBySeason(clickSeason) + + seasons.keys.forEach { currentSeason -> + var episodeIds = seasons[currentSeason] ?: emptyArray() + if (currentSeason == clickSeason) episodeIds = + episodeIds.sliceArray(0 until clickEpisode) + markEpisodes(editor, episodeIds, watchState) + } + editor.apply() + reloadEpisodes() + } + } + + else -> { + val action = VideoClickActionHolder.getActionById(click.action) ?: return + + // Special handling for AlwaysAskAction - show player selection dialog + if (action is AlwaysAskAction) { + activity?.let { ctx -> + // Show player selection dialog + val players = VideoClickActionHolder.getPlayers(ctx) + val options = mutableListOf>() + + // Add internal player option + options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) + + // Add external player options + options.addAll(players.filter { it !is AlwaysAskAction }.map { player -> + player.name to (VideoClickActionHolder.uniqueIdToId(player.uniqueId()) + ?: ACTION_PLAY_EPISODE_IN_PLAYER) + }) + + postPopup( + txt(R.string.player_pref), + options + ) { selectedAction -> + if (selectedAction != null) { + handleEpisodeClickEvent( + click.copy(action = selectedAction) + ) + } + } + } + return + } + + activity?.setKey("last_click_action", action.uniqueId()) + if (action.oneSource) { + acquireSingleLink( + click.data, + action.sourceTypes, + action.name + ) { (result, index) -> + action.runActionSafe( + activity, + click.data, + result, + index + ) + } + } else { + loadLinks(click.data, isVisible = true, action.sourceTypes) { links -> + action.runActionSafe( + activity, + click.data, + links, + null + ) + } + } + } } } @@ -1422,85 +1668,137 @@ 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 = 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 = apis.filter { - it.name.contains("gogoanime", true) || - it.name.contains("9anime", true) - }.map { - it.name - } + 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 - 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 + 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 } @@ -1524,6 +1822,7 @@ class ResultViewModel2 : ViewModel() { postSuccessful( value ?: return@launchSafe, + currentId ?: return@launchSafe, currentRepo ?: return@launchSafe, updateEpisodes ?: return@launchSafe, false @@ -1532,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? { @@ -1558,55 +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) - val watchState = - DataStoreHelper.getVideoWatchState(it.id) ?: VideoWatchState.None - it.copy( - position = posDur?.position ?: 0, - duration = posDur?.duration ?: 0, - videoWatchState = watchState - ) - } + private fun getEpisodes( + indexer: EpisodeIndexer, + range: EpisodeRange, + ): List { + return currentEpisodes[indexer]?.let { list -> + val start = minOf(list.size, range.startIndex) + val end = minOf(list.size, start + range.length) + list.subList(start, end).map { + val posDur = getViewPos(it.id) + val watchState = getVideoWatchState(it.id) ?: VideoWatchState.None + it.copy( + position = posDur?.position ?: 0, + duration = posDur?.duration ?: 0, + videoWatchState = watchState + ) } - ?: emptyList() + } ?: emptyList() + } + + private fun getSortedEpisodes( + episodes: List, + sorting: EpisodeSortType + ): List { + return when (sorting) { + EpisodeSortType.NUMBER_ASC -> episodes.sortedBy { it.episode } + EpisodeSortType.NUMBER_DESC -> episodes.sortedByDescending { it.episode } + EpisodeSortType.RATING_HIGH_LOW -> episodes.sortedByDescending { + it.score?.toDouble() ?: 0.0 + } + + EpisodeSortType.RATING_LOW_HIGH -> episodes.sortedBy { it.score?.toDouble() ?: 0.0 } + EpisodeSortType.DATE_NEWEST -> episodes.sortedByDescending { it.airDate } + EpisodeSortType.DATE_OLDEST -> episodes.sortedBy { it.airDate } + } } private fun postMovie() { 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)) } } @@ -1615,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 } @@ -1636,10 +2000,10 @@ class ResultViewModel2 : ViewModel() { if (ranges?.contains(range) != true) { // if the current ranges does not include the range then select the range with the closest matching start episode - // this usually happends when dub has less episodes then sub -> the range does not exist + // this usually happens when dub has less episodes then sub -> the range does not exist ranges?.minByOrNull { kotlin.math.abs(it.startEpisode - range.startEpisode) } ?.let { r -> - postEpisodeRange(indexer, r) + postEpisodeRange(indexer, r, sorting) return } } @@ -1655,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( @@ -1670,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( @@ -1700,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( @@ -1714,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 -> @@ -1742,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) - if (updateEpisodes) - postEpisodes(loadResponse, updateFillers) - } - - private suspend fun postEpisodes(loadResponse: LoadResponse, updateFillers: Boolean) { - _episodes.postValue(ResourceSome.Loading()) - - val mainId = loadResponse.getId() - currentId = mainId - + postSubscription(loadResponse) + postFavorites(loadResponse) _watchStatus.postValue(getResultWatchState(mainId)) - if (updateFillers && loadResponse is AnimeLoadResponse) { - updateFillers(loadResponse.name) + if (updateEpisodes) + postEpisodes(loadResponse, mainId, updateFillers) + } + + private suspend fun postEpisodes( + loadResponse: LoadResponse, + mainId: Int, + updateFillers: Boolean + ) { + _episodes.postValue(Resource.Loading()) + + if (updateFillers) { + updateFillers(loadResponse) } val allEpisodes = when (loadResponse) { @@ -1791,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) @@ -1800,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 @@ -1823,6 +2232,7 @@ class ResultViewModel2 : ViewModel() { } episodes } + is TvSeriesLoadResponse -> { val episodes: MutableMap> = mutableMapOf() @@ -1838,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 @@ -1867,6 +2289,7 @@ class ResultViewModel2 : ViewModel() { } episodes } + is MovieLoadResponse -> { singleMap( buildResultEpisode( @@ -1884,10 +2307,12 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null, ) ) } + is LiveStreamLoadResponse -> { singleMap( buildResultEpisode( @@ -1905,10 +2330,12 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null ) ) } + is TorrentLoadResponse -> { singleMap( buildResultEpisode( @@ -1926,10 +2353,12 @@ class ResultViewModel2 : ViewModel() { null, null, loadResponse.type, - mainId + mainId, + null ) ) } + else -> { mapOf() } @@ -1946,26 +2375,12 @@ 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 @@ -1982,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 @@ -2007,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" + ) + ) ) } @@ -2032,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 { @@ -2085,7 +2517,6 @@ class ResultViewModel2 : ViewModel() { for (ep in currentRange) { if (ep.getWatchProgress() > 0.9) continue handleAction( - activity, EpisodeClickEvent( getPlayerAction(activity), ep @@ -2095,6 +2526,7 @@ class ResultViewModel2 : ViewModel() { } } } + START_ACTION_LOAD_EP -> { val all = currentEpisodes.values.flatten() val episode = @@ -2105,7 +2537,6 @@ class ResultViewModel2 : ViewModel() { } ?: return@launchSafe handleAction( - activity, EpisodeClickEvent( getPlayerAction(activity), episode @@ -2115,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, @@ -2124,9 +2618,9 @@ class ResultViewModel2 : ViewModel() { 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 @@ -2137,12 +2631,10 @@ class ResultViewModel2 : ViewModel() { _page.postValue( Resource.Failure( false, - null, - null, "This provider does not exist" ) ) - return@launchSafe + return@ioSafe } @@ -2153,21 +2645,15 @@ class ResultViewModel2 : ViewModel() { 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 @@ -2176,12 +2662,13 @@ 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 @@ -2191,30 +2678,32 @@ class ResultViewModel2 : ViewModel() { setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), - VideoDownloadHelper.DownloadHeaderCached( - apiName, - validUrl, - loadResponse.type, - loadResponse.name, - loadResponse.posterUrl, - mainId, - System.currentTimeMillis(), + DownloadObjects.DownloadHeaderCached( + apiName = apiName, + url = validUrl, + type = loadResponse.type, + name = loadResponse.name, + poster = loadResponse.posterUrl, + id = mainId, + cacheTime = System.currentTimeMillis(), ) ) if (loadTrailers) loadTrailers(data.value) postSuccessful( data.value, + mainId, updateEpisodes = true, updateFillers = showFillers, apiRepository = repo ) - if (!isActive) return@launchSafe + if (!isActive) return@ioSafe handleAutoStart(activity, autostart) } + is Resource.Loading -> { debugException { "Invalid load result" } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt index 2e7ec529f..4231819dd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt @@ -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 + } - override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { - if(holder.itemView.hasFocus()) { - holder.itemView.clearFocus() - } - } - - override fun getItemCount(): Int { - return selection.size - } - - fun select(newIndex: Int, recyclerView: RecyclerView?) { - if(recyclerView == null) return - if(newIndex == selectedIndex) return - 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) + isSelected = position == selectedIndex + setText(item.first) + setOnClickListener { + callback.invoke(item.second) } } } } } - fun updateSelectionList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SelectDataCallback(this.selection, newList) - ) - - selection.clear() - selection.addAll(newList) - - diffResult.dispatchUpdatesTo(this) + override fun onViewDetachedFromWindow(holder: ViewHolderState) { + if (holder.itemView.hasFocus()) { + holder.itemView.clearFocus() + } } + fun select(newIndex: Int, recyclerView: RecyclerView?) { + if (recyclerView == null) return + if (newIndex == selectedIndex) return + val oldIndex = selectedIndex + selectedIndex = newIndex - 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 91415d26d..6c5c64ff8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt @@ -4,13 +4,18 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +import com.lagradost.cloudstream3.mvvm.throwAbleToResource +import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.SyncUtil import java.util.* @@ -29,25 +34,25 @@ class SyncViewModel : ViewModel() { const val TAG = "SYNCVM" } - private val repos = SyncApis + private val repos = AccountManager.syncApis - private val _metaResponse: MutableLiveData> = - MutableLiveData() - - val metadata: LiveData> get() = _metaResponse - - private val _userDataResponse: MutableLiveData?> = + private val _metaResponse: MutableLiveData?> = MutableLiveData(null) - val userData: LiveData?> get() = _userDataResponse + val metadata: LiveData?> = _metaResponse + + private val _userDataResponse: MutableLiveData?> = + MutableLiveData(null) + + 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 649641c87..7b63b6ede 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt @@ -4,16 +4,15 @@ 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.Coroutines.main -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 */ @@ -32,85 +31,69 @@ class SearchClickCallback( ) class SearchAdapter( - private val cardList: MutableList, private val resView: AutofitRecyclerView, + private val isHorizontal:Boolean = false, private val clickCallback: (SearchClickCallback) -> Unit, -) : RecyclerView.Adapter() { +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + if (a.id != null || b.id != null) { + a.id == b.id + } else { + a.name == b.name + } +})) { + companion object { + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 10) } + } + var hasNext: Boolean = false - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + private val coverRatio = if(isHorizontal) 1.8 else 0.68 + + private val coverHeight: Int get() = (resView.itemWidth / coverRatio).roundToInt() + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + val inflater = LayoutInflater.from(parent.context) + val layout = - if (parent.context.IsBottomLayout()) R.layout.search_result_grid_expanded else R.layout.search_result_grid - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), - clickCallback, - resView + if (parent.context.isBottomLayout()) SearchResultGridExpandedBinding.inflate( + inflater, + parent, + false + ) else SearchResultGridBinding.inflate( + inflater, + parent, + false + ) + return ViewHolderState(layout) + } + + override fun onClearView(holder: ViewHolderState) { + clearImage( + when (val binding = holder.view) { + is SearchResultGridExpandedBinding -> binding.imageView + is SearchResultGridBinding -> binding.imageView + else -> null + } ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position], position) + 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 + } + + 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, item, position, holder.view.root) } - - override fun getItemCount(): Int { - return cardList.size - } - - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - SearchResponseDiffCallback(this.cardList, newList) - ) - - cardList.clear() - cardList.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - class CardViewHolder - constructor( - itemView: View, - private val clickCallback: (SearchClickCallback) -> Unit, - resView: AutofitRecyclerView - ) : - RecyclerView.ViewHolder(itemView) { - val cardView: ImageView = itemView.imageView - - private val compactView = false//itemView.context.getGridIsCompact() - private val coverHeight: Int = - if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt() - - fun bind(card: SearchResponse, position: Int) { - if (!compactView) { - cardView.apply { - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - coverHeight - ) - } - } - - SearchResultBuilder.bind(clickCallback, card, position, itemView) - } - } -} - -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 b4a38216c..5f5b064b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt @@ -1,7 +1,10 @@ package com.lagradost.cloudstream3.ui.search +import android.app.Activity +import android.content.Intent import android.content.DialogInterface -import android.content.res.Configuration +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -11,59 +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.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.ownHide -import com.lagradost.cloudstream3.utils.AppUtils.ownShow -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.ui.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 -> @@ -82,7 +105,7 @@ class SearchFragment : Fragment() { fun newInstance(query: String): Bundle { return Bundle().apply { - putString(SEARCH_QUERY, query) + if (query.isNotBlank()) putString(SEARCH_QUERY, query) } } } @@ -90,6 +113,21 @@ class SearchFragment : Fragment() { 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, container: ViewGroup?, @@ -99,30 +137,13 @@ class SearchFragment : Fragment() { WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) bottomSheetDialog?.ownShow() - return inflater.inflate( - if (isTvSettings()) R.layout.fragment_search_tv else 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() + return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() + activity?.detachBackPressedCallback("SearchFragment") super.onDestroyView() } @@ -146,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() @@ -181,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 + } + + 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) + } } } - search_autofit_results.adapter = adapter - search_loading_bar.alpha = 0f - 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() @@ -241,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) } @@ -270,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 -> @@ -295,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() } @@ -326,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()) } @@ -336,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) } @@ -364,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().setDefaultFocus() - } 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 } } } @@ -443,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 - } - updateList(newItems) + HomeViewModel.ExpandableHomepageList( + homePageList, + providerData.currentPage, + providerData.hasNext + ) + } + + submitList(newItems) //notifyDataSetChanged() } } catch (e: Exception) { @@ -475,59 +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 -> 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) - - search_master_recycler?.adapter = masterAdapter - search_master_recycler?.layoutManager = GridLayoutManager(context, 1) - - // 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) + 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() + } + } } - // 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()*/ - } + binding.apply { + searchHistoryRecycler.adapter = historyAdapter + searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) + //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) -} \ No newline at end of file + // 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 + + // 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 + } + } + + observe(searchViewModel.currentHistory) { list -> + (binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list) + // Scroll to top to show newest items (list is sorted by newest first) + if (list.isNotEmpty()) { + binding.searchHistoryRecycler.scrollToPosition(0) + } + } + + // Observe search suggestions + observe(searchViewModel.searchSuggestions) { suggestions -> + val hasSuggestions = suggestions.isNotEmpty() + binding.searchSuggestionsRecycler.isVisible = hasSuggestions + (binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions) + + // On non-phone layouts, redirect focus and handle back button + if (!isLayout(PHONE)) { + if (hasSuggestions) { + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler + // Attach back button callback to clear suggestions + activity?.attachBackPressedCallback("SearchFragment") { + searchViewModel.clearSuggestions() + } + } else { + // Reset to default focus target (history) + binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler + // Detach back button callback when no suggestions + activity?.detachBackPressedCallback("SearchFragment") + } + } + } + + searchViewModel.updateHistory() + } +} 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 45336d5b0..449a04bf8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt @@ -1,7 +1,7 @@ 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 @@ -9,61 +9,57 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult +import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper -import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SearchHelper { - fun handleSearchClickCallback(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 -> { - if(!isTvSettings()) { // we only want this on phone as UI is not done yet on tv - (activity as? MainActivity?)?.apply { - loadPopup(callback.card) - } ?: kotlin.run { - showToast(activity, callback.card.name, Toast.LENGTH_SHORT) - } - } else { - 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 8132301b5..4868abb3d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt @@ -1,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 3447ee320..fd99b8d4b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt @@ -1,7 +1,8 @@ package com.lagradost.cloudstream3.ui.search +import android.annotation.SuppressLint import android.content.Context -import android.graphics.drawable.Drawable +import android.content.res.ColorStateList import android.view.View import android.widget.ImageView import android.widget.ProgressBar @@ -10,14 +11,24 @@ 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() @@ -31,33 +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 + 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 @@ -66,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 @@ -102,10 +132,11 @@ object SearchResultBuilder { cardText?.text = card.name cardText?.isVisible = showTitle cardView.isVisible = true - - if (!cardView.setImage(card.posterUrl, card.posterHeaders, colorCallback = colorCallback)) { - cardView.setImageResource(R.drawable.default_cover) - } + if (!card.posterUrl.isNullOrEmpty()) { + cardView.loadImage(card.posterUrl, card.posterHeaders) { + error { getImageFromDrawable(itemView.context, R.drawable.default_cover) } + } + } else cardView.loadImage(R.drawable.default_cover) fun click(view: View?) { clickCallback.invoke( @@ -142,15 +173,42 @@ 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) { + itemView.nextFocusUpId = nextFocusUp + } + + if (nextFocusDown != null) { + itemView.nextFocusDownId = nextFocusDown + } + + /*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 } @@ -158,36 +216,26 @@ object SearchResultBuilder { bg.nextFocusDownId = nextFocusDown } - when (nextFocusBehavior) { - true -> bg.nextFocusLeftId = bg.id - false -> bg.nextFocusRightId = bg.id - null -> { - bg.nextFocusRightId = -1 - bg.nextFocusLeftId = -1 - } - } + */ - 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) @@ -202,6 +250,7 @@ object SearchResultBuilder { } } } + is DataStoreHelper.ResumeWatchingResult -> { val pos = card.watchPos?.fixVisual() if (pos != null) { @@ -209,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()) { @@ -252,5 +302,29 @@ object SearchResultBuilder { } } } + + // This is the logic for making the rounded corners more round on the top and bottom element + // a bit dirty to do memory allocation, but it makes it more extensible and is easier to reason about + // then a large if statement + + // Requires that the ordering here is the same as in the xml + val boxes = arrayListOf() + for (view in arrayOf(textIsDub, textIsSub, rating)) { + if (view?.isVisible == true) { + boxes.add(view) + } + } + if (boxes.size == 1) { + boxes[0].setBackgroundResource(R.drawable.bg_color_both) + } else if (boxes.size > 1) { + boxes[0].setBackgroundResource(R.drawable.bg_color_top) + for (i in 1 until boxes.size) { + boxes[i].setBackgroundResource(R.drawable.bg_color_center) + } + boxes[boxes.size - 1].setBackgroundResource(R.drawable.bg_color_bottom) + } + textIsDub?.apply { + backgroundTintList = ColorStateList.valueOf(context.colorFromAttribute(R.attr.textColor)) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt new file mode 100644 index 000000000..74d5e7b08 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt @@ -0,0 +1,85 @@ +package com.lagradost.cloudstream3.ui.search + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isGone +import com.lagradost.cloudstream3.databinding.SearchSuggestionFooterBinding +import com.lagradost.cloudstream3.databinding.SearchSuggestionItemBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.TV +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout + +const val SEARCH_SUGGESTION_CLICK = 0 +const val SEARCH_SUGGESTION_FILL = 1 +const val SEARCH_SUGGESTION_CLEAR = 2 + +data class SearchSuggestionCallback( + val suggestion: String, + val clickAction: Int, +) + +class SearchSuggestionAdapter( + private val clickCallback: (SearchSuggestionCallback) -> Unit, +) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a == b })) { + + // Add footer for all layouts + override val footers = 1 + + override fun submitList(list: Collection?, commitCallback: Runnable?) { + super.submitList(list, commitCallback) + // Notify footer to rebind when list changes to update visibility + if (footers > 0) { + notifyItemChanged(itemCount - 1) + } + } + + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), + ) + } + + override fun onBindContent( + holder: ViewHolderState, + item: String, + position: Int + ) { + val binding = holder.view as? SearchSuggestionItemBinding ?: return + binding.apply { + suggestionText.text = item + + // Click on the whole item to search + suggestionItem.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_CLICK)) + } + + // Click on the arrow to fill the search box without searching + suggestionFill.setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_FILL)) + } + } + } + + override fun onCreateFooter(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + SearchSuggestionFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) + } + + override fun onBindFooter(holder: ViewHolderState) { + val binding = holder.view as? SearchSuggestionFooterBinding ?: return + binding.clearSuggestionsButton.apply { + isGone = immutableCurrentList.isEmpty() + if (isLayout(TV or EMULATOR)) { + isFocusable = true + isFocusableInTouchMode = true + } + setOnClickListener { + clickCallback.invoke(SearchSuggestionCallback("", SEARCH_SUGGESTION_CLEAR)) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt new file mode 100644 index 000000000..8dbd78178 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt @@ -0,0 +1,74 @@ +package com.lagradost.cloudstream3.ui.search + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.nicehttp.NiceResponse + +/** + * API for fetching search suggestions from external sources. + * Uses TheMovieDB API to provide movie/show/anime related suggestions. + */ +object SearchSuggestionApi { + private const val TMDB_API_URL = "https://api.themoviedb.org/3/search/multi" + private const val TMDB_API_KEY = "e6333b32409e02a4a6eba6fb7ff866bb" + + data class TmdbSearchResult( + @JsonProperty("results") val results: List? + ) + + data class TmdbSearchItem( + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("original_title") val originalTitle: String?, + @JsonProperty("original_name") val originalName: String? + ) + + /** + * Fetches search suggestions from TheMovieDB multi search API. + * Returns suggestions for movies, TV series, and anime. + * + * @param query The search query to get suggestions for + * @return List of suggestion strings, empty list on failure + */ + suspend fun getSuggestions(query: String): List { + if (query.isBlank() || query.length < 2) return emptyList() + + return try { + val response = app.get( + TMDB_API_URL, + params = mapOf( + "api_key" to TMDB_API_KEY, + "query" to query, + "language" to "en-US" + ), + cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes) + ) + + parseSuggestions(response) + } catch (e: Exception) { + logError(e) + emptyList() + } + } + + /** + * Parses the TMDB search response and extracts movie/TV show titles. + * Filters to only include movies, TV shows, and anime. + */ + private fun parseSuggestions(response: NiceResponse): List { + return try { + val parsed = response.parsed() + parsed.results + ?.filter { it.mediaType == "movie" || it.mediaType == "tv" } + ?.mapNotNull { it.title ?: it.name } + ?.distinct() + ?.take(10) + ?: emptyList() + } catch (e: Exception) { + logError(e) + emptyList() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt index aceda644c..f60588e35 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt @@ -5,50 +5,70 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis -import com.lagradost.cloudstream3.AcraApplication.Companion.getKey -import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys -import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.debugAssert +import com.lagradost.cloudstream3.mvvm.debugWarning import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -data class OnGoingSearch( - val apiName: String, - val data: Resource> + +data class ExpandableSearchList( + var list: List, var currentPage: Int, var hasNext: Boolean, ) const val SEARCH_HISTORY_KEY = "search_history" class SearchViewModel : ViewModel() { - private val _searchResponse: MutableLiveData>> = + private val _searchResponse: MutableLiveData> = MutableLiveData() - val searchResponse: LiveData>> get() = _searchResponse + val searchResponse: LiveData> get() = _searchResponse - private val _currentSearch: MutableLiveData> = MutableLiveData() - val currentSearch: LiveData> get() = _currentSearch + private val _currentSearch: MutableLiveData> = + MutableLiveData() + val currentSearch: LiveData> get() = _currentSearch private val _currentHistory: MutableLiveData> = MutableLiveData() val currentHistory: LiveData> get() = _currentHistory - private var repos = apis.map { APIRepository(it) } + private val _searchSuggestions: MutableLiveData> = MutableLiveData() + val searchSuggestions: LiveData> get() = _searchSuggestions + + private var suggestionJob: Job? = null + + private var repos = apis.withLock { apis.map { APIRepository(it) } } fun clearSearch() { - _searchResponse.postValue(Resource.Success(ArrayList())) - _currentSearch.postValue(emptyList()) + _searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false))) + _currentSearch.postValue(emptyMap()) + expandableSearches.clear() } + var lastQuery: String? = null + + /** Save which providers can searched again and which search result page they are on. + * Maps provider name to search list. + * @see [HomeViewModel.expandable] */ + private val expandableSearches: MutableMap = mutableMapOf() + private var currentSearchIndex = 0 private var onGoingSearch: Job? = null fun reloadRepos() { - repos = apis.map { APIRepository(it) } + repos = apis.withLock { apis.map { APIRepository(it) } } } fun searchAndCancel( @@ -62,13 +82,117 @@ class SearchViewModel : ViewModel() { onGoingSearch = search(query, providersActive, ignoreSettings, isQuickSearch) } - fun updateHistory() = viewModelScope.launch { - ioSafe { - val items = getKeys(SEARCH_HISTORY_KEY)?.mapNotNull { - getKey(it) - }?.sortedByDescending { it.searchedAt } ?: emptyList() - _currentHistory.postValue(items) + fun updateHistory() = ioSafe { + val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { + getKey(it) + }?.sortedByDescending { it.searchedAt } ?: emptyList() + _currentHistory.postValue(items) + } + + /** + * Fetches search suggestions with debouncing. + * Waits 300ms before making the API call to avoid too many requests. + * + * @param query The search query to get suggestions for + */ + fun fetchSuggestions(query: String) { + suggestionJob?.cancel() + + if (query.isBlank() || query.length < 2) { + _searchSuggestions.postValue(emptyList()) + return } + + suggestionJob = ioSafe { + delay(300) // Debounce + val suggestions = SearchSuggestionApi.getSuggestions(query) + _searchSuggestions.postValue(suggestions) + } + } + + /** + * Clears the current search suggestions. + */ + fun clearSuggestions() { + suggestionJob?.cancel() + _searchSuggestions.postValue(emptyList()) + } + + private val lock: MutableSet = mutableSetOf() + + // ExpandableHomepageList because the home adapter is reused in the search fragment + suspend fun expandAndReturn(name: String): HomeViewModel.ExpandableHomepageList? { + if (lock.contains(name)) return null + val query = lastQuery ?: return null + val repo = repos.find { it.name == name } ?: return null + + lock += name + + expandableSearches[name]?.let { current -> + debugAssert({ !current.hasNext }) { + "Expand called when not needed" + } + + val nextPage = current.currentPage + 1 + val next = repo.search(query, nextPage) + if (next is Resource.Success) { + val nextValue = next.value + expandableSearches[name]?.apply { + this.hasNext = nextValue.hasNext + this.currentPage = nextPage + + debugWarning({ nextValue.items.any { outer -> this.list.any { it.url == outer.url } } }) { + "Expanded search contained an item that was previously already in the list.\nQuery = $query, ${nextValue.items} = ${this.list}" + } + + // just to be sure we are not adding the same shit for some reason + // Avoids weird behavior in the recyclerview by recreating the list + this.list = (this.list + nextValue.items).distinctBy { it.url } + } ?: debugWarning { + "Expanded an item not in search load named $name, current list is ${expandableSearches.keys}" + } + } else { + current.hasNext = false + } + + _searchResponse.postValue(Resource.Success(bundleSearch(expandableSearches))) + _currentSearch.postValue(expandableSearches) + } + + lock -= name + + val item = expandableSearches[name] ?: return null + return HomeViewModel.ExpandableHomepageList( + HomePageList(name, item.list), + item.currentPage, + item.hasNext + ) + } + + private fun bundleSearch(lists: MutableMap): ExpandableSearchList { + if (lists.size == 1) { + return lists.values.first() + } + + val list = ArrayList() + val nestedList = + lists.map { it.value.list } + + // I do it this way to move the relevant search results to the top + var index = 0 + while (true) { + var added = 0 + for (sublist in nestedList) { + if (sublist.size > index) { + list.add(sublist[index]) + added++ + } + } + if (added == 0) break + index++ + } + + return ExpandableSearchList(list, 1, false) } private fun search( @@ -87,7 +211,7 @@ class SearchViewModel : ViewModel() { if (!isQuickSearch) { val key = query.hashCode().toString() setKey( - SEARCH_HISTORY_KEY, + "$currentAccount/$SEARCH_HISTORY_KEY", key, SearchHistoryItem( searchedAt = System.currentTimeMillis(), @@ -99,45 +223,32 @@ class SearchViewModel : ViewModel() { } _searchResponse.postValue(Resource.Loading()) + _currentSearch.postValue(emptyMap()) + expandableSearches.clear() - - _currentSearch.postValue(ArrayList()) + lastQuery = query withContext(Dispatchers.IO) { // This interrupts UI otherwise - val currentList = ArrayList() - repos.filter { a -> (ignoreSettings || (providersActive.isEmpty() || providersActive.contains(a.name))) && (!isQuickSearch || a.hasQuickSearch) }.amap { a -> // Parallel - val search = if (isQuickSearch) a.quickSearch(query) else a.search(query) + val search = if (isQuickSearch) a.quickSearch(query) else a.search(query, 1) if (currentSearchIndex != currentIndex) return@amap - currentList.add(OnGoingSearch(a.name, search)) - _currentSearch.postValue(currentList) + if (search is Resource.Success) { + val searchValue = search.value + expandableSearches[a.name] = + ExpandableSearchList(searchValue.items, 1, searchValue.hasNext) + } + + _currentSearch.postValue(expandableSearches) } if (currentSearchIndex != currentIndex) return@withContext // this should prevent rewrite of existing data bug - _currentSearch.postValue(currentList) - val list = ArrayList() - val nestedList = - currentList.map { it.data } - .filterIsInstance>>().map { it.value } - - // I do it this way to move the relevant search results to the top - var index = 0 - while (true) { - var added = 0 - for (sublist in nestedList) { - if (sublist.size > index) { - list.add(sublist[index]) - added++ - } - } - if (added == 0) break - index++ - } + _currentSearch.postValue(expandableSearches) + val list = bundleSearch(expandableSearches) _searchResponse.postValue(Resource.Success(list)) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt index 9e03079f6..938b870bb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt @@ -1,13 +1,12 @@ package com.lagradost.cloudstream3.ui.search +import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +//TODO Relevance of this class since it's not used class SyncSearchViewModel { - private val repos = SyncApis - data class SyncSearchResultSearchResponse( override val name: String, override val url: String, @@ -17,5 +16,6 @@ class SyncSearchViewModel { override var id: Int?, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, + override var score: Score? = null, ) : SearchResponse } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt index e879f0dff..be8b4180c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt @@ -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() { + NoStateAdapter( + diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.user.id == b.user.id + }) + ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - return CardViewHolder( - LayoutInflater.from(parent.context).inflate(layout, parent, false), clickCallback + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + AccountSingleBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is CardViewHolder -> { - holder.bind(cardList[position]) - } - } + override fun onClearView(holder: ViewHolderState) { + val binding = holder.view as? AccountSingleBinding ?: return + clearImage(binding.accountProfilePicture) } - override fun getItemCount(): Int { - return cardList.size - } - - override fun getItemId(position: Int): Long { - return cardList[position].accountIndex.toLong() - } - - class CardViewHolder - constructor(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 000000000..93e469a4d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt @@ -0,0 +1,62 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.app.UiModeManager +import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources +import android.os.Build +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.R + +object Globals { + var beneneCount = 0 + + const val PHONE : Int = 0b001 + const val TV : Int = 0b010 + const val EMULATOR : Int = 0b100 + private const val INVALID = -1 + private var layoutId = INVALID + + private fun Context.getLayoutInt(): Int { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + return settingsManager.getInt(this.getString(R.string.app_layout_key), -1) + } + + private fun Context.isAutoTv(): Boolean { + val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? + // AFT = Fire TV + val model = Build.MODEL.lowercase() + return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION || Build.MODEL.contains( + "AFT" + ) || model.contains("firestick") || model.contains("fire tv") || model.contains("chromecast") + } + + private fun Context.layoutIntCorrected(): Int { + return when(getLayoutInt()) { + -1 -> if (isAutoTv()) TV else PHONE + 0 -> PHONE + 1 -> TV + 2 -> EMULATOR + else -> PHONE + } + } + + fun Context.updateTv() { + layoutId = layoutIntCorrected() + } + + /** Returns true if the current orientation is landscape. */ + fun isLandscape(): Boolean = + isLayout(TV or EMULATOR) || + Resources.getSystem().configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + /** Returns true if the layout is any of the flags, + * so isLayout(TV or EMULATOR) is a valid statement for checking if the layout is in the emulator + * or tv. Auto will become the "TV" or the "PHONE" layout. + * + * Valid flags are: PHONE, TV, EMULATOR + * */ + fun isLayout(flags: Int) : Boolean { + return (layoutId and flags) != 0 + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt new file mode 100644 index 000000000..365990646 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.view.LayoutInflater +import android.view.ViewGroup +import com.lagradost.cloudstream3.databinding.ItemLogcatBinding +import com.lagradost.cloudstream3.ui.BaseDiffCallback +import com.lagradost.cloudstream3.ui.NoStateAdapter +import com.lagradost.cloudstream3.ui.ViewHolderState + +class LogcatAdapter() : NoStateAdapter( + diffCallback = BaseDiffCallback( + itemSame = String::equals, + contentSame = String::equals + ) +) { + override fun onCreateContent(parent: ViewGroup): ViewHolderState { + return ViewHolderState( + ItemLogcatBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindContent(holder: ViewHolderState, item: String, position: Int) { + (holder.view as? ItemLogcatBinding)?.apply { + logText.text = item + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index f9627e467..8d96a6b14 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -1,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) - } - } - api.accountIndex = ogIndex - val adapter = AccountAdapter(items, R.layout.account_single) { + binding.accountNone.setOnClickListener { + api.accountId = -1 dialog?.dismissSafe(activity) - api.changeAccount(it.card.accountIndex) + } + + val adapter = AccountAdapter { + dialog?.dismissSafe(activity) + api.accountId = it.card.user.id + }.apply { + submitList(accounts.toList()) } val list = dialog.findViewById(R.id.account_list) list?.adapter = adapter } + @UiThread - fun addAccount(activity: FragmentActivity?, api: AccountManager) { - try { - when (api) { - is OAuth2API -> { - api.authenticate(activity) - } - is InAppAuthAPI -> { - val builder = - AlertDialog.Builder(activity ?: return, R.style.AlertDialogCustom) - .setView(R.layout.add_account_input) - val dialog = builder.show() + fun showPin(activity: FragmentActivity, api: AuthRepo) { + val binding: DeviceAuthBinding = + DeviceAuthBinding.inflate(activity.layoutInflater, null, false) - 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 builder = + AlertDialog.Builder(activity) + .setView(binding.root) - 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 - } - } - - 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 - ) - 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 ?: "") - } - } - - 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, - ) - 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 - ) - ) - } catch (e: Exception) { - logError(e) // format might fail - } - } - } - dialog.dismissSafe(activity) - } - dialog.cancel_btt?.setOnClickListener { - dialog.dismissSafe(activity) - } - } - else -> { - throw NotImplementedError("You are trying to add an account that has an unknown login method") + builder.apply { + setNegativeButton(R.string.cancel) { _, _ -> } + if (api.hasOAuth2) { + setPositiveButton(R.string.auth_locally) { _, _ -> + api.openOAuth2PageWithToast() } } - } catch (e: Exception) { - logError(e) } + + val dialog = builder.create() + + ioSafe { + val pinCodeData = try { + api.pinRequest() + } catch (e: ErrorLoadingException) { + if (e.message != null) { + showToast(e.message) + null + } else { + throw e + } + } catch (t: Throwable) { + logError(t) + null + } + if (pinCodeData == null) { + if (api.hasOAuth2) { + showToast(R.string.device_pin_error_message) + api.openOAuth2PageWithToast() + } else { + showToast( + txt( + R.string.authenticated_user_fail, + api.name + ) + ) + } + return@ioSafe + } + + /*val logoBytes = ContextCompat.getDrawable( + activity, + R.drawable.cloud_2_solid + )?.toBitmapOrNull()?.let { bitmap -> + val csLogo = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, csLogo) + csLogo.toByteArray() + }*/ + + val qrCodeImage = QRCode.ofRoundedSquares() + .withColor(activity.colorFromAttribute(R.attr.textColor)) + .withBackgroundColor(activity.colorFromAttribute(R.attr.primaryBlackBackground)) + //.withLogo(logoBytes, 200.toPx, 200.toPx) //For later if logo needed anytime + .build(pinCodeData.verificationUrl) + .render().nativeImage() as Bitmap + + activity.runOnUiThread { + dialog.show() + binding.apply { + devicePinCode.setText(txt(pinCodeData.userCode)) + deviceAuthMessage.setText( + txt( + R.string.device_pin_url_message, + pinCodeData.verificationUrl + ) + ) + deviceAuthQrcode.loadImage(qrCodeImage) + } + + val expirationMillis = + pinCodeData.expiresIn.times(1000).toLong() + + object : CountDownTimer(expirationMillis, 1000) { + override fun onTick(millisUntilFinished: Long) { + val secondsUntilFinished = + millisUntilFinished.div(1000).toInt() + + binding.deviceAuthValidationCounter.setText( + txt( + R.string.device_pin_counter_text, + secondsUntilFinished.div(60), + secondsUntilFinished.rem(60) + ) + ) + + ioSafe { + if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.login( + pinCodeData + ) + ) { + showToast( + txt( + R.string.authenticated_user, + api.name + ) + ) + dialog.dismissSafe(activity) + cancel() + } + } + } + + override fun onFinish() { + showToast(R.string.device_pin_expired_message) + dialog.dismissSafe(activity) + } + }.start() + } + } + } + + + fun showAppLogin(activity: FragmentActivity, api: AuthRepo) { + + val binding: AddAccountInputBinding = + AddAccountInputBinding.inflate(activity.layoutInflater, null, false) + val builder = + AlertDialog.Builder(activity, R.style.AlertDialogCustom) + .setView(binding.root) + val dialog = builder.show() + val req = + api.inAppLoginRequirement ?: throw ErrorLoadingException("Missing LoginRequirement") + val visibilityMap = listOf( + binding.loginEmailInput to req.email, + binding.loginPasswordInput to req.password, + binding.loginServerInput to req.server, + binding.loginUsernameInput to req.username + ) + + if (isLayout(TV or EMULATOR)) { + visibilityMap.forEach { (input, isVisible) -> + input.isVisible = isVisible + + // Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen + input.setOnEditorActionListener { textView, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_NEXT) { + val view = textView.focusSearch(FOCUS_DOWN) + return@setOnEditorActionListener view?.requestFocus( + FOCUS_DOWN + ) == true + } + return@setOnEditorActionListener true + } + } + } else { + visibilityMap.forEach { (input, isVisible) -> + input.isVisible = isVisible + } + } + + binding.createAccount.isGone = api.createAccountUrl.isNullOrBlank() + binding.createAccount.setOnClickListener { + openBrowser( + api.createAccountUrl ?: return@setOnClickListener, + activity + ) + dialog.dismissSafe() + } + + val displayedItems = listOf( + binding.loginUsernameInput, + binding.loginEmailInput, + binding.loginServerInput, + binding.loginPasswordInput + ).filter { it.isVisible } + + displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> + item.id.let { previous?.nextFocusDownId = it } + previous?.id?.let { item.nextFocusUpId = it } + item + } + + displayedItems.firstOrNull()?.let { + binding.createAccount.nextFocusDownId = it.id + it.nextFocusUpId = binding.createAccount.id + } + binding.applyBtt.id.let { + displayedItems.lastOrNull()?.nextFocusDownId = it + } + + binding.text1.text = api.name + + binding.applyBtt.setOnClickListener { + val loginData = AuthLoginResponse( + username = if (req.username) binding.loginUsernameInput.text?.toString() else null, + password = if (req.password) binding.loginPasswordInput.text?.toString() else null, + email = if (req.email) binding.loginEmailInput.text?.toString() else null, + server = if (req.server) binding.loginServerInput.text?.toString() else null, + ) + ioSafe { + try { + if (api.login(loginData)) { + showToast( + txt( + R.string.authenticated_user, + api.name + ) + ) + dialog.dismissSafe(activity) + } else { + showToast( + txt( + R.string.authenticated_user_fail, + api.name + ) + ) + } + } catch (t: Throwable) { + if (t is ErrorLoadingException && t.message != null) { + showToast(t.message) + return@ioSafe + } + showToast( + txt( + R.string.authenticated_user_fail, + api.name + ) + ) + } + } + } + binding.cancelBtt.setOnClickListener { + dialog.dismissSafe(activity) + } + } + + @UiThread + fun addAccount(activity: FragmentActivity, api: AuthRepo) { + try { + if (api.hasPin && !isLayout(PHONE)) { + showPin(activity, api) + } else if (api.hasOAuth2) { + api.openOAuth2PageWithToast() + } else if (api.hasInApp) { + showAppLogin(activity, api) + } else { + throw NotImplementedError("The api ${api.name} has no login") + } + } catch (t: Throwable) { + showToast(txt(R.string.authenticated_user_fail, api.name)) + logError(t) + } + } + } + + private fun updateAuthPreference(enabled: Boolean) { + val biometricKey = getString(R.string.biometric_key) + + PreferenceManager.getDefaultSharedPreferences(context ?: return).edit { + putBoolean(biometricKey, enabled) + } + findPreference(biometricKey)?.isChecked = enabled + } + + override fun onAuthenticationError() { + updateAuthPreference(!isAuthEnabled(context ?: return)) + } + + override fun onAuthenticationSuccess() { + if (isAuthEnabled(context ?: return)) { + updateAuthPreference(true) + BackupUtils.backup(activity) + activity?.showBottomDialogText( + getString(R.string.biometric_setting), + getString(R.string.biometric_warning).html() + ) { onDialogDismissedEvent } + } else { + updateAuthPreference(false) } } @@ -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 40c996cc3..e41109b59 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -1,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) - } - - private fun Context.isTvSettings(): Boolean { - var value = getLayoutInt() - if (value == -1) { - value = if (isAutoTv()) 1 else 0 - } - return value == 1 || value == 2 - } - - private fun Context.isTrueTvSettings(): Boolean { - var value = getLayoutInt() - if (value == -1) { - value = if (isAutoTv()) 1 else 0 - } - return value == 1 - } - - fun Context.updateTv() { - isTrueTv = isTrueTvSettings() - isTv = isTvSettings() - } - - fun isTrueTvSettings(): Boolean { - return isTrueTv - } - - fun isTvSettings(): Boolean { - return isTv - } - - fun Context.isEmulatorSettings(): Boolean { - return getLayoutInt() == 2 - } - - private fun Context.isAutoTv(): Boolean { - val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? - // AFT = Fire TV - val model = Build.MODEL.lowercase() - return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION || Build.MODEL.contains( - "AFT" - ) || model.contains("firestick") || model.contains("fire tv") || model.contains("chromecast") - } } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.main_settings, container, false) + override fun fixLayout(view: View) { + fixSystemBarsPadding( + view, + padBottom = isLandscape(), + padLeft = isLayout(TV or EMULATOR) + ) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: MainSettingsBinding) { fun navigate(id: Int) { activity?.navigate(id, Bundle()) } - val isTrueTv = isTrueTvSettings() + /** used to debug leaks + showToast(activity,"${VideoDownloadManager.downloadStatusEvent.size} : + ${VideoDownloadManager.downloadProgressEvent.size}") **/ - 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 + fun hasProfilePictureFromAccountManagers(accountManagers: Array): Boolean { + for (syncApi in accountManagers) { + val login = syncApi.authUser() + val pic = login?.profilePicture ?: continue + + 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 + } + + // display local account information if not syncing + if (!hasProfilePictureFromAccountManagers(AccountManager.allApis)) { + val activity = activity ?: return + val currentAccount = try { + DataStoreHelper.accounts.firstOrNull { + it.keyIndex == DataStoreHelper.selectedKeyIndex + } ?: activity.let { DataStoreHelper.getDefaultAccount(activity) } + + } catch (t: IllegalStateException) { + Log.e("AccountManager", "Activity not found", t) + null + } + + binding.settingsProfilePic.loadImage(currentAccount?.image) + binding.settingsProfileText.text = currentAccount?.name + } + + binding.apply { + listOf( + settingsGeneral to R.id.action_navigation_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 + } + } + } + + // 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 649aa634f..57f5aa870 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -1,109 +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( /* begin language list */ - Triple("", "العربية", "ar"), - Triple("", "български език", "bg"), - Triple("", "বাংলা", "bn"), - Triple("\uD83C\uDDE7\uD83C\uDDF7", "Brazilian Portuguese", "bp"), - Triple("", "čeština", "cs"), - Triple("", "Deutsch", "de"), - Triple("", "ελληνικά", "el"), - Triple("", "English", "en"), - Triple("", "Esperanto", "eo"), - Triple("", "Español", "es"), - Triple("", "فارسی", "fa"), - Triple("", "français", "fr"), - Triple("", "हिन्दी", "hi"), - Triple("", "hrvatski jezik", "hr"), - Triple("", "magyar", "hu"), - Triple("\uD83C\uDDEE\uD83C\uDDE9", "Indonesian", "in"), - Triple("", "Italiano", "it"), - Triple("\uD83C\uDDEE\uD83C\uDDF1", "עִברִית", "iw"), - Triple("", "ಕನ್ನಡ", "kn"), - Triple("", "македонски јазик", "mk"), - Triple("", "മലയാളം", "ml"), - Triple("", "Nederlands", "nl"), - Triple("", "Norsk nynorsk", "nn"), - Triple("", "Norsk", "no"), - Triple("", "język polski", "pl"), - Triple("\uD83C\uDDF5\uD83C\uDDF9", "Português", "pt"), - Triple("🦍", "mmmm... monke", "qt"), - Triple("", "Română", "ro"), - Triple("", "Русский", "ru"), - Triple("", "slovenčina", "sk"), - Triple("", "Soomaaliga", "so"), - Triple("", "svenska", "sv"), - Triple("", "தமிழ்", "ta"), - Triple("", "Wikang Tagalog", "tl"), - Triple("", "Türkçe", "tr"), - Triple("", "Українська", "uk"), - Triple("", "اردو", "ur"), - Triple("", "Tiếng Việt", "vi"), - Triple("", "中文 (Zhōngwén)", "zh"), - Triple("\uD83C\uDDF9\uD83C\uDDFC", "Chinese Traditional", "zh-rTW"), + Pair("Afrikaans", "af"), + Pair("Azərbaycan dili", "az"), + Pair("Bahasa Indonesia", "in"), + Pair("Bahasa Melayu", "ms"), + Pair("Deutsch", "de"), + Pair("English", "en"), + Pair("Español", "es"), + Pair("Esperanto", "eo"), + Pair("Français", "fr"), + Pair("Galego", "gl"), + Pair("hrvatski", "hr"), + Pair("Italiano", "it"), + Pair("Latviešu valoda", "lv"), + Pair("Lietuvių kalba", "lt"), + Pair("Magyar", "hu"), + Pair("Malti", "mt"), + Pair("mmmm... monke", "qt"), + Pair("Nederlands", "nl"), + Pair("Norsk bokmål", "no"), + Pair("Norsk nynorsk", "nn"), + Pair("Polski", "pl"), + Pair("Português", "pt"), + Pair("Português (Brasil)", "pt-BR"), + Pair("Română", "ro"), + Pair("Slovenčina", "sk"), + Pair("Soomaaliga", "so"), + Pair("Svenska", "sv"), + Pair("Tagalog", "tl"), + Pair("Tiếng Việt", "vi"), + Pair("Türkçe", "tr"), + Pair("Wikang Filipino", "fil"), + Pair("Čeština", "cs"), + Pair("Ελληνικά", "el"), + Pair("български", "bg"), + Pair("македонски", "mk"), + Pair("русский", "ru"), + Pair("українська", "uk"), + Pair("עברית", "iw"), + Pair("اردو", "ur"), + Pair("العربية", "ar"), + Pair("اللهجة النجدية", "ars"), + Pair("عربي شامي", "apc"), + Pair("فارسی", "fa"), + Pair("کوردیی ناوەندی", "ckb"), + Pair("नेपाली", "ne"), + Pair("हिन्दी", "hi"), + Pair("অসমীয়া", "as"), + Pair("বাংলা", "bn"), + Pair("ଓଡ଼ିଆ", "or"), + Pair("தமிழ்", "ta"), + Pair("ಕನ್ನಡ", "kn"), + Pair("മലയാളം", "ml"), + Pair("ဗမာစာ", "my"), + Pair("ትግርኛ", "ti"), + Pair("አማርኛ", "am"), + Pair("中文", "zh"), + Pair("日本語 (にほんご)", "ja"), + Pair("正體中文(臺灣)", "zh-TW"), + Pair("한국어", "ko"), /* end language list */ -).sortedBy { it.second } //ye, we go alphabetical, so ppl don't put their lang on top +).sortedBy { it.first.lowercase(Locale.ROOT) } // ye, we go alphabetical, so ppl don't put their lang on top -class SettingsGeneral : PreferenceFragmentCompat() { +fun Pair.nameNextToFlagEmoji(): String { + // fallback to [A][A] -> [?] question mak flag + val flag = SubtitleHelper.getFlagFromIso(this.second) ?: "\ud83c\udde6\ud83c\udde6" + + return "$flag\u00a0${this.first}" // \u00a0 non-breaking space +} + +class SettingsGeneral : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_general) setPaddingBottom() + setToolBarScrollFlags() } data class CustomSite( @@ -117,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 + companion object { + fun Fragment.pickDownloadPath(uri: Uri?, path: String?) { + if (uri == null) return - 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() + 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 { @@ -156,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) @@ -183,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 = allProviders.distinctBy { it::class }.sortedBy { it.name } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, @@ -194,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 } @@ -216,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) } } @@ -239,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) } @@ -289,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 + return safe { + context?.let { ctx -> + val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.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 } + val first = listOf(defaultDir) + (try { + val currentDir = ctx.getBasePath().let { it.first?.filePath() ?: it.second } - (first + - requireContext().getExternalFilesDirs("").mapNotNull { it.path } + - currentDir) - } catch (e: Exception) { - first - }).filterNotNull().distinct() + (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, @@ -339,41 +388,40 @@ class SettingsGeneral : PreferenceFragmentCompat() { } else { // Sets both visual and actual paths. // key = used path - // pref = visual path - settingsManager.edit() - .putString(getString(R.string.download_path_key), dirs[it]).apply() - settingsManager.edit() - .putString(getString(R.string.download_path_pref), dirs[it]).apply() + // visual = visual path + settingsManager.edit { + putString(getString(R.string.download_path_key), dirs[it]) + putString(getString(R.string.download_path_key_visual), dirs[it]) + } } } return@setOnPreferenceClickListener true } try { - SettingsFragment.beneneCount = + beneneCount = settingsManager.getInt(getString(R.string.benene_count), 0) getPref(R.string.benene_count)?.let { pref -> pref.summary = - if (SettingsFragment.beneneCount <= 0) getString(R.string.benene_count_text_none) else getString( + if (beneneCount <= 0) getString(R.string.benene_count_text_none) else getString( R.string.benene_count_text ).format( - SettingsFragment.beneneCount + beneneCount ) pref.setOnPreferenceClickListener { try { - SettingsFragment.beneneCount++ - if (SettingsFragment.beneneCount%20 == 0) { - val intent = Intent(context, EasterEggMonke::class.java) - startActivity(intent) + beneneCount++ + if (beneneCount%20 == 0) { + activity?.navigate(R.id.action_navigation_settings_general_to_easterEggMonkeFragment) } - settingsManager.edit().putInt( - getString(R.string.benene_count), - SettingsFragment.beneneCount - ) - .apply() - it.summary = - getString(R.string.benene_count_text).format(SettingsFragment.beneneCount) + settingsManager.edit { + putInt( + getString(R.string.benene_count), + beneneCount + ) + } + it.summary = getString(R.string.benene_count_text).format(beneneCount) } catch (e: Exception) { logError(e) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index 33d419342..0a0fb33c8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -3,32 +3,59 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.text.format.Formatter.formatShortFileSize import android.view.View -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hidePrefs import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard -class SettingsPlayer : PreferenceFragmentCompat() { +class SettingsPlayer : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_player) setPaddingBottom() + setToolBarScrollFlags() } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + //Hide specific prefs on TV/EMULATOR + hidePrefs( + listOf( + R.string.pref_category_gestures_key, + R.string.rotate_video_key, + R.string.auto_rotate_video_key, + R.string.speedup_key + ), + TV or EMULATOR + ) + + getPref(R.string.preview_seekbar_key)?.hideOn(TV) + getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) + getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_length_names) val prefValues = resources.getIntArray(R.array.video_buffer_length_values) @@ -41,10 +68,11 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_length_settings), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.video_buffer_length_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.video_buffer_length_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } @@ -59,38 +87,73 @@ class SettingsPlayer : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.limit_title), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } - /*(getPref(R.string.double_tap_seek_time_key) as? SeekBarPreference?)?.let { - - }*/ - - getPref(R.string.prefer_limit_title_rez_key)?.setOnPreferenceClickListener { - val prefNames = resources.getStringArray(R.array.limit_title_rez_pref_names) - val prefValues = resources.getIntArray(R.array.limit_title_rez_pref_values) - val current = settingsManager.getInt(getString(R.string.prefer_limit_title_rez_key), 3) + getPref(R.string.software_decoding_key)?.setOnPreferenceClickListener { + val prefNames = resources.getStringArray(R.array.software_decoding_switch) + val prefValues = resources.getIntArray(R.array.software_decoding_switch_values) + val current = settingsManager.getInt(getString(R.string.software_decoding_key), -1) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), - getString(R.string.limit_title_rez), + getString(R.string.software_decoding), true, - {}) { - settingsManager.edit() - .putInt(getString(R.string.prefer_limit_title_rez_key), prefValues[it]) - .apply() + {} + ) { + settingsManager.edit { + putInt(getString(R.string.software_decoding_key), prefValues[it]) + } } return@setOnPreferenceClickListener true } + getPref(R.string.prefer_limit_show_player_info)?.setOnPreferenceClickListener { + val ctx = context ?: return@setOnPreferenceClickListener false + + val prefNames = resources.getStringArray(R.array.title_info_pref_names) + val keys = resources.getStringArray(R.array.title_info_pref_values) + + // Player defaults + val playerDefaults = mapOf( + ctx.getString(R.string.show_name_key) to true, + ctx.getString(R.string.show_resolution_key) to true, + ctx.getString(R.string.show_media_info_key) to false + ) + + val selectedIndices = keys.map { key -> + settingsManager.getBoolean(key, playerDefaults[key] ?: false) + }.mapIndexedNotNull { index, enabled -> + if (enabled) index else null + } + + activity?.showMultiDialog( + prefNames.toList(), + selectedIndices, + getString(R.string.limit_title_rez), + {} + ) { selected -> + settingsManager.edit { + for ((index, key) in keys.withIndex()) { + putBoolean(key, selected.contains(index)) + } + } + } + + true + } + + getPref(R.string.hide_player_control_names_key)?.hideOn(TV) + getPref(R.string.quality_pref_key)?.setOnPreferenceClickListener { - val prefValues = Qualities.values().map { it.value }.reversed().toMutableList() + val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() prefValues.remove(Qualities.Unknown.value) val prefNames = prefValues.map { Qualities.getStringByInt(it) } @@ -98,7 +161,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { val currentQuality = settingsManager.getInt( getString(R.string.quality_pref_key), - Qualities.values().last().value + Qualities.entries.last().value ) activity?.showBottomDialog( @@ -106,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 3b01508d8..c8478a840 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -2,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 = APIHolder.apis.withLock { + listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } } - val names = languages.map { - if (it == AllLanguagesName) { - Pair(it, getString(R.string.all_languages_preference)) - } else { - val emoji = SubtitleHelper.getFlagFromIso(it) - val name = SubtitleHelper.fromTwoLettersToLanguage(it) - val fullName = "$emoji $name" - Pair(it, fullName) - } + val currentIndexList = currentLangTags.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } } activity?.showMultiDialog( - names.map { it.second }, - currentList, + languagesTagName.map { it.second }, + currentIndexList, getString(R.string.provider_lang_settings), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.provider_lang_key), - selectedList.map { names[it].first }.toMutableSet() - ).apply() - //APIRepository.providersActive = it.context.getApiSettings() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.provider_lang_key), + selectedList.map { languagesTagName[it].first }.toSet() + ) + } + // APIRepository.providersActive = it.context.getApiSettings() } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index e2fd24ca4..f4c522bf9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -3,33 +3,68 @@ package com.lagradost.cloudstream3.ui.settings import android.os.Build import android.os.Bundle import android.view.View -import androidx.preference.PreferenceFragmentCompat +import androidx.core.content.edit import androidx.preference.PreferenceManager +import androidx.preference.SeekBarPreference +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat +import com.lagradost.cloudstream3.ui.clear +import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter +import com.lagradost.cloudstream3.ui.home.ParentItemAdapter +import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchResultBuilder +import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.UIHelper.toPx -class SettingsUI : PreferenceFragmentCompat() { +class SettingsUI : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_ui) setPaddingBottom() + setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() - setPreferencesFromResource(R.xml.settins_ui, rootKey) + setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { pref, newValue -> + val padding = (newValue as? Int)?.toPx ?: return@setOnPreferenceChangeListener true + (pref.context.getActivity() as? MainActivity)?.binding?.homeRoot?.setPadding(padding, padding, padding, padding) + return@setOnPreferenceChangeListener true + } + + getPref(R.string.bottom_title_key)?.setOnPreferenceChangeListener { _, _ -> + HomeChildItemAdapter.sharedPool.clear() + ParentItemAdapter.sharedPool.clear() + SearchAdapter.sharedPool.clear() + true + } + + getPref(R.string.poster_size_key)?.setOnPreferenceChangeListener { _, newValue -> + HomeChildItemAdapter.sharedPool.clear() + ParentItemAdapter.sharedPool.clear() + SearchAdapter.sharedPool.clear() + context?.let { HomeChildItemAdapter.updatePosterSize(it, newValue as? Int) } + true + } + getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.poster_ui_options) val keys = resources.getStringArray(R.array.poster_ui_options_values) @@ -45,12 +80,13 @@ class SettingsUI : PreferenceFragmentCompat() { prefNames.toList(), prefValues, getString(R.string.poster_ui_settings), - {}) { list -> - val edit = settingsManager.edit() - for ((i, key) in keys.withIndex()) { - edit.putBoolean(key, list.contains(i)) + {} + ) { list -> + settingsManager.edit { + for ((i, key) in keys.withIndex()) { + putBoolean(key, list.contains(i)) + } } - edit.apply() SearchResultBuilder.updateCache(it.context) } @@ -65,31 +101,32 @@ class SettingsUI : PreferenceFragmentCompat() { settingsManager.getInt(getString(R.string.app_layout_key), -1) activity?.showBottomDialog( - prefNames.toList(), - prefValues.indexOf(currentLayout), - getString(R.string.app_layout), - true, - {}) { - try { - settingsManager.edit() - .putInt(getString(R.string.app_layout_key), prefValues[it]) - .apply() - context?.updateTv() - activity?.recreate() - } catch (e: Exception) { - logError(e) + items = prefNames.toList(), + selectedIndex = prefValues.indexOf(currentLayout), + name = getString(R.string.app_layout), + showApply = true, + dismissCallback = {}, + callback = { + try { + settingsManager.edit { + putInt(getString(R.string.app_layout_key), prefValues[it]) + } + context?.updateTv() + activity?.recreate() + } catch (e: Exception) { + logError(e) + } } - } + ) return@setOnPreferenceClickListener true } getPref(R.string.app_theme_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_names).toMutableList() val prefValues = resources.getStringArray(R.array.themes_names_values).toMutableList() - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + val removeIncompatible = { text: String -> val toRemove = prefValues - .mapIndexed { idx, s -> if (s.startsWith("Monet")) idx else null } + .mapIndexed { idx, s -> if (s.startsWith(text)) idx else null } .filterNotNull() var offset = 0 toRemove.forEach { idx -> @@ -98,6 +135,12 @@ class SettingsUI : PreferenceFragmentCompat() { offset += 1 } } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + removeIncompatible("Monet") + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { // Remove system on android 9 and less + removeIncompatible("System") + } val currentLayout = settingsManager.getString(getString(R.string.app_theme_key), prefValues.first()) @@ -107,11 +150,12 @@ class SettingsUI : PreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.app_theme_settings), true, - {}) { + {} + ) { try { - settingsManager.edit() - .putString(getString(R.string.app_theme_key), prefValues[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.app_theme_key), prefValues[it]) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -121,7 +165,8 @@ class SettingsUI : PreferenceFragmentCompat() { } getPref(R.string.primary_color_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_overlay_names).toMutableList() - val prefValues = resources.getStringArray(R.array.themes_overlay_names_values).toMutableList() + val prefValues = + resources.getStringArray(R.array.themes_overlay_names_values).toMutableList() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less val toRemove = prefValues @@ -143,11 +188,12 @@ class SettingsUI : PreferenceFragmentCompat() { prefValues.indexOf(currentLayout), getString(R.string.primary_color_settings), true, - {}) { + {} + ) { try { - settingsManager.edit() - .putString(getString(R.string.primary_color_key), prefValues[it]) - .apply() + settingsManager.edit { + putString(getString(R.string.primary_color_key), prefValues[it]) + } activity?.recreate() } catch (e: Exception) { logError(e) @@ -169,15 +215,37 @@ class SettingsUI : PreferenceFragmentCompat() { names, currentList, getString(R.string.pref_filter_search_quality), - {}) { selectedList -> - settingsManager.edit().putStringSet( - this.getString(R.string.pref_filter_search_quality_key), - selectedList.map { it.toString() }.toMutableSet() - ).apply() + {} + ) { selectedList -> + settingsManager.edit { + putStringSet( + getString(R.string.pref_filter_search_quality_key), + selectedList.map { it.toString() }.toMutableSet() + ) + } } return@setOnPreferenceClickListener true } + getPref(R.string.confirm_exit_key)?.setOnPreferenceClickListener { + val prefNames = resources.getStringArray(R.array.confirm_exit) + val prefValues = resources.getIntArray(R.array.confirm_exit_values) + val confirmExit = settingsManager.getInt(getString(R.string.confirm_exit_key), -1) + + activity?.showBottomDialog( + items = prefNames.toList(), + selectedIndex = prefValues.indexOf(confirmExit), + name = getString(R.string.confirm_before_exiting_title), + showApply = true, + dismissCallback = {}, + callback = { selectedOption -> + settingsManager.edit { + putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) + } + } + ) + return@setOnPreferenceClickListener true + } } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index f9ac3fee9..c04215594 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -1,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.show_logcat_key)?.setOnPreferenceClickListener { pref -> - val builder = - AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) - .setView(R.layout.logcat) + getPref(R.string.backup_path_key)?.hideOn(EMULATOR)?.setOnPreferenceClickListener { + val dirs = getBackupDirsForDisplay() + val currentDir = + settingsManager.getString(getString(R.string.backup_dir_key), null) + ?: context?.let { ctx -> BackupUtils.getDefaultBackupDir(ctx)?.filePath() } - val dialog = builder.create() - dialog.show() - val log = StringBuilder() - try { - //https://developer.android.com/studio/command-line/logcat - val process = Runtime.getRuntime().exec("logcat -d") - val bufferedReader = BufferedReader( - InputStreamReader(process.inputStream) - ) - - var line: String? - while (bufferedReader.readLine().also { line = it } != null) { - log.append("${line}\n") + activity?.showBottomDialog( + dirs + listOf(getString(R.string.custom)), + dirs.indexOf(currentDir), + getString(R.string.backup_path_title), + true, + {} + ) { + // Last = custom + if (it == dirs.size) { + try { + pathPicker.launch(Uri.EMPTY) + } catch (e: Exception) { + logError(e) + } + } else { + // Sets both visual and actual paths. + // path = used uri + // dir = dir path + settingsManager.edit { + putString(getString(R.string.backup_path_key), dirs[it]) + putString(getString(R.string.backup_dir_key), dirs[it]) + } } - } catch (e: Exception) { - logError(e) // kinda ironic - } - - val text = log.toString() - dialog.text1?.text = text - - 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) - } - } - dialog.clear_btt?.setOnClickListener { - Runtime.getRuntime().exec("logcat -c") - dialog.dismissSafe(activity) - } - dialog.save_btt?.setOnClickListener { - 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() - dialog.dismissSafe(activity) - } - } - dialog.close_btt?.setOnClickListener { - dialog.dismissSafe(activity) } return@setOnPreferenceClickListener true } - getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context) + getPref(R.string.show_logcat_key)?.setOnPreferenceClickListener { pref -> + val builder = AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) + val binding = LogcatBinding.inflate(layoutInflater, null, false) + builder.setView(binding.root) + + val dialog = builder.create() + dialog.show() + + val logList = mutableListOf() + try { + // https://developer.android.com/studio/command-line/logcat + val process = Runtime.getRuntime().exec("logcat -d") + val bufferedReader = BufferedReader(InputStreamReader(process.inputStream)) + bufferedReader.lineSequence().forEach { logList.add(it) } + } catch (e: Exception) { + logError(e) // kinda ironic + } + + val adapter = LogcatAdapter().apply { submitList(logList) } + binding.logcatRecyclerView.layoutManager = LinearLayoutManager(pref.context) + binding.logcatRecyclerView.adapter = adapter + + binding.copyBtt.setOnClickListener { + clipboardHelper(txt("Logcat"), logList.joinToString("\n")) + dialog.dismissSafe(activity) + } + + binding.clearBtt.setOnClickListener { + Runtime.getRuntime().exec("logcat -c") + dialog.dismissSafe(activity) + } + + binding.saveBtt.setOnClickListener { + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) + var fileStream: OutputStream? = null + try { + fileStream = VideoDownloadManager.setupStream( + it.context, + "logcat_${date}", + null, + "txt", + false + ).openNew() + fileStream.writer().use { writer -> writer.write(logList.joinToString("\n")) } + dialog.dismissSafe(activity) + } catch (t: Throwable) { + logError(t) + showToast(t.message) + } + } + + binding.closeBtt.setOnClickListener { + dialog.dismissSafe(activity) + } + + return@setOnPreferenceClickListener true + } + + getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) + // Use legacy installer as default until we make the new installer completely reliable val currentInstaller = - settingsManager.getInt(getString(R.string.apk_installer_key), 0) + settingsManager.getInt(getString(R.string.apk_installer_key), 1) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, - {}) { + {} + ) { num -> try { - settingsManager.edit() - .putInt(getString(R.string.apk_installer_key), prefValues[it]) - .apply() + settingsManager.edit { + putInt(getString(R.string.apk_installer_key), prefValues[num]) + } } catch (e: Exception) { logError(e) } @@ -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 7e60910d5..af0d3dfe7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt @@ -1,47 +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.AppUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe -import com.lagradost.cloudstream3.widget.LinearRecycleViewLayoutManager -import kotlinx.android.synthetic.main.add_repo_input.* -import kotlinx.android.synthetic.main.fragment_extensions.* +import com.lagradost.cloudstream3.utils.setText -class ExtensionsFragment : Fragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - return inflater.inflate(R.layout.fragment_extensions, container, false) - } +class ExtensionsFragment : BaseFragment( + BaseFragment.BindingCreator.Inflate(FragmentExtensionsBinding::inflate) +) { + + private val extensionViewModel: ExtensionsViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Int) { val param = LinearLayout.LayoutParams( @@ -52,8 +53,6 @@ class ExtensionsFragment : Fragment() { this.layoutParams = param } - private val extensionViewModel: ExtensionsViewModel by activityViewModels() - override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::reloadRepositories @@ -69,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 -> {} - } - } - 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().setDefaultFocus() + if (!isLayout(TV)) + binding.addRepoButton.let { button -> + button.post { + setPadding( + paddingLeft, + paddingTop, + paddingRight, + button.measuredHeight + button.marginTop + button.marginBottom + ) + } + } + + 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() + } + } + + 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() + } + }) + } observe(extensionViewModel.repositories) { - repo_recycler_view?.isVisible = it.isNotEmpty() - blank_repo_screen?.isVisible = it.isEmpty() - (repo_recycler_view?.adapter as? RepoAdapter)?.updateList(it) + binding.repoRecyclerView.isVisible = it.isNotEmpty() + binding.blankRepoScreen.isVisible = it.isEmpty() + (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) } - repo_recycler_view?.apply { - context?.let { ctx -> - layoutManager = LinearRecycleViewLayoutManager(ctx, nextFocusUpId, nextFocusDownId) + observeNullable(extensionViewModel.pluginStats) { value -> + binding.apply { + if (value == null) { + pluginStorageAppbar.isVisible = false + return@observeNullable + } + + 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) } } -// 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) - } - is Some.None -> { - plugin_storage_appbar?.isVisible = false - } - } - } - - plugin_storage_appbar?.setOnClickListener { + binding.pluginStorageAppbar.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( @@ -176,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) - val newRepo = RepositoryData(fixedName, url) + // Exit if wrong repository + if (repository == null) { + showToast(R.string.no_repository_found_error, Toast.LENGTH_LONG) + return@ioSafe + } + + 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 63ed5357f..482251b78 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt @@ -4,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 0c3d481b5..d0f9ff565 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt @@ -1,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() +) : 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) - 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) + return RepositoryViewHolderState( + RepositoryItemBinding.bind(inflated) // may crash ) } - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - when (holder) { - is PluginViewHolder -> { - holder.bind(plugins[position]) + override fun onClearView(holder: ViewHolderState) { + if (holder is RepositoryViewHolderState) { + holder.recycleCount += 1 + } + when (val binding = holder.view) { + is RepositoryItemBinding -> { + clearImage(binding.entryIcon) } } } - override fun getItemCount(): Int { - return plugins.size - } + @SuppressLint("SetTextI18n") + override fun onBindContent(holder: ViewHolderState, item: PluginViewData, position: Int) { + val binding = holder.view as? RepositoryItemBinding ?: return + val itemView = holder.itemView - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - PluginDiffCallback(this.plugins, newList) + val metadata = item.plugin.second + val disabled = metadata.status == PROVIDER_STATUS_DOWN + val name = metadata.name.removeSuffix("Provider") + val alpha = if (disabled) 0.6f else 1f + val isLocal = !item.plugin.second.url.startsWith("http") + binding.mainText.alpha = alpha + binding.subText.alpha = alpha + + val drawableInt = if (item.isDownloaded) + R.drawable.ic_baseline_delete_outline_24 + else R.drawable.netflix_download + + binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false + binding.actionButton.setImageResource(drawableInt) + + binding.actionButton.setOnClickListener { + iconClickCallback.invoke(item.plugin) + } + itemView.setOnClickListener { + if (isLocal) return@setOnClickListener + + val sheet = PluginDetailsFragment(item) + val activity = itemView.context.getActivity() as AppCompatActivity + sheet.show(activity.supportFragmentManager, "PluginDetails") + } + //if (itemView.context?.isTrueTvSettings() == false) { + // val siteUrl = metadata.repositoryUrl + // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { + // itemView.setOnClickListener { + // openBrowser(siteUrl) + // } + // } + //} + + if (item.isDownloaded) { + // On local plugins page the filepath is provided instead of url. + val plugin = + (PluginManager.urlPlugins[metadata.url] + ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin + + if (plugin?.openSettings != null) { + binding.actionSettings.isVisible = true + binding.actionSettings.setOnClickListener { + try { + plugin.openSettings?.invoke(itemView.context) + } catch (e: Throwable) { + Log.e( + "PluginAdapter", + "Failed to open $name settings: ${ + Log.getStackTraceString(e) + }" + ) + } + } + } else { + binding.actionSettings.isVisible = false + } + } else { + binding.actionSettings.isVisible = false + } + + val url = metadata.iconUrl?.replace( + "%size%", + "$iconSize" + )?.replace( + "%exact_size%", + "$iconSizeExact" ) - plugins.clear() - plugins.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - /* - private var storedPlugins: Array = reloadStoredPlugins() - - private fun reloadStoredPlugins(): Array { - return PluginManager.getPluginsOnline().also { storedPlugins = it } - }*/ - - // Clear glide image because setImageResource doesn't override - override fun onViewRecycled(holder: RecyclerView.ViewHolder) { - holder.itemView.entry_icon?.let { pluginIcon -> - GlideApp.with(pluginIcon).clear(pluginIcon) + if (url.isNullOrBlank()) { + binding.entryIcon.loadImage(R.drawable.ic_baseline_extension_24) + } else { + binding.entryIcon.loadImage( + url + ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } } - super.onViewRecycled(holder) + + binding.extVersion.isVisible = true + binding.extVersion.text = "v${metadata.version}" + + if (metadata.language.isNullOrBlank()) { + binding.langIcon.isVisible = false + } else { + binding.langIcon.isVisible = true + binding.langIcon.text = getNameNextToFlagEmoji(metadata.language) ?: metadata.language + } + + //val oldRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount + + binding.extVotes.isVisible = false + + // Disable this for now as the vote api is down, this will also significantly improve the lag + // from doing all these network requests + /*if (!isLocal) { + ioSafe { + metadata.getVotes().main { votes -> + val currentRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount + + // Only set the text if the view is correctly rendered + if (currentRecycleCount == oldRecycleCount) { + binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(votes))) + binding.extVotes.isVisible = true + } + } + } + }*/ + + if (metadata.fileSize != null) { + binding.extFilesize.isVisible = true + binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) + } else { + binding.extFilesize.isVisible = false + } + + binding.mainText.setText( + if (disabled) txt( + R.string.single_plugin_disabled, + name + ) else txt(name) + ) + + binding.subText.isGone = metadata.description.isNullOrBlank() + binding.subText.text = metadata.description.html() } companion object { + // A high count as we can render in the entire list as the same time + val sharedPool = + newSharedPool { setMaxRecycledViews(CONTENT, 15) } + private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max if (current >= target) return current return findClosestBase2(target, current * 2, max) } - @Test + // DO NOT MOVE, as running this test will result in ExceptionInInitializerError on prerelease due to static variables using Resources.getSystem() + // this test function is only to show how the function works + /*@Test fun testFindClosestBase2() { Assert.assertEquals(16, findClosestBase2(0)) Assert.assertEquals(256, findClosestBase2(170)) Assert.assertEquals(256, findClosestBase2(256)) Assert.assertEquals(512, findClosestBase2(257)) Assert.assertEquals(512, findClosestBase2(700)) - } + }*/ private val iconSizeExact = 32.toPx private val iconSize by lazy { @@ -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 9729b4dea..0dcbece6c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt @@ -1,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.plugins.VotingApi.getVotes -import com.lagradost.cloudstream3.plugins.VotingApi.vote -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe -import com.lagradost.cloudstream3.utils.Coroutines.main -import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute -import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser +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.utils.SubtitleHelper.fromTwoLettersToLanguage -import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso -import kotlinx.android.synthetic.main.repository_item.view.* +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.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)}" - - github_btn.setOnClickListener { - if (metadata.repositoryUrl != null) { - openBrowser(metadata.repositoryUrl) + 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 - if (!metadata.canVote()) { - downvote.alpha = .6f - upvote.alpha = .6f - } + githubBtn.setOnClickListener { + if (metadata.repositoryUrl != null) { + openBrowser(metadata.repositoryUrl) + } + } - 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 (!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]) 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 d328d2264..534ffa62a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -1,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) - - 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" - } - 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() + binding.settingsToolbar.apply { + setOnMenuItemClickListener { menuItem -> + when (menuItem?.itemId) { + R.id.download_all -> { + PluginsViewModel.downloadAll(activity, url, pluginViewModel) } + + 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() + } + } + + 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) + + binding.pluginRecyclerView.apply { + setLinearListLayout( + isHorizontal = false, + nextDown = FOCUS_SELF, + nextRight = FOCUS_SELF, + ) + setRecycledViewPool(PluginAdapter.sharedPool) + adapter = + PluginAdapter { + pluginViewModel.handlePluginAction(activity, url, it, isLocal) + } } - 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) - } - - 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 + binding.tvtypesChipsScroll.root.isVisible = true + // not needed for users but may be useful for devs + downloadAllButton?.isVisible = BuildConfig.DEBUG - bindChips(home_select_group, emptyList(), TvType.values().toList()) { list -> - pluginViewModel.tvTypes.clear() - pluginViewModel.tvTypes.addAll(list.map { it.name }) - pluginViewModel.updateFilteredPlugins() - } + 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 934f65bbd..0cbef9cf2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -8,22 +8,25 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.getPluginPath import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.SitePlugin -import com.lagradost.cloudstream3.ui.result.txt +import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread -import me.xdrop.fuzzywuzzy.FuzzySearch +import com.lagradost.cloudstream3.utils.Levenshtein import java.io.File +// String => repository url typealias Plugin = Pair /** * The boolean signifies if the plugin list should be scrolled to the top, used for searching. @@ -34,13 +37,28 @@ class PluginsViewModel : ViewModel() { /** plugins is an unaltered list of plugins */ private var plugins: List = emptyList() + set(value) { + // Also set all the plugin languages for easier filtering + value.map { pluginViewData -> + val language = pluginViewData.plugin.second.language?.lowercase() + pluginLanguages.add( + when { + language.isNullOrBlank() -> "none" + else -> language.lowercase() + } + ) + // not sorting as most likely this is a language tag instead of name + } + field = value + } + var pluginLanguages = mutableSetOf() // set to avoid duplicates /** filteredPlugins is a subset of plugins following the current search query and tv type selection */ private var _filteredPlugins = MutableLiveData() var filteredPlugins: LiveData = _filteredPlugins val tvTypes = mutableListOf() - var languages = listOf() + var selectedLanguages = listOf() private var currentQuery: String? = null companion object { @@ -86,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) @@ -106,6 +128,7 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, + metadata.fileHash, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN @@ -113,7 +136,6 @@ class PluginsViewModel : ViewModel() { }.main { list -> if (list.any { it }) { showToast( - activity, txt( R.string.batch_download_finish_format, list.count { it }, @@ -123,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) } } } @@ -158,7 +180,8 @@ class PluginsViewModel : ViewModel() { PluginManager.downloadPlugin( activity, metadata.url, - metadata.name, + metadata.fileHash, + metadata.internalName, repo, isEnabled ) to message @@ -166,9 +189,9 @@ class PluginsViewModel : ViewModel() { 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) @@ -179,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)) } @@ -195,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()) } } @@ -215,7 +245,12 @@ class PluginsViewModel : ViewModel() { // Return list to base state if no query this.sortedBy { it.plugin.second.name } } else { - this.sortedBy { -FuzzySearch.partialRatio(it.plugin.second.name.lowercase(), query.lowercase()) } + this.sortedBy { + -Levenshtein.partialRatio( + it.plugin.second.name.lowercase(), + query.lowercase() + ) + } } } @@ -225,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") @@ -255,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 e90166a89..0f9bf5f58 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt @@ -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() + NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> + a.url == b.url + })) { - 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) + 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) + } + } + + 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) + } + } + } + + 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) + } + } } } } - // 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 + companion object { + const val SHAREABLE_REPO_SEPARATOR = " : " } - - 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) - } - - itemView.action_button?.setOnClickListener { - imageClickCallback(repositoryData) - } - - 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] } \ 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 000000000..4ec005a09 --- /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 000000000..c53ff1fcf --- /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 000000000..65ed47a54 --- /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 000000000..22500d931 --- /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.atomicListOf +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 = atomicListOf>() + 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() { + providers.withLock { + val filtered = when (filter) { + ProviderFilter.All -> providers.toList() + 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) { + providers.withLock { + 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 = APIHolder.allProviders.withLock { APIHolder.allProviders.size } + updateProgress() + } + + fun startTest() { + scope = CoroutineScope(Dispatchers.Default) + + val apis = APIHolder.allProviders.withLock { 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 000000000..dfc931174 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.ui.settings.utils + +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.safefile.SafeFile + +fun Fragment.getChooseFolderLauncher(dirSelected: (uri: Uri?, path: String?) -> Unit) = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + // It lies, it can be null if file manager quits. + if(uri == null) { + dirSelected(null, null) + return@registerForActivityResult + } + val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult + // RW perms for the path + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, flags) + + val filePath = SafeFile.fromUri(context, uri)?.filePath() + println("Selected URI path: $uri - Full path: $filePath") + + // store the actual URI instead of the path due to permissions. + // filePath should only be used for cosmetic purposes. + dirSelected(uri, filePath) + } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt index b7d2fff6c..8c2e8e344 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt @@ -1,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() + binding.apply { + if (!isSetup) { + nextBtt.setText(R.string.setup_done) + } + prevBtt.isVisible = isSetup - if (!isSetup) { - next_btt.setText(R.string.setup_done) - } - prev_btt?.isVisible = isSetup + nextBtt.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) + } - 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) + 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 80db59eea..e96a662c3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt @@ -1,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 fixLayout(view: View) { + fixSystemBarsPadding(view) } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - context?.fixPaddingStatusbar(setup_root) - + 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 50fb37d60..4a8e784a1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt @@ -1,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, true) - 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 ) + listview1.setOnItemClickListener { _, _, position, _ -> + settingsManager.edit { + putInt(getString(R.string.app_layout_key), prefValues[position]) + } + activity?.recreate() + } - next_btt?.setOnClickListener { - findNavController().navigate(R.id.navigation_home) - } + nextBtt.setOnClickListener { + setKey(HAS_DONE_SETUP_KEY, true) + findNavController().navigate(R.id.navigation_home) + } - 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/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index 257ce5c1f..8da121daa 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -1,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) + } + + // Regenerate set homepage + DataStoreHelper.currentHomePage = null } - 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) } - } - 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 51abee905..c18be8a2f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt @@ -1,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) + ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) - val current = this.getApiProviderLangSettings() - val langs = APIHolder.apis.map { it.lang }.toSet() - .sortedBy { SubtitleHelper.fromTwoLettersToLanguage(it) } + AllLanguagesName + val currentLangTags = ctx.getApiProviderLangSettings() - val currentList = - current.map { langs.indexOf(it) }.filter { it != -1 } // TODO LOOK INTO + val languagesTagName = APIHolder.apis.withLock { + listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } + .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji + } - val languageNames = langs.map { - if (it == AllLanguagesName) { - getString(R.string.all_languages_preference) - } else { - val emoji = SubtitleHelper.getFlagFromIso(it) - val name = SubtitleHelper.fromTwoLettersToLanguage(it) - "$emoji $name" + val currentIndexList = currentLangTags.map { langTag -> + languagesTagName.indexOfFirst { lang -> lang.first == langTag } + }.filter { it > -1 } + + arrayAdapter.addAll(languagesTagName.map { it.second }) + binding.apply { + listview1.adapter = arrayAdapter + listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE + currentIndexList.forEach { + listview1.setItemChecked(it, true) } - } - arrayAdapter.addAll(languageNames) - - listview1?.adapter = arrayAdapter - listview1?.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE - currentList.forEach { - listview1.setItemChecked(it, true) - } - - listview1?.setOnItemClickListener { _, _, _, _ -> - val currentLanguages = mutableListOf() - listview1?.checkedItemPositions?.forEach { key, value -> - if (value) currentLanguages.add(langs[key]) + 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 83d134cb1..f9b1cb1fe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt @@ -7,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 ff0e0e828..5f716cca3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -6,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 Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { + fun setSubtitleViewStyle( + view: SubtitleView?, + data: SaveCaptionStyle, + applyElevation: Boolean + ) { + if (view == null) return + val ctx = view.context ?: return + val style = ctx.fromSaveToStyle(data) + view.setStyle(style) + + if (applyElevation) { + view.setPadding( + view.paddingLeft, data.elevation.toPx, view.paddingRight, view.paddingBottom + ) + } + + // we default to 25sp, this is needed as RoundedBackgroundColorSpan breaks on override sizes + val size = data.fixedTextSize ?: 25.0f + view.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) + view.setBottomPaddingFraction(0.0f) + /*if (size != null) { + view.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) + } else { + view.setUserDefaultTextSize() + }*/ + } + + fun Cue.Builder.applyStyle(style: SaveCaptionStyle): Cue.Builder { + val edgeSize = style.edgeSize + + /* + This is old code for only applying on non null + + val fixedFontSize = style.fixedTextSize + val absoluteFontSize = + fixedFontSize?.let { getPixels(TypedValue.COMPLEX_UNIT_SP, it).toFloat() } + + // 1. apply override size + if (absoluteFontSize != null) { + setTextSize(absoluteFontSize, Cue.TEXT_SIZE_TYPE_ABSOLUTE) + }*/ + + // 1. remove any subtitle size set by the subtitle file (like ass) + // instead we use the inherit size of the subtitle view + setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET) + + // 2. apply edge + text?.let { text -> + val customSpan = SpannableString.valueOf(text) + if (edgeSize != null) { + customSpan.setSpan( + OutlineSpan(edgeSize), 0, customSpan.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + setText(customSpan) + } + + // 3. apply bold + italic + text?.let { text -> + val customSpan = SpannableString.valueOf(text) + + val typeface = when (style.bold to style.italic) { + (true to true) -> Typeface.BOLD_ITALIC + (true to false) -> Typeface.BOLD + (false to true) -> Typeface.ITALIC + (false to false) -> Typeface.NORMAL + else -> { + Typeface.NORMAL + } + } + if (typeface != Typeface.NORMAL) { + val styleSpan = StyleSpan(typeface) + customSpan.setSpan( + styleSpan, 0, customSpan.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + setText(customSpan) + } + + // 4. apply radius + text?.let { text -> + val customSpan = SpannableString.valueOf(text) + val radius = style.backgroundRadius + + if (radius != null && style.backgroundColor != Color.TRANSPARENT) { + val styleSpan = RoundedBackgroundColorSpan( + style.backgroundColor, + this.textAlignment ?: Layout.Alignment.ALIGN_CENTER, + 2.0F + radius * 0.5f, + radius + ) + customSpan.setSpan( + styleSpan, 0, customSpan.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + setText(customSpan) + } + + // 5. remove captions + text?.let { text -> + if (style.removeCaptions) { + setText(text.replace(captionRegex, "")) + } + } + + // 6. set alignment + return this.setSubtitleAlignment(style.alignment) + } + + private fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { return CaptionStyleCompat( data.foregroundColor, - data.backgroundColor, + // we actually override with a custom span when backgroundRadius != null + if (data.backgroundRadius == null) data.backgroundColor else Color.TRANSPARENT, data.windowColor, data.edgeType, data.edgeColor, @@ -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() - } - - 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() + val dismissCallback = { if (hide) activity?.hideSystemUI() } - } - 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 - } - - 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) - ), - ) - - //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() - } - } - - 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 - } - - 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"), - ) - - //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 - } - } - - 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() - } - - subtitles_remove_captions?.isChecked = state.removeCaptions - subtitles_remove_captions?.setOnCheckedChangeListener { _, b -> - state.removeCaptions = b - } - - 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 - } - - //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) - } - - subtitles_filter_sub_lang?.setOnCheckedChangeListener { _, b -> - context?.let { ctx -> - PreferenceManager.getDefaultSharedPreferences(ctx) - .edit() - .putBoolean(getString(R.string.filter_sub_lang_key), b) - .apply() - } - } - - 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 - } - - //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 + 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" } + + //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 + } + + subsBackgroundRadius.setFocusableInTv() + subsBackgroundRadius.setOnClickListener { textView -> + // tbh this should not be a dialog if it has so many values + val radiusTypes = listOf( + null to textView.context.getString(R.string.none) + ) + (1..10).map { x -> + val i = x * 5 + i to "${i}px" + } + + activity?.showDialog( + radiusTypes.map { it.second }, + radiusTypes.map { it.first }.indexOf(state.backgroundRadius?.toInt()), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.backgroundRadius = radiusTypes.map { it.first }[index]?.toFloat() + textView.context.updateState() + } + } + + subsBackgroundRadius.setOnLongClickListener { + state.backgroundRadius = null + it.context.updateState() + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + subsSubtitleAlignment.setFocusableInTv() + subsSubtitleAlignment.setOnClickListener { textView -> + val alignmentTypes = listOf( + null to R.string.automatic, + CustomDecoder.SSA_ALIGNMENT_BOTTOM_LEFT to R.string.bottom_left, + CustomDecoder.SSA_ALIGNMENT_BOTTOM_CENTER to R.string.bottom_center, + CustomDecoder.SSA_ALIGNMENT_BOTTOM_RIGHT to R.string.bottom_right, + CustomDecoder.SSA_ALIGNMENT_MIDDLE_LEFT to R.string.middle_left, + CustomDecoder.SSA_ALIGNMENT_MIDDLE_CENTER to R.string.middle_center, + CustomDecoder.SSA_ALIGNMENT_MIDDLE_RIGHT to R.string.middle_right, + CustomDecoder.SSA_ALIGNMENT_TOP_LEFT to R.string.top_left, + CustomDecoder.SSA_ALIGNMENT_TOP_CENTER to R.string.top_center, + CustomDecoder.SSA_ALIGNMENT_TOP_RIGHT to R.string.top_right, + ) + + activity?.showDialog( + alignmentTypes.map { textView.context.getString(it.second) }, + alignmentTypes.map { it.first }.indexOf(state.alignment), + (textView as TextView).text.toString(), + false, + dismissCallback + ) { index -> + state.alignment = alignmentTypes.map { it.first }[index] + textView.context.updateState() + } + } + + subsEdgeType.setFocusableInTv() + subsEdgeType.setOnClickListener { textView -> + val edgeTypes = listOf( + CaptionStyleCompat.EDGE_TYPE_NONE to + textView.context.getString(R.string.subtitles_none), + CaptionStyleCompat.EDGE_TYPE_OUTLINE to + textView.context.getString(R.string.subtitles_outline), + CaptionStyleCompat.EDGE_TYPE_DEPRESSED to + textView.context.getString(R.string.subtitles_depressed), + CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW to + textView.context.getString(R.string.subtitles_shadow), + CaptionStyleCompat.EDGE_TYPE_RAISED to + textView.context.getString(R.string.subtitles_raised), + ) + + //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() + } + } + + subsEdgeType.setOnLongClickListener { + state.edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE + it.context.updateState() + showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + 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() + } + } + + 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() + } + } + + subtitlesRemoveBloat.isChecked = state.removeBloat + subtitlesRemoveBloat.setOnCheckedChangeListener { _, b -> + state.removeBloat = b + } + subtitlesUppercase.isChecked = state.upperCase + subtitlesUppercase.setOnCheckedChangeListener { _, b -> + state.upperCase = b + context?.updateState() + } + + 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() + } + + 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 -> + subtitlesFilterSubLang.isChecked = + PreferenceManager.getDefaultSharedPreferences(ctx) + .getBoolean(getString(R.string.filter_sub_lang_key), false) + } + + subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> + context?.let { ctx -> + PreferenceManager.getDefaultSharedPreferences(ctx).edit { + putBoolean(getString(R.string.filter_sub_lang_key), b) + } + } + } + + 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() + } + } + + 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_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 - } + 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 - 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 (langTagsIETF, langNames) = languagesTagName.unzip() - 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]) + 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 - } - - 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()) + 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.setOnLongClickListener { - setKey(SUBTITLE_DOWNLOAD_KEY, listOf("en")) + 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 - showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) - return@setOnLongClickListener true - } + val (langTagsIETF, langNames) = languagesTagName.unzip() - cancel_btt.setOnClickListener { - activity?.popCurrentPage() - } + val selectedLanguages = getDownloadSubsLanguageTagIETF() + .map { langTagsIETF.indexOf(it) } + .filter { it >= 0 } - apply_btt.setOnClickListener { - it.context.saveStyle(state) - applyStyleEvent.invoke(state) - it.context.fromSaveToStyle(state) - activity?.popCurrentPage() + activity?.showMultiDialog( + langNames, + selectedLanguages, + (textView as TextView).text.toString(), + dismissCallback + ) { indexList -> + setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { langTagsIETF[it] }.toList()) + } + } + + subsDownloadLanguages.setOnLongClickListener { + setKey(SUBTITLE_DOWNLOAD_KEY, listOf("en")) + + showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) + return@setOnLongClickListener true + } + + cancelBtt.setOnClickListener { + if (popFragment) { + activity?.popCurrentPage() + } else { + dismiss() + } + } + + 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 e9b69c5b0..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.util.Log -import androidx.annotation.StringRes -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.lagradost.cloudstream3.* -import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId -import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.result.ResultEpisode -import com.lagradost.cloudstream3.ui.result.txt -import java.lang.Long.min - -object EpisodeSkip { - private const val TAG = "EpisodeSkip" - - enum class SkipType(@StringRes name: Int) { - Opening(R.string.skip_type_op), - Ending(R.string.skip_type_ed), - Recap(R.string.skip_type_recap), - MixedOpening(R.string.skip_type_mixed_op), - MixedEnding(R.string.skip_type_mixed_ed), - Credits(R.string.skip_type_creddits), - Intro(R.string.skip_type_creddits), - } - - data class SkipStamp( - val type: SkipType, - val skipToNextEpisode: Boolean, - val startMs: Long, - val endMs: Long, - ) { - val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt( - R.string.skip_type_format, - txt(type.name) - ) - } - - private val cachedStamps = HashMap>() - - private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean { - return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh - } - - suspend fun getStamps( - data: LoadResponse, - episode: ResultEpisode, - episodeDurationMs: Long, - hasNextEpisode: Boolean, - ): List { - cachedStamps[episode.id]?.let { list -> - return list - } - - val out = mutableListOf() - Log.i(TAG, "Requesting SkipStamp from ${data.syncData}") - - if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) { - data.getMalId()?.toIntOrNull()?.let { malId -> - val (resultLength, stamps) = AniSkip.getResult( - malId, - episode.episode, - episodeDurationMs - ) ?: return@let null - // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work - val dur = min(episodeDurationMs, resultLength) - stamps.mapNotNull { stamp -> - val skipType = when (stamp.skipType) { - "op" -> SkipType.Opening - "ed" -> SkipType.Ending - "recap" -> SkipType.Recap - "mixed-ed" -> SkipType.MixedEnding - "mixed-op" -> SkipType.MixedOpening - else -> null - } ?: return@mapNotNull null - val end = (stamp.interval.endTime * 1000.0).toLong() - val start = (stamp.interval.startTime * 1000.0).toLong() - SkipStamp( - type = skipType, - skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode( - end, - dur - ), - startMs = start, - endMs = end - ) - }?.let { list -> - out.addAll(list) - } - } - } - if (out.isNotEmpty()) - cachedStamps[episode.id] = out - return out - } -} - -// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt -// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md -object AniSkip { - private const val TAG = "AniSkip" - suspend fun getResult( - malId: Int, - episodeNumber: Int, - episodeLength: Long - ): Pair>? { - return try { - val url = - "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}" - Log.i(TAG, "Requesting $url") - - val a = app.get(url) - val res = a.parsed() - Log.i(TAG, "Found ${res.found} with ${res.results?.size} results") - if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null - } catch (t: Throwable) { - Log.i(TAG, "error = ${t.message}") - logError(t) - null - } - } - - data class AniSkipResponse( - @JsonSerialize val found: Boolean, - @JsonSerialize val results: List?, - @JsonSerialize val message: String?, - @JsonSerialize val statusCode: Int - ) - - data class Stamp( - @JsonSerialize val interval: AniSkipInterval, - @JsonSerialize val skipType: String, - @JsonSerialize val skipId: String, - @JsonSerialize val episodeLength: Double - ) - - data class AniSkipInterval( - @JsonSerialize val startTime: Double, - @JsonSerialize val endTime: Double - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt similarity index 58% 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 00dee9b22..7278fcdd7 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,48 +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.* -import android.provider.MediaStore +import android.os.Build +import android.os.Handler +import android.os.Looper import android.text.Spanned import android.util.Log +import android.view.View +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.recyclerview.widget.DiffUtil +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 androidx.viewpager2.widget.ViewPager2 -import com.fasterxml.jackson.module.kotlin.readValue import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.wrappers.Wrappers import com.google.android.material.bottomsheet.BottomSheetDialog -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.APIHolder.apis +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.RepositoryManager -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING +import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.WebviewFragment +import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultFragment -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings -import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel.Companion.downloadAll +import com.lagradost.cloudstream3.ui.settings.Globals +import com.lagradost.cloudstream3.ui.settings.extensions.PluginsFragment import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main @@ -61,20 +85,18 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache -import java.io.* +import java.io.File import java.net.URL import java.net.URLDecoder -import kotlin.system.measureTimeMillis +import java.util.concurrent.Executor +import java.util.concurrent.Executors -object AppUtils { - fun RecyclerView.setMaxViewPoolSize(maxViewTypeId: Int, maxPoolSize: Int) { - for (i in 0..maxViewTypeId) - recycledViewPool.setMaxRecycledViews(i, maxPoolSize) - } +object AppContextUtils { fun RecyclerView.isRecyclerScrollable(): Boolean { val layoutManager = this.layoutManager as? LinearLayoutManager? @@ -82,6 +104,9 @@ 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() } @@ -91,15 +116,15 @@ object AppUtils { this?.window?.setWindowAnimations(-1) this?.show() Handler(Looper.getMainLooper()).postDelayed({ - this?.window?.setWindowAnimations(R.style.Animation_Design_BottomSheetDialog) + this?.window?.setWindowAnimations(com.google.android.material.R.style.Animation_Design_BottomSheetDialog) }, 200) } //fun Context.deleteFavorite(data: SearchResponse) { // if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - // normalSafeApiCall { + // safe { // val existingId = - // getWatchNextProgramByVideoId(data.url, this).second ?: return@normalSafeApiCall + // getWatchNextProgramByVideoId(data.url, this).second ?: return@safe // contentResolver.delete( // // TvContractCompat.buildWatchNextProgramUri(existingId), @@ -122,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) { @@ -145,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() @@ -179,6 +204,40 @@ object AppUtils { 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 @@ -253,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 @@ -304,104 +364,207 @@ object AppUtils { } } - @SuppressLint("Range") - fun getVideoContentUri(context: Context, videoFilePath: String): Uri? { - val cursor = context.contentResolver.query( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, arrayOf(MediaStore.Video.Media._ID), - MediaStore.Video.Media.DATA + "=? ", arrayOf(videoFilePath), null + fun sortSubs(subs: Set): List { + return subs.sortedBy { it.name } + } + + fun Context.getApiSettings(): HashSet { + val hashSet = HashSet() + val activeLangs = getApiProviderLangSettings() + val hasUniversal = activeLangs.contains(AllLanguagesName) + hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) }.map { it.name }) + return hashSet + } + + fun Context.getApiDubstatusSettings(): HashSet { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = HashSet() + hashSet.addAll(DubStatus.values()) + val list = settingsManager.getStringSet( + this.getString(R.string.display_sub_key), + hashSet.map { it.name }.toMutableSet() + ) ?: return hashSet + + val names = DubStatus.values().map { it.name }.toHashSet() + //if(realSet.isEmpty()) return hashSet + + return list.filter { names.contains(it) }.map { DubStatus.valueOf(it) }.toHashSet() + } + + fun Context.getApiProviderLangSettings(): HashSet { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = hashSetOf(AllLanguagesName) // def is all languages +// hashSet.add("en") // def is only en + val list = settingsManager.getStringSet( + this.getString(R.string.provider_lang_key), + hashSet ) - return if (cursor != null && cursor.moveToFirst()) { - val id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID)) - cursor.close() - Uri.withAppendedPath(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, "" + id) + + if (list.isNullOrEmpty()) return hashSet + return list.toHashSet() + } + + fun Context.getApiTypeSettings(): HashSet { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val hashSet = HashSet() + hashSet.addAll(TvType.values()) + val list = settingsManager.getStringSet( + this.getString(R.string.search_types_list_key), + hashSet.map { it.name }.toMutableSet() + ) + + if (list.isNullOrEmpty()) return hashSet + + val names = TvType.values().map { it.name }.toHashSet() + val realSet = list.filter { names.contains(it) }.map { TvType.valueOf(it) }.toHashSet() + if (realSet.isEmpty()) return hashSet + + return realSet + } + + fun Context.updateHasTrailers() { + LoadResponse.isTrailersEnabled = getHasTrailers() + } + + private fun Context.getHasTrailers(): Boolean { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true) + } + + fun Context.shouldShowPlayerMetadata(): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + return prefs.getBoolean( + getString(R.string.show_player_metadata_key), + true + ) + } + + fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List { + // We are getting the weirdest crash ever done: + // java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType + // Trying fixing using classloader fuckery + val oldLoader = Thread.currentThread().contextClassLoader + Thread.currentThread().contextClassLoader = TvType::class.java.classLoader + + val default = TvType.values() + .sorted() + .filter { it != TvType.NSFW } + .map { it.ordinal } + + Thread.currentThread().contextClassLoader = oldLoader + + val defaultSet = default.map { it.toString() }.toSet() + val currentPrefMedia = try { + PreferenceManager.getDefaultSharedPreferences(this) + .getStringSet(this.getString(R.string.prefer_media_type_key), defaultSet) + ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } + } catch (e: Throwable) { + null + } ?: default + val langs = this.getApiProviderLangSettings() + val hasUniversal = langs.contains(AllLanguagesName) + val allApis = apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } + return if (currentPrefMedia.isEmpty()) { + allApis } else { - val values = ContentValues() - values.put(MediaStore.Video.Media.DATA, videoFilePath) - context.contentResolver.insert( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values - ) + // Filter API depending on preferred media type + allApis.filter { api -> api.supportedTypes.any { currentPrefMedia.contains(it.ordinal) } } } } + fun Context.filterSearchResultByFilmQuality(data: List): List { + // Filter results omitting entries with certain quality + if (data.isNotEmpty()) { + val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) + ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) + ?.mapNotNull { entry -> + entry.toIntOrNull() ?: return@mapNotNull null + } ?: listOf() + if (filteredSearchQuality.isNotEmpty()) { + return data.filter { item -> + val searchQualVal = item.quality?.ordinal ?: -1 + //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") + !filteredSearchQuality.contains(searchQualVal) + } + } + } + return data + } + + fun Context.filterHomePageListByFilmQuality(data: HomePageList): HomePageList { + // Filter results omitting entries with certain quality + if (data.list.isNotEmpty()) { + val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) + ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) + ?.mapNotNull { entry -> + entry.toIntOrNull() ?: return@mapNotNull null + } ?: listOf() + if (filteredSearchQuality.isNotEmpty()) { + return HomePageList( + name = data.name, + isHorizontalImages = data.isHorizontalImages, + list = data.list.filter { item -> + val searchQualVal = item.quality?.ordinal ?: -1 + //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") + !filteredSearchQuality.contains(searchQualVal) + } + ) + } + } + return data + } + fun Activity.loadRepository(url: String) { ioSafe { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe RepositoryManager.addRepository( RepositoryData( + repo.iconUrl ?: "", repo.name, url ) ) 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) } } - abstract class DiffAdapter( - open val items: MutableList, - val comparison: (first: T, second: T) -> Boolean = { first, second -> - first.hashCode() == second.hashCode() - } - ) : - RecyclerView.Adapter() { - override fun getItemCount(): Int { - return items.size - } + fun Activity.addRepositoryDialog( + repositoryName: String, + repositoryURL: String, + ) { + val repos = RepositoryManager.getRepositories() - fun updateList(newList: List) { - val diffResult = DiffUtil.calculateDiff( - GenericDiffCallback(this.items, newList) - ) - - items.clear() - items.addAll(newList) - - diffResult.dispatchUpdatesTo(this) - } - - inner class GenericDiffCallback( - private val oldList: List, - private val newList: List - ) : - DiffUtil.Callback() { - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) = - comparison(oldList[oldItemPosition], newList[newItemPosition]) - - override fun getOldListSize() = oldList.size - - override fun getNewListSize() = newList.size - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) = - oldList[oldItemPosition] == newList[newItemPosition] - } - } - - - fun Activity.downloadAllPluginsDialog(repositoryUrl: String, repositoryName: String) { - runOnUiThread { - val context = this - val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setTitle( - repositoryName - ) - builder.setMessage( - R.string.download_all_plugins_from_repo - ) - builder.apply { - setPositiveButton(R.string.download) { _, _ -> - downloadAll(context, repositoryUrl, null) - } - - setNegativeButton(R.string.no) { _, _ -> } + // navigate to newly added repository on pressing Open Repository + fun openAddedRepo() { + if (repos.isNotEmpty()) { + navigate( + R.id.global_to_navigation_settings_plugins, + PluginsFragment.newInstance( + repositoryName, + repositoryURL, + false, + ) + ) + } + } + + runOnUiThread { + AlertDialog.Builder(this).apply { + setTitle(repositoryName) + setMessage(R.string.download_all_plugins_from_repo) + setPositiveButton(R.string.open_downloaded_repo) { _, _ -> + openAddedRepo() + } + setNegativeButton(R.string.dismiss, null) + show().setDefaultFocus() } - builder.show().setDefaultFocus() } } @@ -411,7 +574,7 @@ object AppUtils { fun openWebView(fragment: Fragment?, url: String) { if (fragment?.context?.hasWebView() == true) - normalSafeApiCall { + safe { fragment .findNavController() .navigate(R.id.navigation_webview, WebviewFragment.newInstance(url)) @@ -425,10 +588,10 @@ object AppUtils { url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null, - ) { + ) = (this.getActivity() ?: activity)?.runOnUiThread { try { val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(url) + intent.data = url.toUri() intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) // activityResultRegistry is used to fall back to webview if a browser is missing @@ -444,10 +607,7 @@ object AppUtils { openWebView(fragment, url) } }.launch(intent) - } else { - ContextCompat.startActivity(this, intent, null) - } - + } else this.startActivity(intent) } catch (e: Exception) { logError(e) if (fallbackWebview) { @@ -456,6 +616,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 @@ -468,24 +642,6 @@ object AppUtils { return queryPairs } - /** Any object as json string */ - fun Any.toJson(): String { - if (this is String) return this - return mapper.writeValueAsString(this) - } - - inline fun parseJson(value: String): T { - return mapper.readValue(value) - } - - inline fun tryParseJson(value: String?): T? { - return try { - parseJson(value ?: return null) - } catch (_: Exception) { - null - } - } - /**| S1:E2 Hello World * | Episode 2. Hello world * | Hello World @@ -519,6 +675,18 @@ object AppUtils { return "" } + fun Context.getShortSeasonText(episode: Int?, season: Int?): String? { + val rEpisode = if (episode == 0) null else episode + val rSeason = if (season == 0) null else season + val seasonNameShort = getString(R.string.season_short) + val episodeNameShort = getString(R.string.episode_short) + return if (rEpisode != null && rSeason != null) { + "$seasonNameShort${rSeason}:$episodeNameShort${rEpisode}" + } else if (rEpisode != null) { + "$episodeNameShort$rEpisode" + }else null + } + fun Activity?.loadCache() { try { cacheClass("android.net.NetworkCapabilities".load()) @@ -529,28 +697,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 FragmentActivity.loadResult( + 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, @@ -567,12 +762,18 @@ object AppUtils { } fun Activity.requestLocalAudioFocus(focusRequest: AudioFocusRequest?) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (focusRequest == null) { + Log.e("TAG", "focusRequest was null") + return + } + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.requestAudioFocus(focusRequest) } else { val audioManager: AudioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + @Suppress("DEPRECATION") audioManager.requestAudioFocus( null, AudioManager.STREAM_MUSIC, @@ -604,20 +805,26 @@ object AppUtils { val isCastApiAvailable = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(applicationContext) == ConnectionResult.SUCCESS + try { - applicationContext?.let { CastContext.getSharedInstance(it) } + applicationContext?.let { + val task = CastContext.getSharedInstance(it) { it.run() } + task.result + } } catch (e: Exception) { println(e) - // track non-fatal + // Track non-fatal return false } + return isCastApiAvailable } fun Context.isConnectedToChromecast(): Boolean { if (isCastApiAvailable()) { - val castContext = CastContext.getSharedInstance(this) - if (castContext.castState == CastState.CONNECTED) { + val executor: Executor = Executors.newSingleThreadExecutor() + val castContext = CastContext.getSharedInstance(this, executor) + if (castContext.result.castState == CastState.CONNECTED) { return true } } @@ -628,115 +835,26 @@ object AppUtils { * Sets the focus to the negative button when in TV and Emulator layout. **/ fun AlertDialog.setDefaultFocus(buttonFocus: Int = DialogInterface.BUTTON_NEGATIVE) { - if (!isTvSettings()) return + if (!Globals.isLayout(Globals.TV or Globals.EMULATOR)) return this.getButton(buttonFocus).run { isFocusableInTouchMode = true requestFocus() } } - // Copied from https://github.com/videolan/vlc-android/blob/master/application/vlc-android/src/org/videolan/vlc/util/FileUtils.kt - @SuppressLint("Range") - fun Context.getUri(data: Uri?): Uri? { - var uri = data - val ctx = this - if (data != null && data.scheme == "content") { - // Mail-based apps - download the stream to a temporary file and play it - if ("com.fsck.k9.attachmentprovider" == data.host || "gmail-ls" == data.host) { - var inputStream: InputStream? = null - var os: OutputStream? = null - var cursor: Cursor? = null - try { - cursor = ctx.contentResolver.query( - data, - arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), null, null, null - ) - if (cursor != null && cursor.moveToFirst()) { - val filename = - cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)) - .replace("/", "") - inputStream = ctx.contentResolver.openInputStream(data) - if (inputStream == null) return data - os = - FileOutputStream(Environment.getExternalStorageDirectory().path + "/Download/" + filename) - val buffer = ByteArray(1024) - var bytesRead = inputStream.read(buffer) - while (bytesRead >= 0) { - os.write(buffer, 0, bytesRead) - bytesRead = inputStream.read(buffer) - } - uri = - Uri.fromFile(File(Environment.getExternalStorageDirectory().path + "/Download/" + filename)) - } - } catch (e: Exception) { - return null - } finally { - inputStream?.close() - os?.close() - cursor?.close() - } - } else if (data.authority == "media") { - uri = this.contentResolver.query( - data, - arrayOf(MediaStore.Video.Media.DATA), null, null, null - )?.use { - val columnIndex = it.getColumnIndexOrThrow(MediaStore.Video.Media.DATA) - if (it.moveToFirst()) Uri.fromFile(File(it.getString(columnIndex))) - ?: data else data - } - //uri = MediaUtils.getContentMediaUri(data) - /*} else if (data.authority == ctx.getString(R.string.tv_provider_authority)) { - println("TV AUTHORITY") - //val medialibrary = Medialibrary.getInstance() - //val media = medialibrary.getMedia(data.lastPathSegment!!.toLong()) - uri = null//media.uri*/ - } else { - val inputPFD: ParcelFileDescriptor? - try { - inputPFD = ctx.contentResolver.openFileDescriptor(data, "r") - if (inputPFD == null) return data - uri = Uri.parse("fd://" + inputPFD.fd) - // Cursor returnCursor = - // getContentResolver().query(data, null, null, null, null); - // if (returnCursor != null) { - // if (returnCursor.getCount() > 0) { - // int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); - // if (nameIndex > -1) { - // returnCursor.moveToFirst(); - // title = returnCursor.getString(nameIndex); - // } - // } - // returnCursor.close(); - // } - } catch (e: FileNotFoundException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: IllegalArgumentException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: IllegalStateException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: NullPointerException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } catch (e: SecurityException) { - Log.e("TAG", "${e.message} for $data", e) - return null - } - }// Media or MMS URI + fun Context.isUsingMobileData(): Boolean { + val connectionManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val activeNetwork: Network? = connectionManager.activeNetwork + val networkCapabilities = connectionManager.getNetworkCapabilities(activeNetwork) + networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true && + !networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } else { + @Suppress("DEPRECATION") + connectionManager.activeNetworkInfo?.type == ConnectivityManager.TYPE_MOBILE } - 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 - } - } private fun Activity?.cacheClass(clazz: String?) { clazz?.let { c -> @@ -775,9 +893,7 @@ object AppUtils { } build() } - } else { - null - } + } else null return currentAudioFocusRequest } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt new file mode 100644 index 000000000..10736e13e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -0,0 +1,67 @@ +package com.lagradost.cloudstream3.utils + +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import java.lang.ref.WeakReference +import java.util.WeakHashMap + +object BackPressedCallbackHelper { + + private val backPressedCallbacks = + WeakHashMap>() + + class CallbackHelper( + private val activityRef: WeakReference, + private val callback: OnBackPressedCallback + ) { + fun runDefault() { + val activity = activityRef.get() ?: return + val wasEnabled = callback.isEnabled + callback.isEnabled = false + try { + activity.onBackPressedDispatcher.onBackPressed() + } finally { + callback.isEnabled = wasEnabled + } + } + } + + fun ComponentActivity.attachBackPressedCallback( + id: String, + callback: CallbackHelper.() -> Unit + ) { + val callbackMap = backPressedCallbacks.getOrPut(this) { mutableMapOf() } + if (callbackMap.containsKey(id)) return + + // We use WeakReference to protect against potential leaks. + val activityRef = WeakReference(this) + val newCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + CallbackHelper(activityRef, this).callback() + } + } + + callbackMap[id] = newCallback + onBackPressedDispatcher.addCallback(this, newCallback) + } + + fun ComponentActivity.disableBackPressedCallback(id : String) { + backPressedCallbacks[this]?.get(id)?.isEnabled = false + } + + fun ComponentActivity.enableBackPressedCallback(id : String) { + backPressedCallbacks[this]?.get(id)?.isEnabled = true + } + + fun ComponentActivity.detachBackPressedCallback(id: String) { + val callbackMap = backPressedCallbacks[this] ?: return + callbackMap[id]?.let { callback -> + callback.remove() + callbackMap.remove(id) + } + + if (callbackMap.isEmpty()) { + backPressedCallbacks.remove(this) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 2318fda64..62426197e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -1,48 +1,49 @@ package com.lagradost.cloudstream3.utils -import android.annotation.SuppressLint -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_TOKEN_KEY -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_UNIXTIME_KEY -import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_USER_KEY import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_REFRESH_TOKEN_KEY -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_TOKEN_KEY -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_UNIXTIME_KEY -import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_USER_KEY -import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi.Companion.OPEN_SUBTITLES_USER_KEY +import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs -import com.lagradost.cloudstream3.utils.DataStore.mapper -import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW -import com.lagradost.cloudstream3.utils.VideoDownloadManager.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 { @@ -50,40 +51,80 @@ object BackupUtils { * No sensitive or breaking data in the backup * */ private val nonTransferableKeys = listOf( - // When sharing backup we do not want to transfer what is essentially the password - ANILIST_TOKEN_KEY, ANILIST_CACHED_LIST, - ANILIST_UNIXTIME_KEY, - ANILIST_USER_KEY, - MAL_TOKEN_KEY, - MAL_REFRESH_TOKEN_KEY, MAL_CACHED_LIST, - MAL_UNIXTIME_KEY, - MAL_USER_KEY, + KITSU_CACHED_LIST, // The plugins themselves are not backed up PLUGINS_KEY, PLUGINS_KEY_LOCAL, - OPEN_SUBTITLES_USER_KEY, + AccountManager.ACCOUNT_TOKEN, + AccountManager.ACCOUNT_IDS, + + // TODO proper getter for string res keys to ensure that they are updated + "biometric_key", // can lock down users if backup is shared on a incompatible device "nginx_user", // Nginx user key + + // No access rights after restore data from backup + "download_path_key", + "download_path_key_visual", + "backup_path_key", + "backup_dir_path_key", + + // When sharing backup we do not want to transfer what is essentially the password + // Note that this is deprecated, and can be removed after all tokens have expired + "anilist_token", + "anilist_user", + "mal_user", + "mal_token", + "mal_refresh_token", + "mal_unixtime", + "open_subtitles_user", + "subdl_user", + "simkl_token", + + + // Downloads can not be restored from backups. + // The download path URI can not be transferred. + // In the future we may potentially write metadata to files in the download directory + // and make it possible to restore download folders using that metadata. + DOWNLOAD_EPISODE_CACHE_BACKUP, + DOWNLOAD_EPISODE_CACHE, + + // Download headers are unintuitively used in the resume watching system. + // We can therefore not prune download headers in backups. + //DOWNLOAD_HEADER_CACHE_BACKUP, + //DOWNLOAD_HEADER_CACHE, + + + // This may overwrite valid local data with invalid data + KEY_DOWNLOAD_INFO, + + // Prevent backups from automatically starting downloads + KEY_RESUME_IN_QUEUE, + KEY_RESUME_PACKAGES, + QUEUE_KEY, + + // Prevent automatic plugin download after restoring backup + "auto_download_plugins_key2" ) - /** false if blacklisted key */ + /** false if key should not be contained in backup */ private fun String.isTransferable(): Boolean { - return !nonTransferableKeys.contains(this) + return !nonTransferableKeys.any { this.contains(it) } } private var restoreFileSelector: ActivityResultLauncher>? = null // Kinda hack, but I couldn't think of a better way data class BackupVars( - @JsonProperty("_Bool") val _Bool: Map?, - @JsonProperty("_Int") val _Int: Map?, - @JsonProperty("_String") val _String: Map?, - @JsonProperty("_Float") val _Float: Map?, - @JsonProperty("_Long") val _Long: Map?, - @JsonProperty("_StringSet") val _StringSet: Map?>?, + @JsonProperty("_Bool") val bool: Map?, + @JsonProperty("_Int") val int: Map?, + @JsonProperty("_String") val string: Map?, + @JsonProperty("_Float") val float: Map?, + @JsonProperty("_Long") val long: Map?, + @JsonProperty("_StringSet") val stringSet: Map?>?, ) data class BackupFile( @@ -92,9 +133,9 @@ object BackupUtils { ) @Suppress("UNCHECKED_CAST") - fun Context.getBackup(): BackupFile { - val allData = getSharedPrefs().all.filter { it.key.isTransferable() } - val allSettings = getDefaultSharedPrefs().all.filter { it.key.isTransferable() } + private fun getBackup(context: Context): BackupFile { + val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } + val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } val allDataSorted = BackupVars( allData.filter { it.value is Boolean } as? Map, @@ -121,87 +162,59 @@ object BackupUtils { } @WorkerThread - fun Context.restore( + fun restore( + context: Context?, backupFile: BackupFile, restoreSettings: Boolean, restoreDataStore: Boolean ) { + if (context == null) return if (restoreSettings) { - restoreMap(backupFile.settings._Bool, true) - restoreMap(backupFile.settings._Int, true) - restoreMap(backupFile.settings._String, true) - restoreMap(backupFile.settings._Float, true) - restoreMap(backupFile.settings._Long, true) - restoreMap(backupFile.settings._StringSet, true) + context.restoreMap(backupFile.settings.bool, true) + context.restoreMap(backupFile.settings.int, true) + context.restoreMap(backupFile.settings.string, true) + context.restoreMap(backupFile.settings.float, true) + context.restoreMap(backupFile.settings.long, true) + context.restoreMap(backupFile.settings.stringSet, true) } if (restoreDataStore) { - restoreMap(backupFile.datastore._Bool) - restoreMap(backupFile.datastore._Int) - restoreMap(backupFile.datastore._String) - restoreMap(backupFile.datastore._Float) - restoreMap(backupFile.datastore._Long) - restoreMap(backupFile.datastore._StringSet) + context.restoreMap(backupFile.datastore.bool) + context.restoreMap(backupFile.datastore.int) + context.restoreMap(backupFile.datastore.string) + context.restoreMap(backupFile.datastore.float) + context.restoreMap(backupFile.datastore.long) + context.restoreMap(backupFile.datastore.stringSet) + } + + // Make sure the library is fresh + for(api in AccountManager.syncApis) { + api.requireLibraryRefresh = true } } - @SuppressLint("SimpleDateFormat") - fun FragmentActivity.backup() { + fun backup(context: Context?) = ioSafe { + if (context == null) return@ioSafe + + var fileStream: OutputStream? = null + var printStream: PrintWriter? = null try { - if (!checkWrite()) { - showToast(this, getString(R.string.backup_failed), Toast.LENGTH_LONG) - requestRW() - return + if (!context.checkWrite()) { + showToast(R.string.backup_failed, Toast.LENGTH_LONG) + context.getActivity()?.requestRW() + return@ioSafe } - val subDir = getBasePath().first - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) - val ext = "json" + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) val displayName = "CS3_Backup_${date}" - val backupFile = getBackup() + val backupFile = getBackup(context) + val stream = setupBackupStream(context, displayName) - val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && subDir?.isDownloadDir() == true - ) { - val cr = this.contentResolver - val contentUri = - MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) // USE INSTEAD OF MediaStore.Downloads.EXTERNAL_CONTENT_URI - //val currentMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - - val newFile = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) - put(MediaStore.MediaColumns.TITLE, displayName) - // While it a json file we store as txt because not - // all file managers support mimetype json - put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") - //put(MediaStore.MediaColumns.RELATIVE_PATH, folder) - } - - val newFileUri = cr.insert( - contentUri, - newFile - ) ?: throw IOException("Error creating file uri") - cr.openOutputStream(newFileUri, "w") - ?: throw IOException("Error opening stream") - } else { - val fileName = "$displayName.$ext" - val rFile = subDir?.findFile(fileName) - if (rFile?.exists() == true) { - rFile.delete() - } - val file = - subDir?.createFile(fileName) - ?: throw IOException("Error creating file") - if (!file.exists()) throw IOException("File does not exist") - file.openOutputStream() - } - - val printStream = PrintWriter(steam) - printStream.print(mapper.writeValueAsString(backupFile)) - printStream.close() + fileStream = stream.openNew() + printStream = PrintWriter(fileStream) + printStream.print(backupFile.toJson()) showToast( - this, R.string.backup_success, Toast.LENGTH_LONG ) @@ -209,16 +222,30 @@ object BackupUtils { 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 = @@ -230,10 +257,11 @@ object BackupUtils { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe - val restoredValue = - mapper.readValue(input) + val text = input.bufferedReader().readText() + val restoredValue = parseJson(text) - activity.restore( + restore( + activity, restoredValue, restoreSettings = true, restoreDataStore = true @@ -243,7 +271,6 @@ object BackupUtils { logError(e) main { // smth can fail in .format showToast( - activity, getString(R.string.restore_failed_format).format(e.toString()) ) } @@ -270,7 +297,7 @@ object BackupUtils { ) ) } catch (e: Exception) { - showToast(this, e.message) + showToast(e.message) logError(e) } } @@ -280,8 +307,36 @@ object BackupUtils { map: Map?, isEditingAppSettings: Boolean = false ) { - map?.filter { it.key.isTransferable() }?.forEach { - setKeyRaw(it.key, it.value, isEditingAppSettings) + val editor = DataStore.editor(this, isEditingAppSettings) + map?.forEach { + if (it.key.isTransferable()) { + editor.setKeyRaw(it.key, it.value) + } + } + editor.apply() + } + + /** + * Copy of [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.basePathToFile], [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDefaultDir] and [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getBasePath] + * modded for backup specific paths + * */ + + fun getDefaultBackupDir(context: Context): SafeFile? { + return SafeFile.fromMedia(context, MediaFileContentType.Downloads) + } + + fun getCurrentBackupDir(context: Context): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) + val basePathSetting = + settingsManager.getString(context.getString(R.string.backup_path_key), null) + return baseBackupPathToFile(context, basePathSetting) to basePathSetting + } + + private fun baseBackupPathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultBackupDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt new file mode 100644 index 000000000..bce8f09dc --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt @@ -0,0 +1,193 @@ +package com.lagradost.cloudstream3.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.KeyguardManager +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.getString +import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R + +object BiometricAuthenticator { + + const val TAG = "cs3Auth" + private const val MAX_FAILED_ATTEMPTS = 3 + private var failedAttempts = 0 + private var biometricManager: BiometricManager? = null + var biometricPrompt: BiometricPrompt? = null + var promptInfo: BiometricPrompt.PromptInfo? = null + var authCallback: BiometricCallback? = null // listen to authentication success + + private fun initializeBiometrics(activity: FragmentActivity) { + val executor = ContextCompat.getMainExecutor(activity) + + biometricManager = BiometricManager.from(activity) + + biometricPrompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + showToast("$errString") + Log.e(TAG, "$errorCode") + authCallback?.onAuthenticationError() + //activity.finish() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + failedAttempts = 0 + authCallback?.onAuthenticationSuccess() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + failedAttempts++ + if (failedAttempts >= MAX_FAILED_ATTEMPTS) { + failedAttempts = 0 + activity.finish() + } + } + }) + } + + @Suppress("DEPRECATION") + // authentication dialog prompt builder + private fun authenticationDialog( + activity: Activity, + title: Int, + setDeviceCred: Boolean, + ) { + val description = activity.getString(R.string.biometric_prompt_description) + + if (setDeviceCred) { + // For API level > 30, Newer API setAllowedAuthenticators is used + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + + val authFlag = DEVICE_CREDENTIAL or BIOMETRIC_WEAK or BIOMETRIC_STRONG + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(title)) + .setDescription(description) + .setAllowedAuthenticators(authFlag) + .build() + } else { + // for apis < 30 + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(title)) + .setDescription(description) + .setDeviceCredentialAllowed(true) + .build() + } + } else { + // fallback for A12+ when both fingerprint & Face unlock is absent but PIN is set + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(title)) + .setDescription(description) + .setDeviceCredentialAllowed(true) + .build() + } + } + + private fun isBiometricHardWareAvailable(): Boolean { + // Authentication occurs only when this is true and device is truly capable. + var result = false + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA -> { + @SuppressLint("RestrictedApi") + when (biometricManager?.canAuthenticate( + DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK + )) { + BiometricManager.BIOMETRIC_SUCCESS -> result = true + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false + BiometricManager.BIOMETRIC_ERROR_NOT_ENABLED_FOR_APPS -> result = false + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false + } + } + + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + @Suppress("SwitchIntDef") + when (biometricManager?.canAuthenticate( + DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK + )) { + BiometricManager.BIOMETRIC_SUCCESS -> result = true + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false + } + } + + else -> { + @Suppress("DEPRECATION", "SwitchIntDef") + when (biometricManager?.canAuthenticate()) { + BiometricManager.BIOMETRIC_SUCCESS -> result = true + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true + BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false + } + } + } + + return result + } + + // checks if device is secured i.e has at least some type of lock + fun deviceHasPasswordPinLock(context: Context?): Boolean { + val keyMgr = + context?.getSystemService(AppCompatActivity.KEYGUARD_SERVICE) as? KeyguardManager + return keyMgr?.isKeyguardSecure ?: false + } + + // function to start authentication in any fragment or activity + fun startBiometricAuthentication(activity: FragmentActivity, title: Int, setDeviceCred: Boolean) { + initializeBiometrics(activity) + authCallback = activity as? BiometricCallback + if (isBiometricHardWareAvailable()) { + authCallback = activity as? BiometricCallback + authenticationDialog(activity, title, setDeviceCred) + promptInfo?.let { biometricPrompt?.authenticate(it) } + } else { + if (deviceHasPasswordPinLock(activity)) { + authCallback = activity as? BiometricCallback + authenticationDialog(activity, R.string.password_pin_authentication_title, true) + promptInfo?.let { biometricPrompt?.authenticate(it) } + + } else { + showToast(R.string.biometric_unsupported) + } + } + } + + fun isAuthEnabled(ctx: Context):Boolean { + return ctx.let { + PreferenceManager.getDefaultSharedPreferences(ctx) + .getBoolean(getString(ctx, R.string.biometric_key), false) + } + } + + interface BiometricCallback { + fun onAuthenticationSuccess() + fun onAuthenticationError() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt index 9e8cc1d4b..b48c8d40a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt @@ -1,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 000000000..def41d7a0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt @@ -0,0 +1,47 @@ +package com.lagradost.cloudstream3.utils + +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import com.lagradost.cloudstream3.mvvm.Resource + +/** + * This is an atomic LiveData where you can do .value instantly after doing .postValue. + * + * The default behavior is a footgun that will cause race conditions, + * as we do not really care if it is posted as we only want the latest data (even in the binding). + * + * Fuck all that is LiveData, because we want this value to be accessible everywhere instantly. + * */ +open class ConsistentLiveData(initValue : T? = null) : LiveData(initValue) { + @Volatile private var internalValue : T? = initValue + + override fun getValue(): T? { + return internalValue + } + + /** If someone want the old behavior then good for them */ + val postedValue : T? get() = super.getValue() + + public override fun postValue(value : T?) { + super.postValue(value) + internalValue = value + } + + @MainThread + public override fun setValue(value: T?) { + super.setValue(value) + internalValue = value + } +} + +/** Atomic resource livedata, to make it easier to work with resources without local copies */ +class ResourceLiveData(initValue : Resource? = null) : ConsistentLiveData>(initValue) { + var success + get() = when(val output = this.value) { + is Resource.Success -> { + output.value + } + else -> null + } + set(value) = this.postValue(value?.let { Resource.Success(it) } ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/Coroutines.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/Coroutines.kt deleted file mode 100644 index c3b244c26..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Coroutines.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.os.Handler -import android.os.Looper -import com.lagradost.cloudstream3.mvvm.launchSafe -import com.lagradost.cloudstream3.mvvm.logError -import kotlinx.coroutines.* -import java.util.Collections.synchronizedList - -object Coroutines { - fun T.main(work: suspend ((T) -> Unit)): Job { - val value = this - return CoroutineScope(Dispatchers.Main).launchSafe { - work(value) - } - } - - fun T.ioSafe(work: suspend (CoroutineScope.(T) -> Unit)): Job { - val value = this - - return CoroutineScope(Dispatchers.IO).launchSafe { - work(value) - } - } - - suspend fun V.ioWorkSafe(work: suspend (CoroutineScope.(V) -> T)): T? { - val value = this - return withContext(Dispatchers.IO) { - try { - work(value) - } catch (e: Exception) { - logError(e) - null - } - } - } - - suspend fun V.ioWork(work: suspend (CoroutineScope.(V) -> T)): T { - val value = this - return withContext(Dispatchers.IO) { - work(value) - } - } - - suspend fun V.mainWork(work: suspend (CoroutineScope.(V) -> T)): T { - val value = this - return withContext(Dispatchers.Main) { - work(value) - } - } - - fun runOnMainThread(work: (() -> Unit)) { - val mainHandler = Handler(Looper.getMainLooper()) - mainHandler.post { - work() - } - } - - /** - * Safe to add and remove how you want - * If you want to iterate over the list then you need to do: - * synchronized(allProviders) { code here } - **/ - fun threadSafeListOf(vararg items: T): MutableList { - return synchronizedList(items.toMutableList()) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index e1cedd398..02ee69791 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -2,25 +2,103 @@ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit import androidx.preference.PreferenceManager -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.json.JsonMapper -import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral +import kotlin.reflect.KClass +import kotlin.reflect.KProperty +/** Used to display metadata about downloads and resume watching */ const val DOWNLOAD_HEADER_CACHE = "download_header_cache" +const val DOWNLOAD_HEADER_CACHE_BACKUP = "BACKUP_download_header_cache" //const val WATCH_HEADER_CACHE = "watch_header_cache" const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" +const val DOWNLOAD_EPISODE_CACHE_BACKUP = "BACKUP_download_episode_cache" const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" - const val PREFERENCES_NAME = "rebuild_preference" +// TODO degelgate by value for get & set + +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()) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + // Extensions shouldn't have really been using this version of it, but it seems + // some have. Since there has always been a very easy alternative, we won't + // need to deprecate it that long, and should be able to fully remove it + // once extensions at least use the other version. + @Deprecated( + "Please do not use the mapper version from DataStore. Preferably use methods from AppUtils " + + "to parse JSON. However, you can use the stable-API version of the mapper at " + + "com.lagradost.cloudstream3.mapper to access the mapper directly if necessary.", + level = DeprecationLevel.ERROR, + replaceWith = ReplaceWith("com.lagradost.cloudstream3.mapper"), + ) + val mapper = com.lagradost.cloudstream3.mapper private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) @@ -34,22 +112,11 @@ object DataStore { return "${folder}/${path}" } - fun Context.setKeyRaw(path: String, value: T, isEditingAppSettings: Boolean = false) { - try { - val editor: SharedPreferences.Editor = - if (isEditingAppSettings) getDefaultSharedPrefs().edit() else getSharedPrefs().edit() - when (value) { - is Boolean -> editor.putBoolean(path, value) - is Int -> editor.putInt(path, value) - is String -> editor.putString(path, value) - is Float -> editor.putFloat(path, value) - is Long -> editor.putLong(path, value) - (value as? Set != null) -> editor.putStringSet(path, value as Set) - } - editor.apply() - } catch (e: Exception) { - logError(e) - } + fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { + val editor: SharedPreferences.Editor = + if (isEditingAppSettings) context.getDefaultSharedPrefs() + .edit() else context.getSharedPrefs().edit() + return Editor(editor) } fun Context.getDefaultSharedPrefs(): SharedPreferences { @@ -57,7 +124,9 @@ object DataStore { } fun Context.getKeys(folder: String): List { - return this.getSharedPrefs().all.keys.filter { it.startsWith(folder) } + // Ensure that the folder ends with "/" to prevent matching with other folders + val fixedFolder = folder.trimEnd('/') + "/" + return this.getSharedPrefs().all.keys.filter { it.startsWith(fixedFolder) } } fun Context.removeKey(folder: String, path: String) { @@ -77,9 +146,9 @@ object DataStore { try { val prefs = getSharedPrefs() if (prefs.contains(path)) { - val editor: SharedPreferences.Editor = prefs.edit() - editor.remove(path) - editor.apply() + prefs.edit { + remove(path) + } } } catch (e: Exception) { logError(e) @@ -87,29 +156,49 @@ 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, value?.toJsonLiteral()) + } } catch (e: Exception) { logError(e) } } + fun Context.getKey(path: String, valueType: Class): T? { + try { + val json: String = getSharedPrefs().getString(path, null) ?: return null + return parseJson(json, valueType.kotlin) + } catch (e: Exception) { + return null + } + } + fun Context.setKey(folder: String, path: String, value: T) { setKey(getFolderName(folder, path), value) } inline fun String.toKotlinObject(): T { - return mapper.readValue(this, T::class.java) + return parseJson(this) + } + + fun String.toKotlinObject(valueType: Class): T { + return parseJson(this, valueType.kotlin) } // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR @@ -133,4 +222,4 @@ object DataStore { inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { return getKey(getFolderName(folder, path), defVal) ?: defVal } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 281c9c44a..19caead21 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -1,33 +1,237 @@ package com.lagradost.cloudstream3.utils +import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.APIHolder.capitalize -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 @@ -42,19 +246,129 @@ 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, + @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, @@ -63,8 +377,70 @@ object DataStoreHelper { 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, - apiName, type, posterUrl, posterHeaders, quality, this.id + null, + null, + latestUpdatedTime, + apiName, + type, + posterUrl, + posterHeaders, + quality, + year?.toYear(), + this.id, + plot = this.plot, + score = this.score, + tags = this.tags ) } } @@ -75,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?, @@ -85,12 +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 /** * A datastore wide account for future implementations of a multiple account system **/ - private var currentAccount: String = "0" //TODO ACCOUNT IMPLEMENTATION fun getAllWatchStateIds(): List? { val folder = "$currentAccount/$RESULT_WATCH_STATE" @@ -104,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? { @@ -156,7 +530,7 @@ object DataStoreHelper { setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), - VideoDownloadHelper.ResumeWatching( + DownloadObjects.ResumeWatching( parentId, episodeId, episode, @@ -177,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", @@ -185,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", @@ -204,12 +578,132 @@ 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) @@ -232,7 +726,7 @@ object DataStoreHelper { } fun getDub(id: Int): DubStatus? { - return DubStatus.values() + return DubStatus.entries .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) } @@ -242,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) } } @@ -286,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 c1eb649b6..000000000 --- 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 26f83d1e1..f66da4e5f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt @@ -3,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) + } } -} \ No newline at end of file +} + +class EmptyEvent { + private val observers = mutableSetOf() + + val size: Int get() = observers.size + + operator fun plusAssign(observer: Runnable) { + synchronized(observers) { + observers.add(observer) + } + } + + operator fun minusAssign(observer: Runnable) { + synchronized(observers) { + observers.remove(observer) + } + } + + operator fun invoke() { + synchronized(observers) { + for (observer in observers) + observer.run() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt deleted file mode 100644 index 1ad3639b3..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ /dev/null @@ -1,488 +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(), - TomatomatelalClub(), - Cinestart(), - OkRu(), - OkRuHttps(), - Okrulink(), - - // dood extractors - DoodCxExtractor(), - DoodPmExtractor(), - DoodToExtractor(), - DoodSoExtractor(), - DoodLaExtractor(), - DoodWsExtractor(), - DoodShExtractor(), - DoodWatchExtractor(), - DoodWfExtractor(), - - AsianLoad(), - - // GenericM3U8(), - Jawcloud(), - Zplayer(), - ZplayerV2(), - Upstream(), - - Maxstream(), - Tantifilm(), - Userload(), - Supervideo(), - GuardareStream(), - CineGrabber(), - Vanfem(), - - // StreamSB.kt works - // SBPlay(), - // SBPlay1(), - // SBPlay2(), - - PlayerVoxzer(), - - BullStream(), - GMPlayer(), - - Blogger(), - Solidfiles(), - YourUpload(), - - Hxfile(), - KotakAnimeid(), - Neonime8n(), - Neonime7n(), - Yufiles(), - Aico(), - - JWPlayer(), - Meownime(), - DesuArcg(), - DesuOdchan(), - DesuOdvip(), - DesuDrive(), - - Filesim(), - FileMoon(), - 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(), - - Cda(), - Dailymotion(), - ByteShare(), -) - - -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 14d1b0556..8456094d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt @@ -1,112 +1,166 @@ package com.lagradost.cloudstream3.utils -import com.lagradost.cloudstream3.app +import androidx.annotation.WorkerThread +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.utils.Coroutines.main -import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import java.io.InputStream +import kotlin.let object FillerEpisodeCheck { - private const val MAIN_URL = "https://www.animefillerlist.com" - - var list: HashMap? = null - var cache: HashMap> = hashMapOf() - - private fun fixName(name: String): String { - return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ") - .replace("[^a-zA-Z0-9 ]".toRegex(), "") - } - - private suspend fun getFillerList(): Boolean { - if (list != null) return true - try { - val result = app.get("$MAIN_URL/shows").text - val documented = Jsoup.parse(result) - val localHTMLList = documented.select("div#ShowList > div.Group > ul > li > a") - val localList = HashMap() - for (i in localHTMLList) { - val name = i.text() - - if (name.lowercase(Locale.ROOT).contains("manga only")) continue - - val href = i.attr("href") - if (name.isNullOrEmpty() || href.isNullOrEmpty()) { - continue - } - - val values = "(.*) \\((.*)\\)".toRegex().matchEntire(name)?.groups - if (values != null) { - for (index in 1 until values.size) { - val localName = values[index]?.value ?: continue - localList[fixName(localName)] = href - } - } else { - localList[fixName(name)] = href - } - } - if (localList.size > 0) { - list = localList - return true - } - } catch (e: Exception) { - e.printStackTrace() - } - return false - } - fun String?.toClassDir(): String { val q = this ?: "null" val z = (6..10).random().calc() return q + "cache" + z } - suspend fun getFillerEpisodes(query: String): HashMap? { - try { - cache[query]?.let { - return it - } - if (!getFillerList()) return null - val localList = list ?: return null + data class Show( + @JsonProperty("slug") + val slug: String, + @JsonProperty("title") + val title: String, + @JsonProperty("filler") + val filler: ArrayList, + @JsonProperty("mixedCanon") + val mixedCanon: ArrayList, + @JsonProperty("mangaCanon") + val mangaCanon: ArrayList, + @JsonProperty("animeCanon") + val animeCanon: ArrayList, + ) - // Strips these from the name - val blackList = listOf( - "TV Dubbed", - "(Dub)", - "Subbed", - "(TV)", - "(Uncensored)", - "(Censored)", - "(\\d+)" // year - ) - val blackListRegex = - Regex( - """ (${ - blackList.joinToString(separator = "|").replace("(", "\\(") - .replace(")", "\\)") - })""" - ) + data class MappingRoot( + @JsonProperty("type") + val type: String?, + @JsonProperty("anidb_id") + val anidbId: Long?, + @JsonProperty("anilist_id") + val anilistId: Long?, + @JsonProperty("animecountdown_id") + val animecountdownId: Long?, + @JsonProperty("animenewsnetwork_id") + val animenewsnetworkId: Long?, + @JsonProperty("anime-planet_id") + val animePlanetId: String?, + @JsonProperty("anisearch_id") + val anisearchId: Long?, + @JsonProperty("imdb_id") + val imdbId: String?, + @JsonProperty("kitsu_id") + val kitsuId: Long?, + @JsonProperty("livechart_id") + val livechartId: Long?, + @JsonProperty("mal_id") + val malId: Long?, + @JsonProperty("simkl_id") + val simklId: Long?, + @JsonProperty("themoviedb_id") + val themoviedbId: Long?, + @JsonProperty("tvdb_id") + val tvdbId: Long?, + @JsonProperty("season") + val season: Season?, + ) - val realQuery = - fixName(query.replace(blackListRegex, "")).replace("shippuuden", "shippuden") - if (!localList.containsKey(realQuery)) return null - val href = localList[realQuery]?.replace(MAIN_URL, "") ?: return null // JUST IN CASE - val result = app.get("$MAIN_URL$href").text - val documented = Jsoup.parse(result) ?: return null - val hashMap = HashMap() - documented.select("table.EpisodeList > tbody > tr").forEach { - val type = it.selectFirst("td.Type > span")?.text() == "Filler" - val episodeNumber = it.selectFirst("td.Number")?.text()?.toIntOrNull() - if (episodeNumber != null) { - hashMap[episodeNumber] = type - } - } - cache[query] = hashMap - return hashMap - } catch (e: Exception) { - e.printStackTrace() + data class Season( + @JsonProperty("tvdb") + val tvdb: Long?, + @JsonProperty("tmdb") + val tmdb: Long?, + ) + + data class CombinedMedia( + @JsonProperty("mapping") + val mapping: MappingRoot?, + @JsonProperty("show") + val show: Show + ) + + data class Database( + val mal: HashMap = hashMapOf(), + val anilist: HashMap = hashMapOf(), + val kitsu: HashMap = hashMapOf(), + val tmdb: HashMap = hashMapOf(), + val imdb: HashMap = hashMapOf(), + val name: HashMap = hashMapOf(), + ) + + private var database: Database? = null + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String): String = + name.replace(strip, "").lowercase() + + + @Synchronized + @Throws + @WorkerThread + fun loadJson(): Database { + database?.let { + return it + } + + /** The entire "database" is stored as a json file we can parse */ + val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!! + val text = stream.reader().readText() + + val allMedia = parseJson>(text) + val pending = Database() + for (media in allMedia) { + val lowercase = stripName(media.show.title) + pending.name[lowercase] = media + val map = media.mapping ?: continue + + map.imdbId?.let { id -> pending.imdb[id] = media } + map.malId?.let { id -> pending.mal[id] = media } + map.anilistId?.let { id -> pending.anilist[id] = media } + map.kitsuId?.let { id -> pending.kitsu[id] = media } + map.season?.tmdb?.let { id -> pending.tmdb[id] = media } + } + database = pending + return pending + } + + val loadCache: HashMap?> = hashMapOf() + + @Synchronized + @Throws + @WorkerThread + fun getFillerEpisodes(data: LoadResponse): HashSet? { + /** Only for anime */ + if (data.type != TvType.Anime) { return null } + /** Try to hit the cache for this entry, to avoid recreating the hashset */ + loadCache[data.getId()]?.let { cachedResponse -> + return cachedResponse + } + val db = loadJson() + + val media = + db.mal[data.getMalId()?.toLongOrNull()] + ?: db.anilist[data.getAniListId()?.toLongOrNull()] + ?: db.kitsu[data.getKitsuId()?.toLongOrNull()] + ?: db.imdb[data.getImdbId()] + ?: db.tmdb[data.getTMDbId()?.toLongOrNull()] + ?: db.name[stripName(data.name)] + + return media?.show?.filler?.toHashSet().also { response -> + loadCache[data.getId()] = response + } } private fun Int.calc(): Int { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt new file mode 100644 index 000000000..58ff44bb2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/GitInfo.kt @@ -0,0 +1,20 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context + +/** + * Simple helper to get the short commit hash from assets. + * The hash is generated at build and stored as an asset + * that can be accessed at runtime for Gradle + * configuration cache support. + */ +object GitInfo { + fun Context.currentCommitHash(): String = try { + assets.open("git-hash.txt") + .bufferedReader() + .readText() + .trim() + } catch (_: Exception) { + "" + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt deleted file mode 100644 index 4b0ee8903..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/GlideApp.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import android.annotation.SuppressLint -import android.content.Context -import com.bumptech.glide.Glide -import com.bumptech.glide.GlideBuilder -import com.bumptech.glide.Registry -import com.bumptech.glide.annotation.GlideModule -import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.load.model.GlideUrl -import com.bumptech.glide.module.AppGlideModule -import com.bumptech.glide.request.RequestOptions -import com.bumptech.glide.signature.ObjectKey -import com.lagradost.cloudstream3.USER_AGENT -import com.lagradost.cloudstream3.network.DdosGuardKiller -import com.lagradost.cloudstream3.network.initClient -import com.lagradost.nicehttp.Requests -import java.io.InputStream - -@GlideModule -class GlideModule : AppGlideModule() { - @SuppressLint("CheckResult") - override fun applyOptions(context: Context, builder: GlideBuilder) { - super.applyOptions(context, builder) - builder.apply { - RequestOptions() - .diskCacheStrategy(DiskCacheStrategy.ALL) - .signature(ObjectKey(System.currentTimeMillis().toShort())) - } - } - - // Needed for DOH - // https://stackoverflow.com/a/61634041 - override fun registerComponents(context: Context, glide: Glide, registry: Registry) { - val client = - Requests().apply { - defaultHeaders = mapOf("user-agent" to USER_AGENT) - }.initClient(context) - .newBuilder() - .addInterceptor(DdosGuardKiller(false)) - .build() - - registry.replace( - GlideUrl::class.java, - InputStream::class.java, - OkHttpUrlLoader.Factory(client) - ) - super.registerComponents(context, glide, registry) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt deleted file mode 100644 index b4922945b..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/IOnBackPressed.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.lagradost.cloudstream3.utils - -interface IOnBackPressed { - fun onBackPressed(): Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt new file mode 100644 index 000000000..96193fe45 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt @@ -0,0 +1,187 @@ +package com.lagradost.cloudstream3.utils + +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.util.Log +import android.widget.ImageView +import androidx.annotation.DrawableRes +import coil3.EventListener +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.SingletonImageLoader +import coil3.decode.BitmapFactoryDecoder +import coil3.disk.DiskCache +import coil3.dispose +import coil3.load +import coil3.memory.MemoryCache +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.CachePolicy +import coil3.request.ErrorResult +import coil3.request.ImageRequest +import coil3.request.allowHardware +import coil3.request.bitmapConfig +import coil3.request.crossfade +import coil3.util.DebugLogger +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.network.buildDefaultClient +import okio.Path.Companion.toOkioPath +import java.io.File +import java.nio.ByteBuffer + +object ImageLoader { + private const val TAG = "CoilImgLoader" + internal fun buildImageLoader(context: PlatformContext): ImageLoader { + val isBrokenHardware = hasPotentialBrokenHardware() + return ImageLoader.Builder(context) + .crossfade(200) + .allowHardware(SDK_INT >= 28 && !isBrokenHardware) + .diskCachePolicy(CachePolicy.ENABLED) + .networkCachePolicy(CachePolicy.ENABLED) + .memoryCache { + MemoryCache.Builder().maxSizePercent(context, 0.1)//10 % of heap for mem-cache + .strongReferencesEnabled(false) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath()) + .maxSizeBytes(512L * 1024 * 1024) // 512 MB + .maxSizePercent(0.04) // max 4% of storage for disk caching + .build() + } + /** Pass interceptors with care, unnecessary passing tokens to servers + or image hosting services causes unauthorized exceptions **/ + .components { + add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) + if (isBrokenHardware) { + add(BitmapFactoryDecoder.Factory()) + } // sw decoder + } + .apply { + if (isBrokenHardware) { // coil will auto choose optimal config on modern device + bitmapConfig(Bitmap.Config.ARGB_8888) + } + setupCoilLogger() + } + .build() + } + + /** DebugLogger on debug builds which won't slow down release builds & use EventListener for + Errors on release builds. **/ + internal fun ImageLoader.Builder.setupCoilLogger() { + if (BuildConfig.DEBUG) { + logger(DebugLogger()) + } else { + eventListener(object : EventListener() { + override fun onError(request: ImageRequest, result: ErrorResult) { + super.onError(request, result) + Log.e(TAG, "Image load error: ${result.throwable.message ?: result.throwable}") + Log.e(TAG, " URL: ${request.data}") + Log.e(TAG, " allowHardware: ${request.allowHardware}") + Log.e(TAG, " hardware: ${Build.HARDWARE}, board: ${Build.BOARD}") + } + }) + } + } + + /** coil's built in loader attached w/ global synchronized instance **/ + private fun ImageView.loadImageInternal( + imageData: Any?, + headers: Map? = null, + builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations + ) { + // clear image to avoid loading & flickering issue at fast scrolling (~recycler view/lazy column) + this.dispose() + if (imageData == null) return + // setImageResource is better than coil3 on resources due to attr + if (imageData is Int) { + this.setImageResource(imageData); return + } + // headers can be overridden by extensions. + this.load(imageData, SingletonImageLoader.get(context)) { + this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder -> + headerBuilder["User-Agent"] = USER_AGENT + headers?.forEach { (key, value) -> + headerBuilder[key] = value + } + }.build()) + builder() // if passed + } + } + + private fun hasPotentialBrokenHardware(): Boolean { + val hardware = Build.HARDWARE?.lowercase() ?: "" + val board = Build.BOARD?.lowercase() ?: "" + val model = Build.MODEL?.lowercase() ?: "" + val manufacturer = Build.MANUFACTURER?.lowercase() ?: "" + val allwinnerPatterns = listOf("sun50iw9", "h713", "allwinner", "sunxi") + val problematicModels = + listOf("hy320", "hy300", "a10plus", "magcubic", "sinoy", "android tv box") + return allwinnerPatterns.any { it in hardware || it in board || it in manufacturer } || + problematicModels.any { it in model } + } + + /** TYPE_SAFE_LOADERS **/ + fun ImageView.loadImage( + imageData: UiImage?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = when (imageData) { + is UiImage.Image -> loadImageInternal( + imageData = imageData.url, + headers = imageData.headers, + builder = builder + ) + + is UiImage.Bitmap -> loadImageInternal(imageData = imageData.bitmap, builder = builder) + is UiImage.Drawable -> loadImageInternal(imageData = imageData.resId, builder = builder) + null -> loadImageInternal(null, builder = builder) + } + + fun ImageView.loadImage( + imageData: String?, + headers: Map? = null, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) + + fun ImageView.loadImage( + imageData: Uri?, + headers: Map? = null, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) + + fun ImageView.loadImage( + imageData: File?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + @DrawableRes imageData: Int?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + imageData: Drawable?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + imageData: Bitmap?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + imageData: ByteArray?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) + + fun ImageView.loadImage( + imageData: ByteBuffer?, + builder: ImageRequest.Builder.() -> Unit = {} + ) = loadImageInternal(imageData = imageData, builder = builder) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt new file mode 100644 index 000000000..6ed4d4afa --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt @@ -0,0 +1,40 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.createBitmap +import coil3.Image +import coil3.asImage + +/// Type safe any image, because THIS IS NOT PYTHON +sealed class UiImage { + data class Image( + val url: String, + val headers: Map? = null + ) : UiImage() + + data class Drawable(@DrawableRes val resId: Int) : UiImage() + data class Bitmap(val bitmap: android.graphics.Bitmap) : UiImage() +} + +fun getImageFromDrawable(context: Context, drawableRes: Int): Image? { + return ContextCompat.getDrawable(context, drawableRes)?.asImage() +} + +fun drawableToBitmap(drawable: Drawable): Bitmap? { + return when (drawable) { + is BitmapDrawable -> drawable.bitmap + else -> { + val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + bitmap + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 8b516e8cc..b01f6e07e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -3,384 +3,363 @@ package com.lagradost.cloudstream3.utils import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.PackageManager.NameNotFoundException import android.net.Uri import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.content.edit import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.services.PackageInstallerService +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink import okio.buffer import okio.sink -import java.io.File -import android.text.TextUtils -import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus import java.io.BufferedReader +import java.io.File import java.io.IOException import java.io.InputStreamReader +object InAppUpdater { + private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_REPO = "cloudstream" -class InAppUpdater { - companion object { - const val GITHUB_USER_NAME = "recloudstream" - const val GITHUB_REPO = "cloudstream" + private const val PRERELEASE_PACKAGE_NAME = "com.lagradost.cloudstream3.prerelease" + private const val LOG_TAG = "InAppUpdater" - const val LOG_TAG = "InAppUpdater" + private data class GithubAsset( + @JsonProperty("name") val name: String, + @JsonProperty("size") val size: Int, // Size in bytes + @JsonProperty("browser_download_url") val browserDownloadUrl: String, + @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive + ) - // === IN APP UPDATER === - data class GithubAsset( - @JsonProperty("name") val name: String, - @JsonProperty("size") val size: Int, // Size bytes - @JsonProperty("browser_download_url") val browser_download_url: String, // download link - @JsonProperty("content_type") val content_type: String, // application/vnd.android.package-archive - ) + private data class GithubRelease( + @JsonProperty("tag_name") val tagName: String, // Version code + @JsonProperty("body") val body: String, // Description + @JsonProperty("assets") val assets: List, + @JsonProperty("target_commitish") val targetCommitish: String, // Branch + @JsonProperty("prerelease") val prerelease: Boolean, + @JsonProperty("node_id") val nodeId: String, + ) - data class GithubRelease( - @JsonProperty("tag_name") val tag_name: String, // Version code - @JsonProperty("body") val body: String, // Desc - @JsonProperty("assets") val assets: List, - @JsonProperty("target_commitish") val target_commitish: String, // branch - @JsonProperty("prerelease") val prerelease: Boolean, - @JsonProperty("node_id") val node_id: String //Node Id - ) + private data class GithubObject( + @JsonProperty("sha") val sha: String, // SHA-256 hash + @JsonProperty("type") val type: String, + @JsonProperty("url") val url: String, + ) - data class GithubObject( - @JsonProperty("sha") val sha: String, // sha 256 hash - @JsonProperty("type") val type: String, // object type - @JsonProperty("url") val url: String, - ) + private data class GithubTag( + @JsonProperty("object") val githubObject: GithubObject, + ) - data class GithubTag( - @JsonProperty("object") val github_object: GithubObject, - ) + private data class Update( + @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, + @JsonProperty("updateURL") val updateURL: String?, + @JsonProperty("updateVersion") val updateVersion: String?, + @JsonProperty("changelog") val changelog: String?, + @JsonProperty("updateNodeId") val updateNodeId: String?, + ) - data class Update( - @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, - @JsonProperty("updateURL") val updateURL: String?, - @JsonProperty("updateVersion") val updateVersion: String?, - @JsonProperty("changelog") val changelog: String?, - @JsonProperty("updateNodeId") val updateNodeId: String? - ) - - private suspend fun Activity.getAppUpdate(): Update { - return try { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) - if (settingsManager.getBoolean( - getString(R.string.prerelease_update_key), - resources.getBoolean(R.bool.is_prerelease) - ) - ) { - getPreReleaseUpdate() - } else { - getReleaseUpdate() - } - } catch (e: Exception) { - Log.e(LOG_TAG, Log.getStackTraceString(e)) - Update(false, null, null, null, null) + private suspend fun Activity.getAppUpdate(installPrerelease: Boolean): Update { + return try { + when { + // No updates on debug version + BuildConfig.DEBUG -> Update(false, null, null, null, null) + BuildConfig.FLAVOR == "prerelease" || installPrerelease -> getPreReleaseUpdate() + else -> getReleaseUpdate() } + } catch (e: Exception) { + Log.e(LOG_TAG, Log.getStackTraceString(e)) + Update(false, null, null, null, null) } + } - private suspend fun Activity.getReleaseUpdate(): Update { - val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = - parseJson>( - app.get( - url, - headers = headers - ).text - ) + private suspend fun Activity.getReleaseUpdate(): Update { + val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = parseJson>( + app.get(url, headers = headers).text + ).toList() - val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") - val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") - /* - val releases = response.map { it.assets }.flatten() - .filter { it.content_type == "application/vnd.android.package-archive" } - val found = - releases.sortedWith(compareBy { - versionRegex.find(it.name)?.groupValues?.get(2) - }).toList().lastOrNull()*/ - val found = - response.filter { rel -> - !rel.prerelease - }.sortedWith(compareBy { release -> - release.assets.filter { it.content_type == "application/vnd.android.package-archive" } - .getOrNull(0)?.name?.let { it1 -> - versionRegex.find( - it1 - )?.groupValues?.get(2) - } - }).toList().lastOrNull() - - val foundAsset = found?.assets?.getOrNull(0) - val currentVersion = packageName?.let { - packageManager.getPackageInfo( - it, - 0 - ) - } - - foundAsset?.name?.let { assetName -> - val foundVersion = versionRegex.find(assetName) - val shouldUpdate = - if (foundAsset.browser_download_url != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> - versionRegexLocal.find(versionName)?.groupValues?.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - }?.compareTo( - foundVersion.groupValues.let { - it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() - } - )!! < 0 else false - return if (foundVersion != null) { - Update( - shouldUpdate, - foundAsset.browser_download_url, - foundVersion.groupValues[2], - found.body, - found.node_id - ) - } else { - Update(false, null, null, null, null) + val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") + val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") + val foundList = response.filter { rel -> + !rel.prerelease + }.sortedWith(compareBy { release -> + release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> + versionRegex.find(it1)?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } } + }).toList() + + val found = foundList.lastOrNull() + val foundAsset = found?.assets?.getOrNull(0) + val foundVersion = foundAsset?.name?.let { versionRegex.find(it) } + + if (foundVersion == null) { return Update(false, null, null, null, null) } - private suspend fun Activity.getPreReleaseUpdate(): Update { - val tagUrl = - "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" - val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" - val headers = mapOf("Accept" to "application/vnd.github.v3+json") - val response = - parseJson>(app.get(releaseUrl, headers = headers).text) - - val found = - response.lastOrNull { rel -> - rel.prerelease || rel.tag_name == "pre-release" - } - val foundAsset = found?.assets?.filter { it -> - it.content_type == "application/vnd.android.package-archive" - }?.getOrNull(0) - - val tagResponse = - parseJson(app.get(tagUrl, headers = headers).text) - - Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.github_object.sha.take(7)}") - - val shouldUpdate = - (getString(R.string.commit_hash) - .trim { c -> c.isWhitespace() } - .take(7) - != - tagResponse.github_object.sha - .trim { c -> c.isWhitespace() } - .take(7)) - - return if (foundAsset != null) { - Update( - shouldUpdate, - foundAsset.browser_download_url, - tagResponse.github_object.sha, - found.body, - found.node_id - ) - } else { - Update(false, null, null, null, null) - } + val currentVersion = packageName?.let { + packageManager.getPackageInfo(it, 0) } - - private val updateLock = Mutex() - - private suspend fun Activity.downloadUpdate(url: String): Boolean { - try { - Log.d(LOG_TAG, "Downloading update: $url") - val appUpdateName = "CloudStream" - val appUpdateSuffix = "apk" - - // Delete all old updates - this.cacheDir.listFiles()?.filter { - it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix - }?.forEach { - it.deleteOnExit() + val shouldUpdate = if (foundAsset.browserDownloadUrl.isBlank()) { + false + } else { + currentVersion?.versionName?.let { versionName -> + versionRegexLocal.find(versionName)?.groupValues?.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } - - val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") - val sink: BufferedSink = downloadedFile.sink().buffer() - - updateLock.withLock { - sink.writeAll(app.get(url).body.source()) - sink.close() - openApk(this, Uri.fromFile(downloadedFile)) - } - return true - } catch (e: Exception) { - return false - } + }?.compareTo( + foundVersion.groupValues.let { + it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() + })!! < 0 } - private fun openApk(context: Context, uri: Uri) { - try { - uri.path?.let { - val contentUri = FileProvider.getUriForFile( - context, - BuildConfig.APPLICATION_ID + ".provider", - File(it) - ) - val installIntent = Intent(Intent.ACTION_VIEW).apply { - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) - data = contentUri - } - context.startActivity(installIntent) - } - } catch (e: Exception) { - logError(e) - } + return Update( + shouldUpdate, + foundAsset.browserDownloadUrl, + foundVersion.groupValues[2], + found.body, + found.nodeId + ) + } + + private suspend fun Activity.getPreReleaseUpdate(): Update { + val tagUrl = + "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" + val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" + val headers = mapOf("Accept" to "application/vnd.github.v3+json") + val response = parseJson>( + app.get(releaseUrl, headers = headers).text + ).toList() + + val found = response.lastOrNull { rel -> + rel.prerelease || rel.tagName == "pre-release" } - /** - * @param checkAutoUpdate if the update check was launched automatically - **/ - suspend fun Activity.runAutoUpdate(checkAutoUpdate: Boolean = true): Boolean { - val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val foundAsset = found?.assets?.filter { it -> + it.contentType == "application/vnd.android.package-archive" + }?.getOrNull(0) - if (!checkAutoUpdate || settingsManager.getBoolean( - getString(R.string.auto_update_key), - true - ) - ) { - val update = getAppUpdate() - if ( - update.shouldUpdate && - update.updateURL != null) { + if (foundAsset == null) { + return Update(false, null, null, null, null) + } - // Check if update should be skipped - val updateNodeId = - settingsManager.getString(getString(R.string.skip_update_key), "") + val tagResponse = parseJson(app.get(tagUrl, headers = headers).text) + val updateCommitHash = tagResponse.githubObject.sha.trim().take(7) + Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash") - // Skips the update if its an automatic update and the update is skipped - // This allows updating manually - if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { - return false - } + return Update( + currentCommitHash() != updateCommitHash, + foundAsset.browserDownloadUrl, + updateCommitHash, + found.body, + found.nodeId + ) + } - runOnUiThread { - try { - val currentVersion = packageName?.let { - packageManager.getPackageInfo( - it, - 0 - ) - } + private val updateLock = Mutex() - val builder: AlertDialog.Builder = AlertDialog.Builder(this) - builder.setTitle( - getString(R.string.new_update_format).format( - currentVersion?.versionName, - update.updateVersion - ) - ) - builder.setMessage("${update.changelog}") + private suspend fun Activity.downloadUpdate(url: String): Boolean { + try { + Log.d(LOG_TAG, "Downloading update: $url") + val appUpdateName = "CloudStream" + val appUpdateSuffix = "apk" - val context = this - builder.apply { - setPositiveButton(R.string.update) { _, _ -> - // Forcefully start any delayed installations - if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton + // Delete all old updates + this.cacheDir.listFiles()?.filter { + it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix + }?.forEach { deleteFileOnExit(it) } - showToast(context, R.string.download_started, Toast.LENGTH_LONG) + val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") + val sink: BufferedSink = downloadedFile.sink().buffer() - // Check if the setting hasn't been changed - if (settingsManager.getInt( - getString(R.string.apk_installer_key), - -1 - ) == -1 - ) { - if (isMiUi()) // Set to legacy if using miui - settingsManager.edit() - .putInt(getString(R.string.apk_installer_key), 1) - .apply() - } - - val currentInstaller = - settingsManager.getInt( - getString(R.string.apk_installer_key), - 0 - ) - - when (currentInstaller) { - // New method - 0 -> { - val intent = PackageInstallerService.getIntent( - context, - update.updateURL - ) - ContextCompat.startForegroundService(context, intent) - } - // Legacy - 1 -> { - ioSafe { - if (!downloadUpdate(update.updateURL)) - runOnUiThread { - showToast( - context, - R.string.download_failed, - Toast.LENGTH_LONG - ) - } - } - } - } - } - - setNegativeButton(R.string.cancel) { _, _ -> } - - if (checkAutoUpdate) { - setNeutralButton(R.string.skip_update) { _, _ -> - settingsManager.edit().putString( - getString(R.string.skip_update_key), - update.updateNodeId ?: "" - ).apply() - } - } - } - builder.show().setDefaultFocus() - } catch (e: Exception) { - logError(e) - } - } - return true - } - return false + updateLock.withLock { + sink.writeAll(app.get(url).body.source()) + sink.close() + openApk(this, Uri.fromFile(downloadedFile)) } + + return true + } catch (e: Exception) { + logError(e) + return false + } + } + + private fun openApk(context: Context, uri: Uri) = safe { + val path = uri.path ?: return@safe + val contentUri = FileProvider.getUriForFile( + context, BuildConfig.APPLICATION_ID + ".provider", File(path) + ) + val installIntent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + data = contentUri + } + context.startActivity(installIntent) + } + + fun Activity.installPreReleaseIfNeeded() = ioSafe { + val isInstalled = try { + packageManager.getPackageInfo(PRERELEASE_PACKAGE_NAME, 0) + true + } catch (_: NameNotFoundException) { + false + } + + if (isInstalled) { + showToast(R.string.prerelease_already_installed) + } else if (!runAutoUpdate(checkAutoUpdate = false, installPrerelease = true)) { + showToast(R.string.prerelease_install_failed) + } + } + + + /** + * @param checkAutoUpdate if the update check was launched automatically + * @param installPrerelease if we want to install the pre-release version + */ + suspend fun Activity.runAutoUpdate( + checkAutoUpdate: Boolean = true, installPrerelease: Boolean = false + ): Boolean { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val autoUpdateEnabled = + settingsManager.getBoolean(getString(R.string.auto_update_key), true) + if (checkAutoUpdate && !autoUpdateEnabled) { return false } - private fun isMiUi(): Boolean { - return !TextUtils.isEmpty(getSystemProperty("ro.miui.ui.version.name")) + val update = getAppUpdate(installPrerelease) + if (!update.shouldUpdate || update.updateURL == null) { + return false } - private fun getSystemProperty(propName: String): String? { - return try { - val p = Runtime.getRuntime().exec("getprop $propName") - BufferedReader(InputStreamReader(p.inputStream), 1024).use { - it.readLine() + // Check if update should be skipped + val updateNodeId = settingsManager.getString( + getString(R.string.skip_update_key), "" + ) + + // Skips the update if its an automatic update and the update is skipped + // This allows updating manually + if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { + return false + } + + runOnUiThread { + safe { + val currentVersion = packageName?.let { + packageManager.getPackageInfo(it, 0) } - } catch (ex: IOException) { - null + + val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) + builder.setTitle( + getString(R.string.new_update_format).format( + currentVersion?.versionName, update.updateVersion + ) + ) + + val logRegex = Regex("\\[(.*?)]\\((.*?)\\)") + val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> + matchResult.groupValues[1] + } // Sanitized because it looks cluttered + + builder.setMessage(sanitizedChangelog) + builder.apply { + setPositiveButton(R.string.update) { _, _ -> + // Forcefully start any delayed installations + if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton + + showToast(R.string.download_started, Toast.LENGTH_LONG) + + // Check if the setting hasn't been changed + if (settingsManager.getInt( + getString(R.string.apk_installer_key), -1 + ) == -1 + ) { + // Set to legacy installer if using MIUI + if (isMiUi()) { + settingsManager.edit { + putInt(getString(R.string.apk_installer_key), 1) + } + } + } + + val currentInstaller = settingsManager.getInt( + getString(R.string.apk_installer_key), 1 + ) + + when (currentInstaller) { + // New method + 0 -> { + val intent = PackageInstallerService.Companion.getIntent( + this@runAutoUpdate, update.updateURL + ) + ContextCompat.startForegroundService( + this@runAutoUpdate, intent + ) + } + // Legacy + 1 -> { + ioSafe { + if (!downloadUpdate(update.updateURL)) { + runOnUiThread { + showToast( + R.string.download_failed, Toast.LENGTH_LONG + ) + } + } + } + } + } + } + + setNegativeButton(R.string.cancel) { _, _ -> } + + if (checkAutoUpdate) { + setNeutralButton(R.string.skip_update) { _, _ -> + settingsManager.edit { + putString( + getString(R.string.skip_update_key), update.updateNodeId ?: "" + ) + } + } + } + } + builder.show().setDefaultFocus() } } + return true + } + + private fun isMiUi(): Boolean = !getSystemProperty("ro.miui.ui.version.name").isNullOrEmpty() + + private fun getSystemProperty(propName: String): String? = try { + val p = Runtime.getRuntime().exec("getprop $propName") + BufferedReader(InputStreamReader(p.inputStream), 1024).use { + it.readLine() + } + } catch (_: IOException) { + null } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt new file mode 100644 index 000000000..d37d8aad4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt @@ -0,0 +1,24 @@ +package com.lagradost.cloudstream3.utils + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build + +inline fun Intent.getSafeParcelableExtra(key: String): T? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) + @Suppress("DEPRECATION") + getParcelableExtra(key) else getParcelableExtra(key, T::class.java) + +@SuppressLint("UnspecifiedRegisterReceiverFlag") +fun Context.registerBroadcastReceiver(receiver: BroadcastReceiver, actionFilter: IntentFilter) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Register receiver with the context with flag to indicate internal usage + registerReceiver(receiver, actionFilter, Context.RECEIVER_NOT_EXPORTED) + } else { + // For older versions, no special export flag is needed + registerReceiver(receiver, actionFilter) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/M3u8Helper.kt deleted file mode 100644 index 6c5117b4e..000000000 --- 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 bc81a5b9f..67851f629 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.utils +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context @@ -8,16 +9,17 @@ import android.content.IntentFilter import android.content.IntentSender import android.content.pm.PackageInstaller import android.os.Build +import android.util.Log import android.widget.Toast +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall +import com.lagradost.cloudstream3.services.PackageInstallerService import com.lagradost.cloudstream3.utils.Coroutines.main import java.io.InputStream const val INSTALL_ACTION = "ApkInstaller.INSTALL_ACTION" - class ApkInstaller(private val service: PackageInstallerService) { companion object { @@ -25,6 +27,8 @@ class ApkInstaller(private val service: PackageInstallerService) { * Used for postponed installations **/ var delayedInstaller: DelayedInstaller? = null + private var isReceiverRegistered = false + private const val TAG = "ApkInstaller" } inner class DelayedInstaller( @@ -36,6 +40,7 @@ class ApkInstaller(private val service: PackageInstallerService) { session.commit(intent) true } catch (e: Exception) { + logError(e) false }.also { delayedInstaller = null } } @@ -51,13 +56,14 @@ class ApkInstaller(private val service: PackageInstallerService) { } private val installActionReceiver = object : BroadcastReceiver() { + @SuppressLint("UnsafeIntentLaunch") override fun onReceive(context: Context, intent: Intent) { when (intent.getIntExtra( PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE )) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val userAction = intent.getParcelableExtra(Intent.EXTRA_INTENT) + val userAction = intent.getSafeParcelableExtra(Intent.EXTRA_INTENT) userAction?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(userAction) } @@ -104,12 +110,20 @@ class ApkInstaller(private val service: PackageInstallerService) { inputStream.close() } + // We must create an explicit intent or it will fail on Android 15+ + val installIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Intent(service, PackageInstallerService::class.java) + .setAction(INSTALL_ACTION) + } else Intent(INSTALL_ACTION) + + val installFlags = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> PendingIntent.FLAG_MUTABLE + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> PendingIntent.FLAG_IMMUTABLE + else -> 0 + } val intentSender = PendingIntent.getBroadcast( - service, - activeSession, - Intent(INSTALL_ACTION), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0, + service, activeSession, installIntent, installFlags ).intentSender // Use delayed installations on android 13 and only if "allow from unknown sources" is enabled @@ -141,8 +155,30 @@ class ApkInstaller(private val service: PackageInstallerService) { } init { - service.registerReceiver(installActionReceiver, IntentFilter(INSTALL_ACTION)) - service.receivers.add(installActionReceiver) + // Might be dangerous + registerInstallActionReceiver() + } + + private fun registerInstallActionReceiver() { + if (!isReceiverRegistered) { + val intentFilter = IntentFilter().apply { + addAction(INSTALL_ACTION) + } + Log.d(TAG, "Registering install action event receiver") + context?.registerBroadcastReceiver(installActionReceiver, intentFilter) + isReceiverRegistered = true + } + } + + fun unregisterInstallActionReceiver() { + if (isReceiverRegistered) { + Log.d(TAG, "Unregistering install action event receiver") + try { + context?.unregisterReceiver(installActionReceiver) + } catch (e: Exception) { + logError(e) + } + isReceiverRegistered = false + } } } - diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt new file mode 100644 index 000000000..6580182bb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt @@ -0,0 +1,168 @@ +package com.lagradost.cloudstream3.utils +//Reference: https://stackoverflow.com/a/29055283 +import android.content.Context +import android.graphics.Matrix +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import androidx.core.content.withStyledAttributes +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError + +/** + * A custom [AppCompatImageView] that allows precise control over the visible crop area + * of an image by adjusting its horizontal and vertical center offset percentages. + * + * ### Key Features: + * - Allows **manual vertical or horizontal cropping** via percentage offsets. + * - Works seamlessly with Coil, Glide, or any image loading library. + * + * ### Usage (XML): + * You can set the crop offset directly in XML using custom attributes: + * ```xml + * + * ``` + * - `app:cropYCenterOffsetPct` → controls how far vertically the image shifts + * `0.0` = top-aligned, `0.5` = centered, `1.0` = bottom-aligned. + * - `app:cropXCenterOffsetPct` → controls how far horizontally the image shifts + * `0.0` = left, `0.5` = center, `1.0` = right. + * + * ### Programmatic Example: + * ```kotlin + * imageView.cropYCenterOffsetPct = 0.15f // Show slightly more (15%) of the top area + * imageView.cropXCenterOffsetPct = 0.5f // Keep image centered horizontally + * imageView.redraw() //Only needed if you changed cropYCenterOffsetPct/cropXCenterOffsetPct at runtime + * ``` + * + * ### Notes: + * - Must use `android:scaleType="matrix"` to enable manual matrix transformations. + * - Reference: https://stackoverflow.com/a/29055283 + * + * @property cropYCenterOffsetPct the vertical crop percentage (0.0–1.0) + * @property cropXCenterOffsetPct the horizontal crop percentage (0.0–1.0) + * + * @see ImageView.ScaleType.MATRIX + */ +class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { + private var mCropYCenterOffsetPct: Float? = null + private var mCropXCenterOffsetPct: Float? = null + + constructor(context: Context?) : super(context!!) + + constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) { + initAttrs(context, attrs) + } + + constructor( + context: Context?, attrs: AttributeSet?, + defStyle: Int + ) : super(context!!, attrs, defStyle) { + initAttrs(context, attrs) + } + + var cropYCenterOffsetPct: Float + get() = mCropYCenterOffsetPct!! + set(cropYCenterOffsetPct) { + require(cropYCenterOffsetPct <= 1.0) { "Value too large: Must be <= 1.0" } + mCropYCenterOffsetPct = cropYCenterOffsetPct + } + var cropXCenterOffsetPct: Float + get() = mCropXCenterOffsetPct!! + set(cropXCenterOffsetPct) { + require(cropXCenterOffsetPct <= 1.0) { "Value too large: Must be <= 1.0" } + mCropXCenterOffsetPct = cropXCenterOffsetPct + } + + private fun myConfigureBounds() { + if (this.scaleType == ScaleType.MATRIX) { + + val d = this.drawable + if (d != null) { + val dWidth = d.intrinsicWidth + val dHeight = d.intrinsicHeight + val m = Matrix() + val vWidth = width - this.paddingLeft - this.paddingRight + val vHeight = height - this.paddingTop - this.paddingBottom + val scale: Float + var dx = 0f + var dy = 0f + if (dWidth * vHeight > vWidth * dHeight) { + val cropXCenterOffsetPct = + if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!! else 0.5f + scale = vHeight.toFloat() / dHeight.toFloat() + dx = (vWidth - dWidth * scale) * cropXCenterOffsetPct + } else { + val cropYCenterOffsetPct = + if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!! else 0f + scale = vWidth.toFloat() / dWidth.toFloat() + dy = (vHeight - dHeight * scale) * cropYCenterOffsetPct + } + m.setScale(scale, scale) + m.postTranslate((dx + 0.5f).toInt().toFloat(), (dy + 0.5f).toInt().toFloat()) + this.imageMatrix = m + } + } + } + + // These 3 methods call configureBounds in ImageView.java class, which + // adjusts the matrix in a call to center_crop (android's built-in + // scaling and centering crop method). We also want to trigger + // in the same place, but using our own matrix, which is then set + // directly at line 588 of ImageView.java and then copied over + // as the draw matrix at line 942 of ImageView.java + override fun setFrame(l: Int, t: Int, r: Int, b: Int): Boolean { + val changed = super.setFrame(l, t, r, b) + myConfigureBounds() + return changed + } + + override fun setImageDrawable(d: Drawable?) { + super.setImageDrawable(d) + myConfigureBounds() + } + + override fun setImageResource(resId: Int) { + super.setImageResource(resId) + myConfigureBounds() + } + + // In case you can change the ScaleType in code you have to call redraw() + //fullsizeImageView.setScaleType(ScaleType.FIT_CENTER); + //fullsizeImageView.redraw(); + fun redraw() { + val d = this.drawable + if (d != null) { + // Force toggle to recalculate our bounds + setImageDrawable(null) + setImageDrawable(d) + } + } + + private fun initAttrs(context: Context, attrs: AttributeSet?) { + attrs ?: return + context.withStyledAttributes(attrs, R.styleable.PercentageCropImageView) { + try { + if (hasValue(R.styleable.PercentageCropImageView_cropYCenterOffsetPct)) { + mCropYCenterOffsetPct = getFloat( + R.styleable.PercentageCropImageView_cropYCenterOffsetPct, + 0.5f + ) + } + if (hasValue(R.styleable.PercentageCropImageView_cropXCenterOffsetPct)) { + mCropXCenterOffsetPct = getFloat( + R.styleable.PercentageCropImageView_cropXCenterOffsetPct, + 0.5f + ) + } + } catch (e: Exception) { + logError(e) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt new file mode 100644 index 000000000..e3c7d68df --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -0,0 +1,82 @@ +package com.lagradost.cloudstream3.utils + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.Build.VERSION.SDK_INT +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE +import com.lagradost.cloudstream3.ui.settings.Globals.isLayout + +private const val PACKAGE_NAME = BuildConfig.APPLICATION_ID +private const val TAG = "PowerManagerAPI" + +object BatteryOptimizationChecker { + + fun isAppRestricted(context: Context?): Boolean { + if (SDK_INT >= 23 && context != null) { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return !powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + return false // below Marshmallow, it's always unrestricted when app is in background + } + + fun openBatteryOptimizationSettings(context: Context) { + if (shouldShowBatteryOptimizationDialog(context)) { + context.showBatteryOptimizationDialog() + } + } + + fun Context.showBatteryOptimizationDialog() { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + try { + AlertDialog.Builder(this) + .setTitle(R.string.battery_dialog_title) + .setIcon(R.drawable.ic_battery) + .setMessage(R.string.battery_dialog_message) + .setPositiveButton(R.string.ok) { _, _ -> showRequestIgnoreBatteryOptDialog() } + .setNegativeButton(R.string.cancel) { _, _ -> + settingsManager.edit { + putBoolean(getString(R.string.battery_optimisation_key), false) + } + } + .show() + } catch (t: Throwable) { + Log.e(TAG, "Error showing battery optimization dialog", t) + } + } + + private fun shouldShowBatteryOptimizationDialog(context: Context): Boolean { + val isRestricted = isAppRestricted(context) + val isOptimizedNotShown = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.battery_optimisation_key), true) + return isRestricted && isOptimizedNotShown && isLayout(PHONE) + } + + private fun Context.showRequestIgnoreBatteryOptDialog() { + try { + val intent = Intent().apply { + action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS + data = "package:$PACKAGE_NAME".toUri() + } + startActivity(intent) + } catch (t: Throwable) { + Log.e(TAG, "Unable to invoke APP_DETAILS intent", t) + if (t is ActivityNotFoundException) { + showToast("Exception: Activity Not Found") + } else { + showToast(R.string.app_info_intent_error) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt index 2dc6846c4..26c710103 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt @@ -2,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,39 +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 - applyHolder?.isVisible = realShowApply + if (isLayout(PHONE or EMULATOR) && dialog is BottomSheetDialog) { + binding.dragHandle.isVisible = true + listView.isNestedScrollingEnabled = true + } + + 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 @@ -136,7 +155,7 @@ object SingleSelectionHelper { dismissCallback.invoke() } - listView?.setOnItemClickListener { _, _, which, _ -> + listView.setOnItemClickListener { _, _, which, _ -> // lastSelectedIndex = which if (realShowApply) { if (!isMultiSelect) { @@ -148,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]) @@ -157,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, @@ -174,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 @@ -213,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( @@ -232,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), @@ -250,17 +288,6 @@ object SingleSelectionHelper { ) } - fun showBottomDialog( - items: List, - selectedIndex: Int, - name: String, - showApply: Boolean, - dismissCallback: () -> Unit, - callback: (Int) -> Unit, - ) { - - } - /** Only for a low amount of items */ fun Activity?.showBottomDialog( items: List, @@ -271,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), @@ -296,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, @@ -320,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, @@ -333,4 +378,24 @@ object SingleSelectionHelper { dismissCallback ) } + + fun Activity.showBottomDialogText( + title: String, + text: Spanned, + dismissCallback: () -> Unit + ) { + val binding = BottomTextDialogBinding.inflate(layoutInflater) + val dialog = BottomSheetDialog(this) + + dialog.setContentView(binding.root) + + binding.dialogTitle.text = title + binding.dialogText.text = text + + dialog.setOnDismissListener { + dismissCallback.invoke() + } + + dialog.show() + } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt new file mode 100644 index 000000000..b43b51c74 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt @@ -0,0 +1,83 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Activity +import android.view.View +import androidx.annotation.MainThread +import androidx.annotation.StringRes +import com.google.android.material.snackbar.Snackbar +import com.lagradost.api.Log +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute + +object SnackbarHelper { + + private const val TAG = "COMPACT" + private var currentSnackbar: Snackbar? = null + + @MainThread + fun showSnackbar( + act: Activity?, + message: UiText, + duration: Int = Snackbar.LENGTH_SHORT, + actionText: UiText? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null) return + showSnackbar(act, message.asString(act), duration, + actionText?.asString(act), actionCallback) + } + + @MainThread + fun showSnackbar( + act: Activity?, + @StringRes message: Int, + duration: Int = Snackbar.LENGTH_SHORT, + @StringRes actionText: Int? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null) return + showSnackbar(act, act.getString(message), duration, + actionText?.let { act.getString(it) }, actionCallback) + } + + @MainThread + fun showSnackbar( + act: Activity?, + message: String?, + duration: Int = Snackbar.LENGTH_SHORT, + actionText: String? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null || message == null) { + Log.w(TAG, "Invalid showSnackbar: act = $act, message = $message") + return + } + Log.i(TAG, "showSnackbar: $message") + + try { + currentSnackbar?.dismiss() + } catch (e: Exception) { + logError(e) + } + + try { + val parentView = act.findViewById(android.R.id.content) + val snackbar = Snackbar.make(parentView, message, duration) + + actionCallback?.let { + snackbar.setAction(actionText) { actionCallback.invoke() } + } + + snackbar.show() + currentSnackbar = snackbar + + snackbar.setBackgroundTint(act.colorFromAttribute(R.attr.primaryBlackBackground)) + snackbar.setTextColor(act.colorFromAttribute(R.attr.textColor)) + snackbar.setActionTextColor(act.colorFromAttribute(R.attr.colorPrimary)) + + } catch (e: Exception) { + logError(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt deleted file mode 100644 index 33f1b6ffd..000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleHelper.kt +++ /dev/null @@ -1,518 +0,0 @@ -package com.lagradost.cloudstream3.utils - -import com.lagradost.cloudstream3.mvvm.logError -import java.util.* - - -object SubtitleHelper { - data class Language639( - val languageName: String, - val nativeName: String, - val ISO_639_1: String, - val ISO_639_2_T: String, - val ISO_639_2_B: String, - val ISO_639_3: String, - val ISO_639_6: String, - ) - - /*fun createISO() { - val url = "https://infogalactic.com/info/List_of_ISO_639-1_codes" - val response = get(url).text - val document = Jsoup.parse(response) - val headers = document.select("table.wikitable > tbody > tr") - - var text = "listOf(\n" - for (head in headers) { - val tds = head.select("td") - if (tds.size < 8) continue - val name = tds[2].selectFirst("> a").text() - val native = tds[3].text() - val ISO_639_1 = tds[4].ownText().replace("+", "").replace(" ", "") - val ISO_639_2_T = tds[5].ownText().replace("+", "").replace(" ", "") - val ISO_639_2_B = tds[6].ownText().replace("+", "").replace(" ", "") - val ISO_639_3 = tds[7].ownText().replace("+", "").replace(" ", "") - val ISO_639_6 = tds[8].ownText().replace("+", "").replace(" ", "") - - val txtAdd = - "Language(\"$name\", \"$native\", \"$ISO_639_1\", \"$ISO_639_2_T\", \"$ISO_639_2_B\", \"$ISO_639_3\", \"$ISO_639_6\"),\n" - text += txtAdd - } - text += ")" - println("ISO CREATED:\n$text") - }*/ - - /** lang -> ISO_639_1 - * @param looseCheck will use .contains in addition to .equals - * */ - fun fromLanguageToTwoLetters(input: String, looseCheck: Boolean): String? { - languages.forEach { - if (it.languageName.equals(input, ignoreCase = true) - || it.nativeName.equals(input, ignoreCase = true) - ) return it.ISO_639_1 - } - - // Runs as a separate loop as to prioritize fully matching languages. - if (looseCheck) - languages.forEach { - if (input.contains(it.languageName, ignoreCase = true) - || input.contains(it.nativeName, ignoreCase = true) - ) return it.ISO_639_1 - } - - return null - } - - private var ISO_639_1Map: HashMap = hashMapOf() - private fun initISO6391Map() { - for (lang in languages) { - ISO_639_1Map[lang.ISO_639_1] = lang.languageName - } - } - - /** ISO_639_1 -> lang*/ - fun fromTwoLettersToLanguage(input: String): String? { - // pr-BR - if (input.substringBefore("-").length != 2) return null - if (ISO_639_1Map.isEmpty()) { - initISO6391Map() - } - val comparison = input.lowercase(Locale.ROOT) - - return ISO_639_1Map[comparison] - } - - /**ISO_639_2_B or ISO_639_2_T or ISO_639_3-> lang*/ - fun fromThreeLettersToLanguage(input: String): String? { - if (input.length != 3) return null - val comparison = input.lowercase(Locale.ROOT) - for (lang in languages) { - if (lang.ISO_639_2_B == comparison) { - return lang.languageName - } - } - for (lang in languages) { - if (lang.ISO_639_2_T == comparison) { - return lang.languageName - } - } - for (lang in languages) { - if (lang.ISO_639_3 == comparison) { - return lang.languageName - } - } - return null - } - - /** lang -> ISO_639_2_T*/ - fun fromLanguageToThreeLetters(input: String): String? { - for (lang in languages) { - if (lang.languageName == input || lang.nativeName == input) { - return lang.ISO_639_2_T - } - } - return null - } - - private const val flagOffset = 0x1F1E6 - private const val asciiOffset = 0x41 - private const val offset = flagOffset - asciiOffset - - private val flagRegex = Regex("[\uD83C\uDDE6-\uD83C\uDDFF]{2}") - - fun getFlagFromIso(inp: String?): String? { - if (inp.isNullOrBlank() || inp.length < 2) return null - - try { - val ret = getFlagFromIsoShort(flags[inp]) - ?: getFlagFromIsoShort(inp.uppercase()) ?: return null - - return if (flagRegex.matches(ret)) { - ret - } else { - null - } - } catch (e: Exception) { - logError(e) - return null - } - } - - private fun getFlagFromIsoShort(flagAscii: String?): String? { - if (flagAscii.isNullOrBlank() || flagAscii.length < 2) return null - return try { - val firstChar: Int = Character.codePointAt(flagAscii, 0) + offset - val secondChar: Int = Character.codePointAt(flagAscii, 1) + offset - - (String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))) - } catch (e: Exception) { - logError(e) - null - } - } - - private val flags = mapOf( - "af" to "ZA", - "agq" to "CM", - "ak" to "GH", - "am" to "ET", - "ar" to "AE", - "as" to "IN", - "asa" to "TZ", - "az" to "AZ", - "bas" to "CM", - "be" to "BY", - "bem" to "ZM", - "bez" to "IT", - "bg" to "BG", - "bm" to "ML", - "bn" to "BD", - "bo" to "CN", - "br" to "FR", - "brx" to "IN", - "bs" to "BA", - "ca" to "ES", - "cgg" to "UG", - "chr" to "US", - "cs" to "CZ", - "cy" to "GB", - "da" to "DK", - "dav" to "KE", - "de" to "DE", - "dje" to "NE", - "dua" to "CM", - "dyo" to "SN", - "ebu" to "KE", - "ee" to "GH", - "en" to "GB", - "el" to "GR", - "es" to "ES", - "et" to "EE", - "eu" to "ES", - "ewo" to "CM", - "fa" to "IR", - "fil" to "PH", - "fr" to "FR", - "ga" to "IE", - "gl" to "ES", - "gsw" to "CH", - "gu" to "IN", - "guz" to "KE", - "gv" to "GB", - "ha" to "NG", - "haw" to "US", - "he" to "IL", - "hi" to "IN", - "ff" to "CN", - "fi" to "FI", - "fo" to "FO", - "hr" to "HR", - "hu" to "HU", - "hy" to "AM", - "id" to "ID", - "ig" to "NG", - "ii" to "CN", - "is" to "IS", - "it" to "IT", - "ita" to "IT", - "ja" to "JP", - "jmc" to "TZ", - "ka" to "GE", - "kab" to "DZ", - "ki" to "KE", - "kam" to "KE", - "mer" to "KE", - "kde" to "TZ", - "kea" to "CV", - "khq" to "ML", - "kk" to "KZ", - "kl" to "GL", - "kln" to "KE", - "km" to "KH", - "kn" to "IN", - "ko" to "KR", - "kok" to "IN", - "ksb" to "TZ", - "ksf" to "CM", - "kw" to "GB", - "lag" to "TZ", - "lg" to "UG", - "ln" to "CG", - "lt" to "LT", - "lu" to "CD", - "lv" to "LV", - "lat" to "LV", - "luo" to "KE", - "luy" to "KE", - "mas" to "TZ", - "mfe" to "MU", - "mg" to "MG", - "mgh" to "MZ", - "ml" to "IN", - "mk" to "MK", - "mr" to "IN", - "ms" to "MY", - "mt" to "MT", - "mua" to "CM", - "my" to "MM", - "naq" to "NA", - "nb" to "NO", - "no" to "NO", - "nn" to "NO", - "nd" to "ZW", - "ne" to "NP", - "nl" to "NL", - "nmg" to "CM", - "nus" to "SD", - "nyn" to "UG", - "om" to "ET", - "or" to "IN", - "pa" to "PK", - "pl" to "PL", - "ps" to "AF", - "pt" to "PT", - "pt-pt" to "PT", - "pt-br" to "BR", - "rm" to "CH", - "rn" to "BI", - "ro" to "RO", - "ru" to "RU", - "rw" to "RW", - "rof" to "TZ", - "rwk" to "TZ", - "saq" to "KE", - "sbp" to "TZ", - "seh" to "MZ", - "ses" to "ML", - "sg" to "CF", - "shi" to "MA", - "si" to "LK", - "sk" to "SK", - "sl" to "SI", - "sn" to "ZW", - "so" to "SO", - "sq" to "AL", - "sr" to "RS", - "sv" to "SE", - "sw" to "TZ", - "swc" to "CD", - "ta" to "IN", - "te" to "IN", - "teo" to "UG", - "th" to "TH", - "ti" to "ET", - "to" to "TO", - "tr" to "TR", - "twq" to "NE", - "tzm" to "MA", - "uk" to "UA", - "ur" to "PK", - "uz" to "UZ", - "vai" to "LR", - "vi" to "VN", - "vun" to "TZ", - "xog" to "UG", - "yav" to "CM", - "yo" to "NG", - "zh" to "CN", - "zu" to "ZA", - "tl" to "PH", - ) - - val languages = listOf( - Language639("Abkhaz", "аҧсуа бызшәа, аҧсшәа", "ab", "abk", "abk", "abk", "abks"), - Language639("Afar", "Afaraf", "aa", "aar", "aar", "aar", "aars"), - Language639("Afrikaans", "Afrikaans", "af", "afr", "afr", "afr", "afrs"), - Language639("Akan", "Akan", "ak", "aka", "aka", "aka", ""), - Language639("Albanian", "Shqip", "sq", "sqi", "", "sqi", ""), - Language639("Amharic", "አማርኛ", "am", "amh", "amh", "amh", ""), - Language639("Arabic", "العربية", "ar", "ara", "ara", "ara", ""), - Language639("Aragonese", "aragonés", "an", "arg", "arg", "arg", ""), - Language639("Armenian", "Հայերեն", "hy", "hye", "", "hye", ""), - Language639("Assamese", "অসমীয়া", "as", "asm", "asm", "asm", ""), - Language639("Avaric", "авар мацӀ, магӀарул мацӀ", "av", "ava", "ava", "ava", ""), - Language639("Avestan", "avesta", "ae", "ave", "ave", "ave", ""), - Language639("Aymara", "aymar aru", "ay", "aym", "aym", "aym", ""), - Language639("Azerbaijani", "azərbaycan dili", "az", "aze", "aze", "aze", ""), - Language639("Bambara", "bamanankan", "bm", "bam", "bam", "bam", ""), - Language639("Bashkir", "башҡорт теле", "ba", "bak", "bak", "bak", ""), - Language639("Basque", "euskara, euskera", "eu", "eus", "", "eus", ""), - Language639("Belarusian", "беларуская мова", "be", "bel", "bel", "bel", ""), - Language639("Bengali", "বাংলা", "bn", "ben", "ben", "ben", ""), - Language639("Bihari", "भोजपुरी", "bh", "bih", "bih", "", ""), - Language639("Bislama", "Bislama", "bi", "bis", "bis", "bis", ""), - Language639("Bosnian", "bosanski jezik", "bs", "bos", "bos", "bos", "boss"), - Language639("Breton", "brezhoneg", "br", "bre", "bre", "bre", ""), - Language639("Bulgarian", "български език", "bg", "bul", "bul", "bul", "buls"), - Language639("Burmese", "ဗမာစာ", "my", "mya", "", "mya", ""), - Language639("Catalan", "català", "ca", "cat", "cat", "cat", ""), - Language639("Chamorro", "Chamoru", "ch", "cha", "cha", "cha", ""), - Language639("Chechen", "нохчийн мотт", "ce", "che", "che", "che", ""), - Language639("Chichewa", "chiCheŵa, chinyanja", "ny", "nya", "nya", "nya", ""), - Language639("Chinese", "中文 (Zhōngwén), 汉语, 漢語", "zh", "zho", "", "zho", ""), - Language639("Chuvash", "чӑваш чӗлхи", "cv", "chv", "chv", "chv", ""), - Language639("Cornish", "Kernewek", "kw", "cor", "cor", "cor", ""), - Language639("Corsican", "corsu, lingua corsa", "co", "cos", "cos", "cos", ""), - Language639("Cree", "ᓀᐦᐃᔭᐍᐏᐣ", "cr", "cre", "cre", "cre", ""), - Language639("Croatian", "hrvatski jezik", "hr", "hrv", "hrv", "hrv", ""), - Language639("Czech", "čeština, český jazyk", "cs", "ces", "", "ces", ""), - Language639("Danish", "dansk", "da", "dan", "dan", "dan", ""), - Language639("Divehi", "ދިވެހި", "dv", "div", "div", "div", ""), - Language639("Dutch", "Nederlands, Vlaams", "nl", "nld", "", "nld", ""), - Language639("Dzongkha", "རྫོང་ཁ", "dz", "dzo", "dzo", "dzo", ""), - Language639("English", "English", "en", "eng", "eng", "eng", "engs"), - Language639("Esperanto", "Esperanto", "eo", "epo", "epo", "epo", ""), - Language639("Estonian", "eesti, eesti keel", "et", "est", "est", "est", ""), - Language639("Ewe", "Eʋegbe", "ee", "ewe", "ewe", "ewe", ""), - Language639("Faroese", "føroyskt", "fo", "fao", "fao", "fao", ""), - Language639("Fijian", "vosa Vakaviti", "fj", "fij", "fij", "fij", ""), - Language639("Finnish", "suomi, suomen kieli", "fi", "fin", "fin", "fin", ""), - Language639("French", "français, langue française", "fr", "fra", "", "fra", "fras"), - Language639("Fula", "Fulfulde, Pulaar, Pular", "ff", "ful", "ful", "ful", ""), - Language639("Galician", "galego", "gl", "glg", "glg", "glg", ""), - Language639("Georgian", "ქართული", "ka", "kat", "", "kat", ""), - Language639("German", "Deutsch", "de", "deu", "", "deu", "deus"), - Language639("Greek", "ελληνικά", "el", "ell", "", "ell", "ells"), - Language639("Guaraní", "Avañe'ẽ", "gn", "grn", "grn", "grn", ""), - Language639("Gujarati", "ગુજરાતી", "gu", "guj", "guj", "guj", ""), - Language639("Haitian", "Kreyòl ayisyen", "ht", "hat", "hat", "hat", ""), - Language639("Hausa", "(Hausa) هَوُسَ", "ha", "hau", "hau", "hau", ""), - Language639("Hebrew", "עברית", "he", "heb", "heb", "heb", ""), - Language639("Herero", "Otjiherero", "hz", "her", "her", "her", ""), - Language639("Hindi", "हिन्दी, हिंदी", "hi", "hin", "hin", "hin", "hins"), - Language639("Hiri Motu", "Hiri Motu", "ho", "hmo", "hmo", "hmo", ""), - Language639("Hungarian", "magyar", "hu", "hun", "hun", "hun", ""), - Language639("Interlingua", "Interlingua", "ia", "ina", "ina", "ina", ""), - Language639("Indonesian", "Bahasa Indonesia", "id", "ind", "ind", "ind", ""), - Language639( - "Interlingue", - "Originally called Occidental; then Interlingue after WWII", - "ie", - "ile", - "ile", - "ile", - "" - ), - Language639("Irish", "Gaeilge", "ga", "gle", "gle", "gle", ""), - Language639("Igbo", "Asụsụ Igbo", "ig", "ibo", "ibo", "ibo", ""), - Language639("Inupiaq", "Iñupiaq, Iñupiatun", "ik", "ipk", "ipk", "ipk", ""), - Language639("Ido", "Ido", "io", "ido", "ido", "ido", "idos"), - Language639("Icelandic", "Íslenska", "is", "isl", "", "isl", ""), - Language639("Italian", "italiano", "it", "ita", "ita", "ita", "itas"), - Language639("Inuktitut", "ᐃᓄᒃᑎᑐᑦ", "iu", "iku", "iku", "iku", ""), - Language639("Japanese", "日本語 (にほんご)", "ja", "jpn", "jpn", "jpn", ""), - Language639("Javanese", "ꦧꦱꦗꦮ", "jv", "jav", "jav", "jav", ""), - Language639("Kalaallisut", "kalaallisut, kalaallit oqaasii", "kl", "kal", "kal", "kal", ""), - Language639("Kannada", "ಕನ್ನಡ", "kn", "kan", "kan", "kan", ""), - Language639("Kanuri", "Kanuri", "kr", "kau", "kau", "kau", ""), - Language639("Kashmiri", "कश्मीरी, كشميري‎", "ks", "kas", "kas", "kas", ""), - Language639("Kazakh", "қазақ тілі", "kk", "kaz", "kaz", "kaz", ""), - Language639("Khmer", "ខ្មែរ, ខេមរភាសា, ភាសាខ្មែរ", "km", "khm", "khm", "khm", ""), - Language639("Kikuyu", "Gĩkũyũ", "ki", "kik", "kik", "kik", ""), - Language639("Kinyarwanda", "Ikinyarwanda", "rw", "kin", "kin", "kin", ""), - Language639("Kyrgyz", "Кыргызча, Кыргыз тили", "ky", "kir", "kir", "kir", ""), - Language639("Komi", "коми кыв", "kv", "kom", "kom", "kom", ""), - Language639("Kongo", "Kikongo", "kg", "kon", "kon", "kon", ""), - Language639("Korean", "한국어, 조선어", "ko", "kor", "kor", "kor", ""), - Language639("Kurdish", "Kurdî, كوردی‎", "ku", "kur", "kur", "kur", ""), - Language639("Kwanyama", "Kuanyama", "kj", "kua", "kua", "kua", ""), - Language639("Latin", "latine, lingua latina", "la", "lat", "lat", "lat", "lats"), - Language639("Luxembourgish", "Lëtzebuergesch", "lb", "ltz", "ltz", "ltz", ""), - Language639("Ganda", "Luganda", "lg", "lug", "lug", "lug", ""), - Language639("Limburgish", "Limburgs", "li", "lim", "lim", "lim", ""), - Language639("Lingala", "Lingála", "ln", "lin", "lin", "lin", ""), - Language639("Lao", "ພາສາລາວ", "lo", "lao", "lao", "lao", ""), - Language639("Lithuanian", "lietuvių kalba", "lt", "lit", "lit", "lit", ""), - Language639("Luba-Katanga", "Tshiluba", "lu", "lub", "lub", "lub", ""), - Language639("Latvian", "latviešu valoda", "lv", "lav", "lav", "lav", ""), - Language639("Manx", "Gaelg, Gailck", "gv", "glv", "glv", "glv", ""), - Language639("Macedonian", "македонски јазик", "mk", "mkd", "", "mkd", ""), - Language639("Malagasy", "fiteny malagasy", "mg", "mlg", "mlg", "mlg", ""), - Language639("Malay", "bahasa Melayu, بهاس ملايو‎", "ms", "msa", "", "msa", ""), - Language639("Malayalam", "മലയാളം", "ml", "mal", "mal", "mal", ""), - Language639("Maltese", "Malti", "mt", "mlt", "mlt", "mlt", ""), - Language639("Māori", "te reo Māori", "mi", "mri", "", "mri", ""), - Language639("Marathi", "मराठी", "mr", "mar", "mar", "mar", ""), - Language639("Marshallese", "Kajin M̧ajeļ", "mh", "mah", "mah", "mah", ""), - Language639("Mongolian", "Монгол хэл", "mn", "mon", "mon", "mon", ""), - Language639("Nauruan", "Dorerin Naoero", "na", "nau", "nau", "nau", ""), - Language639("Navajo", "Diné bizaad", "nv", "nav", "nav", "nav", ""), - Language639("Northern Ndebele", "isiNdebele", "nd", "nde", "nde", "nde", ""), - Language639("Nepali", "नेपाली", "ne", "nep", "nep", "nep", ""), - Language639("Ndonga", "Owambo", "ng", "ndo", "ndo", "ndo", ""), - Language639("Norwegian Bokmål", "Norsk bokmål", "nb", "nob", "nob", "nob", ""), - Language639("Norwegian Nynorsk", "Norsk nynorsk", "nn", "nno", "nno", "nno", ""), - Language639("Norwegian", "Norsk", "no", "nor", "nor", "nor", ""), - Language639("Nuosu", "ꆈꌠ꒿ Nuosuhxop", "ii", "iii", "iii", "iii", ""), - Language639("Southern Ndebele", "isiNdebele", "nr", "nbl", "nbl", "nbl", ""), - Language639("Occitan", "occitan, lenga d'òc", "oc", "oci", "oci", "oci", ""), - Language639("Ojibwe", "ᐊᓂᔑᓈᐯᒧᐎᓐ", "oj", "oji", "oji", "oji", ""), - Language639("Old Church Slavonic", "ѩзыкъ словѣньскъ", "cu", "chu", "chu", "chu", ""), - Language639("Oromo", "Afaan Oromoo", "om", "orm", "orm", "orm", ""), - Language639("Oriya", "ଓଡ଼ିଆ", "or", "ori", "ori", "ori", ""), - Language639("Ossetian", "ирон æвзаг", "os", "oss", "oss", "oss", ""), - Language639("Panjabi", "ਪੰਜਾਬੀ, پنجابی‎", "pa", "pan", "pan", "pan", ""), - Language639("Pāli", "पाऴि", "pi", "pli", "pli", "pli", ""), - Language639("Persian", "فارسی", "fa", "fas", "", "fas", ""), - Language639("Polish", "język polski, polszczyzna", "pl", "pol", "pol", "pol", "pols"), - Language639("Pashto", "پښتو", "ps", "pus", "pus", "pus", ""), - Language639("Portuguese", "português", "pt-pt", "por", "por", "por", ""), - // Addition to support Brazilian Portuguese properly, might break other things - Language639("Portuguese (Brazilian)", "português", "pt-br", "por", "por", "por", ""), - Language639("Quechua", "Runa Simi, Kichwa", "qu", "que", "que", "que", ""), - Language639("Romansh", "rumantsch grischun", "rm", "roh", "roh", "roh", ""), - Language639("Kirundi", "Ikirundi", "rn", "run", "run", "run", ""), - Language639("Reunion Creole", "Kréol Rénioné", "rc", "rcf", "rcf", "rcf", ""), - Language639("Romanian", "limba română", "ro", "ron", "", "ron", ""), - Language639("Russian", "Русский", "ru", "rus", "rus", "rus", ""), - Language639("Sanskrit", "संस्कृतम्", "sa", "san", "san", "san", ""), - Language639("Sardinian", "sardu", "sc", "srd", "srd", "srd", ""), - Language639("Sindhi", "सिन्धी, سنڌي، سندھی‎", "sd", "snd", "snd", "snd", ""), - Language639("Northern Sami", "Davvisámegiella", "se", "sme", "sme", "sme", ""), - Language639("Samoan", "gagana fa'a Samoa", "sm", "smo", "smo", "smo", ""), - Language639("Sango", "yângâ tî sängö", "sg", "sag", "sag", "sag", ""), - Language639("Serbian", "српски језик", "sr", "srp", "srp", "srp", ""), - Language639("Scottish Gaelic", "Gàidhlig", "gd", "gla", "gla", "gla", ""), - Language639("Shona", "chiShona", "sn", "sna", "sna", "sna", ""), - Language639("Sinhalese", "සිංහල", "si", "sin", "sin", "sin", ""), - Language639("Slovak", "slovenčina, slovenský jazyk", "sk", "slk", "", "slk", ""), - Language639("Slovene", "slovenski jezik, slovenščina", "sl", "slv", "slv", "slv", ""), - Language639("Somali", "Soomaaliga, af Soomaali", "so", "som", "som", "som", ""), - Language639("Southern Sotho", "Sesotho", "st", "sot", "sot", "sot", ""), - Language639("Spanish", "español", "es", "spa", "spa", "spa", ""), - Language639("Sundanese", "Basa Sunda", "su", "sun", "sun", "sun", ""), - Language639("Swahili", "Kiswahili", "sw", "swa", "swa", "swa", ""), - Language639("Swati", "SiSwati", "ss", "ssw", "ssw", "ssw", ""), - Language639("Swedish", "svenska", "sv", "swe", "swe", "swe", ""), - Language639("Tamil", "தமிழ்", "ta", "tam", "tam", "tam", ""), - Language639("Telugu", "తెలుగు", "te", "tel", "tel", "tel", ""), - Language639("Tajik", "тоҷикӣ, toçikī, تاجیکی‎", "tg", "tgk", "tgk", "tgk", ""), - Language639("Thai", "ไทย", "th", "tha", "tha", "tha", ""), - Language639("Tigrinya", "ትግርኛ", "ti", "tir", "tir", "tir", ""), - Language639("Tibetan Standard", "བོད་ཡིག", "bo", "bod", "", "bod", ""), - Language639("Turkmen", "Türkmen, Түркмен", "tk", "tuk", "tuk", "tuk", ""), - Language639("Tagalog", "Wikang Tagalog", "tl", "tgl", "tgl", "tgl", ""), - Language639("Tswana", "Setswana", "tn", "tsn", "tsn", "tsn", ""), - Language639("Tonga", "faka Tonga", "to", "ton", "ton", "ton", ""), - Language639("Turkish", "Türkçe", "tr", "tur", "tur", "tur", ""), - Language639("Tsonga", "Xitsonga", "ts", "tso", "tso", "tso", ""), - Language639("Tatar", "татар теле, tatar tele", "tt", "tat", "tat", "tat", ""), - Language639("Twi", "Twi", "tw", "twi", "twi", "twi", ""), - Language639("Tahitian", "Reo Tahiti", "ty", "tah", "tah", "tah", ""), - Language639("Uyghur", "ئۇيغۇرچە‎, Uyghurche", "ug", "uig", "uig", "uig", ""), - Language639("Ukrainian", "Українська", "uk", "ukr", "ukr", "ukr", ""), - Language639("Urdu", "اردو", "ur", "urd", "urd", "urd", ""), - Language639("Uzbek", "Oʻzbek, Ўзбек, أۇزبېك‎", "uz", "uzb", "uzb", "uzb", ""), - Language639("Venda", "Tshivenḓa", "ve", "ven", "ven", "ven", ""), - Language639("Vietnamese", "Tiếng Việt", "vi", "vie", "vie", "vie", ""), - Language639("Volapük", "Volapük", "vo", "vol", "vol", "vol", ""), - Language639("Walloon", "walon", "wa", "wln", "wln", "wln", ""), - Language639("Welsh", "Cymraeg", "cy", "cym", "", "cym", ""), - Language639("Wolof", "Wollof", "wo", "wol", "wol", "wol", ""), - Language639("Western Frisian", "Frysk", "fy", "fry", "fry", "fry", ""), - Language639("Xhosa", "isiXhosa", "xh", "xho", "xho", "xho", ""), - Language639("Yiddish", "ייִדיש", "yi", "yid", "yid", "yid", ""), - Language639("Yoruba", "Yorùbá", "yo", "yor", "yor", "yor", ""), - Language639("Zhuang", "Saɯ cueŋƅ, Saw cuengh", "za", "zha", "zha", "zha", ""), - Language639("Zulu", "isiZulu", "zu", "zul", "zul", "zul", ""), - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt new file mode 100644 index 000000000..c0068f91a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -0,0 +1,60 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import com.lagradost.api.Log +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects + +object SubtitleUtils { + + // Only these files are allowed, so no videos as subtitles + private val allowedExtensions = listOf( + ".vtt", ".srt", ".txt", ".ass", + ".ttml", ".sbv", ".dfxp" + ) + + fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { + val cleanDisplay = cleanDisplayName(info.displayName) + + val base = basePathToFile(context, info.basePath) + val folder = + base?.gotoDirectory(info.relativePath, createMissingDirectories = false) ?: return + val folderFiles = folder.listFiles() ?: return + + for (file in folderFiles) { + val name = file.name() ?: continue + if (!isMatchingSubtitle(name, info.displayName, cleanDisplay)) { + continue + } + if (file.delete() != true) { + Log.e("SubtitleDeletion", "Failed to delete subtitle file: $name") + } + } + } + + /** + * @param name the file name of the subtitle + * @param display the file name of the video + * @param cleanDisplay the cleanDisplayName of the video file name + */ + fun isMatchingSubtitle( + name: String, + display: String, + cleanDisplay: String + ): Boolean { + // Check if the file has a valid subtitle extension + val hasValidExtension = allowedExtensions.any { name.endsWith(it, ignoreCase = true) } + + // We can't have the exact same file as a subtitle + val isNotDisplayName = !name.equals(display, ignoreCase = true) + + // Check if the file name starts with a cleaned version of the display name + val startsWithCleanDisplay = cleanDisplayName(name).startsWith(cleanDisplay, ignoreCase = true) + + return hasValidExtension && isNotDisplayName && startsWithCleanDisplay + } + + fun cleanDisplayName(name: String): String { + return name.substringBeforeLast('.').trim() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt index e5f2f2dc5..6e74fa00a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -8,12 +8,12 @@ import com.lagradost.cloudstream3.APIHolder.apis //import com.lagradost.cloudstream3.animeproviders.AniflixProvider import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import java.util.concurrent.TimeUnit object SyncUtil { private val regexs = listOf( - Regex("""(9anime)\.(?:to|center|id)/watch/(?:.*?)\.([^/?]*)"""), + Regex("""(9anime)\.(?:to|center|id)/watch/.*?\.([^/?]*)"""), Regex("""(gogoanime|gogoanimes)\..*?/category/([^/?]*)"""), Regex("""(twist\.moe)/a/([^/?]*)"""), ) @@ -44,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 + } + } } } } @@ -64,10 +71,10 @@ object SyncUtil { val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text - val mapped = parseJson(response) + val mapped = tryParseJson(response) - val overrideMal = mapped?.malId ?: mapped?.Mal?.id ?: mapped?.Anilist?.malId - val overrideAnilist = mapped?.aniId ?: mapped?.Anilist?.id + val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId + val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id if (overrideMal != null) { return overrideMal.toString() to overrideAnilist?.toString() @@ -126,8 +133,8 @@ object SyncUtil { @JsonProperty("createdAt") val createdAt: String?, @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String?, - @JsonProperty("Mal") val Mal: Mal?, - @JsonProperty("Anilist") val Anilist: Anilist?, + @JsonProperty("Mal") val mal: Mal?, + @JsonProperty("Anilist") val anilist: Anilist?, @JsonProperty("malUrl") val malUrl: String? ) @@ -160,4 +167,4 @@ object SyncUtil { @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String? ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt new file mode 100644 index 000000000..8c50afee7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -0,0 +1,328 @@ +package com.lagradost.cloudstream3.utils + +import com.lagradost.cloudstream3.* +import com.lagradost.cloudstream3.mvvm.logError +import kotlinx.coroutines.* +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)) + } + } + + private fun fail(message: String): Nothing = throw AssertionError(message) + private fun assertTrue(message: String, condition: Boolean) { if (!condition) fail(message) } + private fun assertNotNull(message: String, value: Any?) { if (value == null) fail(message) } + + class TestResultList(val results: List) : TestResult(true) + class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true) + + class TestResultProvider( + success: Boolean, + val log: List, + val exception: Throwable? + ) : + TestResult(success) + + @Throws(AssertionError::class, CancellationException::class) + suspend fun testHomepage( + api: MainAPI, + logger: 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 -> { + 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) { + fail("Provider has not implemented search()") + } else if (e is CancellationException) { + throw e + } + logError(e) + null + } + } + + return if (searchResults.isNullOrEmpty()) { + 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) { + fail("Provider has not implemented load()") + } + throw e + } + } + + @Throws(AssertionError::class, CancellationException::class) + private suspend fun testLinkLoading( + api: MainAPI, + url: String?, + logger: Logger + ): TestResult { + assertNotNull("Api ${api.name} has invalid url on episode", url) + if (url == null) return TestResult.Fail // Should never trigger + + var linksLoaded = 0 + try { + val success = api.loadLinks(url, false, {}) { link -> + logger.log("Video loaded: ${link.name}") + assertTrue( + "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 { + fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") + } + } catch (e: Throwable) { + when (e) { + is NotImplementedError -> { + fail("Provider has not implemented loadLinks()") + } + + 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) + 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) + 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) + } + } + } +} 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 81ef8d57b..4f3a74737 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/UiText.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TextUtil.kt @@ -1,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 000000000..feecbe312 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt @@ -0,0 +1,164 @@ +package com.lagradost.cloudstream3.utils + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.net.toUri +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.PreviewProgram +import androidx.tvprovider.media.tv.TvContractCompat +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.base64Encode +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE +import com.lagradost.cloudstream3.utils.DataStore.getKey +import com.lagradost.cloudstream3.utils.DataStore.setKey +import java.net.URLEncoder + +const val PROGRAM_ID_LIST_KEY = "persistent_program_ids" + +object TvChannelUtils { + fun Context.saveProgramId(programId: Long) { + val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + val updated = (existing + programId).distinct() + setKey(PROGRAM_ID_LIST_KEY, updated) + } + fun Context.getStoredProgramIds(): List { + return getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + } + fun Context.removeProgramId(programId: Long) { + val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() + val updated = existing.filter { it != programId } + setKey(PROGRAM_ID_LIST_KEY, updated) + } + + + fun getChannelId(context: Context, channelName: String): Long? { + return try { + context.contentResolver.query( + TvContractCompat.Channels.CONTENT_URI, + arrayOf( + TvContractCompat.Channels._ID, + TvContractCompat.Channels.COLUMN_DISPLAY_NAME + ), + null, + null, + null + )?.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong( + cursor.getColumnIndexOrThrow(TvContractCompat.Channels._ID) + ) + val name = cursor.getString( + cursor.getColumnIndexOrThrow(TvContractCompat.Channels.COLUMN_DISPLAY_NAME) + ) + if (name == channelName) return id + } + null + } + } catch (e: Exception) { + Log.e("TvChannelUtils", "Query failed: ${e.message}", e) + null + } + } + + /** Insert programs into a channel */ + @SuppressLint("RestrictedApi") + fun addPrograms(context: Context, channelId: Long, items: List) { + for (item in items) { + try { + val nameBase64 = base64Encode(item.apiName.toByteArray(Charsets.UTF_8)) + val urlBase64 = base64Encode(item.url.toByteArray(Charsets.UTF_8)) + val csshareUri = "$APP_STRING_SHARE:$nameBase64?$urlBase64" + val poster=item.posterUrl + val builder = PreviewProgram.Builder() + .setChannelId(channelId) + .setTitle(item.name) + .apply { + val scoreText = item.score?.toStringNull(0.1, 10, 1)?.let { + " - " + txt(R.string.rating_format, it).asString(context) + } ?: "" + setDescription("${item.apiName}$scoreText") + } + .setContentId(item.url) + .setType(TvContractCompat.PreviewPrograms.TYPE_MOVIE) + .setIntentUri(csshareUri.toUri()) + .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_2_3) + + // Validate poster URL before setting + if (!poster.isNullOrBlank() && poster.startsWith("http")) { + builder.setPosterArtUri(poster.toUri()) + + } + val program = builder.build() + + val uri = context.contentResolver.insert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + program.toContentValues() + ) + + if (uri != null) { + val programId = ContentUris.parseId(uri) + context.saveProgramId(programId) + Log.d("TvChannelUtils", "Inserted program ${item.name}, ID=$programId") + } else { + Log.e("TvChannelUtils", "Insert failed for ${item.name}") + } + + } catch (error: Exception) { + Log.e("TvChannelUtils", "Error inserting ${item.name}: $error") + } + } + } + + fun deleteStoredPrograms(context: Context) { + val programIds = context.getStoredProgramIds() + + for (id in programIds) { + val uri = ContentUris.withAppendedId(TvContractCompat.PreviewPrograms.CONTENT_URI, id) + try { + val rowsDeleted = context.contentResolver.delete(uri, null, null) + if (rowsDeleted > 0) { + context.removeProgramId(id) // Remove from persistent list + } else { + Log.w("ProgramDelete", "No program found for ID: $id") + } + } catch (e: Exception) { + Log.e("ProgramDelete", "Failed to delete program ID: $id", e) + } + } + + Log.d("ProgramDelete", "Finished deleting stored programs") + } + + fun createTvChannel(context: Context) { + val componentName = ComponentName(context, MainActivity::class.java) + val iconUri = "android.resource://${context.packageName}/mipmap/ic_launcher".toUri() + val inputId = TvContractCompat.buildInputId(componentName) + val channel = Channel.Builder() + .setType(TvContractCompat.Channels.TYPE_PREVIEW) + .setAppLinkIconUri(iconUri) + .setDisplayName(context.getString(R.string.app_name)) + .setAppLinkIntent(Intent(Intent.ACTION_VIEW).apply { + data = "cloudstreamapp://open".toUri() + }) + .setInputId(inputId) + .build() + + val channelUri = context.contentResolver.insert( + TvContractCompat.Channels.CONTENT_URI, + channel.toContentValues() + ) + + channelUri?.let { + val channelId = ContentUris.parseId(it) + TvContractCompat.requestChannelBrowsable(context, channelId) + Log.d("TvChannelUtils", "Channel Created: $channelId") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index c300d615f..c12674816 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -5,54 +5,84 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.AppOpsManager import android.app.Dialog +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle -import android.view.* +import android.os.TransactionTooLargeException +import android.util.Log +import android.view.Gravity +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowManager import android.view.inputmethod.InputMethodManager -import android.widget.ImageView import android.widget.ListAdapter import android.widget.ListView +import android.widget.Toast.LENGTH_LONG import androidx.annotation.AttrRes import androidx.annotation.ColorInt -import androidx.annotation.DrawableRes -import androidx.annotation.IdRes +import androidx.annotation.DimenRes +import androidx.annotation.StyleRes import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.content.withStyledAttributes import androidx.core.graphics.alpha import androidx.core.graphics.blue -import androidx.core.graphics.drawable.toBitmapOrNull import androidx.core.graphics.green import androidx.core.graphics.red +import androidx.core.view.marginBottom +import androidx.core.view.marginLeft +import androidx.core.view.marginRight +import androidx.core.view.marginTop +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.load.engine.GlideException -import com.bumptech.glide.load.model.GlideUrl -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions -import com.bumptech.glide.request.RequestListener -import com.bumptech.glide.request.target.Target +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipDrawable +import com.google.android.material.chip.ChipGroup +import com.lagradost.cloudstream3.CloudStreamApp.Companion.context +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.ui.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() @@ -60,7 +90,7 @@ object UIHelper { val Int.toDp: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt() val Float.toDp: Float get() = (this / Resources.getSystem().displayMetrics.density) - fun Activity.checkWrite(): Boolean { + fun Context.checkWrite(): Boolean { return (ContextCompat.checkSelfPermission( this, Manifest.permission.WRITE_EXTERNAL_STORAGE @@ -71,6 +101,38 @@ object UIHelper { || Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) } + fun populateChips( + view: ChipGroup?, + tags: List, + @StyleRes style: Int = R.style.ChipFilled, + @AttrRes textColor: Int? = R.attr.white, + ) { + 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() { ActivityCompat.requestPermissions( this, @@ -83,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. @@ -113,17 +204,15 @@ object UIHelper { listView.requestLayout() } - fun Context?.getSpanCount(): Int? { - val compactView = false - val spanCountLandscape = if (compactView) 2 else 6 - val spanCountPortrait = if (compactView) 1 else 3 - val orientation = this?.resources?.configuration?.orientation ?: return null + fun Context.getSpanCount(isHorizontal:Boolean=false): Int { +// val compactView = false + val spanCountLandscape = if (isHorizontal) 3 else 6 + val spanCountPortrait = if (isHorizontal) 2 else 3 + val orientation = resources.configuration.orientation return if (orientation == Configuration.ORIENTATION_LANDSCAPE) { spanCountLandscape - } else { - spanCountPortrait - } + } else spanCountPortrait } fun Fragment.hideKeyboard() { @@ -133,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 { @@ -140,32 +237,95 @@ object UIHelper { } } - fun Activity?.navigate(@IdRes navigation: Int, arguments: Bundle? = null) { - try { - if (this is FragmentActivity) { - (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.navController?.navigate( - navigation, arguments - ) + fun Activity?.navigate( + navigationId: Int, + args: Bundle? = null, + navOptions: NavOptions? = null // To control nav graph & manage back stack + ) { + val tag = "NavComponent" + if (this is FragmentActivity) { + try { + runOnUiThread { + // Navigate using navigation ID + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment + Log.i(tag, "Navigating to fragment: $navigationId") + navHostFragment?.navController?.navigate(navigationId, args, navOptions) + } + } catch (t: Throwable) { + logError(t) } + } + } + + // Open activities from an activity outside the nav graph + fun Context.openActivity(activity: Class<*>, args: Bundle? = null, baseIntent: Intent? = null) { + val tag = "NavComponent" + try { + val intent = baseIntent ?: Intent() + intent.setClass(this, activity) + + if (args != null) { + intent.putExtras(args) + } + Log.i(tag, "Navigating to Activity: ${activity.simpleName}") + startActivity(intent) } catch (t: Throwable) { logError(t) } } + /** If you want to call this from a BackPressedCallback, pass the name of the callback to temporarily disable it */ + fun FragmentActivity.popCurrentPage(fromBackPressedCallback: String? = null) { + // Use the main looper handler to post actions on the main thread + main { + // Post the back press action to the main thread handler to ensure it executes + // after any currently pending UI updates or fragment transactions. + if (fromBackPressedCallback != null) { + disableBackPressedCallback(fromBackPressedCallback) + } + if (!supportFragmentManager.isStateSaved) { + // Get the top fragment from the back stack + Log.d("popFragment", "Destroying Fragment") + // If the state is not saved, it's safe to perform the back press action. + onBackPressedDispatcher.onBackPressed() + } else { + // If the state is saved, retry the back press action after a slight delay. + // This gives the FragmentManager time to complete any ongoing state-saving + // operations or transactions, ensuring that we do not encounter an IllegalStateException. + delay(100) + if (!supportFragmentManager.isStateSaved) { + Log.d("popFragment", "Destroying after delay") + onBackPressedDispatcher.onBackPressed() + } + } + if (fromBackPressedCallback != null) { + enableBackPressedCallback(fromBackPressedCallback) + } + } + } + @ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { - val typedArray = obtainStyledAttributes(intArrayOf(resource)) - val color = typedArray.getColor(0, 0) - typedArray.recycle() + val color = colorFromAttribute(resource) + return if (alphaFactor < 1f) adjustAlpha(color, alphaFactor) else color + } - if (alphaFactor < 1f) { - val alpha = (color.alpha * alphaFactor).roundToInt() - return Color.argb(alpha, color.red, color.green, color.blue) + @ColorInt + fun Context.colorFromAttribute(@AttrRes attribute: Int): Int { + var color = 0 + withStyledAttributes(attrs = intArrayOf(attribute)) { + color = getColor(0, 0) } - return color } + @ColorInt + fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val alpha = (color.alpha * factor).roundToInt() + return Color.argb(alpha, color.red, color.green, color.blue) + } + var createPaletteAsyncCache: HashMap = hashMapOf() fun createPaletteAsync(url: String, bitmap: Bitmap, callback: (Palette) -> Unit) { createPaletteAsyncCache[url]?.let { palette -> @@ -180,108 +340,19 @@ object UIHelper { } } - fun ImageView?.setImage( - url: String?, - headers: Map? = null, - @DrawableRes - errorImageDrawable: Int? = null, - fadeIn: Boolean = true, - colorCallback: ((Palette) -> Unit)? = null - ): 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 (colorCallback != null) { - builder.listener(object : RequestListener { - @SuppressLint("CheckResult") - override fun onResourceReady( - resource: Drawable?, - model: Any?, - target: Target?, - dataSource: DataSource?, - isFirstResource: Boolean - ): Boolean { - resource?.toBitmapOrNull() - ?.let { bitmap -> createPaletteAsync(url, bitmap, colorCallback) } - return false - } - - @SuppressLint("CheckResult") - override fun onLoadFailed( - e: GlideException?, - model: Any?, - target: Target?, - isFirstResource: Boolean - ): Boolean { - return false - } - }) - } - - val res = if (errorImageDrawable != null) - builder.error(errorImageDrawable).into(this) - else - builder.into(this) - res.clearOnDetach() - - true - } catch (e: Exception) { - logError(e) - false - } - } - - fun ImageView?.setImageBlur( - url: String?, - radius: Int, - sample: Int = 3, - headers: Map? = null - ) { - if (this == null || url.isNullOrBlank()) return - try { - val res = GlideApp.with(this) - .load(GlideUrl(url) { headers ?: emptyMap() }) - .apply(bitmapTransform(BlurTransformation(radius, sample))) - .transition( - DrawableTransitionOptions.withCrossFade() - ) - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .into(this) - res.clearOnDetach() - } catch (e: Exception) { - logError(e) - } - } - - fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { - val alpha = (Color.alpha(color) * factor).roundToInt() - val red = Color.red(color) - val green = Color.green(color) - val blue = Color.blue(color) - return Color.argb(alpha, red, green, blue) - } - - fun Context.colorFromAttribute(attribute: Int): Int { - val attributes = obtainStyledAttributes(intArrayOf(attribute)) - val color = attributes.getColor(0, 0) - attributes.recycle() - return color - } - fun Activity.hideSystemUI() { // Enables regular immersive mode. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val controller = WindowCompat.getInsetsController(window, window.decorView) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + return + } + // For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE. // Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY + @Suppress("DEPRECATION") window.decorView.systemUiVisibility = ( View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY // Set the content to appear under the system bars so that the @@ -292,74 +363,25 @@ object UIHelper { // Hide the nav bar and status bar or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN - // or View.SYSTEM_UI_FLAG_LOW_PROFILE ) - // window.addFlags(View.KEEP_SCREEN_ON) } - fun FragmentActivity.popCurrentPage() { - this.onBackPressed() - /*val currentFragment = supportFragmentManager.fragments.lastOrNull { - it.isVisible - } ?: return - - supportFragmentManager.beginTransaction() - .setCustomAnimations( - R.anim.enter_anim, - R.anim.exit_anim, - R.anim.pop_enter, - R.anim.pop_exit - ) - .remove(currentFragment) - .commitAllowingStateLoss()*/ + fun Activity.enableEdgeToEdgeCompat() { + // edge-to-edge is very buggy on earlier versions + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + WindowCompat.enableEdgeToEdge(window) } - /* - fun FragmentActivity.popCurrentPage(isInPlayer: Boolean, isInExpandedView: Boolean, isInResults: Boolean) { - val currentFragment = supportFragmentManager.fragments.lastOrNull { - it.isVisible - } - ?: //this.onBackPressed() - return -/* - if (tvActivity == null) { - requestedOrientation = if (settingsManager?.getBoolean("force_landscape", false) == true) { - ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - }*/ + fun Activity.setNavigationBarColorCompat(@AttrRes resourceId: Int) { + // edge-to-edge handles this + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return - // No fucked animations leaving the player :) - when { - isInPlayer -> { - supportFragmentManager.beginTransaction() - //.setCustomAnimations(R.anim.enter, R.anim.exit, R.anim.pop_enter, R.anim.pop_exit) - .remove(currentFragment) - .commitAllowingStateLoss() - } - isInExpandedView && !isInResults -> { - supportFragmentManager.beginTransaction() - .setCustomAnimations( - R.anim.enter_anim,//R.anim.enter_from_right, - R.anim.exit_anim,//R.anim.exit_to_right, - R.anim.pop_enter, - R.anim.pop_exit - ) - .remove(currentFragment) - .commitAllowingStateLoss() - } - else -> { - supportFragmentManager.beginTransaction() - .setCustomAnimations(R.anim.enter_anim, R.anim.exit_anim, R.anim.pop_enter, R.anim.pop_exit) - .remove(currentFragment) - .commitAllowingStateLoss() - } - } - }*/ + @Suppress("DEPRECATION") + window?.navigationBarColor = colorFromAttribute(resourceId) + } fun Context.getStatusBarHeight(): Int { - if (isTvSettings()) { + if (isLayout(TV or EMULATOR)) { return 0 } @@ -371,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") @@ -398,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?) { @@ -478,7 +578,7 @@ object UIHelper { } fun Dialog?.dismissSafe() { - if (this?.isShowing == true) { + if (this?.isShowing == true && activity?.isFinishing != true) { this.dismiss() } } @@ -490,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) @@ -514,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) @@ -530,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 a76cc1155..000000000 --- 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 a629dad94..000000000 --- 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 000000000..898c30a1c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt @@ -0,0 +1,132 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.getFolderPrefix +import com.lagradost.cloudstream3.isEpisodeBased +import com.lagradost.safefile.MediaFileContentType +import com.lagradost.safefile.SafeFile + +object DownloadFileManagement { + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" + internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + var tempName = name + for (c in RESERVED_CHARS) { + tempName = tempName.replace(c, ' ') + } + if (removeSpaces) tempName = tempName.replace(" ", "") + return tempName.replace(" ", " ").trim(' ') + } + + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ + internal fun getFolder( + context: Context, + relativePath: String, + basePath: String? + ): List>? { + val base = basePathToFile(context, basePath) + val folder = + base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null + + //if (folder.isDirectory() != false) return null + + return folder.listFiles() + ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + internal fun basePathToFile(context: Context, path: String?): SafeFile? { + return when { + path.isNullOrBlank() -> getDefaultDir(context) + path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) + else -> SafeFile.fromFilePath(context, path) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + internal fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + internal fun getFileName( + context: Context, + metadata: DownloadObjects.DownloadEpisodeMetadata + ): String { + return getFileName(context, metadata.name, metadata.episode, metadata.season) + } + + internal fun getFileName( + context: Context, + epName: String?, + episode: Int?, + season: Int? + ): String { + // kinda ugly ik + return sanitizeFilename( + if (epName == null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" + } else { + "${context.getString(R.string.episode)} $episode" + } + } else { + if (episode != null) { + if (season != null) { + "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" + } else { + "${context.getString(R.string.episode)} $episode - $epName" + } + } else { + epName + } + } + ) + } + + + internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { + return basePathToFile(context, this.basePath)?.gotoDirectory( + relativePath, + createMissingDirectories = false + ) + ?.findFile(displayName) + } + + internal fun getFolder(currentType: TvType, titleName: String): String { + return if (currentType.isEpisodeBased()) { + val sanitizedFileName = sanitizeFilename(titleName) + "${currentType.getFolderPrefix()}/$sanitizedFileName" + } else currentType.getFolderPrefix() + } + + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * As of writing UniFile is used for everything but download directory on scoped storage. + * Special ContentResolver fuckery is needed for that as UniFile doesn't work. + * */ + fun getDefaultDir(context: Context): SafeFile? { + // See https://www.py4u.net/discuss/614761 + return SafeFile.fromMedia( + context, MediaFileContentType.Downloads + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt new file mode 100644 index 000000000..7cb190667 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt @@ -0,0 +1,2095 @@ +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?, + buffer: ByteArray, + 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() + + 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, + buffer: ByteArray, + 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, buffer, callback) + // no end defined, so we don't care exactly where it ended + if (end == null) return true + // we have download more or exactly what we needed + if (start >= end) return true + } catch (_: 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) + } + } + } + + // Reuse a download buffer to decrease unnecessary alloc + val buffer = ByteArray(items.bufferSize) + + // This will take up the first available job and resolve + while (true) { + if (!isActive) return@launch + + var isTooFarAhead = false + fileMutex.withLock { + if (metadata.type == DownloadType.IsStopped + || metadata.type == DownloadType.IsFailed + ) return@launch + + // Limit RAM usage by throttling if too much data is downloaded but not yet written to disk + // 50MB limit + if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) { + isTooFarAhead = true + } + } + + if (isTooFarAhead) { + delay(500) + continue + } + + // mutex just in case, we never want this to fail due to multithreading + 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, buffer = buffer, 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 000000000..25a9fdf2a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt @@ -0,0 +1,224 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.net.Uri +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.Score +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.safefile.SafeFile +import java.io.IOException +import java.io.OutputStream +import java.util.Objects + +object DownloadObjects { + /** An item can either be something to resume or something new to start */ + data class DownloadQueueWrapper( + @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?, + @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?, + ) { + init { + assert(resumePackage != null || downloadItem != null) { + "ResumeID and downloadItem cannot both be null at the same time!" + } + } + + /** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */ + fun isCurrentlyDownloading(): Boolean { + return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } + } + + @JsonProperty("id") + val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id + + @JsonProperty("parentId") + val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId + } + + /** General data about the episode and show to start a download from. */ + data class DownloadQueueItem( + @JsonProperty("episode") val episode: ResultEpisode, + @JsonProperty("isMovie") val isMovie: Boolean, + @JsonProperty("resultName") val resultName: String, + @JsonProperty("resultType") val resultType: TvType, + @JsonProperty("resultPoster") val resultPoster: String?, + @JsonProperty("apiName") val apiName: String, + @JsonProperty("resultId") val resultId: Int, + @JsonProperty("resultUrl") val resultUrl: String, + @JsonProperty("links") val links: List? = null, + @JsonProperty("subs") val subs: List? = null, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(null, this) + } + } + + + abstract class DownloadCached( + @JsonProperty("id") open val id: Int, + ) + + data class DownloadEpisodeCached( + @JsonProperty("name") val name: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("episode") val episode: Int, + @JsonProperty("season") val season: Int?, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("score") var score: Score? = null, + @JsonProperty("description") val description: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) { + @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) + @Deprecated( + "`rating` is the old scoring system, use score instead", + replaceWith = ReplaceWith("score"), + level = DeprecationLevel.ERROR + ) + var rating: Int? = null + set(value) { + if (value != null) { + @Suppress("DEPRECATION_ERROR") + score = Score.fromOld(value) + } + } + } + + /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ + data class DownloadHeaderCached( + @JsonProperty("apiName") val apiName: String, + @JsonProperty("url") val url: String, + @JsonProperty("type") val type: TvType, + @JsonProperty("name") val name: String, + @JsonProperty("poster") val poster: String?, + @JsonProperty("cacheTime") val cacheTime: Long, + override val id: Int, + ) : DownloadCached(id) + + data class DownloadResumePackage( + @JsonProperty("item") val item: DownloadItem, + /** Tills which link should get resumed */ + @JsonProperty("linkIndex") val linkIndex: Int?, + ) { + fun toWrapper(): DownloadQueueWrapper { + return DownloadQueueWrapper(this, null) + } + } + + data class DownloadItem( + @JsonProperty("source") val source: String?, + @JsonProperty("folder") val folder: String?, + @JsonProperty("ep") val ep: DownloadEpisodeMetadata, + @JsonProperty("links") val links: List, + ) + + /** Metadata for a specific episode and how to display it. */ + data class DownloadEpisodeMetadata( + @JsonProperty("id") val id: Int, + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("mainName") val mainName: String, + @JsonProperty("sourceApiName") val sourceApiName: String?, + @JsonProperty("poster") val poster: String?, + @JsonProperty("name") val name: String?, + @JsonProperty("season") val season: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("type") val type: TvType?, + ) + + + data class DownloadedFileInfo( + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("relativePath") val relativePath: String, + @JsonProperty("displayName") val displayName: String, + @JsonProperty("extraInfo") val extraInfo: String? = null, + @JsonProperty("basePath") val basePath: String? = null, // null is for legacy downloads. See getBasePath() + // Hash of the link associated with this DownloadFile, used so not override old data in the DownloadedFileInfo + @JsonProperty("linkHash") val linkHash : Int? = null + ) + + data class DownloadedFileInfoResult( + @JsonProperty("fileLength") val fileLength: Long, + @JsonProperty("totalBytes") val totalBytes: Long, + @JsonProperty("path") val path: Uri, + ) + + + data class ResumeWatching( + @JsonProperty("parentId") val parentId: Int, + @JsonProperty("episodeId") val episodeId: Int?, + @JsonProperty("episode") val episode: Int?, + @JsonProperty("season") val season: Int?, + @JsonProperty("updateTime") val updateTime: Long, + @JsonProperty("isFromDownload") val isFromDownload: Boolean, + ) + + + data class DownloadStatus( + /** if you should retry with the same args and hope for a better result */ + val retrySame: Boolean, + /** if you should try the next mirror */ + val tryNext: Boolean, + /** if the result is what the user intended */ + val success: Boolean, + ) + + + data class CreateNotificationMetadata( + val type: VideoDownloadManager.DownloadType, + val bytesDownloaded: Long, + val bytesTotal: Long, + val hlsProgress: Long? = null, + val hlsTotal: Long? = null, + val bytesPerSecond: Long + ) + + data class StreamData( + private val fileLength: Long, + val file: SafeFile, + //val fileStream: OutputStream, + ) { + @Throws(IOException::class) + fun open(): OutputStream { + return file.openOutputStreamOrThrow(resume) + } + + @Throws(IOException::class) + fun openNew(): OutputStream { + return file.openOutputStreamOrThrow(false) + } + + fun delete(): Boolean { + return file.delete() == true + } + + val resume: Boolean get() = fileLength > 0L + val startAt: Long get() = if (resume) fileLength else 0L + val exists: Boolean get() = file.exists() == true + } + + + /** bytes have the size end-start where the byte range is [start,end) + * note that ByteArray is a pointer and therefore cant be stored without cloning it */ + data class LazyStreamDownloadResponse( + val bytes: ByteArray, + val startByte: Long, + val endByte: Long, + ) { + val size get() = endByte - startByte + + override fun toString(): String { + return "$startByte->$endByte" + } + + override fun equals(other: Any?): Boolean { + if (other !is LazyStreamDownloadResponse) return false + return other.startByte == startByte && other.endByte == endByte + } + + override fun hashCode(): Int { + return Objects.hash(startByte, endByte) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt new file mode 100644 index 000000000..f38664088 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt @@ -0,0 +1,250 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.util.Log +import androidx.core.content.ContextCompat +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys +import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey +import com.lagradost.cloudstream3.MainActivity.Companion.lastError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.services.DownloadQueueService +import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadQueuePackage +import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet + +// 1. Put a download on the queue +// 2. The queue manager starts a foreground service to handle the queue +// 3. The service starts work manager jobs to handle the downloads? +object DownloadQueueManager { + private const val TAG = "DownloadQueueManager" + const val QUEUE_KEY = "download_queue_key" + + /** Flow of all active queued download, no active downloads. + * This flow may see many changes, do not place expensive observers. + * downloadInstances is the flow keeping track of active downloads. + * @see com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances + */ + private val _queue: MutableStateFlow> by lazy { + /** Persistent queue */ + val currentValue = getKey>(QUEUE_KEY) ?: emptyArray() + MutableStateFlow(currentValue) + } + + val queue: StateFlow> by lazy { _queue } + + /** Start the queue, marks all queue objects as in progress. + * Note that this may run twice without the service restarting + * because MainActivity may be recreated. */ + fun init(context: Context) { + ioSafe { + _queue.collect { queue -> + setKey(QUEUE_KEY, queue) + } + } + + ioSafe startQueue@{ + // Do not automatically start the queue if safe mode is activated. + if (PluginManager.isSafeMode()) { + // Prevent misleading UI + VideoDownloadManager.cancelAllDownloadNotifications(context) + return@startQueue + } + + val resumeQueue = + getPreResumeIds().filterNot { + VideoDownloadManager.currentDownloads.value.contains(it) + } + .mapNotNull { id -> + getDownloadResumePackage(context, id)?.toWrapper() + ?: getDownloadQueuePackage(context, id) + } + + val newQueue = _queue.updateAndGet { localQueue -> + // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started + (resumeQueue + localQueue).distinctBy { it.id }.toTypedArray() + } + + // Once added to the queue they can be safely removed + removeKeys(KEY_RESUME_IN_QUEUE) + + // Make sure the download buttons display a pending status + newQueue.forEach { obj -> + setQueueStatus(obj.id, VideoDownloadManager.DownloadType.IsPending) + } + + if (newQueue.any()) { + startQueueService(context) + } + } + } + + /** Downloads not yet started or in progress. */ + private fun getPreResumeIds(): Set { + return getKeys(KEY_RESUME_IN_QUEUE)?.mapNotNull { + it.substringAfter("$KEY_RESUME_IN_QUEUE/").toIntOrNull() + }?.toSet() + ?: emptySet() + } + + /** Adds an object to the internal persistent queue. It does not re-add an existing item. @return true if successfully added */ + private fun add(downloadQueueWrapper: DownloadQueueWrapper): Boolean { + Log.d(TAG, "Download added to queue: $downloadQueueWrapper") + val newQueue = _queue.updateAndGet { localQueue -> + // Do not add the same episode twice + if (downloadQueueWrapper.isCurrentlyDownloading() || localQueue.any { it.id == downloadQueueWrapper.id }) { + return@updateAndGet localQueue + } + localQueue + downloadQueueWrapper + } + return newQueue.any { it.id == downloadQueueWrapper.id } + } + + /** Removes all objects with the same id from the internal persistent queue */ + private fun remove(id: Int) { + Log.d(TAG, "Download removed from the queue: $id") + _queue.update { localQueue -> + // The check is to prevent unnecessary updates + if (!localQueue.any { it.id == id }) { + return@update localQueue + } + + localQueue.filter { it.id != id }.toTypedArray() + } + } + + /** Removes all items and returns the previous queue */ + private fun removeAll(): Array { + Log.d(TAG, "Removed everything from queue") + return _queue.getAndUpdate { + emptyArray() + } + } + + private fun reorder(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + _queue.update { localQueue -> + val newIndex = newPosition.coerceIn(0, localQueue.size) + val id = downloadQueueWrapper.id + + val newQueue = localQueue.filter { it.id != id }.toMutableList().apply { + this.add(newIndex, downloadQueueWrapper) + }.toTypedArray() + + newQueue + } + } + + /** Start a real download from the first item in the queue */ + fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { + val first = queue.value.firstOrNull() ?: return null + + remove(first.id) + + val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) + + return downloadInstance + } + + /** Marks the item as in queue for the download button */ + private fun setQueueStatus(id: Int, status: VideoDownloadManager.DownloadType) { + downloadStatusEvent.invoke( + Pair( + id, + status + ) + ) + downloadStatus[id] = status + } + + private fun startQueueService(context: Context?) { + if (context == null) { + Log.d(TAG, "Cannot start download queue service, null context.") + return + } + // Do not restart the download queue service + if (DownloadQueueService.isRunning) { + return + } + ioSafe { + val intent = DownloadQueueService.getIntent(context) + ContextCompat.startForegroundService(context, intent) + } + } + + /** Cancels an active download or removes it from queue depending on where it is. */ + fun cancelDownload(id: Int) { + Log.d(TAG, "Cancelling download: $id") + + val currentInstance = downloadInstances.value.find { it.downloadQueueWrapper.id == id } + + if (currentInstance != null) { + currentInstance.cancelDownload() + } else { + removeFromQueue(id) + } + } + + /** Removes all queued items */ + fun removeAllFromQueue() { + removeAll().forEach { wrapper -> + setQueueStatus(wrapper.id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Removes all objects with the same id from the internal persistent queue */ + fun removeFromQueue(id: Int) { + ioSafe { + remove(id) + setQueueStatus(id, VideoDownloadManager.DownloadType.IsStopped) + } + } + + /** Will move the download queue wrapper to a new position in the queue. + * If the item does not exist it will also insert it. */ + fun reorderItem(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { + ioSafe { + reorder(downloadQueueWrapper, newPosition) + } + } + + /** Add a new object to the queue. Will not queue completed downloads or current downloads. */ + fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) = safe { + val context = CloudStreamApp.context ?: return@safe + val fileInfo = getDownloadFileInfo(context, downloadQueueWrapper.id) + val isComplete = fileInfo != null && + // Assure no division by 0 + fileInfo.totalBytes > 0 && + // If more than 98% downloaded then do not add to queue + (fileInfo.fileLength.toFloat() / fileInfo.totalBytes.toFloat()) > 0.98f + // Do not queue completed files! + if (isComplete) return@safe + + if (add(downloadQueueWrapper)) { + setQueueStatus(downloadQueueWrapper.id, VideoDownloadManager.DownloadType.IsPending) + startQueueService(context) + } + } + + + /** Refreshes the queue flow with the same value, but copied. + * Good to run if the downloads are affected by some outside value change. */ + fun forceRefreshQueue() { + _queue.update { localQueue -> + localQueue.copyOf() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt new file mode 100644 index 000000000..9f2c31d9a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt @@ -0,0 +1,165 @@ +package com.lagradost.cloudstream3.utils.downloader + +import android.content.Context +import android.graphics.Bitmap +import androidx.core.graphics.drawable.toBitmap +import coil3.Extras +import coil3.SingletonImageLoader +import coil3.asDrawable +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safe +import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream3.utils.UiText +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName +import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder +import com.lagradost.cloudstream3.utils.txt +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap + +/** Separate object with helper functions for the downloader */ +object DownloadUtils { + private val cachedBitmaps = ConcurrentHashMap() + internal fun Context.getImageBitmapFromUrl( + url: String, + headers: Map? = null + ): Bitmap? = safe { + cachedBitmaps[url]?.let { + return@safe it + } + + val imageLoader = SingletonImageLoader.get(this) + + val request = ImageRequest.Builder(this) + .data(url) + .apply { + headers?.forEach { (key, value) -> + extras[Extras.Key(key)] = value + } + } + .build() + + val bitmap = runBlocking { + val result = imageLoader.execute(request) + (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) + ?.toBitmap() + } + + bitmap?.let { + cachedBitmaps.putIfAbsent(url, it) + } + + return@safe bitmap + } + + //calculate the time + internal fun getEstimatedTimeLeft( + context: Context, + bytesPerSecond: Long, + progress: Long, + total: Long + ): String { + if (bytesPerSecond <= 0) return "" + val timeInSec = (total - progress) / bytesPerSecond + val hrs = timeInSec / 3600 + val mins = (timeInSec % 3600) / 60 + val secs = timeInSec % 60 + val timeFormated: UiText? = when { + hrs > 0 -> txt( + R.string.download_time_left_hour_min_sec_format, + hrs, + mins, + secs + ) + + mins > 0 -> txt( + R.string.download_time_left_min_sec_format, + mins, + secs + ) + + secs > 0 -> txt( + R.string.download_time_left_sec_format, + secs + ) + + else -> null + } + return timeFormated?.asString(context) ?: "" + } + + internal fun downloadSubtitle( + context: Context?, + link: ExtractorSubtitleLink, + fileName: String, + folder: String + ) { + ioSafe { + VideoDownloadManager.downloadThing( + context ?: return@ioSafe, + link, + "$fileName ${link.name}", + folder, + if (link.url.contains(".srt")) "srt" else "vtt", + false, + null, createNotificationCallback = {} + ) + } + } + + fun downloadSubtitle( + context: Context?, + link: SubtitleData, + meta: DownloadObjects.DownloadEpisodeMetadata, + ) { + context?.let { ctx -> + val fileName = getFileName(ctx, meta) + val folder = getFolder(meta.type ?: return, meta.mainName) + downloadSubtitle( + ctx, + ExtractorSubtitleLink(link.name, link.url, "", link.headers), + fileName, + folder + ) + } + } + + + /** Helper function to make sure duplicate attributes don't get overridden or inserted without lowercase cmp + * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) + * */ + internal fun Map.appendAndDontOverride(rhs: Map): Map { + val out = this.toMutableMap() + val current = this.keys.map { it.lowercase() } + for ((key, value) in rhs) { + if (current.contains(key.lowercase())) continue + out[key] = value + } + return out + } + + internal fun List.cancel() { + forEach { job -> + try { + job.cancel() + } catch (t: Throwable) { + logError(t) + } + } + } + + internal suspend fun List.join() { + forEach { job -> + try { + job.join() + } catch (t: Throwable) { + logError(t) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt new file mode 100644 index 000000000..7c73a6889 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/serializers/UriSerializer.kt @@ -0,0 +1,40 @@ +package com.lagradost.cloudstream3.utils.serializers + +import android.net.Uri +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Custom KSerializer for Android's [Uri] type. + * + * Uri is an Android platform type and cannot be annotated with @Serializable directly. + * Registering it in a SerializersModule globally would require a custom module passed to + * every Json instance, which adds hidden coupling. This serializer is also used sparingly + * across the codebase, so the overhead of a global registration isn't justified. + * Instead, we keep it explicit so that each usage site opts in intentionally and the + * serialization behavior remains visible. + * + * Usage: + * + * @Serializable + * data class MyData( + * @Serializable(with = UriSerializer::class) + * val uri: Uri, + * ) + */ +object UriSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Uri) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uri { + return Uri.parse(decoder.decodeString()) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt new file mode 100644 index 000000000..0db90afea --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AniSkip.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt +// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md +class AniSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + if (data !is AnimeLoadResponse) return null // Filter actual anime + + val malId = data.getMalId()?.toIntOrNull() ?: return null + val url = + "https://api.aniskip.com/v2/skip-times/$malId/${episode.episode}?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeDurationMs / 1000L}" + + val response = app.get(url).parsed() + + // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work + return response.results?.mapNotNull { stamp -> + val skipType = when (stamp.skipType) { + "op" -> SkipType.Opening + "ed" -> SkipType.Ending + "recap" -> SkipType.Recap + "mixed-ed" -> SkipType.MixedEnding + "mixed-op" -> SkipType.MixedOpening + else -> null + } ?: return@mapNotNull null + val end = (stamp.interval.endTime * 1000.0).toLong() + val start = (stamp.interval.startTime * 1000.0).toLong() + SkipStamp( + type = skipType, + startMs = start, + endMs = end, + ) + } + } + + data class AniSkipResponse( + @JsonSerialize val found: Boolean, + @JsonSerialize val results: List?, + @JsonSerialize val message: String?, + @JsonSerialize val statusCode: Int + ) + + data class Stamp( + @JsonSerialize val interval: AniSkipInterval, + @JsonSerialize val skipType: String, + @JsonSerialize val skipId: String, + @JsonSerialize val episodeLength: Double + ) + + data class AniSkipInterval( + @JsonSerialize val startTime: Double, + @JsonSerialize val endTime: Double + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt new file mode 100644 index 000000000..f9254576b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt @@ -0,0 +1,370 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.ErrorLoadingException +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi +import com.lagradost.cloudstream3.syncproviders.AuthAPI +import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement +import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse +import com.lagradost.cloudstream3.syncproviders.AuthToken +import com.lagradost.cloudstream3.syncproviders.AuthUser +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap +import java.security.MessageDigest + +class AnimeSkipAuth : AuthAPI() { + override val name = "AnimeSkip" + override val inAppLoginRequirement: AuthLoginRequirement = + AuthLoginRequirement(password = true, username = true) + override val idPrefix = "anime-skip" + override val hasInApp = true + override val createAccountUrl = "https://anime-skip.com/account" + val baseClientId = "as1JgiMbW4wKfmTLWXS79iTDQFll76pk" + fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(input.toByteArray())).toString(16).padStart(32, '0') + } + + data class LoginRoot( + @JsonProperty("data") + val data: LoginData, + ) + + data class LoginData( + @JsonProperty("login") + val login: Login, + ) + + data class Login( + @JsonProperty("authToken") + val authToken: String, + @JsonProperty("refreshToken") + val refreshToken: String, + @JsonProperty("account") + val account: Account, + ) + + data class ApiRoot( + @JsonProperty("data") + val data: ApiData, + ) + + data class ApiData( + @JsonProperty("myApiClients") + val myApiClients: List, + ) + + data class MyApiClient( + @JsonProperty("id") + val id: String, + ) + + data class Account( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + ) + + data class Payload( + @JsonProperty("profileUrl") + val profileUrl: String, + @JsonProperty("username") + val username: String, + @JsonProperty("email") + val email: String, + @JsonProperty("clientId") + val clientId: String, + ) + + override suspend fun user(token: AuthToken?): AuthUser? { + val payload = parseJson(token?.payload ?: return null) + return AuthUser( + name = payload.username, + id = payload.email.hashCode(), + profilePicture = payload.profileUrl + ) + } + + override suspend fun login(form: AuthLoginResponse): AuthToken? { + val hash = md5(form.password ?: return null) + val emailOrUserName = form.email ?: form.username ?: return null + + val loginQuery = """ + { + login(usernameEmail: "$emailOrUserName", passwordHash: "$hash") { + authToken + refreshToken + account { + profileUrl + username + email + } + } + } +""" + val loginRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to loginQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val authToken = loginRoot.data.login.authToken + val refreshToken = loginRoot.data.login.refreshToken + val account = loginRoot.data.login.account + + val clientQuery = """ + { + myApiClients { + id + } + } + """.trimIndent() + + val apiRoot = app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to clientQuery), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "Authorization" to "Bearer $authToken", + "X-Client-ID" to baseClientId + ) + ).parsed() + + val clientId = apiRoot.data.myApiClients.getOrNull(0)?.id + ?: throw ErrorLoadingException("No API token found") + + val payload = Payload( + profileUrl = account.profileUrl, + username = account.username, + email = account.email, + clientId = clientId, + ) + return AuthToken( + accessToken = authToken, + refreshToken = refreshToken, + payload = payload.toJson() + ) + } +} + +class AnimeSkip : SkipAPI() { + override val name: String = "AniSkip" + override val supportedTypes: Set = setOf(TvType.Anime, TvType.OVA) + + val auth = PlainAuthRepo(animeSkipApi) + //val clientId = "ZGfO0sMF3eCwLYf8yMSCJjlynwNGRXWE" + + companion object { + const val MIN_LENGTH: Int = 4 + + private val strip = Regex("[ :\\-.!]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun stripName(name: String?): String? = + name?.replace(strip, "")?.lowercase() + + private val asciiRegex = Regex("[^a-zA-Z0-9 ]") + + /** Makes names more uniform to make partial matches more still give a result */ + fun asciiName(name: String?): String? = + name?.replace(asciiRegex, "")?.lowercase() + } + + data class Root( + @JsonProperty("data") + val data: Data, + ) + + data class Data( + @JsonProperty("searchShows") + val searchShows: List, + ) + + data class SearchShow( + @JsonProperty("name") + val name: String, + @JsonProperty("originalName") + val originalName: String?, + @JsonProperty("seasonCount") + val seasonCount: Long, + @JsonProperty("episodeCount") + val episodeCount: Long, + @JsonProperty("baseDuration") + val baseDuration: Double, + @JsonProperty("episodes") + val episodes: List, + ) + + data class Episode( + @JsonProperty("number") + val number: String?, + @JsonProperty("absoluteNumber") + val absoluteNumber: String?, + @JsonProperty("season") + val season: String?, + @JsonProperty("timestamps") + val timestamps: List, + ) + + data class Timestamp( + @JsonProperty("at") + val at: Double, + @JsonProperty("type") + val type: Type, + ) + + data class Type( + @JsonProperty("name") + val name: String, + ) + + val cache: ConcurrentHashMap = ConcurrentHashMap() + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val clientId : String = parseJson( + auth.authData()?.token?.payload ?: return null + ).clientId + + when (data) { + is AnimeLoadResponse, is TvSeriesLoadResponse -> { + /** Require episode based anime */ + } + + else -> return null + } + + val query = """{ + searchShows(search: "${data.name}", limit: 1) { + name + originalName + seasonCount + episodeCount + episodes { + number + absoluteNumber + season + baseDuration + timestamps { + at + type { + name + } + } + } + } +}""" + val root = cache[data.name] ?: run { + app.post( + "https://api.anime-skip.com/graphql", + json = mapOf("query" to query), + headers = mapOf( + "Accept" to "*/*", + "content-type" to "application/json", + "X-Client-ID" to clientId + ) + ) + .parsed().data.also { root -> + cache[data.name] = root + } + } + val show = root.searchShows.firstOrNull { show -> + /** Match ascii */ + val ascii1 = asciiName(data.name) + val ascii2 = asciiName(show.name) + if (ascii1 == ascii2 && (ascii1?.length ?: 0) > MIN_LENGTH) { + return@firstOrNull true + } + + if (data !is AnimeLoadResponse) { + return@firstOrNull false + } + + /** Match original name */ + val strip1 = stripName(show.originalName) + val strip2 = stripName(data.japName) + + /** Match english name*/ + val ascii3 = stripName(data.engName) + (strip1 == strip2 && (strip1?.length ?: 0) > MIN_LENGTH) || + (ascii2 == ascii3 && (ascii2?.length ?: 0) > MIN_LENGTH) + } ?: return null + + val showEpisode = when (data) { + is AnimeLoadResponse -> { + val episodeNumber = episode.episode.toString() + /** For anime, match on number */ + show.episodes.firstOrNull { + it.absoluteNumber == episodeNumber + } ?: show.episodes.firstOrNull { + it.number == episodeNumber + } + } + + is TvSeriesLoadResponse -> { + /** For tv-series, match on season + number */ + val seasonNumber = episode.season?.toString() + val episodeNumber = episode.episode.toString() + val episodeIndex = episode.totalEpisodeIndex.toString() + + show.episodes.firstOrNull { + it.season == seasonNumber && it.number == episodeNumber + } ?: show.episodes.firstOrNull { + it.absoluteNumber == episodeIndex + } + } + + else -> null + } ?: return null + + val result = ArrayList() + var pending: SkipStamp? = null + for (stamp in showEpisode.timestamps) { + val startMS = (stamp.at * 1000.0).toLong() + pending?.let { pending -> + result.add(pending.copy(endMs = startMS)) + } + val type = when (stamp.type.name) { + "Intro", "New Intro" -> SkipType.Intro + "Credits" -> SkipType.Credits + "Preview" -> SkipType.Preview + "Recap" -> SkipType.Recap + "Mixed Credits" -> SkipType.MixedEnding + "Filler", "Transition", "Branding", "Canon", "Title Card" -> null + else -> null + } + if (type == null) { + pending = null + continue + } + pending = SkipStamp(type, startMS, 0L) + } + pending?.let { pending -> + result.add(pending.copy(endMs = episodeDurationMs)) + /** Base duration = fucked */ + } + + return result + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt new file mode 100644 index 000000000..869515f43 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/IntroDbSkip.kt @@ -0,0 +1,77 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.ui.result.ResultEpisode + +class IntroDbSkip : SkipAPI() { + override val name = "IntroDb" + + override val supportedTypes = setOf(TvType.TvSeries, TvType.AsianDrama) + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val season = episode.season ?: return null + val imdbId = data.getImdbId() ?: return null + + val url = + "https://api.introdb.app/segments?imdb_id=$imdbId&season=$season&episode=${episode.episode}" + val response = app.get(url).parsed() + + return listOfNotNull( + response.intro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Opening, + startMs = start, + endMs = end + ) + }, + response.recap?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Recap, + startMs = start, + endMs = end + ) + }, + response.outro?.let { + val start = it.startMs ?: return@let null + val end = it.endMs ?: return@let null + SkipStamp( + type = SkipType.Ending, + startMs = start, + endMs = end + ) + } + ) + } + + + data class IntroDbResponse( + @JsonProperty("imdb_id") val imdbId: String?, + val season: Int?, + val episode: Int?, + val intro: Segment?, + val recap: Segment?, + val outro: Segment?, + ) + + data class Segment( + @JsonProperty("start_sec") val startSec: Double?, + @JsonProperty("end_sec") val endSec: Double?, + @JsonProperty("start_ms") val startMs: Long?, + @JsonProperty("end_ms") val endMs: Long?, + val confidence: Double?, + @JsonProperty("submission_count") val submissionCount: Int?, + @JsonProperty("updated_at") val updatedAt: String?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt new file mode 100644 index 000000000..60cc3ae1e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/SkipAPI.kt @@ -0,0 +1,105 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import androidx.annotation.StringRes +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.mvvm.safeAsync +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.txt +import java.util.concurrent.ConcurrentHashMap + + +enum class SkipType(@StringRes val res: Int) { + Opening(R.string.skip_type_op), + Ending(R.string.skip_type_ed), + Recap(R.string.skip_type_recap), + MixedOpening(R.string.skip_type_mixed_op), + MixedEnding(R.string.skip_type_mixed_ed), + Credits(R.string.skip_type_credits), + Intro(R.string.skip_type_intro), + Preview(R.string.skip_type_preview), +} + +data class SkipStamp( + val type: SkipType, + /** Start position in milliseconds of the skip, where it should start showing up */ + val startMs: Long, + /** End position in milliseconds of the skip, where it will skip to */ + val endMs: Long, + /** Custom visual label instead of using the type. Only use this for content not covered by SkipType */ + val label: String? = null, +) + +data class VideoSkipStamp( + val timestamp: SkipStamp, + val skipToNextEpisode: Boolean, + val source: String, +) { + val uiText = + if (skipToNextEpisode) txt(R.string.next_episode) else + txt( + R.string.skip_type_format, + timestamp.label?.let { txt(it) } ?: txt(timestamp.type.res) + ) +} + +abstract class SkipAPI { + open val name: String = "NONE" + + /** On what types SkipAPI should trigger on */ + abstract val supportedTypes: Set + + /** Get all video skip stamps of the associated episode */ + @Throws + open suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + ): List? { + throw NotImplementedError() + } + + companion object { + private val skipApis: List = listOf(AniSkip(), TheIntroDBSkip(), IntroDbSkip(), AnimeSkip()) + private val cachedStamps = ConcurrentHashMap>() + + /** Get all video timestamps from an episode */ + suspend fun videoStamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long, + hasNextEpisode: Boolean, + ): List { + cachedStamps[episode.id]?.let { list -> + return list + } + + for (api in skipApis) { + /** Unsupported type, so we do not waste a get call */ + if (!api.supportedTypes.contains(data.type)) { + continue + } + + /** Find first non-empty stamps */ + val stamps = safeAsync { api.stamps(data, episode, episodeDurationMs) } + if (stamps.isNullOrEmpty()) { + continue + } + + return stamps.map { stamp -> + VideoSkipStamp( + timestamp = stamp, + skipToNextEpisode = hasNextEpisode && episodeDurationMs - stamp.endMs < 20_000L, + source = api.name + ) + }.also { stamps -> + /** Put in cache, this is such small data, it should be fine to never clear it */ + cachedStamps[episode.id] = stamps + } + } + return emptyList() + } + } +} + diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt new file mode 100644 index 000000000..cc2661cb0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/TheIntroDBSkip.kt @@ -0,0 +1,76 @@ +package com.lagradost.cloudstream3.utils.videoskip + +import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId +import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId +import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.app + +/** https://theintrodb.org/docs */ +class TheIntroDBSkip : SkipAPI() { + override val name = "TheIntroDB" + override val supportedTypes = setOf( + TvType.TvSeries, TvType.Cartoon, TvType.Anime, TvType.Movie, + TvType.AsianDrama + ) + + val mainUrl = "https://api.theintrodb.org" + + override suspend fun stamps( + data: LoadResponse, + episode: ResultEpisode, + episodeDurationMs: Long + ): List? { + val idSuffix = + data.getTMDbId()?.let { tmdbId -> "tmdb_id=$tmdbId" } + ?: data.getImdbId()?.let { imdbId -> "imdb_id=$imdbId" } + ?: return null + + val url = if (data.isMovie()) { + "$mainUrl/v2/media?$idSuffix" + } else { + val season = episode.season ?: return null + "$mainUrl/v2/media?$idSuffix&season=$season&episode=${episode.episode}" + } + val root = app.get(url).parsed() + return arrayOf( + root.intro to SkipType.Intro, + root.credits to SkipType.Credits, + root.recap to SkipType.Recap, + root.preview to SkipType.Preview + ).map { (list, type) -> + list.map { stamp -> + SkipStamp( + type, + stamp.startMs ?: 0L, + stamp.endMs ?: episodeDurationMs + ) + } + }.flatten() + } + + data class Root( + @JsonProperty("tmdb_id") + val tmdbId: Long, + @JsonProperty("type") + val type: String, + @JsonProperty("intro") + val intro: List = emptyList(), + @JsonProperty("recap") + val recap: List = emptyList(), + @JsonProperty("credits") + val credits: List = emptyList(), + @JsonProperty("preview") + val preview: List = emptyList(), + ) + + data class Stamp( + @JsonProperty("start_ms") + val startMs: Long?, + @JsonProperty("end_ms") + val endMs: Long?, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt index d4725d53e..c18ad39c6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -4,6 +4,8 @@ import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.ViewGroup +import androidx.core.content.withStyledAttributes +import androidx.core.view.isVisible import androidx.core.view.marginEnd import com.lagradost.cloudstream3.R import kotlin.math.max @@ -18,9 +20,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + itemSpacing = getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) + } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -32,10 +34,12 @@ class FlowLayout : ViewGroup { val childCount = this.childCount for (i in 0 until childCount) { val child = getChildAt(i) + if (!child.isVisible) { + continue + } measureChild(child, widthMeasureSpec, heightMeasureSpec) val childWidth = child.measuredWidth val childHeight = child.measuredHeight - currentHeight = max(currentHeight, currentChildHookPointy + childHeight) //check if child can be placed in the current row, else go to next line if (currentChildHookPointx + childWidth - child.marginEnd - child.paddingEnd > realWidth) { @@ -44,8 +48,10 @@ class FlowLayout : ViewGroup { //reset for new line currentChildHookPointx = 0 - currentChildHookPointy += childHeight + currentChildHookPointy += childHeight + itemSpacing } + + currentHeight = max(currentHeight, currentChildHookPointy + childHeight) val nextChildHookPointx = currentChildHookPointx + childWidth + if (childWidth == 0) 0 else itemSpacing @@ -99,9 +105,9 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { - val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - spacing = 0//t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); - t.recycle() + c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { + spacing = 0 + } } internal constructor(width: Int, height: Int) : super(width, height) { diff --git a/app/src/main/res/anim/nav_enter_anim.xml b/app/src/main/res/anim/nav_enter_anim.xml deleted file mode 100644 index 84fa9e978..000000000 --- a/app/src/main/res/anim/nav_enter_anim.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/nav_exit_anim.xml b/app/src/main/res/anim/nav_exit_anim.xml deleted file mode 100644 index 970655147..000000000 --- a/app/src/main/res/anim/nav_exit_anim.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/nav_pop_enter.xml b/app/src/main/res/anim/nav_pop_enter.xml deleted file mode 100644 index 84fa9e978..000000000 --- a/app/src/main/res/anim/nav_pop_enter.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/nav_pop_exit.xml b/app/src/main/res/anim/nav_pop_exit.xml deleted file mode 100644 index 970655147..000000000 --- a/app/src/main/res/anim/nav_pop_exit.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/rotate_around_center_point.xml b/app/src/main/res/anim/rotate_around_center_point.xml new file mode 100644 index 000000000..76e7b39b4 --- /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 000000000..d2a6b6c4d --- /dev/null +++ b/app/src/main/res/color/black_button_ripple.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/button_selector_color.xml b/app/src/main/res/color/button_selector_color.xml new file mode 100644 index 000000000..9975946db --- /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 000000000..e6d1f8c9e --- /dev/null +++ b/app/src/main/res/color/color_primary_transparent.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/color/item_select_color.xml b/app/src/main/res/color/item_select_color.xml index 3d69c540b..208afb18b 100644 --- a/app/src/main/res/color/item_select_color.xml +++ b/app/src/main/res/color/item_select_color.xml @@ -3,5 +3,5 @@ - - \ No newline at end of file + + diff --git a/app/src/main/res/color/item_select_color_tv.xml b/app/src/main/res/color/item_select_color_tv.xml new file mode 100644 index 000000000..3042fd588 --- /dev/null +++ b/app/src/main/res/color/item_select_color_tv.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/color/player_on_button_tv_attr.xml b/app/src/main/res/color/player_on_button_tv_attr.xml new file mode 100644 index 000000000..feb1eeb08 --- /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 9bb16931e..a7c826044 100644 --- a/app/src/main/res/color/toggle_selector.xml +++ b/app/src/main/res/color/toggle_selector.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/app/src/main/res/color/white_attr_20.xml b/app/src/main/res/color/white_attr_20.xml new file mode 100644 index 000000000..e0237df00 --- /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 000000000..8f1bb3105 --- /dev/null +++ b/app/src/main/res/drawable/animeskip.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/arrow_and_edge_24px.xml b/app/src/main/res/drawable/arrow_and_edge_24px.xml new file mode 100644 index 000000000..2d5f74e14 --- /dev/null +++ b/app/src/main/res/drawable/arrow_and_edge_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/arrow_or_edge_24px.xml b/app/src/main/res/drawable/arrow_or_edge_24px.xml new file mode 100644 index 000000000..0e80a074e --- /dev/null +++ b/app/src/main/res/drawable/arrow_or_edge_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/arrows_input_24px.xml b/app/src/main/res/drawable/arrows_input_24px.xml new file mode 100644 index 000000000..f4b60368b --- /dev/null +++ b/app/src/main/res/drawable/arrows_input_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_downloading_24.xml b/app/src/main/res/drawable/baseline_downloading_24.xml new file mode 100644 index 000000000..c6fd08a38 --- /dev/null +++ b/app/src/main/res/drawable/baseline_downloading_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_headphones_24.xml b/app/src/main/res/drawable/baseline_headphones_24.xml new file mode 100644 index 000000000..938b17ead --- /dev/null +++ b/app/src/main/res/drawable/baseline_headphones_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_help_outline_24.xml b/app/src/main/res/drawable/baseline_help_outline_24.xml new file mode 100644 index 000000000..3a72cda09 --- /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 000000000..1caae6671 --- /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 000000000..cf589c6d4 --- /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 791a2f81a..f4455598a 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 000000000..9937885e7 --- /dev/null +++ b/app/src/main/res/drawable/baseline_skip_previous_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_stop_24.xml b/app/src/main/res/drawable/baseline_stop_24.xml new file mode 100644 index 000000000..100cb1fce --- /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 000000000..c1f3654b2 --- /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 000000000..bb71f8731 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_both.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_bottom.xml b/app/src/main/res/drawable/bg_color_bottom.xml new file mode 100644 index 000000000..7c744f19f --- /dev/null +++ b/app/src/main/res/drawable/bg_color_bottom.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_center.xml b/app/src/main/res/drawable/bg_color_center.xml new file mode 100644 index 000000000..7cb437452 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_center.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_color_top.xml b/app/src/main/res/drawable/bg_color_top.xml new file mode 100644 index 000000000..45497d272 --- /dev/null +++ b/app/src/main/res/drawable/bg_color_top.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_imdb_badge.xml b/app/src/main/res/drawable/bg_imdb_badge.xml new file mode 100644 index 000000000..de7a6704b --- /dev/null +++ b/app/src/main/res/drawable/bg_imdb_badge.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml new file mode 100644 index 000000000..b4701e42a --- /dev/null +++ b/app/src/main/res/drawable/bg_player_metadata_scrim_netflix.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bookmark_star_24px.xml b/app/src/main/res/drawable/bookmark_star_24px.xml new file mode 100644 index 000000000..81b400d92 --- /dev/null +++ b/app/src/main/res/drawable/bookmark_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/circle_shape_dotted.xml b/app/src/main/res/drawable/circle_shape_dotted.xml new file mode 100644 index 000000000..6ce2808cd --- /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 000000000..a2e7f0226 --- /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 000000000..477e8db1f --- /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 000000000..eed446281 --- /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 000000000..f41eea84a --- /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 000000000..dbbc7dc9f --- /dev/null +++ b/app/src/main/res/drawable/clear_all_24px.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/cloud_2_solid.xml b/app/src/main/res/drawable/cloud_2_solid.xml new file mode 100644 index 000000000..3810b4bf6 --- /dev/null +++ b/app/src/main/res/drawable/cloud_2_solid.xml @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_line_horizontal.xml b/app/src/main/res/drawable/dashed_line_horizontal.xml new file mode 100644 index 000000000..737ff1959 --- /dev/null +++ b/app/src/main/res/drawable/dashed_line_horizontal.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/download_icon_done.xml b/app/src/main/res/drawable/download_icon_done.xml new file mode 100644 index 000000000..a41ac14ed --- /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 000000000..ef56f19ad --- /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 000000000..bde9a1606 --- /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 000000000..084555218 --- /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 000000000..a77cbf252 --- /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 000000000..764cb9660 Binary files /dev/null and b/app/src/main/res/drawable/example_qr.png differ diff --git a/app/src/main/res/drawable/go_back_30.xml b/app/src/main/res/drawable/go_back_30.xml index e57946b65..149990116 100644 --- a/app/src/main/res/drawable/go_back_30.xml +++ b/app/src/main/res/drawable/go_back_30.xml @@ -1,6 +1,7 @@ + + diff --git a/app/src/main/res/drawable/home_icon_outline_24.xml b/app/src/main/res/drawable/home_icon_outline_24.xml new file mode 100644 index 000000000..2d5f93a67 --- /dev/null +++ b/app/src/main/res/drawable/home_icon_outline_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/home_icon_selector.xml b/app/src/main/res/drawable/home_icon_selector.xml new file mode 100644 index 000000000..2280cdd09 --- /dev/null +++ b/app/src/main/res/drawable/home_icon_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/hourglass_24.xml b/app/src/main/res/drawable/hourglass_24.xml new file mode 100644 index 000000000..7bd1ebbde --- /dev/null +++ b/app/src/main/res/drawable/hourglass_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml index ebe459b29..dbda1cc01 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 6c3197a6d..516df382c 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 2ec8c110d..48ac45e75 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 dad38dca6..000000000 --- a/app/src/main/res/drawable/ic_baseline_bug_report_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml index 70db409b3..7dea8241e 100644 --- a/app/src/main/res/drawable/ic_baseline_close_24.xml +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -1,4 +1,4 @@ - diff --git a/app/src/main/res/drawable/ic_baseline_edit_24.xml b/app/src/main/res/drawable/ic_baseline_edit_24.xml new file mode 100644 index 000000000..dba3e567c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_edit_24.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_equalizer_24.xml b/app/src/main/res/drawable/ic_baseline_equalizer_24.xml new file mode 100644 index 000000000..cd20ad156 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_equalizer_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_exit_24.xml b/app/src/main/res/drawable/ic_baseline_exit_24.xml new file mode 100644 index 000000000..6aebfabdc --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_exit_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_film_roll_24.xml b/app/src/main/res/drawable/ic_baseline_film_roll_24.xml new file mode 100644 index 000000000..941d936fa --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_film_roll_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_folder_open_24.xml b/app/src/main/res/drawable/ic_baseline_folder_open_24.xml new file mode 100644 index 000000000..66afaed2c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_folder_open_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml index 916c761c1..b67188dba 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_language_24.xml b/app/src/main/res/drawable/ic_baseline_language_24.xml index 1749952e5..89b479371 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 249fe2a29..b6908e96b 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 000000000..c46eb4b0c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_north_west_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml b/app/src/main/res/drawable/ic_baseline_notifications_active_24.xml index 2003bfe78..5d6045e7e 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 000000000..2651015ca --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_open_in_new_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_people_24.xml b/app/src/main/res/drawable/ic_baseline_people_24.xml new file mode 100644 index 000000000..2e7c9b070 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_people_24.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_replay_24.xml b/app/src/main/res/drawable/ic_baseline_replay_24.xml new file mode 100644 index 000000000..e247aa924 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_replay_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_restart_24.xml b/app/src/main/res/drawable/ic_baseline_restart_24.xml new file mode 100644 index 000000000..aed3a562c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_restart_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_resume_arrow.xml b/app/src/main/res/drawable/ic_baseline_resume_arrow.xml new file mode 100644 index 000000000..0326fbd49 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_resume_arrow.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml b/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml new file mode 100644 index 000000000..fc533a0e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_resume_arrow2.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml b/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml new file mode 100644 index 000000000..a8c43bbdb --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml b/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml new file mode 100644 index 000000000..452c4dd99 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_battery.xml b/app/src/main/res/drawable/ic_battery.xml new file mode 100644 index 000000000..24d0a77f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml b/app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml new file mode 100644 index 000000000..4b8964f8c --- /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 000000000..ed46f973d --- /dev/null +++ b/app/src/main/res/drawable/ic_filled_notifications_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fingerprint.xml b/app/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 000000000..5c96e5a54 --- /dev/null +++ b/app/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 000000000..71c2cbfcd --- /dev/null +++ b/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_network_stream.xml b/app/src/main/res/drawable/ic_network_stream.xml new file mode 100644 index 000000000..8e21fd25e --- /dev/null +++ b/app/src/main/res/drawable/ic_network_stream.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_offline_pin_24.xml b/app/src/main/res/drawable/ic_offline_pin_24.xml new file mode 100644 index 000000000..455006b31 --- /dev/null +++ b/app/src/main/res/drawable/ic_offline_pin_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline_account_circle_24.xml b/app/src/main/res/drawable/ic_outline_account_circle_24.xml index cc5644715..27c2d5749 100644 --- a/app/src/main/res/drawable/ic_outline_account_circle_24.xml +++ b/app/src/main/res/drawable/ic_outline_account_circle_24.xml @@ -1,6 +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 000000000..928ad0864 --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_notifications_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 000000000..e61dcf1ce --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/kid_star_24px.xml b/app/src/main/res/drawable/kid_star_24px.xml new file mode 100644 index 000000000..2efe84195 --- /dev/null +++ b/app/src/main/res/drawable/kid_star_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/library_icon.xml b/app/src/main/res/drawable/library_icon.xml new file mode 100644 index 000000000..f62dceac0 --- /dev/null +++ b/app/src/main/res/drawable/library_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/library_icon_filled.xml b/app/src/main/res/drawable/library_icon_filled.xml new file mode 100644 index 000000000..eba49782c --- /dev/null +++ b/app/src/main/res/drawable/library_icon_filled.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/library_icon_selector.xml b/app/src/main/res/drawable/library_icon_selector.xml new file mode 100644 index 000000000..9c6495bea --- /dev/null +++ b/app/src/main/res/drawable/library_icon_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/metadata_overlay_icon.xml b/app/src/main/res/drawable/metadata_overlay_icon.xml new file mode 100644 index 000000000..6d1b6510a --- /dev/null +++ b/app/src/main/res/drawable/metadata_overlay_icon.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/netflix_download_batch.xml b/app/src/main/res/drawable/netflix_download_batch.xml new file mode 100644 index 000000000..8ef633fd2 --- /dev/null +++ b/app/src/main/res/drawable/netflix_download_batch.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/netflix_skip_back.xml b/app/src/main/res/drawable/netflix_skip_back.xml index bb63e9485..5ad9c1a13 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 000000000..9226029a4 --- /dev/null +++ b/app/src/main/res/drawable/notifications_icon_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_15_gray.xml b/app/src/main/res/drawable/outline_big_15_gray.xml new file mode 100644 index 000000000..b94500279 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_15_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_20.xml b/app/src/main/res/drawable/outline_big_20.xml new file mode 100644 index 000000000..7faf8a889 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_20.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_20_gray.xml b/app/src/main/res/drawable/outline_big_20_gray.xml new file mode 100644 index 000000000..ebcdc0bf4 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_20_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_25_gray.xml b/app/src/main/res/drawable/outline_big_25_gray.xml new file mode 100644 index 000000000..ea5f31a1f --- /dev/null +++ b/app/src/main/res/drawable/outline_big_25_gray.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_big_35_gray.xml b/app/src/main/res/drawable/outline_big_35_gray.xml new file mode 100644 index 000000000..ab18a1354 --- /dev/null +++ b/app/src/main/res/drawable/outline_big_35_gray.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_bookmark_add_24.xml b/app/src/main/res/drawable/outline_bookmark_add_24.xml new file mode 100644 index 000000000..a4e18af3f --- /dev/null +++ b/app/src/main/res/drawable/outline_bookmark_add_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/outline_card.xml b/app/src/main/res/drawable/outline_card.xml index 02116bb88..5716de450 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 000000000..16eba83cc --- /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 000000000..7736f088a --- /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 0b641074d..aa3a8d0df 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 000000000..29096d867 --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_less_inset.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/outline_drawable_round_20.xml b/app/src/main/res/drawable/outline_drawable_round_20.xml new file mode 100644 index 000000000..a2e8253b7 --- /dev/null +++ b/app/src/main/res/drawable/outline_drawable_round_20.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pin_ic.xml b/app/src/main/res/drawable/pin_ic.xml new file mode 100644 index 000000000..1425ff05a --- /dev/null +++ b/app/src/main/res/drawable/pin_ic.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/play_button.xml b/app/src/main/res/drawable/play_button.xml index 04886b6e5..ee3d47dfe 100644 --- a/app/src/main/res/drawable/play_button.xml +++ b/app/src/main/res/drawable/play_button.xml @@ -1,25 +1,19 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + android:name="vector" + android:width="842dp" + android:height="842dp" + android:viewportWidth="842" + android:viewportHeight="842"> + android:name="path" + android:pathData="M 421.44 17.5 C 336.15 17.5 253.011 44.513 184.01 94.646 C 115.009 144.778 63.626 215.5 37.27 296.616 C 10.914 377.732 10.914 465.148 37.27 546.264 C 63.626 627.38 115.009 698.102 184.01 748.234 C 253.011 798.367 336.15 825.38 421.44 825.38 C 506.73 825.38 589.869 798.367 658.87 748.234 C 727.871 698.102 779.254 627.38 805.61 546.264 C 831.966 465.148 831.966 377.732 805.61 296.616 C 779.254 215.5 727.871 144.778 658.87 94.646 C 589.869 44.513 506.73 17.5 421.44 17.5 Z" + android:fillColor="#B3000000" + android:strokeWidth="1"/> - + android:name="path_2" + android:pathData="M 598.91 419.24 L 333.91 266.24 L 333.91 572.24 L 598.91 419.24 Z" + android:fillColor="#ffffff" + android:strokeWidth="1"/> diff --git a/app/src/main/res/drawable/play_button_transparent.xml b/app/src/main/res/drawable/play_button_transparent.xml new file mode 100644 index 000000000..caa7041e6 --- /dev/null +++ b/app/src/main/res/drawable/play_button_transparent.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/player_button_tv_attr.xml b/app/src/main/res/drawable/player_button_tv_attr.xml new file mode 100644 index 000000000..ed83887d2 --- /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 000000000..0dd8c256a --- /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 79bb3af5f..8077b418f 100644 --- a/app/src/main/res/drawable/player_gradient_tv.xml +++ b/app/src/main/res/drawable/player_gradient_tv.xml @@ -4,10 +4,10 @@ @@ -15,10 +15,10 @@ diff --git a/app/src/main/res/drawable/preview_seekbar_24.xml b/app/src/main/res/drawable/preview_seekbar_24.xml new file mode 100644 index 000000000..657f62470 --- /dev/null +++ b/app/src/main/res/drawable/preview_seekbar_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/profile_bg_blue.jpg b/app/src/main/res/drawable/profile_bg_blue.jpg new file mode 100644 index 000000000..e573439b0 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 000000000..d59e4888c 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 000000000..a97e7179f 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 000000000..9d4940f0d 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 000000000..15723dba3 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 000000000..6a27ff313 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 000000000..932366508 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 index 60e62babe..4cf33aba0 100644 --- a/app/src/main/res/drawable/rating_bg_color.xml +++ b/app/src/main/res/drawable/rating_bg_color.xml @@ -1,6 +1,6 @@ - - + + - \ No newline at end of file + diff --git a/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml new file mode 100644 index 000000000..d1360f948 --- /dev/null +++ b/app/src/main/res/drawable/round_keyboard_arrow_up_24.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_outline.xml b/app/src/main/res/drawable/rounded_outline.xml new file mode 100644 index 000000000..b85ace8ea --- /dev/null +++ b/app/src/main/res/drawable/rounded_outline.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/rounded_select_ripple.xml b/app/src/main/res/drawable/rounded_select_ripple.xml new file mode 100644 index 000000000..5dd7559b3 --- /dev/null +++ b/app/src/main/res/drawable/rounded_select_ripple.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/screen_rotation.xml b/app/src/main/res/drawable/screen_rotation.xml new file mode 100644 index 000000000..da0ac0fd5 --- /dev/null +++ b/app/src/main/res/drawable/screen_rotation.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/settings_icon_filled.xml b/app/src/main/res/drawable/settings_icon_filled.xml new file mode 100644 index 000000000..1d31bb7d0 --- /dev/null +++ b/app/src/main/res/drawable/settings_icon_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settings_icon_outline.xml b/app/src/main/res/drawable/settings_icon_outline.xml new file mode 100644 index 000000000..bdc9e98d3 --- /dev/null +++ b/app/src/main/res/drawable/settings_icon_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settings_icon_selector.xml b/app/src/main/res/drawable/settings_icon_selector.xml new file mode 100644 index 000000000..c54a9760d --- /dev/null +++ b/app/src/main/res/drawable/settings_icon_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/simkl_logo.xml b/app/src/main/res/drawable/simkl_logo.xml new file mode 100644 index 000000000..eb29fb5bc --- /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 000000000..879ef852c --- /dev/null +++ b/app/src/main/res/drawable/speedup.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/subdl_logo_big.xml b/app/src/main/res/drawable/subdl_logo_big.xml new file mode 100644 index 000000000..12116eabc --- /dev/null +++ b/app/src/main/res/drawable/subdl_logo_big.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/sun_7_24.xml b/app/src/main/res/drawable/sun_7_24.xml new file mode 100644 index 000000000..26e3f43e8 --- /dev/null +++ b/app/src/main/res/drawable/sun_7_24.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/title_24px.xml b/app/src/main/res/drawable/title_24px.xml new file mode 100644 index 000000000..3e725ff7a --- /dev/null +++ b/app/src/main/res/drawable/title_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/video_frame.xml b/app/src/main/res/drawable/video_frame.xml new file mode 100644 index 000000000..19fcf26d0 --- /dev/null +++ b/app/src/main/res/drawable/video_frame.xml @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/video_outline.xml b/app/src/main/res/drawable/video_outline.xml new file mode 100644 index 000000000..558c4ec3e --- /dev/null +++ b/app/src/main/res/drawable/video_outline.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-port/player_select_source_and_subs.xml b/app/src/main/res/layout-port/player_select_source_and_subs.xml new file mode 100644 index 000000000..4710473d4 --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_and_subs.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/player_select_source_priority.xml b/app/src/main/res/layout-port/player_select_source_priority.xml new file mode 100644 index 000000000..2cba9c869 --- /dev/null +++ b/app/src/main/res/layout-port/player_select_source_priority.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/subtitle_offset.xml b/app/src/main/res/layout-port/subtitle_offset.xml new file mode 100644 index 000000000..b6c4f61fd --- /dev/null +++ b/app/src/main/res/layout-port/subtitle_offset.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/account_edit_dialog.xml b/app/src/main/res/layout/account_edit_dialog.xml new file mode 100644 index 000000000..f52c8ea51 --- /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 000000000..3cbfc72fb --- /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 000000000..dea64484f --- /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 000000000..3f41a23c2 --- /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 389a34066..e7afb382c 100644 --- a/app/src/main/res/layout/account_managment.xml +++ b/app/src/main/res/layout/account_managment.xml @@ -62,14 +62,16 @@ + android:id="@+id/account_switch_account" + android:text="@string/switch_account" + style="@style/SettingsItem" + android:focusable="true"/> + android:id="@+id/account_logout" + android:text="@string/logout" + style="@style/SettingsItem" + android:focusable="true"> diff --git a/app/src/main/res/layout/account_select_linear.xml b/app/src/main/res/layout/account_select_linear.xml new file mode 100644 index 000000000..b78c0d44c --- /dev/null +++ b/app/src/main/res/layout/account_select_linear.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/account_single.xml b/app/src/main/res/layout/account_single.xml index cbfb9f18f..c4f7fa394 100644 --- a/app/src/main/res/layout/account_single.xml +++ b/app/src/main/res/layout/account_single.xml @@ -1,10 +1,11 @@ + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:foreground="?android:attr/selectableItemBackgroundBorderless" + android:orientation="horizontal" + android:layout_height="wrap_content" + android:layout_width="match_parent" + android:focusable="true"> + android:id="@+id/account_profile_picture" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:ignore="ContentDescription" /> + android:foreground="@null" + android:id="@+id/account_name" + tools:text="Account 1" + style="@style/SettingsItem" /> diff --git a/app/src/main/res/layout/account_switch.xml b/app/src/main/res/layout/account_switch.xml index 659ad840a..ac6e41a60 100644 --- a/app/src/main/res/layout/account_switch.xml +++ b/app/src/main/res/layout/account_switch.xml @@ -7,18 +7,27 @@ android:layout_height="match_parent"> + android:id="@+id/account_list" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + android:background="?attr/primaryBlackBackground" + tools:listitem="@layout/account_single" + android:layout_width="match_parent" + android:layout_rowWeight="1" + android:layout_height="wrap_content" + android:focusable="true"/> + android:id="@+id/account_none" + android:text="@string/no_account" + style="@style/SettingsItem" + android:focusable="true"> + + + diff --git a/app/src/main/res/layout/activity_account_select.xml b/app/src/main/res/layout/activity_account_select.xml new file mode 100644 index 000000000..9f62d5601 --- /dev/null +++ b/app/src/main/res/layout/activity_account_select.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_easter_egg_monke.xml b/app/src/main/res/layout/activity_easter_egg_monke.xml deleted file mode 100644 index 9003cd211..000000000 --- a/app/src/main/res/layout/activity_easter_egg_monke.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index b62908655..2483a3714 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -14,20 +14,21 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/add_account_input.xml b/app/src/main/res/layout/add_account_input.xml index ea48a80f0..4f96b109e 100644 --- a/app/src/main/res/layout/add_account_input.xml +++ b/app/src/main/res/layout/add_account_input.xml @@ -80,6 +80,7 @@ android:id="@+id/login_server_input" android:layout_width="match_parent" android:layout_height="wrap_content" + android:autofillHints="no" android:hint="@string/example_ip" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" @@ -96,7 +97,7 @@ android:layout_height="wrap_content" android:autofillHints="password" android:hint="@string/example_password" - android:inputType="textVisiblePassword" + android:inputType="textPassword" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" diff --git a/app/src/main/res/layout/add_remove_sites.xml b/app/src/main/res/layout/add_remove_sites.xml index 9ef6ad6a4..653f607f1 100644 --- a/app/src/main/res/layout/add_remove_sites.xml +++ b/app/src/main/res/layout/add_remove_sites.xml @@ -1,19 +1,21 @@ + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/add_site" + android:text="@string/add_site_pref" + android:focusable="true" + style="@style/SettingsItem"> + android:id="@+id/remove_site" + android:text="@string/remove_site_pref" + android:focusable="true" + style="@style/SettingsItem" /> \ No newline at end of file diff --git a/app/src/main/res/layout/add_repo_input.xml b/app/src/main/res/layout/add_repo_input.xml index 6f6b4d5bd..a8bdf2a38 100644 --- a/app/src/main/res/layout/add_repo_input.xml +++ b/app/src/main/res/layout/add_repo_input.xml @@ -72,7 +72,7 @@ android:inputType="text" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" - android:nextFocusDown="@id/site_url_input" + android:nextFocusDown="@id/repo_url_input" android:requiresFadingEdge="vertical" android:textColorHint="?attr/grayTextColor" tools:ignore="LabelFor" /> @@ -81,13 +81,13 @@ android:id="@+id/repo_url_input" android:layout_width="match_parent" android:layout_height="wrap_content" + android:autofillHints="no" android:hint="@string/repository_url_hint" android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" - - android:nextFocusUp="@id/site_name_input" - android:nextFocusDown="@id/site_lang_input" + android:nextFocusUp="@id/repo_name_input" + android:nextFocusDown="@id/apply_btt" android:requiresFadingEdge="vertical" android:textColorHint="?attr/grayTextColor" tools:ignore="LabelFor" /> diff --git a/app/src/main/res/layout/add_site_input.xml b/app/src/main/res/layout/add_site_input.xml index 1c61f8b4d..519b790da 100644 --- a/app/src/main/res/layout/add_site_input.xml +++ b/app/src/main/res/layout/add_site_input.xml @@ -62,6 +62,7 @@ + xmlns:tools="http://schemas.android.com/tools" + android:nextFocusDown="@id/nginx_text_input" + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent"> + android:id="@+id/nginx_text_input" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + android:layout_marginBottom="60dp" + android:layout_marginHorizontal="10dp" + android:paddingTop="10dp" + android:requiresFadingEdge="vertical" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_rowWeight="1" + android:autofillHints="no" + android:inputType="text" + tools:text="nginx.com" + tools:ignore="LabelFor" /> + android:id="@+id/apply_btt_holder" + android:orientation="horizontal" + android:layout_gravity="bottom" + android:gravity="bottom|end" + android:layout_marginTop="-60dp" + android:layout_width="match_parent" + android:layout_height="60dp"> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + android:id="@+id/apply_btt" + style="@style/WhiteButton" /> + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + android:id="@+id/cancel_btt" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/bottom_loading.xml b/app/src/main/res/layout/bottom_loading.xml index ab05889d0..1637aa5ad 100644 --- a/app/src/main/res/layout/bottom_loading.xml +++ b/app/src/main/res/layout/bottom_loading.xml @@ -1,33 +1,61 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> - + + + android:layout_height="wrap_content" + android:orientation="vertical"> + + + + + + + + + + + + + + + + - + android:id="@+id/progressBar" + style="@android:style/Widget.Material.ProgressBar.Horizontal" + android:layout_width="match_parent" + android:layout_height="15dp" + android:layout_gravity="center" + android:layout_marginBottom="-6.5dp" + android:indeterminate="true" + android:indeterminateTint="?attr/colorPrimary" + android:progressTint="?attr/colorPrimary" + android:visibility="gone" /> diff --git a/app/src/main/res/layout/bottom_resultview_preview.xml b/app/src/main/res/layout/bottom_resultview_preview.xml index ce41cb65b..3372fe7b1 100644 --- a/app/src/main/res/layout/bottom_resultview_preview.xml +++ b/app/src/main/res/layout/bottom_resultview_preview.xml @@ -41,17 +41,50 @@ android:layout_marginStart="10dp" android:orientation="vertical"> - - android:textStyle="bold" - tools:text="The Perfect Run"> + - + + + + + - + android:orientation="horizontal" + android:padding="7dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/bottom_selection_dialog.xml b/app/src/main/res/layout/bottom_selection_dialog.xml index 0532f2506..55ca6562e 100644 --- a/app/src/main/res/layout/bottom_selection_dialog.xml +++ b/app/src/main/res/layout/bottom_selection_dialog.xml @@ -1,58 +1,65 @@ + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> - + + + + + tools:text="Test" /> + + android:id="@+id/listview1" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:paddingTop="10dp" + android:dividerHeight="1dp" + android:requiresFadingEdge="vertical" + android:nextFocusRight="@id/cancel_btt" + android:nextFocusLeft="@id/apply_btt" + tools:listitem="@layout/sort_bottom_single_choice" /> + android:id="@+id/apply_btt_holder" + android:layout_width="match_parent" + android:layout_height="60dp" + android:gravity="center_vertical|end" + android:orientation="horizontal"> + android:id="@+id/apply_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_apply" + style="@style/WhiteButton" /> + android:id="@+id/cancel_btt" + android:layout_width="wrap_content" + android:layout_gravity="center_vertical|end" + android:text="@string/sort_cancel" + style="@style/BlackButton" /> diff --git a/app/src/main/res/layout/bottom_selection_dialog_direct.xml b/app/src/main/res/layout/bottom_selection_dialog_direct.xml index 0d179ebb6..cf31ba1ff 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 000000000..01b4834d3 --- /dev/null +++ b/app/src/main/res/layout/bottom_text_dialog.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml index 8403940ce..4f7bdf74d 100644 --- a/app/src/main/res/layout/cast_item.xml +++ b/app/src/main/res/layout/cast_item.xml @@ -1,105 +1,102 @@ - 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:id="@+id/voice_actor_image" - tools:src="@drawable/example_poster" - - android:scaleType="centerCrop" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:contentDescription="@string/episode_poster_img_des" /> + + + 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 624c2201c..92d0bd350 100644 --- a/app/src/main/res/layout/chromecast_subtitle_settings.xml +++ b/app/src/main/res/layout/chromecast_subtitle_settings.xml @@ -1,16 +1,21 @@ - + + + android:layout_height="match_parent"> - + android:layout_height="wrap_content" + android:orientation="vertical"> - - - - - + - - - - - - - - - - - - + + - + - - \ No newline at end of file + + diff --git a/app/src/main/res/layout/confirm_exit_dialog.xml b/app/src/main/res/layout/confirm_exit_dialog.xml new file mode 100644 index 000000000..c312e64e3 --- /dev/null +++ b/app/src/main/res/layout/confirm_exit_dialog.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/layout/custom_preference_category_material.xml b/app/src/main/res/layout/custom_preference_category_material.xml new file mode 100644 index 000000000..f5d78e835 --- /dev/null +++ b/app/src/main/res/layout/custom_preference_category_material.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/custom_preference_material.xml b/app/src/main/res/layout/custom_preference_material.xml new file mode 100644 index 000000000..c6685ee29 --- /dev/null +++ b/app/src/main/res/layout/custom_preference_material.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/custom_preference_widget_seekbar.xml b/app/src/main/res/layout/custom_preference_widget_seekbar.xml new file mode 100644 index 000000000..132091e5f --- /dev/null +++ b/app/src/main/res/layout/custom_preference_widget_seekbar.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/device_auth.xml b/app/src/main/res/layout/device_auth.xml new file mode 100644 index 000000000..38ff1325f --- /dev/null +++ b/app/src/main/res/layout/device_auth.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_online_subtitles.xml b/app/src/main/res/layout/dialog_online_subtitles.xml index 7803e2617..48dc48a04 100644 --- a/app/src/main/res/layout/dialog_online_subtitles.xml +++ b/app/src/main/res/layout/dialog_online_subtitles.xml @@ -22,25 +22,23 @@ android:orientation="vertical"> + - - + tools:ignore="UseCompoundDrawables" + android:padding="10dp"> + android:layout_marginEnd="10dp" + android:background="@drawable/search_background"> + - - - - - - - - + - + - - diff --git a/app/src/main/res/layout/download_button.xml b/app/src/main/res/layout/download_button.xml new file mode 100644 index 000000000..e80232436 --- /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 000000000..0ceca181e --- /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 000000000..6e40a5973 --- /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 f2633dd6c..cb9c13d53 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_width="match_parent" - android:layout_height="50dp" - app:cardCornerRadius="@dimen/rounded_image_radius" - app:cardBackgroundColor="@color/transparent" - app:cardElevation="0dp" - android:foreground="@drawable/outline_drawable" - android:layout_marginBottom="5dp"> - - + + + + + + + + + + - - + android:layout_width="match_parent" + android:layout_height="match_parent" + android:foreground="?android:attr/selectableItemBackgroundBorderless"> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center"> - - - - - - + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginStart="5dp"> - + - + - 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 da4b36174..7b8b2c91e 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 000000000..86562a513 --- /dev/null +++ b/app/src/main/res/layout/download_queue_item.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/empty_layout.xml b/app/src/main/res/layout/empty_layout.xml index 388e862b2..e128f7cec 100644 --- a/app/src/main/res/layout/empty_layout.xml +++ b/app/src/main/res/layout/empty_layout.xml @@ -1,18 +1,19 @@ - + - - \ No newline at end of file + + diff --git a/app/src/main/res/layout/extra_brightness_overlay.xml b/app/src/main/res/layout/extra_brightness_overlay.xml new file mode 100644 index 000000000..8f82121bb --- /dev/null +++ b/app/src/main/res/layout/extra_brightness_overlay.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_child_downloads.xml b/app/src/main/res/layout/fragment_child_downloads.xml index a3cc8ce83..0a7b42327 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"> + + + +