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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- xmlns:android
-
- ^$
-
-
-
-
-
-
-
-
- xmlns:.*
-
- ^$
-
-
- BY_NAME
-
-
-
-
-
-
- .*:id
-
- http://schemas.android.com/apk/res/android
-
-
-
-
-
-
-
-
- .*:name
-
- http://schemas.android.com/apk/res/android
-
-
-
-
-
-
-
-
- name
-
- ^$
-
-
-
-
-
-
-
-
- style
-
- ^$
-
-
-
-
-
-
-
-
- .*
-
- ^$
-
-
- BY_NAME
-
-
-
-
-
-
- .*
-
- http://schemas.android.com/apk/res/android
-
-
- ANDROID_ATTRIBUTE_ORDER
-
-
-
-
-
-
- .*
-
- .*
-
-
- BY_NAME
-
-
-
-
-
-
-
-
-
-
\ 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.**
[](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.
+
-
\ 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