diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml
index 931db3bd9..f35900673 100644
--- a/.github/ISSUE_TEMPLATE/application-bug.yml
+++ b/.github/ISSUE_TEMPLATE/application-bug.yml
@@ -80,13 +80,13 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
+ - label: I am sure my issue is related to the app and **NOT some extension**.
+ required: true
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**.
required: true
- - label: If related to a provider, I have checked the site and it works, but not the app.
- required: true
- label: I will fill out all of the requested information in this form.
required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 250734cdd..b56cdf8ed 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -2,7 +2,7 @@ blank_issues_enabled: false
contact_links:
- name: Request a new provider or report bug with an existing provider
url: https://github.com/recloudstream
- about: Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
+ about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
- name: Discord
url: https://discord.gg/5Hus6fM
about: Join our discord for faster support on smaller issues.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
index 9c35ba56f..e18daebb3 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -27,9 +27,7 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
+ - label: My suggestion is **NOT** about adding a new provider
+ required: true
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
- required: true
- - label: I have written a short but informative title.
- required: true
- - label: I will fill out all of the requested information in this form.
- required: true
+ required: true
\ No newline at end of file
diff --git a/.github/locales.py b/.github/locales.py
index 7d6d6b90d..6127d9d80 100644
--- a/.github/locales.py
+++ b/.github/locales.py
@@ -7,7 +7,7 @@ import lxml.etree as ET # builtin library doesn't preserve comments
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
START_MARKER = "/* begin language list */"
END_MARKER = "/* end language list */"
-XML_NAME = "app/src/main/res/values-"
+XML_NAME = "app/src/main/res/values-b+"
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
INDENT = " "*4
@@ -20,29 +20,29 @@ rest, after_src = rest.split(END_MARKER)
# Load already added langs
languages = {}
-for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
- flag, name, iso = lang.groups()
- languages[iso] = (flag, name)
+for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest):
+ name, iso = lang.groups()
+ languages[iso] = name
# Add not yet added langs
for folder in glob.glob(f"{XML_NAME}*"):
- iso = folder[len(XML_NAME):]
+ iso = folder[len(XML_NAME):].replace("+", "-")
if iso not in languages.keys():
- entry = iso_map.get(iso.lower(),{'nativeName':iso})
- languages[iso] = ("", entry['nativeName'].split(',')[0])
+ entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found
+ languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple
-# Create triples
-triples = []
-for iso in sorted(languages.keys()):
- flag, name = languages[iso]
- triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
+# Create pairs
+pairs = []
+for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name
+ name = languages[iso]
+ pairs.append(f'{INDENT}Pair("{name}", "{iso}"),')
# Update settings file
open(SETTINGS_PATH, "w+",encoding='utf-8').write(
before_src +
START_MARKER +
"\n" +
- "\n".join(triples) +
+ "\n".join(pairs) +
"\n" +
END_MARKER +
after_src
@@ -53,6 +53,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
try:
tree = ET.parse(file)
for child in tree.getroot():
+ if not child.text:
+ continue
if child.text.startswith("\\@string/"):
print(f"[{file}] fixing {child.attrib['name']}")
child.text = child.text.replace("\\@string/", "@string/")
diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml
index 3b7aa9aec..30bedcc1b 100644
--- a/.github/workflows/build_to_archive.yml
+++ b/.github/workflows/build_to_archive.yml
@@ -1,78 +1,93 @@
-name: Archive build
-
-on:
- push:
- branches: [ master ]
- paths-ignore:
- - '*.md'
- - '*.json'
- - '**/wcokey.txt'
- workflow_dispatch:
-
-concurrency:
- group: "Archive-build"
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - name: Generate access token
- id: generate_token
- uses: tibdex/github-app-token@v1
- with:
- app_id: ${{ secrets.GH_APP_ID }}
- private_key: ${{ secrets.GH_APP_KEY }}
- repository: "recloudstream/secrets"
- - name: Generate access token (archive)
- id: generate_archive_token
- uses: tibdex/github-app-token@v1
- with:
- app_id: ${{ secrets.GH_APP_ID }}
- private_key: ${{ secrets.GH_APP_KEY }}
- repository: "recloudstream/cloudstream-archive"
- - uses: actions/checkout@v2
- - name: Set up JDK 17
- uses: actions/setup-java@v2
- with:
- java-version: '17'
- distribution: 'adopt'
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
- - name: Fetch keystore
- id: fetch_keystore
- run: |
- TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore
- mkdir -p "${TMP_KEYSTORE_FILE_PATH}"
- curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks"
- curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt"
- KEY_PWD="$(cat keystore_password.txt)"
- echo "::add-mask::${KEY_PWD}"
- echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
- - name: Run Gradle
- run: |
- ./gradlew assemblePrerelease
- env:
- SIGNING_KEY_ALIAS: "key0"
- SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
- SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
- SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
- SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- - uses: actions/checkout@v3
- with:
- repository: "recloudstream/cloudstream-archive"
- token: ${{ steps.generate_archive_token.outputs.token }}
- path: "archive"
-
- - name: Move build
- run: |
- cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
-
- - name: Push archive
- run: |
- cd $GITHUB_WORKSPACE/archive
- git config --local user.email "actions@github.com"
- git config --local user.name "GitHub Actions"
- git add .
- git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit
- git push --force
\ No newline at end of file
+name: Archive build
+
+on:
+ push:
+ branches: [ master ]
+ paths-ignore:
+ - '*.md'
+ - '*.json'
+ - '**/wcokey.txt'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: "Archive-build"
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate access token
+ id: generate_token
+ uses: tibdex/github-app-token@v2
+ with:
+ app_id: ${{ secrets.GH_APP_ID }}
+ private_key: ${{ secrets.GH_APP_KEY }}
+ repository: "recloudstream/secrets"
+
+ - name: Generate access token (archive)
+ id: generate_archive_token
+ uses: tibdex/github-app-token@v2
+ with:
+ app_id: ${{ secrets.GH_APP_ID }}
+ private_key: ${{ secrets.GH_APP_KEY }}
+ repository: "recloudstream/cloudstream-archive"
+
+ - uses: actions/checkout@v6
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 17
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Fetch keystore
+ id: fetch_keystore
+ run: |
+ TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore
+ mkdir -p "${TMP_KEYSTORE_FILE_PATH}"
+ curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks"
+ curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt"
+ KEY_PWD="$(cat keystore_password.txt)"
+ echo "::add-mask::${KEY_PWD}"
+ echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+
+ - name: Run Gradle
+ run: ./gradlew assemblePrereleaseRelease
+ env:
+ SIGNING_KEY_ALIAS: "key0"
+ SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
+ SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
+ SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
+ SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
+ TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
+ MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
+
+ - uses: actions/checkout@v6
+ with:
+ repository: "recloudstream/cloudstream-archive"
+ token: ${{ steps.generate_archive_token.outputs.token }}
+ path: "archive"
+
+ - name: Move build
+ run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
+
+ - name: Push archive
+ run: |
+ cd $GITHUB_WORKSPACE/archive
+ git config --local user.email "actions@github.com"
+ git config --local user.name "GitHub Actions"
+ git add .
+ git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit
+ git push --force
diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml
index abeee0b29..d67b8a519 100644
--- a/.github/workflows/generate_dokka.yml
+++ b/.github/workflows/generate_dokka.yml
@@ -1,64 +1,67 @@
name: Dokka
-# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency
-concurrency:
- group: "dokka"
- cancel-in-progress: true
-
on:
push:
- branches:
- # choose your default branch
- - master
- - main
+ branches: [ master ]
paths-ignore:
- '*.md'
+permissions:
+ contents: read
+
+concurrency:
+ group: "dokka"
+ cancel-in-progress: true
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/dokka"
+
- name: Checkout
- uses: actions/checkout@master
+ uses: actions/checkout@v6
with:
path: "src"
- name: Checkout dokka
- uses: actions/checkout@master
+ uses: actions/checkout@v6
with:
repository: "recloudstream/dokka"
path: "dokka"
token: ${{ steps.generate_token.outputs.token }}
-
+
- name: Clean old builds
run: |
cd $GITHUB_WORKSPACE/dokka/
- rm -rf "./-cloudstream"
+ rm -rf "./app"
+ rm -rf "./library"
- - name: Setup JDK 17
- uses: actions/setup-java@v1
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
with:
+ distribution: temurin
java-version: 17
- - name: Setup Android SDK
- uses: android-actions/setup-android@v2
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Generate Dokka
run: |
cd $GITHUB_WORKSPACE/src/
chmod +x gradlew
- ./gradlew app:dokkaHtml
+ ./gradlew docs:dokkaGeneratePublicationHtml
- name: Copy Dokka
- run: |
- cp -r $GITHUB_WORKSPACE/src/app/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
+ run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
- name: Push builds
run: |
diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml
deleted file mode 100644
index 108cec82e..000000000
--- a/.github/workflows/issue_action.yml
+++ /dev/null
@@ -1,88 +0,0 @@
-name: Issue automatic actions
-
-on:
- issues:
- types: [opened]
-
-jobs:
- issue-moderator:
- runs-on: ubuntu-latest
- steps:
- - name: Generate access token
- id: generate_token
- uses: tibdex/github-app-token@v1
- with:
- app_id: ${{ secrets.GH_APP_ID }}
- private_key: ${{ secrets.GH_APP_KEY }}
- - name: Similarity analysis
- id: similarity
- uses: actions-cool/issues-similarity-analysis@v1
- with:
- token: ${{ steps.generate_token.outputs.token }}
- filter-threshold: 0.60
- title-excludes: ''
- comment-title: |
- ### Your issue looks similar to these issues:
- Please close if duplicate.
- comment-body: '${index}. ${similarity} #${number}'
- - name: Label if possible duplicate
- if: steps.similarity.outputs.similar-issues-found =='true'
- uses: actions/github-script@v6
- with:
- github-token: ${{ steps.generate_token.outputs.token }}
- script: |
- github.rest.issues.addLabels({
- issue_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- labels: ["possible duplicate"]
- })
- - uses: actions/checkout@v2
- - name: Automatically close issues that dont follow the issue template
- uses: lucasbento/auto-close-issues@v1.0.2
- with:
- github-token: ${{ steps.generate_token.outputs.token }}
- issue-close-message: |
- @${issue.user.login}: hello! :wave:
- This issue is being automatically closed because it does not follow the issue template."
- closed-issues-label: "invalid"
- - name: Check if issue mentions a provider
- id: provider_check
- env:
- GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}"
- run: |
- wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py"
- pip3 install httpx
- RES="$(python3 ./check_issue.py)"
- echo "name=${RES}" >> $GITHUB_OUTPUT
- - name: Comment if issue mentions a provider
- if: steps.provider_check.outputs.name != 'none'
- uses: actions-cool/issues-helper@v3
- with:
- actions: 'create-comment'
- token: ${{ steps.generate_token.outputs.token }}
- body: |
- Hello ${{ github.event.issue.user.login }}.
- Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM).
-
- Found provider name: `${{ steps.provider_check.outputs.name }}`
- - name: Label if mentions provider
- if: steps.provider_check.outputs.name != 'none'
- uses: actions/github-script@v6
- with:
- github-token: ${{ steps.generate_token.outputs.token }}
- script: |
- github.rest.issues.addLabels({
- issue_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- labels: ["possible provider issue"]
- })
- - name: Add eyes reaction to all issues
- uses: actions-cool/emoji-helper@v1.0.0
- with:
- type: 'issue'
- token: ${{ steps.generate_token.outputs.token }}
- emoji: 'eyes'
-
-
diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
index 58009a7a7..b5b17ba6a 100644
--- a/.github/workflows/prerelease.yml
+++ b/.github/workflows/prerelease.yml
@@ -8,29 +8,36 @@ on:
- '*.json'
- '**/wcokey.txt'
-concurrency:
+concurrency:
group: "pre-release"
cancel-in-progress: true
+permissions:
+ contents: write
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- - uses: actions/checkout@v2
+
+ - uses: actions/checkout@v6
+
- name: Set up JDK 17
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v5
with:
- java-version: '17'
- distribution: 'adopt'
+ distribution: temurin
+ java-version: 17
+
- name: Grant execute permission for gradlew
run: chmod +x gradlew
+
- name: Fetch keystore
id: fetch_keystore
run: |
@@ -41,17 +48,25 @@ jobs:
KEY_PWD="$(cat keystore_password.txt)"
echo "::add-mask::${KEY_PWD}"
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+
- name: Run Gradle
- run: |
- ./gradlew assemblePrerelease makeJar androidSourcesJar
+ run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
+ TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
+ MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
+
- name: Create pre-release
- uses: "marvinpinto/action-automatic-releases@latest"
+ uses: marvinpinto/action-automatic-releases@latest
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "pre-release"
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index b6177710d..8f5c62866 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -2,22 +2,35 @@ name: Artifact Build
on: [pull_request]
+permissions:
+ contents: read
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v6
+
- name: Set up JDK 17
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v5
with:
- java-version: '17'
- distribution: 'adopt'
+ distribution: temurin
+ java-version: 17
+
- name: Grant execute permission for gradlew
run: chmod +x gradlew
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache-read-only: false
+
- name: Run Gradle
- run: ./gradlew assemblePrereleaseDebug
+ run: ./gradlew assemblePrereleaseDebug lint check
+
- name: Upload Artifact
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v7
with:
name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk"
diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml
index 628e9bc92..0a538d5d4 100644
--- a/.github/workflows/update_locales.yml
+++ b/.github/workflows/update_locales.yml
@@ -1,37 +1,41 @@
name: Fix locale issues
on:
- workflow_dispatch:
push:
+ branches: [ master ]
paths:
- '**.xml'
- branches:
- - master
+ workflow_dispatch:
-concurrency:
+concurrency:
group: "locale"
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
create:
runs-on: ubuntu-latest
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream"
- - uses: actions/checkout@v2
+
+ - uses: actions/checkout@v6
with:
token: ${{ steps.generate_token.outputs.token }}
+
- name: Install dependencies
- run: |
- pip3 install lxml
+ run: pip3 install lxml requests
+
- name: Edit files
- run: |
- python3 .github/locales.py
+ run: python3 .github/locales.py
+
- name: Commit to the repo
run: |
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
diff --git a/.gitignore b/.gitignore
index 2ac6c9695..5fc9f0870 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,3 @@
-*.iml
-.gradle
/local.properties
/.idea/caches
/.idea/misc.xml
@@ -11,6 +9,220 @@
.DS_Store
/build
/captures
-.externalNativeBuild
.cxx
+.kotlin/*
+
+# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode
+# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode
+
+### Android ###
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
local.properties
+
+# Log/OS Files
+*.log
+
+# Android Studio generated files and folders
+captures/
+.externalNativeBuild/
+.cxx/
+*.apk
+output.json
+
+# IntelliJ
+*.iml
+.idea/
+misc.xml
+deploymentTargetDropDown.xml
+render.experimental.xml
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Android Profiling
+*.hprof
+
+### Android Patch ###
+gen-external-apklibs
+
+# Replacement of .externalNativeBuild directories introduced
+# with Android Studio 3.5.
+
+### Java ###
+# Compiled class file
+*.class
+
+# Log file
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+replay_pid*
+
+### Kotlin ###
+# Compiled class file
+
+# Log file
+
+# BlueJ files
+
+# Mobile Tools for Java (J2ME)
+
+# Package Files #
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+
+### VisualStudioCode ###
+.vscode/*
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+### VisualStudioCode Patch ###
+# Ignore all local history of files
+.history
+.ionide
+
+### AndroidStudio ###
+# Covers files to be ignored for android development using Android Studio.
+
+# Built application files
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle
+
+# Signing files
+.signing/
+
+# Local configuration file (sdk path, etc)
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+
+# Android Studio
+/*/build/
+/*/local.properties
+/*/out
+/*/*/build
+/*/*/production
+.navigation/
+*.ipr
+*~
+*.swp
+
+# Keystore files
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Android Patch
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+
+# NDK
+obj/
+
+# IntelliJ IDEA
+*.iws
+/out/
+
+# User-specific configurations
+.idea/caches/
+.idea/libraries/
+.idea/shelf/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/.name
+.idea/compiler.xml
+.idea/copyright/profiles_settings.xml
+.idea/encodings.xml
+.idea/misc.xml
+.idea/modules.xml
+.idea/scopes/scope_settings.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+.idea/datasources.xml
+.idea/dataSources.ids
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+.idea/assetWizardSettings.xml
+.idea/gradle.xml
+.idea/jarRepositories.xml
+.idea/navEditor.xml
+
+# Legacy Eclipse project files
+.classpath
+.project
+.cproject
+.settings/
+
+# Mobile Tools for Java (J2ME)
+
+# Package Files #
+
+# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
+
+## Plugin-specific files:
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Mongo Explorer plugin
+.idea/mongoSettings.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### AndroidStudio Patch ###
+
+!/gradle/wrapper/gradle-wrapper.jar
+
+# End of https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index 1eb497a93..000000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-CloudStream
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
deleted file mode 100644
index 7643783a8..000000000
--- a/.idea/codeStyles/Project.xml
+++ /dev/null
@@ -1,123 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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 b589d56e9..000000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/discord.xml b/.idea/discord.xml
deleted file mode 100644
index d8e956166..000000000
--- a/.idea/discord.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index a8a2961a1..000000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
deleted file mode 100644
index 333d49373..000000000
--- a/.idea/jarRepositories.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1ddfb..000000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 7282979ad..000000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "githubPullRequests.ignoredPullRequestBranches": [
- "master"
- ],
- "java.configuration.updateBuildConfiguration": "interactive"
-}
\ No newline at end of file
diff --git a/AI-POLICY.md b/AI-POLICY.md
new file mode 100644
index 000000000..5409393fb
--- /dev/null
+++ b/AI-POLICY.md
@@ -0,0 +1,11 @@
+# AI Policy
+
+AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions.
+
+1. Always state any AI usage in pull requests and issues.
+
+2. Always test code before making a pull request. We do not want to test your AI generated code.
+
+3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI.
+
+4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions.
diff --git a/README.md b/README.md
index 8949304e9..c2492c5d8 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,46 @@
# CloudStream
-**⚠️ Warning: By default this app doesn't provide any video sources, you have to install extensions in order to add functionality to the app.**
-
+**⚠️ Warning: By default, this app doesn't provide any video sources; you have to install extensions to add functionality to the app.**
[](https://discord.gg/5Hus6fM)
-### Features:
+
+## Table of Contents:
++ [About Us:](#about_us)
++ [Installation Steps:](#install_rules)
++ [Contributing:](#contributing)
++ [Issues:](#issues)
+ + [Bugs Reports:](#bug_report)
+ + [Enhancement:](#enhancment)
++ [Extension Development:](#extensions)
++ [Language Support:](#languages)
++ [Further Sources](#contact_and_sources)
+
+
+
+
+## About us:
+
+**CloudStream is a media center that prioritizes and emphasizes complete freedom and flexibility for users and developers.**
+
+CloudStream is an extension-based multimedia player with tracking support. There are extensions to view videos from:
+
++ [Librevox (audio-books)](https://librivox.org/)
++ [Youtube](https://www.youtube.com/)
++ [Twitch](https://www.twitch.tv/)
++ [iptv-org (A collection of publicly available IPTV (Internet Protocol television) channels from all over the world.)](https://github.com/iptv-org/iptv)
++ [nginx](https://nginx.org/)
++ And more...
+
+
+**Please don't create illegal extensions or use any that host any copyrighted media.** For more details about our stance on the DMCA and EUCD, you can read about it on our organization: [reCloudStream](https://github.com/recloudstream)
+
+#### Important Copyright Note:
+
+Our documentation is unmaintained and open to contributions; therefore, apps and sources, extensions in recommended sources, and recommended apps are not officially moderated or endorsed by CloudStream; if you or another copyright owner identify an extension that breaches your copyright, please let us know.
+
+
+#### Features:
+ **AdFree**, No ads whatsoever
+ No tracking/analytics
+ Bookmarks
@@ -13,7 +48,64 @@
+ Chromecast
+ Extension system for personal customization
+
+
+
+## Installation:
+
+Our documentation provides the steps to install and configure CloudStream for your streaming needs.
+
+[Getting Started With CloudStream:](https://recloudstream.github.io/csdocs/)
+
+
+
+## Contributing:
+We **happily** accept any contributions to our project. To find out where you can start contributing towards the project, please look [at our issues tab](/cloudstream/issues)
+
+
+
+
+
+### Issues:
+While we **actively** accept issues and pull requests, we do require you fill out an [template](https://github.com/recloudstream/cloudstream/issues/new/choose) for issues. These include the following:
+
+
+
+- [Bug Report Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=bug&projects=&template=application-bug.yml)
+ - For bug reports, we want as much info as possible, including your downloaded version of CloudeStream, device and updated version (if possible, current API),
+ expected behavior of the program, and the actual behavior that the program did, most importantly we require clear, reproducible steps of the bug. If your bug can't be reproduced, it is unlikely we'll work on your issue.
+
+
+
+- [Feature Request Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yml)
+ - Before adding a feature request, please check to see if a feature request already has been requested.
+
+
+### Extensions:
+
+**Further details on creating extensions for CloudStream are found in our documentation.**
+
+[Guide: For Extension Developers](https://recloudstream.github.io/csdocs/devs/gettingstarted/)
+
+
+
+## Further Sources:
+
+As well as providing clear install steps, our [website](https://dweb.link/ipns/cloudstream.on.fleek.co/) includes a wide variety of other tools, such as:
+- [Troubleshooting](https://recloudstream.github.io/csdocs/troubleshooting/)
+- [Further CloudStream Repositories](https://recloudstream.github.io/csdocs/repositories/)
+- Set-Up for other devices, such as:
+ - [Android TV](https://recloudstream.github.io/csdocs/other-devices/tv/)
+ - [Windows](https://recloudstream.github.io/csdocs/other-devices/windows/)
+ - [Linux](https://recloudstream.github.io/csdocs/other-devices/linux/)
+- And more...
+
+
+
### Supported languages:
+
+Even if you can't contribute to the code or documentation, we always look for those who can contribute to translation and language support. Your contribution is exceptionally appreciated; you can check our translation from the figure below.
+
diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt
deleted file mode 100644
index 7f7fd14c1..000000000
--- a/app/CMakeLists.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-# Set this to the minimum version your project supports.
-cmake_minimum_required(VERSION 3.18)
-project(CrashHandler)
-find_library(log-lib log)
-add_library(native-lib SHARED src/main/cpp/native-lib.cpp)
-target_link_libraries(native-lib ${log-lib})
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f52d6e5e5..6c784f3ef 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,48 +1,96 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
-import org.jetbrains.dokka.gradle.DokkaTask
-import java.io.ByteArrayOutputStream
-import java.net.URL
+import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
+import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier
+import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
plugins {
- id("com.android.application")
- id("kotlin-android")
- id("kotlin-kapt")
- id("org.jetbrains.dokka")
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.dokka)
+ alias(libs.plugins.kotlin.serialization)
}
-val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
-val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
+val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
-fun String.execute() = ByteArrayOutputStream().use { baot ->
- if (project.exec {
- workingDir = projectDir
- commandLine = this@execute.split(Regex("\\s"))
- standardOutput = baot
- }.exitValue == 0)
- String(baot.toByteArray()).trim()
- else null
+abstract class GenerateGitHashTask : DefaultTask() {
+
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val headFile: RegularFileProperty
+
+ @get:InputDirectory
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val headsDir: DirectoryProperty
+
+ @get:OutputDirectory
+ abstract val outputDir: DirectoryProperty
+
+ @TaskAction
+ fun generate() {
+ val head = headFile.get().asFile
+
+ val hash = try {
+ if (head.exists()) {
+ // Read the commit hash from .git/HEAD
+ val headContent = head.readText().trim()
+ if (headContent.startsWith("ref:")) {
+ val refPath = headContent.substring(5) // e.g., refs/heads/main
+ val commitFile = File(head.parentFile, refPath)
+ if (commitFile.exists()) commitFile.readText().trim() else ""
+ } else headContent // If it's a detached HEAD (commit hash directly)
+ } else "" // If .git/HEAD doesn't exist
+ } catch (_: Throwable) {
+ "" // Just set to an empty string if any exception occurs
+ }.take(7) // Get the short commit hash
+
+ val outFile = outputDir.file("git-hash.txt").get().asFile
+ outFile.parentFile.mkdirs()
+ outFile.writeText(hash)
+ }
+}
+
+val generateGitHash = tasks.register("generateGitHash") {
+ val gitDir = layout.projectDirectory.dir("../.git")
+
+ headFile.set(gitDir.file("HEAD"))
+ headsDir.set(gitDir.dir("refs/heads"))
+
+ outputDir.set(layout.buildDirectory.dir("generated/git"))
}
android {
+ @Suppress("UnstableApiUsage")
testOptions {
unitTests.isReturnDefaultValues = true
}
- viewBinding {
- enable = true
+ // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
+ dependenciesInfo {
+ // Disables dependency metadata when building APKs.
+ includeInApk = false
+ // Disables dependency metadata when building Android App Bundles.
+ includeInBundle = false
}
- // disable this for now
- //externalNativeBuild {
- // cmake {
- // path("CMakeLists.txt")
- // }
- //}
+ androidComponents {
+ onVariants { variant ->
+ variant.sources.assets?.addGeneratedSourceDirectory(
+ generateGitHash,
+ GenerateGitHashTask::outputDir
+ )
+ }
+ }
signingConfigs {
- create("prerelease") {
- if (prereleaseStoreFile != null) {
- storeFile = file(prereleaseStoreFile)
+ // We just use SIGNING_KEY_ALIAS here since it won't change
+ // so won't kill the configuration cache.
+ if (System.getenv("SIGNING_KEY_ALIAS") != null) {
+ create("prerelease") {
+ val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
+ val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
+
+ storeFile = prereleaseStoreFile?.let { file(it) }
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
@@ -50,28 +98,24 @@ android {
}
}
- compileSdk = 33
- buildToolsVersion = "34.0.0"
+ 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 = 59
- versionName = "4.1.8"
-
- resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
- resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
- resValue("bool", "is_prerelease", "false")
+ manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
// Reads local.properties
- val localProperties = gradleLocalProperties(rootDir)
+ val localProperties = gradleLocalProperties(rootDir, project.providers)
buildConfigField(
- "String",
- "BUILDDATE",
- "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));"
+ "long",
+ "BUILD_DATE",
+ "${System.currentTimeMillis()}"
)
buildConfigField(
"String",
@@ -84,10 +128,6 @@ android {
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
-
- kapt {
- includeCompileClasspath = true
- }
}
buildTypes {
@@ -109,184 +149,195 @@ android {
)
}
}
+
flavorDimensions.add("state")
productFlavors {
create("stable") {
dimension = "state"
- resValue("bool", "is_prerelease", "false")
}
create("prerelease") {
dimension = "state"
- resValue("bool", "is_prerelease", "true")
- buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease"
- signingConfig = signingConfigs.getByName("prerelease")
+ if (signingConfigs.names.contains("prerelease")) {
+ signingConfig = signingConfigs.getByName("prerelease")
+ } else {
+ logger.warn("No prerelease signing config!")
+ }
versionNameSuffix = "-PRE"
versionCode = (System.currentTimeMillis() / 60000).toInt()
}
}
- //toolchain {
- // languageVersion.set(JavaLanguageVersion.of(17))
- // }
- // jvmToolchain(17)
compileOptions {
isCoreLibraryDesugaringEnabled = true
+ sourceCompatibility = JavaVersion.toVersion(javaTarget.target)
+ targetCompatibility = JavaVersion.toVersion(javaTarget.target)
+ }
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = "1.8"
- freeCompilerArgs = listOf("-Xjvm-default=compatibility")
+ java {
+ // Use Java 17 toolchain even if a higher JDK runs the build.
+ // We still use Java 8 for now which higher JDKs have deprecated.
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
+ }
}
+
lint {
- abortOnError = false
checkReleaseBuilds = false
}
+
+ buildFeatures {
+ buildConfig = true
+ viewBinding = true
+ }
+
+ packaging {
+ jniLibs {
+ // Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23).
+ // Note: This may increase app startup time slightly.
+ useLegacyPackaging = true
+ }
+ }
+
namespace = "com.lagradost.cloudstream3"
}
-repositories {
- maven("https://jitpack.io")
-}
-
dependencies {
- implementation("com.google.android.mediahome:video:1.0.0")
- implementation("androidx.test.ext:junit-ktx:1.1.5")
- testImplementation("org.json:json:20180813")
+ // Testing
+ testImplementation(libs.junit)
+ testImplementation(libs.json)
+ androidTestImplementation(libs.core)
+ androidTestImplementation(libs.classgraph)
+ androidTestImplementation(libs.espresso.core)
+ androidTestImplementation(libs.ext.junit)
+ androidTestImplementation(libs.instancio.core)
+ androidTestImplementation(libs.junit.ktx)
+ androidTestImplementation(libs.kotlin.test)
- implementation("androidx.core:core-ktx:1.10.1")
- implementation("androidx.appcompat:appcompat:1.6.1") // need target 32 for 1.5.0
+ // Android Core & Lifecycle
+ implementation(libs.core.ktx)
+ implementation(libs.activity.ktx)
+ implementation(libs.annotation)
+ implementation(libs.appcompat)
+ implementation(libs.fragment.ktx)
+ implementation(libs.bundles.lifecycle)
+ implementation(libs.bundles.navigation)
+ implementation(libs.kotlinx.collections.immutable)
+ implementation(libs.kotlinx.serialization.json) // JSON Parser
- // dont change this to 1.6.0 it looks ugly af
- implementation("com.google.android.material:material:1.5.0")
- implementation("androidx.constraintlayout:constraintlayout:2.1.4")
- implementation("androidx.navigation:navigation-fragment-ktx:2.6.0")
- implementation("androidx.navigation:navigation-ui-ktx:2.6.0")
- implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.1")
- implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
- testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.5")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
- androidTestImplementation("androidx.test:core")
+ // Design & UI
+ implementation(libs.preference.ktx)
+ implementation(libs.material)
+ implementation(libs.constraintlayout)
- //implementation("io.karn:khttp-android:0.1.2") //okhttp instead
- // implementation("org.jsoup:jsoup:1.13.1")
- implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1")
+ // Coil Image Loading
+ implementation(libs.bundles.coil)
- implementation("androidx.preference:preference-ktx:1.2.0")
+ // Media 3 (ExoPlayer)
+ implementation(libs.bundles.media3)
+ implementation(libs.video)
- implementation("com.github.bumptech.glide:glide:4.13.1")
- kapt("com.github.bumptech.glide:compiler:4.13.1")
- implementation("com.github.bumptech.glide:okhttp3-integration:4.13.0")
+ // FFmpeg Decoding
+ implementation(libs.bundles.nextlib)
- implementation("jp.wasabeef:glide-transformations:4.3.0")
+ // Anime-db for filler
+ implementation(libs.anime.db)
- implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
+ // PlayBack
+ implementation(libs.colorpicker) // Subtitle Color Picker
+ implementation(libs.newpipeextractor) // For Trailers
+ implementation(libs.juniversalchardet) // Subtitle Decoding
- // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09")
+ // UI Stuff
+ implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
+ implementation(libs.palette.ktx) // Palette for Images -> Colors
+ implementation(libs.tvprovider)
+ implementation(libs.overlappingpanels) // Gestures
+ implementation(libs.biometric) // Fingerprint Authentication
+ implementation(libs.previewseekbar.media3) // SeekBar Preview
+ implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
- // Media 3
- implementation("androidx.media3:media3-common:1.1.1")
- implementation("androidx.media3:media3-exoplayer:1.1.1")
- implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
- implementation("androidx.media3:media3-ui:1.1.1")
- implementation("androidx.media3:media3-session:1.1.1")
- implementation("androidx.media3:media3-cast:1.1.1")
- implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
- implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
- // Custom ffmpeg extension for audio codecs
- implementation("com.github.recloudstream:media-ffmpeg:1.1.0")
+ // Extensions & Other Libs
+ implementation(libs.jsoup) // HTML Parser
+ implementation(libs.rhino) // Run JavaScript
+ implementation(libs.safefile) // To Prevent the URI File Fu*kery
+ coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
+ implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
+ implementation(libs.jackson.module.kotlin) // JSON Parser
+ implementation(libs.zipline)
- //implementation("com.google.android.exoplayer:extension-leanback:2.14.0")
-
- // Bug reports
- implementation("ch.acra:acra-core:5.11.0")
- implementation("ch.acra:acra-toast:5.11.0")
-
- compileOnly("com.google.auto.service:auto-service-annotations:1.0")
- //either for java sources:
- annotationProcessor("com.google.auto.service:auto-service:1.0")
- //or for kotlin sources (requires kapt gradle plugin):
- kapt("com.google.auto.service:auto-service:1.0")
-
- // subtitle color picker
- implementation("com.jaredrummler:colorpicker:1.1.0")
-
- //run JS
- // do not upgrade to 1.7.14, since in 1.7.14 Rhino uses the `SourceVersion` class, which is not
- // available on Android (even when using desugaring), and `NoClassDefFoundError` is thrown
- implementation("org.mozilla:rhino:1.7.13")
-
- // TorrentStream
- //implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
-
- // Downloading
- implementation("androidx.work:work-runtime:2.8.1")
- implementation("androidx.work:work-runtime-ktx:2.8.1")
-
- // Networking
- // implementation("com.squareup.okhttp3:okhttp:4.9.2")
- // implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
- implementation("com.github.Blatzar:NiceHttp:0.4.3")
- // To fix SSL fuckery on android 9
- implementation("org.conscrypt:conscrypt-android:2.2.1")
- // Util to skip the URI file fuckery 🙏
- implementation("com.github.LagradOst:SafeFile:0.0.5")
-
- // API because cba maintaining it myself
- implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0")
-
- implementation("com.github.discord:OverlappingPanels:0.1.5")
- // debugImplementation because LeakCanary should only run in debug builds.
- //debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
-
- // for shimmer when loading
- implementation("com.facebook.shimmer:shimmer:0.5.0")
-
- implementation("androidx.tvprovider:tvprovider:1.0.0")
-
- // used for subtitle decoding https://github.com/albfernandez/juniversalchardet
- implementation("com.github.albfernandez:juniversalchardet:2.4.0")
-
- // newpipe yt taken from https://github.com/TeamNewPipe/NewPipeExtractor/commits/dev
- // this should be updated frequently to avoid trailer fu*kery
- implementation("com.github.teamnewpipe:NewPipeExtractor:1f08d28")
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6")
-
- // Library/extensions searching with Levenshtein distance
+ // Deprecated; will be removed once extensions have time to migrate from using it
implementation("me.xdrop:fuzzywuzzy:1.4.0")
- // color palette for images -> colors
- implementation("androidx.palette:palette-ktx:1.0.0")
+ // Torrent Support
+ implementation(libs.torrentserver)
+
+ // Downloading & Networking
+ implementation(libs.work.runtime.ktx)
+ implementation(libs.nicehttp) // HTTP Lib
+
+ implementation(project(":library"))
}
-tasks.register("androidSourcesJar", Jar::class) {
+tasks.register("androidSourcesJar") {
archiveClassifier.set("sources")
- from(android.sourceSets.getByName("main").java.srcDirs) //full sources
+ from(android.sourceSets.getByName("main").java.directories) // Full Sources
}
-// this is used by the gradlew plugin
-tasks.register("makeJar", Copy::class) {
- from("build/intermediates/compile_app_classes_jar/prereleaseDebug")
- into("build")
- include("classes.jar")
- dependsOn("build")
+tasks.register("copyJar") {
+ dependsOn("build", ":library:jvmJar")
+ from(
+ "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
+ "../library/build/libs"
+ )
+ into("build/app-classes")
+ include("classes.jar", "library-jvm*.jar")
+ // Remove the version
+ rename("library-jvm.*.jar", "library-jvm.jar")
}
-tasks.withType().configureEach {
- moduleName.set("Cloudstream")
+// Merge the app classes and the library classes into classes.jar
+tasks.register("makeJar") {
+ // Duplicates cause hard to catch errors, better to fail at compile time.
+ duplicatesStrategy = DuplicatesStrategy.FAIL
+ dependsOn(tasks.getByName("copyJar"))
+ from(
+ zipTree("build/app-classes/classes.jar"),
+ zipTree("build/app-classes/library-jvm.jar")
+ )
+ destinationDirectory.set(layout.buildDirectory)
+ archiveBaseName = "classes"
+}
+
+tasks.withType {
+ compilerOptions {
+ jvmTarget.set(javaTarget)
+ jvmDefault.set(JvmDefaultMode.ENABLE)
+ freeCompilerArgs.add("-Xannotation-default-target=param-property")
+ optIn.addAll(
+ "com.lagradost.cloudstream3.InternalAPI",
+ "com.lagradost.cloudstream3.Prerelease",
+ "kotlin.uuid.ExperimentalUuidApi",
+ )
+ }
+}
+
+dokka {
+ moduleName = "App"
dokkaSourceSets {
- named("main") {
- sourceLink {
- // Unix based directory relative path to the root of the project (where you execute gradle respectively).
- localDirectory.set(file("src/main/java"))
+ configureEach {
+ suppress = name != "prereleaseDebug"
+ analysisPlatform = KotlinPlatform.JVM
+ displayName = "JVM"
+ documentedVisibilities(
+ VisibilityModifier.Public,
+ VisibilityModifier.Protected
+ )
- // URL showing where the source code can be accessed through the web browser
- remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java"))
- // Suffix which is used to append the line number to the URL. Use #L for GitHub
- remoteLineSuffix.set("#L")
+ sourceLink {
+ localDirectory = file("..")
+ remoteUrl("https://github.com/recloudstream/cloudstream/tree/master")
+ remoteLineSuffix = "#L"
}
}
}
diff --git a/app/lint.xml b/app/lint.xml
new file mode 100644
index 000000000..b2f5e8f2b
--- /dev/null
+++ b/app/lint.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
index df41ef91f..4c5cdea5b 100644
--- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
@@ -7,8 +7,11 @@ import android.view.LayoutInflater
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewbinding.ViewBinding
+import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
+import com.lagradost.cloudstream3.databinding.FragmentLibraryTvBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding
import com.lagradost.cloudstream3.databinding.FragmentResultBinding
@@ -17,6 +20,7 @@ import com.lagradost.cloudstream3.databinding.FragmentSearchBinding
import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
+import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding
import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding
@@ -85,6 +89,8 @@ class ExampleInstrumentedTest {
// testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv)
//testAllLayouts(activity, R.layout.activity_main_tv)
+ testAllLayouts(activity, R.layout.bottom_resultview_preview,R.layout.bottom_resultview_preview_tv)
+
testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
@@ -117,9 +123,12 @@ class ExampleInstrumentedTest {
// testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
// testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
- testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent)
- testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
+ testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
}
}
}
@@ -127,14 +136,14 @@ class ExampleInstrumentedTest {
@Test
@Throws(AssertionError::class)
fun providerCorrectData() {
- val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
- Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
+ val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag }
+ Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty())
for (api in getAllProviders()) {
Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE")
Assert.assertTrue("Api does not contain a name", api.name != "NONE")
Assert.assertTrue(
"Api ${api.name} does not contain a valid language code",
- isoNames.contains(api.lang)
+ langTagsIETF.contains(api.lang)
)
Assert.assertTrue(
"Api ${api.name} does not contain any supported types",
@@ -148,7 +157,7 @@ class ExampleInstrumentedTest {
fun providerCorrectHomepage() {
runBlocking {
getAllProviders().toList().amap { api ->
- TestingUtils.testHomepage(api, ::println)
+ TestingUtils.testHomepage(api, TestingUtils.Logger())
}
}
println("Done providerCorrectHomepage")
@@ -160,7 +169,6 @@ class ExampleInstrumentedTest {
TestingUtils.getDeferredProviderTests(
this,
getAllProviders(),
- ::println
) { _, _ -> }
}
}
diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt
new file mode 100644
index 000000000..d1a11e003
--- /dev/null
+++ b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt
@@ -0,0 +1,135 @@
+package com.lagradost.cloudstream3
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import dalvik.system.DexFile
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.InternalSerializationApi
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.serializer
+import kotlinx.serialization.serializerOrNull
+import org.instancio.Instancio
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.reflect.KClass
+import kotlin.reflect.jvm.jvmName
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+
+@RunWith(AndroidJUnit4::class)
+class SerializationClassTester {
+ // Same as app, or using app reference
+ val jacksonMapper = mapper
+ val kotlinxMapper = json
+
+ @Test
+ fun isIdenticalSerialization() {
+ val serializableClasses = findSerializableClasses("com.lagradost")
+ println("Number of serializable classes: ${serializableClasses.size}")
+
+ serializableClasses.forEach { kClass ->
+ val instance = Instancio.create(kClass.java)
+
+ val jacksonJson = jacksonMapper.writeValueAsString(instance)
+ val kotlinxJson = serializeWithKotlinx(kClass, instance)
+
+ assertEquals(
+ jacksonJson,
+ kotlinxJson,
+ """
+ Serialization mismatch for:
+ ${kClass.qualifiedName}
+
+ Jackson:
+ $jacksonJson
+
+ Kotlinx:
+ $kotlinxJson
+
+ """.trimIndent()
+ )
+ println("Identical serialization for: ${kClass.jvmName}")
+ }
+ }
+
+ @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
+ @Test
+ fun isIdenticalDeserialization() {
+ val serializableClasses = findSerializableClasses("com.lagradost")
+ println("Number of serializable classes: ${serializableClasses.size}")
+
+ serializableClasses.forEach { kClass ->
+ val instance = Instancio.create(kClass.java)
+ // Convert to JSON to get example JSON object
+ // We prefer jackson here because the app may have many jackson JSON strings in local storage
+ val originalJson = jacksonMapper.writeValueAsString(instance)
+
+ // Create an object from the JSON using kotlinx
+ val serializer =
+ kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass)
+ assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!")
+ val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson)
+
+ // Create an object from the JSON using jackson
+ val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java)
+
+
+ // Deep inspect both object using the mapper toJson function.
+ // This deep equality check can be performed using other methods, but this just works.
+ val jacksonJson = mapperDecoded.toJson()
+ val kotlinxJson = kotlinxDecoded.toJson()
+
+ assertEquals(
+ jacksonJson,
+ kotlinxJson,
+ """
+ Serialization mismatch for:
+ ${kClass.qualifiedName}
+
+ Jackson:
+ $jacksonJson
+
+ Kotlinx:
+ $kotlinxJson
+
+ """.trimIndent()
+ )
+ println("Identical deserialization for: ${kClass.jvmName}")
+ }
+ }
+
+ // DEX files are the best solution to read all our classes dynamically.
+ // ClassGraph() can be used instead, but it only gives results on the JVM, not Android.
+ @Suppress("DEPRECATION")
+ private fun findSerializableClasses(packageName: String): List> {
+ val context = InstrumentationRegistry
+ .getInstrumentation()
+ .targetContext
+
+ val dexFile = DexFile(context.packageCodePath)
+
+ return dexFile.entries()
+ .toList()
+ .filter { it.startsWith(packageName) }
+ .mapNotNull {
+ runCatching { Class.forName(it).kotlin }.getOrNull()
+ }.filter { kClass ->
+ // Not possible to use .hasAnnotation() on newer Android versions.
+ kClass.java.annotations.any {
+ it is Serializable
+ }
+ }
+ }
+
+ @OptIn(InternalSerializationApi::class)
+ @Suppress("UNCHECKED_CAST")
+ private fun serializeWithKotlinx(
+ kClass: KClass<*>,
+ value: Any
+ ): String {
+ val serializer = kClass.serializer() as KSerializer
+ return kotlinxMapper.encodeToString(serializer, value)
+ }
+}
diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt
new file mode 100644
index 000000000..15ad532f8
--- /dev/null
+++ b/app/src/androidTest/java/com/lagradost/cloudstream3/utils/serializers/SerializerTest.kt
@@ -0,0 +1,157 @@
+package com.lagradost.cloudstream3.utils.serializers
+
+import android.net.Uri
+import com.lagradost.cloudstream3.utils.AppUtils.parseJson
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.KeepGeneratedSerializer
+import kotlinx.serialization.Serializable
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalSerializationApi::class)
+@KeepGeneratedSerializer
+@Serializable(with = NonEmptyData.Serializer::class)
+data class NonEmptyData(
+ val title: String = "",
+ val tags: List = emptyList(),
+ val meta: Map = emptyMap(),
+ val name: String = "hello",
+) {
+ object Serializer : NonEmptySerializer(NonEmptyData.generatedSerializer())
+}
+
+@OptIn(ExperimentalSerializationApi::class)
+@KeepGeneratedSerializer
+@Serializable(with = WriteOnlyData.Serializer::class)
+data class WriteOnlyData(
+ val fieldA: String = "",
+ val fieldB: String = "",
+) {
+ object Serializer : WriteOnlySerializer(
+ WriteOnlyData.generatedSerializer(),
+ setOf("fieldB"),
+ )
+}
+
+@OptIn(ExperimentalSerializationApi::class)
+@KeepGeneratedSerializer
+@Serializable(with = MultiWriteOnly.Serializer::class)
+data class MultiWriteOnly(
+ val fieldA: String = "",
+ val fieldB: String = "",
+ val fieldC: String = "",
+) {
+ object Serializer : WriteOnlySerializer(
+ MultiWriteOnly.generatedSerializer(),
+ setOf("fieldB", "fieldC"),
+ )
+}
+
+@Serializable
+data class UriData(
+ @Serializable(with = UriSerializer::class)
+ val uri: Uri = Uri.EMPTY,
+)
+
+class SerializerTest {
+
+ @Test
+ fun nonEmptySerializerOmitsEmptyStrings() {
+ val data = NonEmptyData(title = "", name = "hello")
+ val result = data.toJson()
+ assertFalse(result.contains("title"))
+ assertTrue(result.contains("name"))
+ }
+
+ @Test
+ fun nonEmptySerializerOmitsEmptyLists() {
+ val data = NonEmptyData(tags = emptyList(), name = "hello")
+ val result = data.toJson()
+ assertFalse(result.contains("tags"))
+ }
+
+ @Test
+ fun nonEmptySerializerOmitsEmptyMaps() {
+ val data = NonEmptyData(meta = emptyMap(), name = "hello")
+ val result = data.toJson()
+ assertFalse(result.contains("meta"))
+ }
+
+ @Test
+ fun nonEmptySerializerKeepsNonEmptyFields() {
+ val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v"))
+ val result = data.toJson()
+ assertTrue(result.contains("title"))
+ assertTrue(result.contains("tags"))
+ assertTrue(result.contains("meta"))
+ }
+
+ @Test
+ fun nonEmptySerializerDoesNotAffectDeserialization() {
+ val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}"""
+ val result = parseJson(input)
+ assertEquals("hello", result.title)
+ assertEquals(listOf("a"), result.tags)
+ assertEquals(mapOf("k" to "v"), result.meta)
+ assertEquals("world", result.name)
+ }
+
+ @Test
+ fun writeOnlySerializerOmitsFieldOnSerialize() {
+ val data = WriteOnlyData(fieldA = "hello", fieldB = "secret")
+ val result = data.toJson()
+ assertTrue(result.contains("fieldA"))
+ assertFalse(result.contains("fieldB"))
+ }
+
+ @Test
+ fun writeOnlySerializerDeserializesNormally() {
+ val input = """{"fieldA":"hello","fieldB":"secret"}"""
+ val result = parseJson(input)
+ assertEquals("hello", result.fieldA)
+ assertEquals("secret", result.fieldB)
+ }
+
+ @Test
+ fun writeOnlySerializerDeserializesMissingAsDefault() {
+ val input = """{"fieldA":"hello"}"""
+ val result = parseJson(input)
+ assertEquals("hello", result.fieldA)
+ assertEquals("", result.fieldB)
+ }
+
+ @Test
+ fun writeOnlySerializerHandlesMultipleKeys() {
+ val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2")
+ val result = data.toJson()
+ assertTrue(result.contains("fieldA"))
+ assertFalse(result.contains("fieldB"))
+ assertFalse(result.contains("fieldC"))
+ }
+
+ @Test
+ fun uriSerializerSerializesUriToString() {
+ val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
+ val result = data.toJson()
+ assertTrue(result.contains("https://example.com/path?query=1"))
+ }
+
+ @Test
+ fun uriSerializerDeserializesStringToUri() {
+ val input = """{"uri":"https://example.com/path?query=1"}"""
+ val result = parseJson(input)
+ assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri)
+ }
+
+ @Test
+ fun uriSerializerRoundtripsCorrectly() {
+ val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
+ val encoded = data.toJson()
+ val decoded = parseJson(encoded)
+ assertEquals(data.uri, decoded.uri)
+ }
+}
diff --git a/app/src/debug/res/drawable-v24/ic_banner_background.xml b/app/src/debug/res/drawable-v24/ic_banner_background.xml
index 7b05b7111..caed023d5 100644
--- a/app/src/debug/res/drawable-v24/ic_banner_background.xml
+++ b/app/src/debug/res/drawable-v24/ic_banner_background.xml
@@ -25,9 +25,8 @@
android:endY="245.72"
android:endX="292.58"
android:type="linear">
-
-
-
+
+
@@ -40,9 +39,8 @@
android:endY="245.72"
android:endX="248.76"
android:type="linear">
-
-
-
+
+
@@ -55,46 +53,45 @@
android:endY="245.69"
android:endX="210.03"
android:type="linear">
-
-
-
+
+
+ android:fillColor="#39A11D"/>
+ android:fillColor="#39A11D"/>
+ android:fillColor="#39A11D"/>
+ android:fillColor="#39A11D"/>
+ android:fillColor="#39A11D"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
@@ -104,9 +101,9 @@
android:endY="252.3"
android:endX="373.57"
android:type="linear">
-
-
-
+
+
+
@@ -117,9 +114,9 @@
android:startX="400.11"
android:endX="900"
android:type="linear">
-
-
-
+
+
+
@@ -132,9 +129,9 @@
android:endY="252.3"
android:endX="373.57"
android:type="linear">
-
-
-
+
+
+
@@ -145,9 +142,9 @@
android:startX="700.11"
android:endX="900.57"
android:type="linear">
-
-
-
+
+
+
@@ -158,9 +155,9 @@
android:startX="400.11"
android:endX="800.57"
android:type="linear">
-
-
-
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 15767d7b6..ee4c978f2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,16 +6,63 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+ tools:targetApi="${target_sdk_version}">
+ android:supportsPictureInPicture="true"
+ android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
+ android:launchMode="singleTask"
+ tools:ignore="DiscouragedApi">
@@ -79,25 +125,55 @@
+
+
+
+
+
+
+
+
+
+
+
+ android:supportsPictureInPicture="true" />
+
+
+
+
+
+
@@ -124,7 +200,14 @@
+
+
+
+
+
+
+
@@ -148,7 +231,7 @@
-
+
@@ -161,15 +244,11 @@
-
-
-
+ android:exported="false">
+
@@ -177,14 +256,28 @@
+
+
+
+
+
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp
deleted file mode 100644
index f4cb531fa..000000000
--- a/app/src/main/cpp/native-lib.cpp
+++ /dev/null
@@ -1,28 +0,0 @@
-#include
-#include
-#include
-
-#define TAG "CloudStream Crash Handler"
-volatile sig_atomic_t gSignalStatus = 0;
-void handleNativeCrash(int signal) {
- gSignalStatus = signal;
-}
-
-extern "C" JNIEXPORT void JNICALL
-Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) {
- #define REGISTER_SIGNAL(X) signal(X, handleNativeCrash);
- REGISTER_SIGNAL(SIGSEGV)
- #undef REGISTER_SIGNAL
-}
-
-//extern "C" JNIEXPORT void JNICALL
-//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) {
-// int *p = nullptr;
-// *p = 0;
-//}
-
-extern "C" JNIEXPORT int JNICALL
-Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) {
- //__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus);
- return gSignalStatus;
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
index 5f3162b49..bbe7d97de 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
@@ -1,222 +1,78 @@
package com.lagradost.cloudstream3
-import android.app.Activity
-import android.app.Application
-import android.content.Context
-import android.content.ContextWrapper
-import android.content.Intent
-import android.widget.Toast
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentActivity
-import com.google.auto.service.AutoService
-import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
-import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
-import com.lagradost.cloudstream3.plugins.PluginManager
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
-import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
-import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
-import com.lagradost.cloudstream3.utils.DataStore.getKey
-import com.lagradost.cloudstream3.utils.DataStore.getKeys
-import com.lagradost.cloudstream3.utils.DataStore.removeKey
-import com.lagradost.cloudstream3.utils.DataStore.removeKeys
-import com.lagradost.cloudstream3.utils.DataStore.setKey
-import kotlinx.coroutines.runBlocking
-import org.acra.ACRA
-import org.acra.ReportField
-import org.acra.config.CoreConfiguration
-import org.acra.data.CrashReportData
-import org.acra.data.StringFormat
-import org.acra.ktx.initAcra
-import org.acra.sender.ReportSender
-import org.acra.sender.ReportSenderFactory
-import java.io.File
-import java.io.FileNotFoundException
-import java.io.PrintStream
-import java.lang.Exception
-import java.lang.ref.WeakReference
-import kotlin.concurrent.thread
-import kotlin.system.exitProcess
+/**
+ * Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
+ * Use CloudStreamApp instead.
+ */
+@Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
+ level = DeprecationLevel.WARNING
+)
+class AcraApplication {
+ companion object {
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
+ level = DeprecationLevel.WARNING
+ )
+ val context get() = CloudStreamApp.context
-class CustomReportSender : ReportSender {
- // Sends all your crashes to google forms
- override fun send(context: Context, errorContent: CrashReportData) {
- println("Sending report")
- val url =
- "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
- val data = mapOf(
- "entry.1993829403" to errorContent.toJSON()
- )
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun removeKeys(folder: String): Int? =
+ CloudStreamApp.removeKeys(folder)
- thread { // to not run it on main thread
- runBlocking {
- suspendSafeApiCall {
- app.post(url, data = data)
- //println("Report response: $post")
- }
- }
- }
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun setKey(path: String, value: T) =
+ CloudStreamApp.setKey(path, value)
- runOnMainThread { // to run it on main looper
- normalSafeApiCall {
- Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show()
- }
- }
- }
-}
-
-@AutoService(ReportSenderFactory::class)
-class CustomSenderFactory : ReportSenderFactory {
- override fun create(context: Context, config: CoreConfiguration): ReportSender {
- return CustomReportSender()
- }
-
- override fun enabled(config: CoreConfiguration): Boolean {
- return true
- }
-}
-
-class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
- Thread.UncaughtExceptionHandler {
- override fun uncaughtException(thread: Thread, error: Throwable) {
- ACRA.errorReporter.handleException(error)
- try {
- PrintStream(errorFile).use { ps ->
- ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}"))
- ps.println(
- String.format(
- "Fatal exception on thread %s (%d)",
- thread.name,
- thread.id
- )
- )
- error.printStackTrace(ps)
- }
- } catch (ignored: FileNotFoundException) {
- }
- try {
- onError.invoke()
- } catch (ignored: Exception) {
- }
- exitProcess(1)
- }
-
-}
-
-class AcraApplication : Application() {
-
- override fun onCreate() {
- super.onCreate()
- //NativeCrashHandler.initCrashHandler()
- ExceptionHandler(filesDir.resolve("last_error")) {
- val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
- startActivity(Intent.makeRestartActivityTask(intent!!.component))
- }.also {
- exceptionHandler = it
- Thread.setDefaultUncaughtExceptionHandler(it)
- }
- }
-
- override fun attachBaseContext(base: Context?) {
- super.attachBaseContext(base)
- context = base
-
- initAcra {
- //core configuration:
- buildConfigClass = BuildConfig::class.java
- reportFormat = StringFormat.JSON
-
- reportContent = listOf(
- ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
- ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
- ReportField.STACK_TRACE,
- )
-
- // removed this due to bug when starting the app, moved it to when it actually crashes
- //each plugin you chose above can be configured in a block like this:
- /*toast {
- text = getString(R.string.acra_report_toast)
- //opening this block automatically enables the plugin.
- }*/
- }
- }
-
- companion object {
- var exceptionHandler: ExceptionHandler? = null
-
- /** Use to get activity from Context */
- tailrec fun Context.getActivity(): Activity? = this as? Activity
- ?: (this as? ContextWrapper)?.baseContext?.getActivity()
-
- private var _context: WeakReference? = null
- var context
- get() = _context?.get()
- private set(value) {
- _context = WeakReference(value)
- }
-
- fun getKeyClass(path: String, valueType: Class): T? {
- return context?.getKey(path, valueType)
- }
-
- fun setKeyClass(path: String, value: T) {
- context?.setKey(path, value)
- }
-
- fun removeKeys(folder: String): Int? {
- return context?.removeKeys(folder)
- }
-
- fun setKey(path: String, value: T) {
- context?.setKey(path, value)
- }
-
- fun setKey(folder: String, path: String, value: T) {
- context?.setKey(folder, path, value)
- }
-
- inline fun getKey(path: String, defVal: T?): T? {
- return context?.getKey(path, defVal)
- }
-
- inline fun getKey(path: String): T? {
- return context?.getKey(path)
- }
-
- inline fun getKey(folder: String, path: String): T? {
- return context?.getKey(folder, path)
- }
-
- inline fun getKey(folder: String, path: String, defVal: T?): T? {
- return context?.getKey(folder, path, defVal)
- }
-
- fun getKeys(folder: String): List? {
- return context?.getKeys(folder)
- }
-
- fun removeKey(folder: String, path: String) {
- context?.removeKey(folder, path)
- }
-
- fun removeKey(path: String) {
- context?.removeKey(path)
- }
-
- /**
- * If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails.
- * */
- fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) {
- context?.openBrowser(url, fallbackWebview, fragment)
- }
-
- /** Will fallback to webview if in TV layout */
- fun openBrowser(url: String, activity: FragmentActivity?) {
- openBrowser(
- url,
- isTvSettings(),
- activity?.supportFragmentManager?.fragments?.lastOrNull()
- )
- }
- }
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun setKey(folder: String, path: String, value: T) =
+ CloudStreamApp.setKey(folder, path, value)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
+ level = DeprecationLevel.WARNING
+ )
+ inline fun getKey(path: String, defVal: T?): T? =
+ CloudStreamApp.getKey(path, defVal)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
+ level = DeprecationLevel.WARNING
+ )
+ inline fun getKey(path: String): T? =
+ CloudStreamApp.getKey(path)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
+ level = DeprecationLevel.WARNING
+ )
+ inline fun getKey(folder: String, path: String): T? =
+ CloudStreamApp.getKey(folder, path)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
+ level = DeprecationLevel.WARNING
+ )
+ inline fun getKey(folder: String, path: String, defVal: T?): T? =
+ CloudStreamApp.getKey(folder, path, defVal)
+ }
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt
new file mode 100644
index 000000000..a9cd9c01e
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt
@@ -0,0 +1,181 @@
+package com.lagradost.cloudstream3
+
+import android.app.Activity
+import android.app.Application
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.os.Build
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import coil3.ImageLoader
+import coil3.PlatformContext
+import coil3.SingletonImageLoader
+import com.lagradost.api.setContext
+import com.lagradost.cloudstream3.BuildConfig
+import com.lagradost.cloudstream3.mvvm.safe
+import com.lagradost.cloudstream3.mvvm.safeAsync
+import com.lagradost.cloudstream3.plugins.PluginManager
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
+import com.lagradost.cloudstream3.utils.AppDebug
+import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
+import com.lagradost.cloudstream3.utils.DataStore.getKey
+import com.lagradost.cloudstream3.utils.DataStore.getKeys
+import com.lagradost.cloudstream3.utils.DataStore.removeKey
+import com.lagradost.cloudstream3.utils.DataStore.removeKeys
+import com.lagradost.cloudstream3.utils.DataStore.setKey
+import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader
+import kotlinx.coroutines.runBlocking
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.PrintStream
+import java.lang.ref.WeakReference
+import java.util.Locale
+import kotlin.concurrent.thread
+import kotlin.system.exitProcess
+
+class ExceptionHandler(
+ val errorFile: File,
+ val onError: (() -> Unit)
+) : Thread.UncaughtExceptionHandler {
+
+ override fun uncaughtException(thread: Thread, error: Throwable) {
+ try {
+ val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
+ thread.threadId()
+ } else {
+ @Suppress("DEPRECATION")
+ thread.id
+ }
+
+ PrintStream(errorFile).use { ps ->
+ ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
+ ps.println("Fatal exception on thread ${thread.name} ($threadId)")
+ error.printStackTrace(ps)
+ }
+ } catch (_: FileNotFoundException) {
+ }
+ try {
+ onError()
+ } catch (_: Exception) {
+ }
+ exitProcess(1)
+ }
+}
+
+class CloudStreamApp : Application(), SingletonImageLoader.Factory {
+
+ override fun onCreate() {
+ super.onCreate()
+ // If we want to initialize Coil as early as possible, maybe when
+ // loading an image or GIF in a splash screen activity.
+ // buildImageLoader(applicationContext)
+
+ ExceptionHandler(filesDir.resolve("last_error")) {
+ val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
+ startActivity(Intent.makeRestartActivityTask(intent!!.component))
+ }.also {
+ exceptionHandler = it
+ Thread.setDefaultUncaughtExceptionHandler(it)
+ }
+
+ AppDebug.isDebug = BuildConfig.DEBUG
+ }
+
+ override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+ context = base
+ }
+
+ override fun newImageLoader(context: PlatformContext): ImageLoader {
+ // Coil module will be initialized globally when first loadImage() is invoked.
+ return buildImageLoader(applicationContext)
+ }
+
+ companion object {
+ var exceptionHandler: ExceptionHandler? = null
+
+ /** Use to get Activity from Context. */
+ tailrec fun Context.getActivity(): Activity? {
+ return when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.getActivity()
+ else -> null
+ }
+ }
+
+ private var _context: WeakReference? = null
+ var context
+ get() = _context?.get()
+ private set(value) {
+ _context = WeakReference(value)
+ setContext(WeakReference(value))
+ }
+
+ fun getKeyClass(path: String, valueType: Class): T? {
+ return context?.getKey(path, valueType)
+ }
+
+ fun setKeyClass(path: String, value: T) {
+ context?.setKey(path, value)
+ }
+
+ fun removeKeys(folder: String): Int? {
+ return context?.removeKeys(folder)
+ }
+
+ fun setKey(path: String, value: T) {
+ context?.setKey(path, value)
+ }
+
+ fun setKey(folder: String, path: String, value: T) {
+ context?.setKey(folder, path, value)
+ }
+
+ inline fun getKey(path: String, defVal: T?): T? {
+ return context?.getKey(path, defVal)
+ }
+
+ inline fun getKey(path: String): T? {
+ return context?.getKey(path)
+ }
+
+ inline fun getKey(folder: String, path: String): T? {
+ return context?.getKey(folder, path)
+ }
+
+ inline fun getKey(folder: String, path: String, defVal: T?): T? {
+ return context?.getKey(folder, path, defVal)
+ }
+
+ fun getKeys(folder: String): List? {
+ return context?.getKeys(folder)
+ }
+
+ fun removeKey(folder: String, path: String) {
+ context?.removeKey(folder, path)
+ }
+
+ fun removeKey(path: String) {
+ context?.removeKey(path)
+ }
+
+ /** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */
+ fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) {
+ context?.openBrowser(url, fallbackWebView, fragment)
+ }
+
+ /** Will fall back to WebView if in TV or emulator layout. */
+ fun openBrowser(url: String, activity: FragmentActivity?) {
+ openBrowser(
+ url,
+ isLayout(TV or EMULATOR),
+ activity?.supportFragmentManager?.fragments?.lastOrNull()
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
index a7d899b63..4ce09bd44 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
@@ -1,21 +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.Gravity
import android.view.KeyEvent
-import android.view.LayoutInflater
import android.view.View
import android.view.View.NO_ID
import android.view.ViewGroup
-import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
@@ -25,29 +27,41 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.children
+import androidx.core.view.isNotEmpty
import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession
import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.actions.VideoClickActionHolder
+import com.lagradost.cloudstream3.databinding.ToastBinding
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.ui.player.PlayerEventType
-import com.lagradost.cloudstream3.ui.result.ResultFragment
-import com.lagradost.cloudstream3.ui.result.UiText
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
-import com.lagradost.cloudstream3.utils.AppUtils.isRtl
-import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.syncproviders.AccountManager
+import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
+import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
+import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
+import com.lagradost.cloudstream3.ui.player.Torrent
+import com.lagradost.cloudstream3.ui.result.ActorAdaptor
+import com.lagradost.cloudstream3.ui.result.EpisodeAdapter
+import com.lagradost.cloudstream3.ui.result.ImageAdapter
+import com.lagradost.cloudstream3.ui.search.SearchAdapter
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
+import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter
+import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
+import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Event
-import com.lagradost.cloudstream3.utils.UIHelper
-import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
-import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
+import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
import com.lagradost.cloudstream3.utils.UIHelper.toPx
-import org.schabi.newpipe.extractor.NewPipe
+import com.lagradost.cloudstream3.utils.UiText
import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
+import org.schabi.newpipe.extractor.NewPipe
enum class FocusDirection {
Start,
@@ -65,6 +79,11 @@ object CommonActivity {
_activity = WeakReference(value)
}
+ @MainThread
+ fun setActivityInstance(newActivity: Activity?) {
+ activity = newActivity
+ }
+
@MainThread
fun Activity?.getCastSession(): CastSession? {
return (this as MainActivity?)?.mSessionManager?.currentCastSession
@@ -82,20 +101,26 @@ object CommonActivity {
get() {
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
+ val screenWidthWithOrientation: Int
+ get() {
+ return displayMetrics.widthPixels
+ }
+ val screenHeightWithOrientation: Int
+ get() {
+ return displayMetrics.heightPixels
+ }
-
- var canEnterPipMode: Boolean = false
- var canShowPipMode: Boolean = false
+ var isPipDesired: Boolean = false
var isInPIPMode: Boolean = false
val onColorSelectedEvent = Event>()
val onDialogDismissedEvent = Event()
- var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair) -> Boolean)? = null
+ var appliedTheme: Int = 0
+ var appliedColor: Int = 0
-
- var currentToast: Toast? = null
+ private var currentToast: Toast? = null
fun showToast(@StringRes message: Int, duration: Int? = null) {
val act = activity ?: return
@@ -151,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)
@@ -194,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() {
@@ -203,44 +241,38 @@ object CommonActivity {
setLocale(this, localeCode)
}
- fun init(act: ComponentActivity?) {
- if (act == null) return
- activity = act
- //https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
- //https://developer.android.com/guide/topics/ui/picture-in-picture
- canShowPipMode =
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
- act.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
- act.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
+ fun init(act: Activity) {
+ setActivityInstance(act)
+ ioSafe { Torrent.deleteAllFiles() }
+ val componentActivity = activity as? ComponentActivity ?: return
- act.updateLocale()
- act.updateTv()
+ componentActivity.updateLocale()
+ componentActivity.updateTv()
+ AccountManager.initMainAPI()
NewPipe.init(DownloaderTestImpl.getInstance())
- for (resumeApp in resumeApps) {
- resumeApp.launcher =
- act.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- val resultCode = result.resultCode
- val data = result.data
- if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
- val pos = resumeApp.getPosition(data)
- val dur = resumeApp.getDuration(data)
- if (dur > 0L && pos > 0L)
- DataStoreHelper.setViewPos(getKey(resumeApp.lastId), pos, dur)
- removeKey(resumeApp.lastId)
- ResultFragment.updateUI()
- }
+ MainActivity.activityResultLauncher =
+ componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == AppCompatActivity.RESULT_OK) {
+ val actionUid =
+ getKey("last_click_action") ?: return@registerForActivityResult
+ Log.d(TAG, "Loading action $actionUid result handler")
+ val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction
+ ?: return@registerForActivityResult
+ action.onResultSafe(act, result.data)
+ removeKey("last_click_action")
+ removeKey("last_opened")
}
- }
+ }
// Ask for notification permissions on Android 13
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
- act,
+ componentActivity,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
- val requestPermissionLauncher = act.registerForActivityResult(
+ val requestPermissionLauncher = componentActivity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Log.d(TAG, "Notification permission: $isGranted")
@@ -251,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()
}
}
@@ -270,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
}
}
@@ -282,6 +342,7 @@ object CommonActivity {
val currentTheme =
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
+ "System" -> mapSystemTheme(act)
"Black" -> R.style.AppTheme
"Light" -> R.style.LightMode
"Amoled" -> R.style.AmoledMode
@@ -289,18 +350,25 @@ object CommonActivity {
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.MonetMode else R.style.AppTheme
+ "Dracula" -> R.style.DraculaMode
+ "Lavender" -> R.style.LavenderMode
+ "SilentBlue" -> R.style.SilentBlueMode
+
else -> R.style.AppTheme
}
val currentOverlayTheme =
when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) {
"Normal" -> R.style.OverlayPrimaryColorNormal
+ "DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow
"CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink
+ "Orange" -> R.style.OverlayPrimaryColorOrange
"DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen
"Maroon" -> R.style.OverlayPrimaryColorMaroon
"NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue
"Grey" -> R.style.OverlayPrimaryColorGrey
"White" -> R.style.OverlayPrimaryColorWhite
+ "CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue
"Brown" -> R.style.OverlayPrimaryColorBrown
"Purple" -> R.style.OverlayPrimaryColorPurple
"Green" -> R.style.OverlayPrimaryColorGreen
@@ -309,6 +377,7 @@ object CommonActivity {
"Banana" -> R.style.OverlayPrimaryColorBanana
"Party" -> R.style.OverlayPrimaryColorParty
"Pink" -> R.style.OverlayPrimaryColorPink
+ "Lavender" -> R.style.OverlayPrimaryColorLavender
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
@@ -317,9 +386,13 @@ object CommonActivity {
else -> R.style.OverlayPrimaryColorNormal
}
+
act.theme.applyStyle(currentTheme, true)
act.theme.applyStyle(currentOverlayTheme, true)
-
+ appliedTheme = currentTheme
+ appliedColor = currentOverlayTheme
+ act.updateTv()
+ if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true)
act.theme.applyStyle(
R.style.LoadedStyle,
true
@@ -348,10 +421,9 @@ object CommonActivity {
currentLook = currentLook.parent as? View ?: break
}*/
- private fun View.hasContent() : Boolean {
- return isShown && when(this) {
- //is RecyclerView -> this.childCount > 0
- is ViewGroup -> this.childCount > 0
+ private fun View.hasContent(): Boolean {
+ return isShown && when (this) {
+ is ViewGroup -> this.isNotEmpty()
else -> true
}
}
@@ -381,7 +453,7 @@ object CommonActivity {
// if cant focus but visible then break and let android decide
// the exception if is the view is a parent and has children that wants focus
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
- parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
+ parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty()
} ?: false
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
@@ -459,98 +531,8 @@ object CommonActivity {
}
- fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
- //println("Keycode: $keyCode")
- //showToast(
- // this,
- // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
- // Toast.LENGTH_LONG
- //)
-
- // Tested keycodes on remote:
- // KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
- // KeyEvent.KEYCODE_MEDIA_REWIND
- // KeyEvent.KEYCODE_MENU
- // KeyEvent.KEYCODE_MEDIA_NEXT
- // KeyEvent.KEYCODE_MEDIA_PREVIOUS
- // KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
-
- // 149 keycode_numpad 5
- when (keyCode) {
- KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
- PlayerEventType.SeekForward
- }
-
- KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
- PlayerEventType.SeekBack
- }
-
- KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
- PlayerEventType.NextEpisode
- }
-
- KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> {
- PlayerEventType.PrevEpisode
- }
-
- KeyEvent.KEYCODE_MEDIA_PAUSE -> {
- PlayerEventType.Pause
- }
-
- KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
- PlayerEventType.Play
- }
-
- KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
- PlayerEventType.Lock
- }
-
- KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
- PlayerEventType.ToggleHide
- }
-
- KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
- PlayerEventType.ToggleMute
- }
-
- KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
- PlayerEventType.ShowMirrors
- }
- // OpenSubtitles shortcut
- KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
- PlayerEventType.SearchSubtitlesOnline
- }
-
- KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
- PlayerEventType.ShowSpeed
- }
-
- KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
- PlayerEventType.Resize
- }
-
- KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
- PlayerEventType.SkipOp
- }
-
- KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
- PlayerEventType.SkipCurrentChapter
- }
-
- KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
- PlayerEventType.PlayPauseToggle
- }
-
- else -> null
- }?.let { playerEvent ->
- playerEventListener?.invoke(playerEvent)
- }
-
- //when (keyCode) {
- // KeyEvent.KEYCODE_DPAD_CENTER -> {
- // println("DPAD PRESSED")
- // }
- //}
+ fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
+ return null
}
/** overrides focus and custom key events */
@@ -587,6 +569,7 @@ object CommonActivity {
else -> null
}
+
// println("NEXT FOCUS : $nextView")
if (nextView != null) {
nextView.requestFocus()
@@ -594,10 +577,15 @@ object CommonActivity {
return true
}
- if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
+ // TODO: Figure out why removing the check for SearchAutoComplete seems
+ // to break focus on TV as it shouldn't need to be used.
+ // Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote)
+ // send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button.
+ @SuppressLint("RestrictedApi")
+ if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
) {
- UIHelper.showInputMethod(act.currentFocus?.findFocus())
+ showInputMethod(act.currentFocus?.findFocus())
}
//println("Keycode: $keyCode")
@@ -606,7 +594,6 @@ object CommonActivity {
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG
//)
-
}
// if someone else want to override the focus then don't handle the event as it is already
diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
index 0a2db2bd4..8da7ca384 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
@@ -2,6 +2,7 @@ package com.lagradost.cloudstream3
import okhttp3.OkHttpClient
import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
@@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit
class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() {
- private val client: OkHttpClient
+ private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build()
override fun execute(request: Request): Response {
val httpMethod: String = request.httpMethod()
val url: String = request.url()
@@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
val dataToSend: ByteArray? = request.dataToSend()
var requestBody: RequestBody? = null
if (dataToSend != null) {
- requestBody = RequestBody.create(null, dataToSend)
+ requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size)
}
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.method(httpMethod, requestBody).url(url)
@@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
return instance
}
}
-
- init {
- client = builder.readTimeout(30, TimeUnit.SECONDS).build()
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt
deleted file mode 100644
index 045a7963a..000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/HeaderDecorationBindingAdapter.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.lagradost.cloudstream3
-
-import android.view.LayoutInflater
-import androidx.annotation.LayoutRes
-import androidx.recyclerview.widget.RecyclerView
-import com.lagradost.cloudstream3.ui.HeaderViewDecoration
-
-fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) {
- val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null)
- view.addItemDecoration(HeaderViewDecoration(headerView))
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt
deleted file mode 100644
index 0175e0d00..000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt
+++ /dev/null
@@ -1,1837 +0,0 @@
-package com.lagradost.cloudstream3
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.net.Uri
-import android.util.Base64.encodeToString
-import androidx.annotation.WorkerThread
-import androidx.preference.PreferenceManager
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.databind.json.JsonMapper
-import com.fasterxml.jackson.module.kotlin.KotlinModule
-import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
-import com.lagradost.cloudstream3.syncproviders.SyncIdName
-import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
-import com.lagradost.cloudstream3.ui.player.SubtitleData
-import com.lagradost.cloudstream3.utils.*
-import com.lagradost.cloudstream3.utils.AppUtils.toJson
-import com.lagradost.cloudstream3.utils.Coroutines.mainWork
-import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
-import com.lagradost.nicehttp.RequestBodyTypes
-import okhttp3.Interceptor
-import okhttp3.MediaType.Companion.toMediaTypeOrNull
-import okhttp3.RequestBody.Companion.toRequestBody
-import java.text.SimpleDateFormat
-import java.util.*
-import kotlin.math.absoluteValue
-
-const val USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
-
-//val baseHeader = mapOf("User-Agent" to USER_AGENT)
-val mapper = JsonMapper.builder().addModule(KotlinModule())
- .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!!
-
-/**
- * Defines the constant for the all languages preference, if this is set then it is
- * the equivalent of all languages being set
- **/
-const val AllLanguagesName = "universal"
-
-object APIHolder {
- val unixTime: Long
- get() = System.currentTimeMillis() / 1000L
- val unixTimeMS: Long
- get() = System.currentTimeMillis()
-
- private const val defProvider = 0
-
- // ConcurrentModificationException is possible!!!
- val allProviders = threadSafeListOf()
-
- fun initAll() {
- synchronized(allProviders) {
- for (api in allProviders) {
- api.init()
- }
- }
- apiMap = null
- }
-
- fun String.capitalize(): String {
- return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
- }
-
- var apis: List = threadSafeListOf()
- var apiMap: Map? = null
-
- fun addPluginMapping(plugin: MainAPI) {
- synchronized(apis) {
- apis = apis + plugin
- }
- initMap(true)
- }
-
- fun removePluginMapping(plugin: MainAPI) {
- synchronized(apis) {
- apis = apis.filter { it != plugin }
- }
- initMap(true)
- }
-
- private fun initMap(forcedUpdate: Boolean = false) {
- synchronized(apis) {
- if (apiMap == null || forcedUpdate)
- apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap()
- }
- }
-
- fun getApiFromNameNull(apiName: String?): MainAPI? {
- if (apiName == null) return null
- synchronized(allProviders) {
- initMap()
- synchronized(apis) {
- return apiMap?.get(apiName)?.let { apis.getOrNull(it) }
- // Leave the ?. null check, it can crash regardless
- ?: allProviders.firstOrNull { it.name == apiName }
- }
- }
- }
-
- fun getApiFromUrlNull(url: String?): MainAPI? {
- if (url == null) return null
- synchronized(allProviders) {
- allProviders.forEach { api ->
- if (url.startsWith(api.mainUrl)) return api
- }
- }
- return null
- }
-
- private fun getLoadResponseIdFromUrl(url: String, apiName: String): Int {
- return url.replace(getApiFromNameNull(apiName)?.mainUrl ?: "", "").replace("/", "")
- .hashCode()
- }
-
- fun LoadResponse.getId(): Int {
- return getLoadResponseIdFromUrl(url, apiName)
- }
-
- /**
- * Gets the website captcha token
- * discovered originally by https://github.com/ahmedgamal17
- * optimized by https://github.com/justfoolingaround
- *
- * @param url the main url, likely the same website you found the key from.
- * @param key used to fill https://www.google.com/recaptcha/api.js?render=....
- *
- * @param referer the referer for the google.com/recaptcha/api.js... request, optional.
- * */
-
- // Try document.select("script[src*=https://www.google.com/recaptcha/api.js?render=]").attr("src").substringAfter("render=")
- // To get the key
- suspend fun getCaptchaToken(url: String, key: String, referer: String? = null): String? {
- try {
- val uri = Uri.parse(url)
- val domain = encodeToString(
- (uri.scheme + "://" + uri.host + ":443").encodeToByteArray(),
- 0
- ).replace("\n", "").replace("=", ".")
-
- val vToken =
- app.get(
- "https://www.google.com/recaptcha/api.js?render=$key",
- referer = referer,
- cacheTime = 0
- )
- .text
- .substringAfter("releases/")
- .substringBefore("/")
- val recapToken =
- app.get("https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=cs3&k=$key&co=$domain&v=$vToken")
- .document
- .selectFirst("#recaptcha-token")?.attr("value")
- if (recapToken != null) {
- return app.post(
- "https://www.google.com/recaptcha/api2/reload?k=$key",
- data = mapOf(
- "v" to vToken,
- "k" to key,
- "c" to recapToken,
- "co" to domain,
- "sa" to "",
- "reason" to "q"
- ), cacheTime = 0
- ).text
- .substringAfter("rresp\",\"")
- .substringBefore("\"")
- }
- } catch (e: Exception) {
- logError(e)
- }
- return null
- }
-
- private var trackerCache: HashMap = hashMapOf()
-
- /**
- * Get anime tracker information based on title, year and type.
- * Both titles are attempted to be matched with both Romaji and English title.
- * Uses the anilist api.
- *
- * @param titles uses first index to search, but if you have multiple titles and want extra guarantee to match you can also have that
- * @param types Optional parameter to narrow down the scope to Movies, TV, etc. See TrackerType.getTypes()
- * @param year Optional parameter to only get anime with a specific year
- **/
- suspend fun getTracker(
- titles: List,
- types: Set?,
- year: Int?,
- lessAccurate: Boolean = false
- ): Tracker? {
- return try {
- require(titles.isNotEmpty()) { "titles must no be empty when calling getTracker" }
-
- val mainTitle = titles[0]
- val search =
- trackerCache[mainTitle]
- ?: searchAnilist(mainTitle)?.also {
- trackerCache[mainTitle] = it
- } ?: return null
-
- val res = search.data?.page?.media?.find { media ->
- val matchingYears = year == null || media.seasonYear == year
- val matchingTitles = media.title?.let { title ->
- titles.any { userTitle ->
- title.isMatchingTitles(userTitle)
- }
- } ?: false
-
- val matchingTypes = types?.any { it.name.equals(media.format, true) } == true
- if(lessAccurate) matchingTitles || matchingTypes && matchingYears else matchingTitles && matchingTypes && matchingYears
- } ?: return null
-
- Tracker(res.idMal, res.id.toString(), res.coverImage?.extraLarge ?: res.coverImage?.large, res.bannerImage)
- } catch (t: Throwable) {
- logError(t)
- null
- }
- }
-
- private suspend fun searchAnilist(
- title: String?,
- ): AniSearch? {
- val query = """
- query (
- ${'$'}page: Int = 1
- ${'$'}search: String
- ${'$'}sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC]
- ${'$'}type: MediaType
- ) {
- Page(page: ${'$'}page, perPage: 20) {
- media(
- search: ${'$'}search
- sort: ${'$'}sort
- type: ${'$'}type
- ) {
- id
- idMal
- title { romaji english }
- coverImage { extraLarge large }
- bannerImage
- seasonYear
- format
- }
- }
- }
- """.trimIndent().trim()
-
- val data = mapOf(
- "query" to query,
- "variables" to mapOf(
- "search" to title,
- "sort" to "SEARCH_MATCH",
- "type" to "ANIME",
- )
- ).toJson().toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull())
-
- return app.post("https://graphql.anilist.co", requestBody = data)
- .parsedSafe()
- }
-
-
- fun Context.getApiSettings(): HashSet {
- //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
-
- val hashSet = HashSet()
- val activeLangs = getApiProviderLangSettings()
- val hasUniversal = activeLangs.contains(AllLanguagesName)
- hashSet.addAll(synchronized(apis) { apis.filter { hasUniversal || activeLangs.contains(it.lang) } }
- .map { it.name })
-
- /*val set = settingsManager.getStringSet(
- this.getString(R.string.search_providers_list_key),
- hashSet
- )?.toHashSet() ?: hashSet
-
- val list = HashSet()
- for (name in set) {
- val api = getApiFromNameNull(name) ?: continue
- if (activeLangs.contains(api.lang)) {
- list.add(name)
- }
- }*/
- //if (list.isEmpty()) return hashSet
- //return list
- return hashSet
- }
-
- fun Context.getApiDubstatusSettings(): HashSet {
- val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val hashSet = HashSet()
- hashSet.addAll(DubStatus.values())
- val list = settingsManager.getStringSet(
- this.getString(R.string.display_sub_key),
- hashSet.map { it.name }.toMutableSet()
- ) ?: return hashSet
-
- val names = DubStatus.values().map { it.name }.toHashSet()
- //if(realSet.isEmpty()) return hashSet
-
- return list.filter { names.contains(it) }.map { DubStatus.valueOf(it) }.toHashSet()
- }
-
- fun Context.getApiProviderLangSettings(): HashSet {
- val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val hashSet = hashSetOf(AllLanguagesName) // def is all languages
-// hashSet.add("en") // def is only en
- val list = settingsManager.getStringSet(
- this.getString(R.string.provider_lang_key),
- hashSet
- )
-
- if (list.isNullOrEmpty()) return hashSet
- return list.toHashSet()
- }
-
- fun Context.getApiTypeSettings(): HashSet {
- val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val hashSet = HashSet()
- hashSet.addAll(TvType.values())
- val list = settingsManager.getStringSet(
- this.getString(R.string.search_types_list_key),
- hashSet.map { it.name }.toMutableSet()
- )
-
- if (list.isNullOrEmpty()) return hashSet
-
- val names = TvType.values().map { it.name }.toHashSet()
- val realSet = list.filter { names.contains(it) }.map { TvType.valueOf(it) }.toHashSet()
- if (realSet.isEmpty()) return hashSet
-
- return realSet
- }
-
- fun Context.updateHasTrailers() {
- LoadResponse.isTrailersEnabled = getHasTrailers()
- }
-
- private fun Context.getHasTrailers(): Boolean {
- val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true)
- }
-
- fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List {
- // We are getting the weirdest crash ever done:
- // java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType
- // Trying fixing using classloader fuckery
- val oldLoader = Thread.currentThread().contextClassLoader
- Thread.currentThread().contextClassLoader = TvType::class.java.classLoader
-
- val default = TvType.values()
- .sorted()
- .filter { it != TvType.NSFW }
- .map { it.ordinal }
-
- Thread.currentThread().contextClassLoader = oldLoader
-
- val defaultSet = default.map { it.toString() }.toSet()
- val currentPrefMedia = try {
- PreferenceManager.getDefaultSharedPreferences(this)
- .getStringSet(this.getString(R.string.prefer_media_type_key), defaultSet)
- ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null }
- } catch (e: Throwable) {
- null
- } ?: default
- val langs = this.getApiProviderLangSettings()
- val hasUniversal = langs.contains(AllLanguagesName)
- val allApis = synchronized(apis) {
- apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) }
- }
- return if (currentPrefMedia.isEmpty()) {
- allApis
- } else {
- // Filter API depending on preferred media type
- allApis.filter { api -> api.supportedTypes.any { currentPrefMedia.contains(it.ordinal) } }
- }
- }
-
- fun Context.filterSearchResultByFilmQuality(data: List): List {
- // Filter results omitting entries with certain quality
- if (data.isNotEmpty()) {
- val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this)
- ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf())
- ?.mapNotNull { entry ->
- entry.toIntOrNull() ?: return@mapNotNull null
- } ?: listOf()
- if (filteredSearchQuality.isNotEmpty()) {
- return data.filter { item ->
- val searchQualVal = item.quality?.ordinal ?: -1
- //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}")
- !filteredSearchQuality.contains(searchQualVal)
- }
- }
- }
- return data
- }
-
- fun Context.filterHomePageListByFilmQuality(data: HomePageList): HomePageList {
- // Filter results omitting entries with certain quality
- if (data.list.isNotEmpty()) {
- val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this)
- ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf())
- ?.mapNotNull { entry ->
- entry.toIntOrNull() ?: return@mapNotNull null
- } ?: listOf()
- if (filteredSearchQuality.isNotEmpty()) {
- return HomePageList(
- name = data.name,
- isHorizontalImages = data.isHorizontalImages,
- list = data.list.filter { item ->
- val searchQualVal = item.quality?.ordinal ?: -1
- //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}")
- !filteredSearchQuality.contains(searchQualVal)
- }
- )
- }
- }
- return data
- }
-}
-
-/*
-// THIS IS WORK IN PROGRESS API
-interface ITag {
- val name: UiText
-}
-
-data class SimpleTag(override val name: UiText, val data: String) : ITag
-
-enum class SelectType {
- SingleSelect,
- MultiSelect,
- MultiSelectAndExclude,
-}
-
-enum class SelectValue {
- Selected,
- Excluded,
-}
-
-interface GenreSelector {
- val title: UiText
- val id : Int
-}
-
-data class TagSelector(
- override val title: UiText,
- override val id : Int,
- val tags: Set,
- val defaultTags : Set = setOf(),
- val selectType: SelectType = SelectType.SingleSelect,
-) : GenreSelector
-
-data class BoolSelector(
- override val title: UiText,
- override val id : Int,
-
- val defaultValue : Boolean = false,
-) : GenreSelector
-
-data class InputField(
- override val title: UiText,
- override val id : Int,
-
- val hint : UiText? = null,
-) : GenreSelector
-
-// This response describes how a user might filter the homepage or search results
-data class GenreResponse(
- val searchSelectors : List,
- val filterSelectors: List = searchSelectors
-) */
-
-/*
-0 = Site not good
-1 = All good
-2 = Slow, heavy traffic
-3 = restricted, must donate 30 benenes to use
- */
-const val PROVIDER_STATUS_KEY = "PROVIDER_STATUS_KEY"
-const val PROVIDER_STATUS_BETA_ONLY = 3
-const val PROVIDER_STATUS_SLOW = 2
-const val PROVIDER_STATUS_OK = 1
-const val PROVIDER_STATUS_DOWN = 0
-
-data class ProvidersInfoJson(
- @JsonProperty("name") var name: String,
- @JsonProperty("url") var url: String,
- @JsonProperty("credentials") var credentials: String? = null,
- @JsonProperty("status") var status: Int,
-)
-
-data class SettingsJson(
- @JsonProperty("enableAdult") var enableAdult: Boolean = false,
-)
-
-
-data class MainPageData(
- val name: String,
- val data: String,
- val horizontalImages: Boolean = false
-)
-
-data class MainPageRequest(
- val name: String,
- val data: String,
- val horizontalImages: Boolean,
- //TODO genre selection or smth
-)
-
-fun mainPage(url: String, name: String, horizontalImages: Boolean = false): MainPageData {
- return MainPageData(name = name, data = url, horizontalImages = horizontalImages)
-}
-
-fun mainPageOf(vararg elements: MainPageData): List {
- return elements.toList()
-}
-
-/** return list of MainPageData with url to name, make for more readable code */
-fun mainPageOf(vararg elements: Pair): List {
- return elements.map { (url, name) -> MainPageData(name = name, data = url) }
-}
-
-fun newHomePageResponse(
- name: String,
- list: List,
- hasNext: Boolean? = null,
-): HomePageResponse {
- return HomePageResponse(
- listOf(HomePageList(name, list)),
- hasNext = hasNext ?: list.isNotEmpty()
- )
-}
-
-fun newHomePageResponse(
- data: MainPageRequest,
- list: List,
- hasNext: Boolean? = null,
-): HomePageResponse {
- return HomePageResponse(
- listOf(HomePageList(data.name, list, data.horizontalImages)),
- hasNext = hasNext ?: list.isNotEmpty()
- )
-}
-
-fun newHomePageResponse(list: HomePageList, hasNext: Boolean? = null): HomePageResponse {
- return HomePageResponse(listOf(list), hasNext = hasNext ?: list.list.isNotEmpty())
-}
-
-fun newHomePageResponse(list: List, hasNext: Boolean? = null): HomePageResponse {
- return HomePageResponse(list, hasNext = hasNext ?: list.any { it.list.isNotEmpty() })
-}
-
-/**Every provider will **not** have try catch built in, so handle exceptions when calling these functions*/
-abstract class MainAPI {
- companion object {
- var overrideData: HashMap? = null
- var settingsForProvider: SettingsJson = SettingsJson()
- }
-
- fun init() {
- overrideData?.get(this.javaClass.simpleName)?.let { data ->
- overrideWithNewData(data)
- }
- }
-
- fun overrideWithNewData(data: ProvidersInfoJson) {
- if (!canBeOverridden) return
- this.name = data.name
- if (data.url.isNotBlank() && data.url != "NONE")
- this.mainUrl = data.url
- this.storedCredentials = data.credentials
- }
-
- open var name = "NONE"
- open var mainUrl = "NONE"
- open var storedCredentials: String? = null
- open var canBeOverridden: Boolean = true
-
- /** if this is turned on then it will request the homepage one after the other,
- used to delay if they block many request at the same time*/
- open var sequentialMainPage: Boolean = false
-
- /** in milliseconds, this can be used to add more delay between homepage requests
- * on first load if sequentialMainPage is turned on */
- open var sequentialMainPageDelay: Long = 0L
-
- /** in milliseconds, this can be used to add more delay between homepage requests when scrolling */
- open var sequentialMainPageScrollDelay: Long = 0L
-
- /** used to keep track when last homepage request was in unixtime ms */
- var lastHomepageRequest: Long = 0L
-
- open var lang = "en" // ISO_639_1 check SubtitleHelper
-
- /**If link is stored in the "data" string, so links can be instantly loaded*/
- open val instantLinkLoading = false
-
- /**Set false if links require referer or for some reason cant be played on a chromecast*/
- open val hasChromecastSupport = true
-
- /**If all links are encrypted then set this to false*/
- open val hasDownloadSupport = true
-
- /**Used for testing and can be used to disable the providers if WebView is not available*/
- open val usesWebView = false
-
- /** Determines which plugin a given provider is from */
- var sourcePlugin: String? = null
-
- open val hasMainPage = false
- open val hasQuickSearch = false
-
- /**
- * A set of which ids the provider can open with getLoadUrl()
- * If the set contains SyncIdName.Imdb then getLoadUrl() can be started with
- * an Imdb class which inherits from SyncId.
- *
- * getLoadUrl() is then used to get page url based on that ID.
- *
- * Example:
- * "tt6723592" -> getLoadUrl(ImdbSyncId("tt6723592")) -> "mainUrl/imdb/tt6723592" -> load("mainUrl/imdb/tt6723592")
- *
- * This is used to launch pages from personal lists or recommendations using IDs.
- **/
- open val supportedSyncNames = setOf()
-
- open val supportedTypes = setOf(
- TvType.Movie,
- TvType.TvSeries,
- TvType.Cartoon,
- TvType.Anime,
- TvType.OVA,
- )
-
- open val vpnStatus = VPNStatus.None
- open val providerType = ProviderType.DirectProvider
-
- //emptyList() //
- open val mainPage = listOf(MainPageData("", "", false))
-
- @WorkerThread
- open suspend fun getMainPage(
- page: Int,
- request: MainPageRequest,
- ): HomePageResponse? {
- throw NotImplementedError()
- }
-
- @WorkerThread
- open suspend fun search(query: String): List? {
- throw NotImplementedError()
- }
-
- @WorkerThread
- open suspend fun quickSearch(query: String): List? {
- throw NotImplementedError()
- }
-
- @WorkerThread
- /**
- * Based on data from search() or getMainPage() it generates a LoadResponse,
- * basically opening the info page from a link.
- * */
- open suspend fun load(url: String): LoadResponse? {
- throw NotImplementedError()
- }
-
- /**
- * Largely redundant feature for most providers.
- *
- * This job runs in the background when a link is playing in exoplayer.
- * First implemented to do polling for sflix to keep the link from getting expired.
- *
- * This function might be updated to include exoplayer timestamps etc in the future
- * if the need arises.
- * */
- @WorkerThread
- open suspend fun extractorVerifierJob(extractorData: String?) {
- throw NotImplementedError()
- }
-
- /**Callback is fired once a link is found, will return true if method is executed successfully*/
- @WorkerThread
- open suspend fun loadLinks(
- data: String,
- isCasting: Boolean,
- subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
- ): Boolean {
- throw NotImplementedError()
- }
-
- /** An okhttp interceptor for used in OkHttpDataSource */
- open fun getVideoInterceptor(extractorLink: ExtractorLink): Interceptor? {
- return null
- }
-
- /**
- * Get the load() url based on a sync ID like IMDb or MAL.
- * Only contains SyncIds based on supportedSyncUrls.
- **/
- open suspend fun getLoadUrl(name: SyncIdName, id: String): String? {
- return null
- }
-}
-
-/** Might need a different implementation for desktop*/
-@SuppressLint("NewApi")
-fun base64Decode(string: String): String {
- return String(base64DecodeArray(string), Charsets.ISO_8859_1)
-}
-
-@SuppressLint("NewApi")
-fun base64DecodeArray(string: String): ByteArray {
- return try {
- android.util.Base64.decode(string, android.util.Base64.DEFAULT)
- } catch (e: Exception) {
- Base64.getDecoder().decode(string)
- }
-}
-
-@SuppressLint("NewApi")
-fun base64Encode(array: ByteArray): String {
- return try {
- String(android.util.Base64.encode(array, android.util.Base64.NO_WRAP), Charsets.ISO_8859_1)
- } catch (e: Exception) {
- String(Base64.getEncoder().encode(array))
- }
-}
-
-class ErrorLoadingException(message: String? = null) : Exception(message)
-
-fun MainAPI.fixUrlNull(url: String?): String? {
- if (url.isNullOrEmpty()) {
- return null
- }
- return fixUrl(url)
-}
-
-fun MainAPI.fixUrl(url: String): String {
- if (url.startsWith("http") ||
- // Do not fix JSON objects when passed as urls.
- url.startsWith("{\"")
- ) {
- return url
- }
- if (url.isEmpty()) {
- return ""
- }
-
- val startsWithNoHttp = url.startsWith("//")
- if (startsWithNoHttp) {
- return "https:$url"
- } else {
- if (url.startsWith('/')) {
- return mainUrl + url
- }
- return "$mainUrl/$url"
- }
-}
-
-fun sortUrls(urls: Set): List {
- return urls.sortedBy { t -> -t.quality }
-}
-
-fun sortSubs(subs: Set): List {
- return subs.sortedBy { it.name }
-}
-
-fun capitalizeString(str: String): String {
- return capitalizeStringNullable(str) ?: str
-}
-
-fun capitalizeStringNullable(str: String?): String? {
- if (str == null)
- return null
- return try {
- str.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
- } catch (e: Exception) {
- str
- }
-}
-
-fun fixTitle(str: String): String {
- return str.split(" ").joinToString(" ") {
- it.lowercase()
- .replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it }
- }
-}
-
-/**
- * Get rhino context in a safe way as it needs to be initialized on the main thread.
- * Make sure you get the scope using: val scope: Scriptable = rhino.initSafeStandardObjects()
- * Use like the following: rhino.evaluateString(scope, js, "JavaScript", 1, null)
- **/
-suspend fun getRhinoContext(): org.mozilla.javascript.Context {
- return Coroutines.mainWork {
- val rhino = org.mozilla.javascript.Context.enter()
- rhino.initSafeStandardObjects()
- rhino.optimizationLevel = -1
- rhino
- }
-}
-
-/** https://www.imdb.com/title/tt2861424/ -> tt2861424 */
-fun imdbUrlToId(url: String): String? {
- return Regex("/title/(tt[0-9]*)").find(url)?.groupValues?.get(1)
- ?: Regex("tt[0-9]{5,}").find(url)?.groupValues?.get(0)
-}
-
-fun imdbUrlToIdNullable(url: String?): String? {
- if (url == null) return null
- return imdbUrlToId(url)
-}
-
-enum class ProviderType {
- // When data is fetched from a 3rd party site like imdb
- MetaProvider,
-
- // When all data is from the site
- DirectProvider,
-}
-
-enum class VPNStatus {
- None,
- MightBeNeeded,
- Torrent,
-}
-
-enum class ShowStatus {
- Completed,
- Ongoing,
-}
-
-enum class DubStatus(val id: Int) {
- None(-1),
- Dubbed(1),
- Subbed(0),
-}
-
-enum class TvType(value: Int?) {
- Movie(1),
- AnimeMovie(2),
- TvSeries(3),
- Cartoon(4),
- Anime(5),
- OVA(6),
- Torrent(7),
- Documentary(8),
- AsianDrama(9),
- Live(10),
- NSFW(11),
- Others(12)
-}
-
-public enum class AutoDownloadMode(val value: Int) {
- Disable(0),
- FilterByLang(1),
- All(2),
- NsfwOnly(3)
- ;
-
- companion object {
- infix fun getEnum(value: Int): AutoDownloadMode? =
- AutoDownloadMode.values().firstOrNull { it.value == value }
- }
-}
-
-// IN CASE OF FUTURE ANIME MOVIE OR SMTH
-fun TvType.isMovieType(): Boolean {
- return this == TvType.Movie || this == TvType.AnimeMovie || this == TvType.Torrent || this == TvType.Live
-}
-
-fun TvType.isLiveStream(): Boolean {
- return this == TvType.Live
-}
-
-// returns if the type has an anime opening
-fun TvType.isAnimeOp(): Boolean {
- return this == TvType.Anime || this == TvType.OVA
-}
-
-data class SubtitleFile(val lang: String, val url: String)
-
-data class HomePageResponse(
- val items: List,
- val hasNext: Boolean = false
-)
-
-data class HomePageList(
- val name: String,
- var list: List,
- val isHorizontalImages: Boolean = false
-)
-
-enum class SearchQuality(value: Int?) {
- //https://en.wikipedia.org/wiki/Pirated_movie_release_types
- Cam(1),
- CamRip(2),
- HdCam(3),
- Telesync(4), // TS
- WorkPrint(5),
- Telecine(6), // TC
- HQ(7),
- HD(8),
- HDR(9), // high dynamic range
- BlueRay(10),
- DVD(11),
- SD(12),
- FourK(13),
- UHD(14),
- SDR(15), // standard dynamic range
- WebRip(16)
-}
-
-/**Add anything to here if you find a site that uses some specific naming convention*/
-fun getQualityFromString(string: String?): SearchQuality? {
- val check = (string ?: return null).trim().lowercase().replace(" ", "")
-
- return when (check) {
- "cam" -> SearchQuality.Cam
- "camrip" -> SearchQuality.CamRip
- "hdcam" -> SearchQuality.HdCam
- "hdtc" -> SearchQuality.HdCam
- "hdts" -> SearchQuality.HdCam
- "highquality" -> SearchQuality.HQ
- "hq" -> SearchQuality.HQ
- "highdefinition" -> SearchQuality.HD
- "hdrip" -> SearchQuality.HD
- "hd" -> SearchQuality.HD
- "hdtv" -> SearchQuality.HD
- "rip" -> SearchQuality.CamRip
- "telecine" -> SearchQuality.Telecine
- "tc" -> SearchQuality.Telecine
- "telesync" -> SearchQuality.Telesync
- "ts" -> SearchQuality.Telesync
- "dvd" -> SearchQuality.DVD
- "dvdrip" -> SearchQuality.DVD
- "dvdscr" -> SearchQuality.DVD
- "blueray" -> SearchQuality.BlueRay
- "bluray" -> SearchQuality.BlueRay
- "blu" -> SearchQuality.BlueRay
- "fhd" -> SearchQuality.HD
- "br" -> SearchQuality.BlueRay
- "standard" -> SearchQuality.SD
- "sd" -> SearchQuality.SD
- "4k" -> SearchQuality.FourK
- "uhd" -> SearchQuality.UHD // may also be 4k or 8k
- "blue" -> SearchQuality.BlueRay
- "wp" -> SearchQuality.WorkPrint
- "workprint" -> SearchQuality.WorkPrint
- "webrip" -> SearchQuality.WebRip
- "webdl" -> SearchQuality.WebRip
- "web" -> SearchQuality.WebRip
- "hdr" -> SearchQuality.HDR
- "sdr" -> SearchQuality.SDR
- else -> null
- }
-}
-
-interface SearchResponse {
- val name: String
- val url: String
- val apiName: String
- var type: TvType?
- var posterUrl: String?
- var posterHeaders: Map?
- var id: Int?
- var quality: SearchQuality?
-}
-
-fun MainAPI.newMovieSearchResponse(
- name: String,
- url: String,
- type: TvType = TvType.Movie,
- fix: Boolean = true,
- initializer: MovieSearchResponse.() -> Unit = { },
-): MovieSearchResponse {
- val builder = MovieSearchResponse(name, if (fix) fixUrl(url) else url, this.name, type)
- builder.initializer()
-
- return builder
-}
-
-fun MainAPI.newTvSeriesSearchResponse(
- name: String,
- url: String,
- type: TvType = TvType.TvSeries,
- fix: Boolean = true,
- initializer: TvSeriesSearchResponse.() -> Unit = { },
-): TvSeriesSearchResponse {
- val builder = TvSeriesSearchResponse(name, if (fix) fixUrl(url) else url, this.name, type)
- builder.initializer()
-
- return builder
-}
-
-
-fun MainAPI.newAnimeSearchResponse(
- name: String,
- url: String,
- type: TvType = TvType.Anime,
- fix: Boolean = true,
- initializer: AnimeSearchResponse.() -> Unit = { },
-): AnimeSearchResponse {
- val builder = AnimeSearchResponse(name, if (fix) fixUrl(url) else url, this.name, type)
- builder.initializer()
-
- return builder
-}
-
-fun SearchResponse.addQuality(quality: String) {
- this.quality = getQualityFromString(quality)
-}
-
-fun SearchResponse.addPoster(url: String?, headers: Map? = null) {
- this.posterUrl = url
- this.posterHeaders = headers
-}
-
-fun LoadResponse.addPoster(url: String?, headers: Map? = null) {
- this.posterUrl = url
- this.posterHeaders = headers
-}
-
-enum class ActorRole {
- Main,
- Supporting,
- Background,
-}
-
-data class Actor(
- val name: String,
- val image: String? = null,
-)
-
-data class ActorData(
- val actor: Actor,
- val role: ActorRole? = null,
- val roleString: String? = null,
- val voiceActor: Actor? = null,
-)
-
-data class AnimeSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType? = null,
-
- override var posterUrl: String? = null,
- var year: Int? = null,
- var dubStatus: EnumSet? = null,
-
- var otherName: String? = null,
- var episodes: MutableMap = mutableMapOf(),
-
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
-) : SearchResponse
-
-fun AnimeSearchResponse.addDubStatus(status: DubStatus, episodes: Int? = null) {
- this.dubStatus = dubStatus?.also { it.add(status) } ?: EnumSet.of(status)
- if (this.type?.isMovieType() != true)
- if (episodes != null && episodes > 0)
- this.episodes[status] = episodes
-}
-
-fun AnimeSearchResponse.addDubStatus(isDub: Boolean, episodes: Int? = null) {
- addDubStatus(if (isDub) DubStatus.Dubbed else DubStatus.Subbed, episodes)
-}
-
-fun AnimeSearchResponse.addDub(episodes: Int?) {
- if (episodes == null || episodes <= 0) return
- addDubStatus(DubStatus.Dubbed, episodes)
-}
-
-fun AnimeSearchResponse.addSub(episodes: Int?) {
- if (episodes == null || episodes <= 0) return
- addDubStatus(DubStatus.Subbed, episodes)
-}
-
-fun AnimeSearchResponse.addDubStatus(
- dubExist: Boolean,
- subExist: Boolean,
- dubEpisodes: Int? = null,
- subEpisodes: Int? = null
-) {
- if (dubExist)
- addDubStatus(DubStatus.Dubbed, dubEpisodes)
-
- if (subExist)
- addDubStatus(DubStatus.Subbed, subEpisodes)
-}
-
-fun AnimeSearchResponse.addDubStatus(status: String, episodes: Int? = null) {
- if (status.contains("(dub)", ignoreCase = true)) {
- addDubStatus(DubStatus.Dubbed, episodes)
- } else if (status.contains("(sub)", ignoreCase = true)) {
- addDubStatus(DubStatus.Subbed, episodes)
- }
-}
-
-data class TorrentSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType?,
-
- override var posterUrl: String?,
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
-) : SearchResponse
-
-data class MovieSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType? = null,
-
- override var posterUrl: String? = null,
- var year: Int? = null,
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
-) : SearchResponse
-
-data class LiveSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType? = null,
-
- override var posterUrl: String? = null,
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
- val lang: String? = null,
-) : SearchResponse
-
-data class TvSeriesSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType? = null,
-
- override var posterUrl: String? = null,
- val year: Int? = null,
- val episodes: Int? = null,
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
-) : SearchResponse
-
-data class TrailerData(
- val extractorUrl: String,
- val referer: String?,
- val raw: Boolean,
- //var mirros: List,
- //var subtitles: List = emptyList(),
-)
-
-interface LoadResponse {
- var name: String
- var url: String
- var apiName: String
- var type: TvType
- var posterUrl: String?
- var year: Int?
- var plot: String?
- var rating: Int? // 0-10000
- var tags: List?
- var duration: Int? // in minutes
- var trailers: MutableList
-
- var recommendations: List?
- var actors: List?
- var comingSoon: Boolean
- var syncData: MutableMap
- var posterHeaders: Map?
- var backgroundPosterUrl: String?
-
- companion object {
- private val malIdPrefix = malApi.idPrefix
- private val aniListIdPrefix = aniListApi.idPrefix
- private val simklIdPrefix = simklApi.idPrefix
- var isTrailersEnabled = true
-
- fun LoadResponse.isMovie(): Boolean {
- return this.type.isMovieType() || this is MovieLoadResponse
- }
-
- @JvmName("addActorNames")
- fun LoadResponse.addActors(actors: List?) {
- this.actors = actors?.map { ActorData(Actor(it)) }
- }
-
- @JvmName("addActors")
- fun LoadResponse.addActors(actors: List>?) {
- this.actors = actors?.map { (actor, role) -> ActorData(actor, roleString = role) }
- }
-
- @JvmName("addActorsRole")
- fun LoadResponse.addActors(actors: List>?) {
- this.actors = actors?.map { (actor, role) -> ActorData(actor, role = role) }
- }
-
- /**
- * Internal helper function to add simkl ids from other databases.
- */
- private fun LoadResponse.addSimklId(
- database: SimklApi.Companion.SyncServices,
- id: String?
- ) {
- normalSafeApiCall {
- this.syncData[simklIdPrefix] =
- SimklApi.addIdToString(this.syncData[simklIdPrefix], database, id.toString())
- ?: return@normalSafeApiCall
- }
- }
-
- @JvmName("addActorsOnly")
- fun LoadResponse.addActors(actors: List?) {
- this.actors = actors?.map { actor -> ActorData(actor) }
- }
-
- fun LoadResponse.getMalId(): String? {
- return this.syncData[malIdPrefix]
- }
-
- fun LoadResponse.getAniListId(): String? {
- return this.syncData[aniListIdPrefix]
- }
-
- fun LoadResponse.addMalId(id: Int?) {
- this.syncData[malIdPrefix] = (id ?: return).toString()
- this.addSimklId(SimklApi.Companion.SyncServices.Mal, id.toString())
- }
-
- fun LoadResponse.addAniListId(id: Int?) {
- this.syncData[aniListIdPrefix] = (id ?: return).toString()
- this.addSimklId(SimklApi.Companion.SyncServices.AniList, id.toString())
- }
-
- fun LoadResponse.addSimklId(id: Int?) {
- this.addSimklId(SimklApi.Companion.SyncServices.Simkl, id.toString())
- }
-
- fun LoadResponse.addImdbUrl(url: String?) {
- addImdbId(imdbUrlToIdNullable(url))
- }
-
- /**better to call addTrailer with mutible trailers directly instead of calling this multiple times*/
- suspend fun LoadResponse.addTrailer(
- trailerUrl: String?,
- referer: String? = null,
- addRaw: Boolean = false
- ) {
- if (!isTrailersEnabled || trailerUrl.isNullOrBlank()) return
- this.trailers.add(TrailerData(trailerUrl, referer, addRaw))
- /*val links = arrayListOf()
- val subs = arrayListOf()
- if (!loadExtractor(
- trailerUrl,
- referer,
- { subs.add(it) },
- { links.add(it) }) && addRaw
- ) {
- this.trailers.add(
- TrailerData(
- listOf(
- ExtractorLink(
- "",
- "Trailer",
- trailerUrl,
- referer ?: "",
- Qualities.Unknown.value,
- trailerUrl.contains(".m3u8")
- )
- ), listOf()
- )
- )
- } else {
- this.trailers.add(TrailerData(links, subs))
- }*/
- }
-
- /*
- fun LoadResponse.addTrailer(newTrailers: List) {
- trailers.addAll(newTrailers.map { TrailerData(listOf(it)) })
- }*/
-
- suspend fun LoadResponse.addTrailer(
- trailerUrls: List?,
- referer: String? = null,
- addRaw: Boolean = false
- ) {
- if (!isTrailersEnabled || trailerUrls == null) return
- trailers.addAll(trailerUrls.map { TrailerData(it, referer, addRaw) })
- /*val trailers = trailerUrls.filter { it.isNotBlank() }.amap { trailerUrl ->
- val links = arrayListOf()
- val subs = arrayListOf()
- if (!loadExtractor(
- trailerUrl,
- referer,
- { subs.add(it) },
- { links.add(it) }) && addRaw
- ) {
- arrayListOf(
- ExtractorLink(
- "",
- "Trailer",
- trailerUrl,
- referer ?: "",
- Qualities.Unknown.value,
- trailerUrl.contains(".m3u8")
- )
- ) to arrayListOf()
- } else {
- links to subs
- }
- }.map { (links, subs) -> TrailerData(links, subs) }
- this.trailers.addAll(trailers)*/
- }
-
- fun LoadResponse.addImdbId(id: String?) {
- // TODO add imdb sync
- this.addSimklId(SimklApi.Companion.SyncServices.Imdb, id)
- }
-
- fun LoadResponse.addTrackId(id: String?) {
- // TODO add trackt sync
- }
-
- fun LoadResponse.addkitsuId(id: String?) {
- // TODO add kitsu sync
- }
-
- fun LoadResponse.addTMDbId(id: String?) {
- // TODO add TMDb sync
- this.addSimklId(SimklApi.Companion.SyncServices.Tmdb, id)
- }
-
- fun LoadResponse.addRating(text: String?) {
- addRating(text.toRatingInt())
- }
-
- fun LoadResponse.addRating(value: Int?) {
- if ((value ?: return) < 0 || value > 10000) {
- return
- }
- this.rating = value
- }
-
- fun LoadResponse.addDuration(input: String?) {
- this.duration = getDurationFromString(input) ?: this.duration
- }
- }
-}
-
-fun getDurationFromString(input: String?): Int? {
- val cleanInput = input?.trim()?.replace(" ", "") ?: return null
- //Use first as sometimes the text passes on the 2 other Regex, but failed to provide accurate return value
- Regex("(\\d+\\shr)|(\\d+\\shour)|(\\d+\\smin)|(\\d+\\ssec)").findAll(input).let { values ->
- var seconds = 0
- values.forEach {
- val time_text = it.value
- if (time_text.isNotBlank()) {
- val time = time_text.filter { s -> s.isDigit() }.trim().toInt()
- val scale = time_text.filter { s -> !s.isDigit() }.trim()
- //println("Scale: $scale")
- val timeval = when (scale) {
- "hr", "hour" -> time * 60 * 60
- "min" -> time * 60
- "sec" -> time
- else -> 0
- }
- seconds += timeval
- }
- }
- if (seconds > 0) {
- return seconds / 60
- }
- }
- Regex("([0-9]*)h.*?([0-9]*)m").find(cleanInput)?.groupValues?.let { values ->
- if (values.size == 3) {
- val hours = values[1].toIntOrNull()
- val minutes = values[2].toIntOrNull()
- if (minutes != null && hours != null) {
- return hours * 60 + minutes
- }
- }
- }
- Regex("([0-9]*)m").find(cleanInput)?.groupValues?.let { values ->
- if (values.size == 2) {
- val return_value = values[1].toIntOrNull()
- if (return_value != null) {
- return return_value
- }
- }
- }
- return null
-}
-
-fun LoadResponse?.isEpisodeBased(): Boolean {
- if (this == null) return false
- return this is EpisodeResponse && this.type.isEpisodeBased()
-}
-
-fun LoadResponse?.isAnimeBased(): Boolean {
- if (this == null) return false
- return (this.type == TvType.Anime || this.type == TvType.OVA) // && (this is AnimeLoadResponse)
-}
-
-fun TvType?.isEpisodeBased(): Boolean {
- if (this == null) return false
- return (this == TvType.TvSeries || this == TvType.Anime || this == TvType.AsianDrama)
-}
-
-
-data class NextAiring(
- val episode: Int,
- val unixTime: Long,
-)
-
-/**
- * @param season To be mapped with episode season, not shown in UI if displaySeason is defined
- * @param name To be shown next to the season like "Season $displaySeason $name" but if displaySeason is null then "$name"
- * @param displaySeason What to be displayed next to the season name, if null then the name is the only thing shown.
- * */
-data class SeasonData(
- val season: Int,
- val name: String? = null,
- val displaySeason: Int? = null, // will use season if null
-)
-
-interface EpisodeResponse {
- var showStatus: ShowStatus?
- var nextAiring: NextAiring?
- var seasonNames: List?
- fun getLatestEpisodes(): Map
-}
-
-@JvmName("addSeasonNamesString")
-fun EpisodeResponse.addSeasonNames(names: List) {
- this.seasonNames = if (names.isEmpty()) null else names.mapIndexed { index, s ->
- SeasonData(
- season = index + 1,
- s
- )
- }
-}
-
-@JvmName("addSeasonNamesSeasonData")
-fun EpisodeResponse.addSeasonNames(names: List) {
- this.seasonNames = names.ifEmpty { null }
-}
-
-data class TorrentLoadResponse(
- override var name: String,
- override var url: String,
- override var apiName: String,
- var magnet: String?,
- var torrent: String?,
- override var plot: String?,
- override var type: TvType = TvType.Torrent,
- override var posterUrl: String? = null,
- override var year: Int? = null,
- override var rating: Int? = null,
- override var tags: List? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse
-
-data class AnimeLoadResponse(
- var engName: String? = null,
- var japName: String? = null,
- override var name: String,
- override var url: String,
- override var apiName: String,
- override var type: TvType,
-
- override var posterUrl: String? = null,
- override var year: Int? = null,
-
- var episodes: MutableMap> = mutableMapOf(),
- override var showStatus: ShowStatus? = null,
-
- override var plot: String? = null,
- override var tags: List? = null,
- var synonyms: List? = null,
-
- override var rating: Int? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var nextAiring: NextAiring? = null,
- override var seasonNames: List? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse, EpisodeResponse {
- override fun getLatestEpisodes(): Map {
- return episodes.map { (status, episodes) ->
- val maxSeason = episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE }
- .takeUnless { it == Int.MIN_VALUE }
- status to episodes
- .filter { it.season == maxSeason }
- .maxOfOrNull { it.episode ?: Int.MIN_VALUE }
- .takeUnless { it == Int.MIN_VALUE }
- }.toMap()
- }
-}
-
-/**
- * If episodes already exist appends the list.
- * */
-fun AnimeLoadResponse.addEpisodes(status: DubStatus, episodes: List?) {
- if (episodes.isNullOrEmpty()) return
- this.episodes[status] = (this.episodes[status] ?: emptyList()) + episodes
-}
-
-suspend fun MainAPI.newAnimeLoadResponse(
- name: String,
- url: String,
- type: TvType,
- comingSoonIfNone: Boolean = true,
- initializer: suspend AnimeLoadResponse.() -> Unit = { },
-): AnimeLoadResponse {
- val builder = AnimeLoadResponse(name = name, url = url, apiName = this.name, type = type)
- builder.initializer()
- if (comingSoonIfNone) {
- builder.comingSoon = true
- for (key in builder.episodes.keys)
- if (!builder.episodes[key].isNullOrEmpty()) {
- builder.comingSoon = false
- break
- }
- }
- return builder
-}
-
-data class LiveStreamLoadResponse(
- override var name: String,
- override var url: String,
- override var apiName: String,
- var dataUrl: String,
-
- override var posterUrl: String? = null,
- override var year: Int? = null,
- override var plot: String? = null,
-
- override var type: TvType = TvType.Live,
- override var rating: Int? = null,
- override var tags: List? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse
-
-data class MovieLoadResponse(
- override var name: String,
- override var url: String,
- override var apiName: String,
- override var type: TvType,
- var dataUrl: String,
-
- override var posterUrl: String? = null,
- override var year: Int? = null,
- override var plot: String? = null,
-
- override var rating: Int? = null,
- override var tags: List? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse
-
-suspend fun MainAPI.newMovieLoadResponse(
- name: String,
- url: String,
- type: TvType,
- data: T?,
- initializer: suspend MovieLoadResponse.() -> Unit = { }
-): MovieLoadResponse {
- // just in case
- if (data is String) return newMovieLoadResponse(
- name,
- url,
- type,
- dataUrl = data,
- initializer = initializer
- )
- val dataUrl = data?.toJson() ?: ""
- val builder = MovieLoadResponse(
- name = name,
- url = url,
- apiName = this.name,
- type = type,
- dataUrl = dataUrl,
- comingSoon = dataUrl.isBlank()
- )
- builder.initializer()
- return builder
-}
-
-suspend fun MainAPI.newMovieLoadResponse(
- name: String,
- url: String,
- type: TvType,
- dataUrl: String,
- initializer: suspend MovieLoadResponse.() -> Unit = { }
-): MovieLoadResponse {
- val builder = MovieLoadResponse(
- name = name,
- url = url,
- apiName = this.name,
- type = type,
- dataUrl = dataUrl,
- comingSoon = dataUrl.isBlank()
- )
- builder.initializer()
- return builder
-}
-
-data class Episode(
- var data: String,
- var name: String? = null,
- var season: Int? = null,
- var episode: Int? = null,
- var posterUrl: String? = null,
- var rating: Int? = null,
- var description: String? = null,
- var date: Long? = null,
-)
-
-fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") {
- try {
- this.date = SimpleDateFormat(format)?.parse(date ?: return)?.time
- } catch (e: Exception) {
- logError(e)
- }
-}
-
-fun Episode.addDate(date: Date?) {
- this.date = date?.time
-}
-
-fun MainAPI.newEpisode(
- url: String,
- initializer: Episode.() -> Unit = { },
- fix: Boolean = true,
-): Episode {
- val builder = Episode(
- data = if (fix) fixUrl(url) else url
- )
- builder.initializer()
- return builder
-}
-
-fun MainAPI.newEpisode(
- data: T,
- initializer: Episode.() -> Unit = { }
-): Episode {
- if (data is String) return newEpisode(
- url = data,
- initializer = initializer
- ) // just in case java is wack
-
- val builder = Episode(
- data = data?.toJson() ?: throw ErrorLoadingException("invalid newEpisode")
- )
- builder.initializer()
- return builder
-}
-
-data class TvSeriesLoadResponse(
- override var name: String,
- override var url: String,
- override var apiName: String,
- override var type: TvType,
- var episodes: List,
-
- override var posterUrl: String? = null,
- override var year: Int? = null,
- override var plot: String? = null,
-
- override var showStatus: ShowStatus? = null,
- override var rating: Int? = null,
- override var tags: List? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var nextAiring: NextAiring? = null,
- override var seasonNames: List? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse, EpisodeResponse {
- override fun getLatestEpisodes(): Map {
- val maxSeason =
- episodes.maxOfOrNull { it.season ?: Int.MIN_VALUE }.takeUnless { it == Int.MIN_VALUE }
- val max = episodes
- .filter { it.season == maxSeason }
- .maxOfOrNull { it.episode ?: Int.MIN_VALUE }
- .takeUnless { it == Int.MIN_VALUE }
- return mapOf(DubStatus.None to max)
- }
-}
-
-suspend fun MainAPI.newTvSeriesLoadResponse(
- name: String,
- url: String,
- type: TvType,
- episodes: List,
- initializer: suspend TvSeriesLoadResponse.() -> Unit = { }
-): TvSeriesLoadResponse {
- val builder = TvSeriesLoadResponse(
- name = name,
- url = url,
- apiName = this.name,
- type = type,
- episodes = episodes,
- comingSoon = episodes.isEmpty(),
- )
- builder.initializer()
- return builder
-}
-
-fun fetchUrls(text: String?): List {
- if (text.isNullOrEmpty()) {
- return listOf()
- }
- val linkRegex =
- Regex("""(https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))""")
- return linkRegex.findAll(text).map { it.value.trim().removeSurrounding("\"") }.toList()
-}
-
-fun String?.toRatingInt(): Int? =
- this?.replace(" ", "")?.trim()?.toDoubleOrNull()?.absoluteValue?.times(1000f)?.toInt()
-
-data class Tracker(
- val malId: Int? = null,
- val aniId: String? = null,
- val image: String? = null,
- val cover: String? = null,
-)
-
-data class AniSearch(
- @JsonProperty("data") var data: Data? = Data()
-) {
- data class Data(
- @JsonProperty("Page") var page: Page? = Page()
- ) {
- data class Page(
- @JsonProperty("media") var media: ArrayList = arrayListOf()
- ) {
- data class Media(
- @JsonProperty("title") var title: Title? = null,
- @JsonProperty("id") var id: Int? = null,
- @JsonProperty("idMal") var idMal: Int? = null,
- @JsonProperty("seasonYear") var seasonYear: Int? = null,
- @JsonProperty("format") var format: String? = null,
- @JsonProperty("coverImage") var coverImage: CoverImage? = null,
- @JsonProperty("bannerImage") var bannerImage: String? = null,
- ) {
- data class CoverImage(
- @JsonProperty("extraLarge") var extraLarge: String? = null,
- @JsonProperty("large") var large: String? = null,
- )
- data class Title(
- @JsonProperty("romaji") var romaji: String? = null,
- @JsonProperty("english") var english: String? = null,
- ) {
- fun isMatchingTitles(title: String?): Boolean {
- if (title == null) return false
- return english.equals(title, true) || romaji.equals(title, true)
- }
- }
- }
- }
- }
-}
-
-/**
- * used for the getTracker() method
- **/
-enum class TrackerType {
- MOVIE,
- TV,
- TV_SHORT,
- ONA,
- OVA,
- SPECIAL,
- MUSIC;
-
- companion object {
- fun getTypes(type: TvType): Set {
- return when (type) {
- TvType.Movie -> setOf(MOVIE)
- TvType.AnimeMovie -> setOf(MOVIE)
- TvType.TvSeries -> setOf(TV, TV_SHORT)
- TvType.Anime -> setOf(TV, TV_SHORT, ONA, OVA)
- TvType.OVA -> setOf(OVA, SPECIAL, ONA)
- TvType.Others -> setOf(MUSIC)
- else -> emptySet()
- }
- }
- }
-}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
index a07ae2c28..90583011d 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
@@ -1,32 +1,40 @@
package com.lagradost.cloudstream3
import android.animation.ValueAnimator
-import android.content.ComponentName
+import android.annotation.SuppressLint
+import android.app.Dialog
import android.content.Context
import android.content.Intent
+import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.content.res.Configuration
import android.graphics.Rect
-import android.net.Uri
-import android.os.Build
import android.os.Bundle
import android.util.AttributeSet
import android.util.Log
+import android.view.Gravity
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
+import android.widget.CheckBox
+import android.widget.ImageView
+import android.widget.LinearLayout
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IdRes
import androidx.annotation.MainThread
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
-import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.cardview.widget.CardView
+import androidx.core.content.edit
+import androidx.core.net.toUri
import androidx.core.view.children
+import androidx.core.view.get
import androidx.core.view.isGone
+import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.marginStart
import androidx.fragment.app.FragmentActivity
@@ -42,9 +50,7 @@ import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSnapHelper
import androidx.recyclerview.widget.RecyclerView
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import androidx.viewpager2.widget.ViewPager2
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.Session
import com.google.android.gms.cast.framework.SessionManager
@@ -57,95 +63,120 @@ import com.google.common.collect.Comparators.min
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.APIHolder.apis
-import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.initAll
-import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
-import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.loadThemes
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint
import com.lagradost.cloudstream3.CommonActivity.screenHeight
+import com.lagradost.cloudstream3.CommonActivity.setActivityInstance
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.CommonActivity.updateLocale
+import com.lagradost.cloudstream3.CommonActivity.updateTheme
+import com.lagradost.cloudstream3.actions.temp.fcast.FcastManager
import com.lagradost.cloudstream3.databinding.ActivityMainBinding
import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding
import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
+import com.lagradost.cloudstream3.mvvm.safe
+import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.network.initClient
import com.lagradost.cloudstream3.plugins.PluginManager
-import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins
+import com.lagradost.cloudstream3.plugins.PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringPlayer
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths
+import com.lagradost.cloudstream3.services.SubscriptionWorkManager
+import com.lagradost.cloudstream3.syncproviders.AccountManager
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi
+import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.APIRepository
+import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.WatchType
+import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
import com.lagradost.cloudstream3.ui.home.HomeViewModel
+import com.lagradost.cloudstream3.ui.library.LibraryViewModel
import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator
import com.lagradost.cloudstream3.ui.result.LinearListLayout
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
-import com.lagradost.cloudstream3.ui.result.setImage
-import com.lagradost.cloudstream3.ui.result.setText
-import com.lagradost.cloudstream3.ui.result.txt
+import com.lagradost.cloudstream3.ui.result.SyncViewModel
import com.lagradost.cloudstream3.ui.search.SearchFragment
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY
import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions
import com.lagradost.cloudstream3.utils.ApkInstaller
-import com.lagradost.cloudstream3.utils.AppUtils.html
-import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable
-import com.lagradost.cloudstream3.utils.AppUtils.isLtr
-import com.lagradost.cloudstream3.utils.AppUtils.isNetworkAvailable
-import com.lagradost.cloudstream3.utils.AppUtils.isRtl
-import com.lagradost.cloudstream3.utils.AppUtils.loadCache
-import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
-import com.lagradost.cloudstream3.utils.AppUtils.loadResult
-import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
-import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
+import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
+import com.lagradost.cloudstream3.utils.AppContextUtils.html
+import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
+import com.lagradost.cloudstream3.utils.AppContextUtils.isLtr
+import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable
+import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
+import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
+import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackupUtils.backup
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.setKey
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
import com.lagradost.cloudstream3.utils.Event
-import com.lagradost.cloudstream3.utils.IOnBackPressed
-import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
+import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
+import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
+import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
+import com.lagradost.cloudstream3.utils.TvChannelUtils
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
-import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
+import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
+import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
+import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
-import com.lagradost.nicehttp.Requests
-import com.lagradost.nicehttp.ResponseParser
+import com.lagradost.cloudstream3.utils.setText
+import com.lagradost.cloudstream3.utils.setTextHtml
+import com.lagradost.cloudstream3.utils.txt
import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -156,133 +187,53 @@ import java.net.URLDecoder
import java.nio.charset.Charset
import kotlin.math.abs
import kotlin.math.absoluteValue
-import kotlin.reflect.KClass
import kotlin.system.exitProcess
+import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
-
-//https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
-//https://wiki.videolan.org/Android_Player_Intents/
-
-//https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904
-//https://mpv-android.github.io/mpv-android/intent.html
-
-// https://www.webvideocaster.com/integrations
-
-//https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225
-
-const val VLC_PACKAGE = "org.videolan.vlc"
-const val MPV_PACKAGE = "is.xyz.mpv"
-const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo"
-
-val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity")
-val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity")
-
-//TODO REFACTOR AF
-open class ResultResume(
- val packageString: String,
- val action: String = Intent.ACTION_VIEW,
- val position: String? = null,
- val duration: String? = null,
- var launcher: ActivityResultLauncher? = null,
-) {
- val defaultTime = -1L
-
- val lastId get() = "${packageString}_last_open_id"
- suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) {
- val intent = Intent(action)
-
- if (id != null)
- setKey(lastId, id)
- else
- removeKey(lastId)
-
- intent.setPackage(packageString)
- callback.invoke(intent)
- launcher?.launch(intent)
- }
-
- open fun getPosition(intent: Intent?): Long {
- return defaultTime
- }
-
- open fun getDuration(intent: Intent?): Long {
- return defaultTime
- }
-}
-
-val VLC = object : ResultResume(
- VLC_PACKAGE,
- // Android 13 intent restrictions fucks up specifically launching the VLC player
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
- "org.videolan.vlc.player.result"
- } else {
- Intent.ACTION_VIEW
- },
- "extra_position",
- "extra_duration",
-) {
- override fun getPosition(intent: Intent?): Long {
- return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime
- }
-
- override fun getDuration(intent: Intent?): Long {
- return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime
- }
-}
-
-val MPV = object : ResultResume(
- MPV_PACKAGE,
- //"is.xyz.mpv.MPVActivity.result", // resume not working :pensive:
- position = "position",
- duration = "duration",
-) {
- override fun getPosition(intent: Intent?): Long {
- return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong() ?: defaultTime
- }
-
- override fun getDuration(intent: Intent?): Long {
- return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong() ?: defaultTime
- }
-}
-
-val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE)
-
-val resumeApps = arrayOf(
- VLC, MPV, WEB_VIDEO
-)
-
-// Short name for requests client to make it nicer to use
-
-var app = Requests(responseParser = object : ResponseParser {
- val mapper: ObjectMapper = jacksonObjectMapper().configure(
- DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
- false
- )
-
- override fun parse(text: String, kClass: KClass): T {
- return mapper.readValue(text, kClass.java)
- }
-
- override fun parseSafe(text: String, kClass: KClass): T? {
- return try {
- mapper.readValue(text, kClass.java)
- } catch (e: Exception) {
- null
- }
- }
-
- override fun writeValueAsString(obj: Any): String {
- return mapper.writeValueAsString(obj)
- }
-}).apply {
- defaultHeaders = mapOf("user-agent" to USER_AGENT)
-}
-
-class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
+class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
companion object {
+ var activityResultLauncher: ActivityResultLauncher? = null
+
const val TAG = "MAINACT"
+ const val ANIMATED_OUTLINE: Boolean = false
var lastError: String? = null
+ /** Update lastError variable based on error file, to check if app crashed.
+ * Can be called multiple times without changing the lastError variable changing.
+ **/
+ fun setLastError(context: Context) {
+ if (lastError != null) return
+
+ val errorFile = context.filesDir.resolve("last_error")
+ if (errorFile.exists() && errorFile.isFile) {
+ lastError = errorFile.readText(Charset.defaultCharset())
+ errorFile.delete()
+ } else {
+ lastError = null
+ }
+ }
+
+ private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
+ const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
+
+ /**
+ * Transient files to delete on application exit.
+ * Deletes files on onDestroy().
+ */
+ private var filesToDelete: Set
+ // This needs to be persistent because the application may exit without calling onDestroy.
+ get() = getKey>(FILE_DELETE_KEY) ?: setOf()
+ private set(value) = setKey(FILE_DELETE_KEY, value)
+
+ /**
+ * Add file to delete on Exit.
+ */
+ fun deleteFileOnExit(file: File) {
+ filesToDelete = filesToDelete + file.path
+ }
+
/**
* Setting this will automatically enter the query in the search
* next time the search fragment is opened.
@@ -306,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)
@@ -314,13 +279,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
fun handleAppIntentUrl(
activity: FragmentActivity?,
str: String?,
- isWebview: Boolean
+ isWebview: Boolean,
+ extraArgs: Bundle? = null
): Boolean =
with(activity) {
// TODO MUCH BETTER HANDLING
// Invalid URIs can crash
- fun safeURI(uri: String) = normalSafeApiCall { URI(uri) }
+ fun safeURI(uri: String) = safe { URI(uri) }
if (str != null && this != null) {
if (str.startsWith("https://cs.repo")) {
@@ -328,29 +294,30 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
println("Repository url: $realUrl")
loadRepository(realUrl)
return true
- } else if (str.contains(appString)) {
- for (api in OAuth2Apis) {
- if (str.contains("/${api.redirectUrl}")) {
+ } else if (str.contains(APP_STRING)) {
+ for (api in AccountManager.allApis) {
+ if (api.isValidRedirectUrl(str)) {
ioSafe {
Log.i(TAG, "handleAppIntent $str")
- val isSuccessful = api.handleRedirect(str)
-
- if (isSuccessful) {
- Log.i(TAG, "authenticated ${api.name}")
- } else {
- Log.i(TAG, "failed to authenticate ${api.name}")
- }
-
- this@with.runOnUiThread {
- try {
- showToast(
- getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail).format(
- api.name
- )
- )
- } catch (e: Exception) {
- logError(e) // format might fail
+ try {
+ val isSuccessful = api.login(str)
+ if (isSuccessful) {
+ Log.i(TAG, "authenticated ${api.name}")
+ } else {
+ Log.i(TAG, "failed to authenticate ${api.name}")
}
+ showToast(
+ if (isSuccessful) {
+ txt(R.string.authenticated_user, api.name)
+ } else {
+ txt(R.string.authenticated_user_fail, api.name)
+ }
+ )
+ } catch (t: Throwable) {
+ logError(t)
+ showToast(
+ txt(R.string.authenticated_user_fail, api.name)
+ )
}
}
return true
@@ -358,15 +325,19 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
// This specific intent is used for the gradle deployWithAdb
// https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46
- if (str == "$appString:") {
- PluginManager.hotReloadAllLocalPlugins(activity)
+ if (str == "$APP_STRING:") {
+ ioSafe {
+ PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(
+ activity
+ )
+ }
}
- } else if (safeURI(str)?.scheme == appStringRepo) {
- val url = str.replaceFirst(appStringRepo, "https")
+ } else if (safeURI(str)?.scheme == APP_STRING_REPO) {
+ val url = str.replaceFirst(APP_STRING_REPO, "https")
loadRepository(url)
return true
- } else if (safeURI(str)?.scheme == appStringSearch) {
- val query = str.substringAfter("$appStringSearch://")
+ } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) {
+ val query = str.substringAfter("$APP_STRING_SEARCH://")
nextSearchQuery =
try {
URLDecoder.decode(query, "UTF-8")
@@ -380,8 +351,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
R.id.navigation_search
activity?.findViewById(R.id.nav_rail_view)?.selectedItemId =
R.id.navigation_search
- } else if (safeURI(str)?.scheme == appStringPlayer) {
- val uri = Uri.parse(str)
+ } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
+ val uri = str.toUri()
val name = uri.getQueryParameter("name")
val url = URLDecoder.decode(uri.authority, "UTF-8")
@@ -391,12 +362,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
LinkGenerator(
listOf(BasicLink(url, name)),
extract = true,
- )
+ id = url.hashCode()
+ ), 0
)
)
- } else if (safeURI(str)?.scheme == appStringResumeWatching) {
+ } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
val id =
- str.substringAfter("$appStringResumeWatching://").toIntOrNull()
+ str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull()
?: return false
ioSafe {
val resumeWatchingCard =
@@ -407,34 +379,93 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
START_ACTION_RESUME_LATEST
)
}
+ } else if (str.startsWith(APP_STRING_SHARE)) {
+ try {
+ val data = str.substringAfter("$APP_STRING_SHARE:")
+ val parts = data.split("?", limit = 2)
+ loadResult(
+ String(base64DecodeArray(parts[1]), Charsets.UTF_8),
+ String(base64DecodeArray(parts[0]), Charsets.UTF_8),
+ ""
+ )
+ return true
+ } catch (e: Exception) {
+ showToast("Invalid Uri", Toast.LENGTH_SHORT)
+ return false
+ }
} else if (!isWebview) {
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
this.navigate(R.id.navigation_downloads)
return true
} else {
- synchronized(apis) {
- for (api in apis) {
- if (str.startsWith(api.mainUrl)) {
- loadResult(str, api.name)
- return true
- }
- }
+ val apiName = extraArgs?.getString(API_NAME_EXTRA_KEY)
+ ?.takeIf { it.isNotBlank() }
+ // if provided, try to match the api name instead of the api url
+ // this is in order to also support providers that use JSON dataUrls
+ // for example
+ if (apiName != null) {
+ loadResult(str, apiName, "")
+ return true
+ }
+
+ val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull()
+ if (matchedApi != null) {
+ loadResult(str, matchedApi.name, "")
+ return true
}
}
}
}
return false
}
+
+
+ fun centerView(view: View?) {
+ if (view == null) return
+ try {
+ Log.v(TAG, "centerView: $view")
+ val r = Rect(0, 0, 0, 0)
+ view.getDrawingRect(r)
+ val x = r.centerX()
+ val y = r.centerY()
+ val dx = r.width() / 2 //screenWidth / 2
+ val dy = screenHeight / 2
+ val r2 = Rect(x - dx, y - dy, x + dx, y + dy)
+ view.requestRectangleOnScreen(r2, false)
+ // TvFocus.current =TvFocus.current.copy(y=y.toFloat())
+ } catch (_: Throwable) {
+ }
+ }
}
+
var lastPopup: SearchResponse? = null
- fun loadPopup(result: SearchResponse) {
+ var lastPopupJob: Job? = null
+ fun loadPopup(result: SearchResponse, load: Boolean = true) {
lastPopup = result
- viewModel.load(
- this, result.url, result.apiName, false, if (getApiDubstatusSettings()
- .contains(DubStatus.Dubbed)
- ) DubStatus.Dubbed else DubStatus.Subbed, null
- )
+ val syncName = syncViewModel.syncName(result.apiName)
+
+ // based on apiName we decide on if it is a local list or not, this is because
+ // we want to show a bit of extra UI to sync apis
+ if (result is SyncAPI.LibraryItem && syncName != null) {
+ isLocalList = false
+ syncViewModel.setSync(syncName, result.syncId)
+ syncViewModel.updateMetaAndUser()
+ } else {
+ isLocalList = true
+ syncViewModel.clear()
+ }
+
+ lastPopupJob?.cancel()
+ lastPopupJob = if (load) {
+ viewModel.load(
+ this, result.url, result.apiName, false, if (getApiDubstatusSettings()
+ .contains(DubStatus.Dubbed)
+ ) DubStatus.Dubbed else DubStatus.Subbed, null
+ )
+ } else {
+ viewModel.loadSmall(result)
+ }
}
override fun onColorSelected(dialogId: Int, color: Int) {
@@ -448,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
@@ -472,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,
@@ -486,18 +519,19 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
).contains(destination.id)
- val dontPush = listOf(
+ /*val dontPush = listOf(
R.id.navigation_home,
R.id.navigation_search,
R.id.navigation_results_phone,
R.id.navigation_results_tv,
R.id.navigation_player,
+ R.id.navigation_quick_search,
).contains(destination.id)
binding?.navHostFragment?.apply {
val params = layoutParams as ConstraintLayout.LayoutParams
val push =
- if (!dontPush && isTvSettings()) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0
+ if (!dontPush && isLayout(TV or EMULATOR)) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0
if (!this.isLtr()) {
params.setMargins(
@@ -516,34 +550,58 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
layoutParams = params
- }
+ }*/
- val landscape = when (resources.configuration.orientation) {
- Configuration.ORIENTATION_LANDSCAPE -> {
- true
- }
-
- Configuration.ORIENTATION_PORTRAIT -> {
- isTvSettings()
- }
-
- else -> {
- false
- }
- }
binding?.apply {
- navView.isVisible = isNavVisible && !landscape
- navRailView.isVisible = isNavVisible && landscape
+ navRailView.isVisible = isNavVisible && isLandscape()
+ navView.isVisible = isNavVisible && !isLandscape()
+ navHostFragment.apply {
+ val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
+ layoutParams =
+ (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
+ marginStart =
+ if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
+ }
+ }
- // Hide library on TV since it is not supported yet :(
- val isTrueTv = isTrueTvSettings()
- navView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv
- navRailView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv
+ /**
+ * We need to make sure if we return to a sub-fragment,
+ * the correct navigation item is selected so that it does not
+ * highlight the wrong one in UI.
+ */
+ when (destination.id) {
+ in listOf(
+ R.id.navigation_downloads,
+ R.id.navigation_download_child,
+ R.id.navigation_download_queue
+ ) -> {
+ navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
+ navView.menu.findItem(R.id.navigation_downloads).isChecked = true
+ }
+
+ in listOf(
+ R.id.navigation_settings,
+ R.id.navigation_subtitles,
+ R.id.navigation_chrome_subtitles,
+ R.id.navigation_settings_player,
+ R.id.navigation_settings_updates,
+ R.id.navigation_settings_ui,
+ R.id.navigation_settings_account,
+ R.id.navigation_settings_providers,
+ R.id.navigation_settings_general,
+ R.id.navigation_settings_extensions,
+ R.id.navigation_settings_plugins,
+ R.id.navigation_test_providers
+ ) -> {
+ navRailView.menu.findItem(R.id.navigation_settings).isChecked = true
+ navView.menu.findItem(R.id.navigation_settings).isChecked = true
+ }
+ }
}
}
//private var mCastSession: CastSession? = null
- lateinit var mSessionManager: SessionManager
+ var mSessionManager: SessionManager? = null
private val mSessionManagerListener: SessionManagerListener by lazy { SessionManagerListenerImpl() }
private inner class SessionManagerListenerImpl : SessionManagerListener {
@@ -580,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)
@@ -599,7 +657,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
try {
if (isCastApiAvailable()) {
- mSessionManager.removeSessionManagerListener(mSessionManagerListener)
+ mSessionManager?.removeSessionManagerListener(mSessionManagerListener)
//mCastSession = null
}
} catch (e: Exception) {
@@ -607,18 +665,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
}
- override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
- val response = CommonActivity.dispatchKeyEvent(this, event)
- if (response != null)
- return response
- return super.dispatchKeyEvent(event)
- }
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean =
+ CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
- override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
- CommonActivity.onKeyDown(this, keyCode, event)
-
- return super.onKeyDown(keyCode, event)
- }
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean =
+ CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event)
override fun onUserLeaveHint() {
@@ -626,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)
}
@@ -683,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)
@@ -702,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
}
}
@@ -715,19 +810,21 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
private fun onAllPluginsLoaded(success: Boolean = false) {
ioSafe {
pluginsLock.withLock {
- synchronized(allProviders) {
+ allProviders.withLock {
// Load cloned sites after plugins have been loaded since clones depend on plugins.
try {
getKey>(USER_PROVIDER_API)?.let { list ->
list.forEach { custom ->
allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass }
?.let {
- allProviders.add(it.javaClass.newInstance().apply {
- name = custom.name
- lang = custom.lang
- mainUrl = custom.url.trimEnd('/')
- canBeOverridden = false
- })
+ allProviders.add(
+ it.javaClass.getDeclaredConstructor().newInstance()
+ .apply {
+ name = custom.name
+ lang = custom.lang
+ mainUrl = custom.url.trimEnd('/')
+ canBeOverridden = false
+ })
}
}
}
@@ -744,30 +841,52 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
lateinit var viewModel: ResultViewModel2
+ lateinit var syncViewModel: SyncViewModel
+ private var libraryViewModel: LibraryViewModel? = null
+ /** kinda dirty, however it signals that we should use the watch status as sync or not*/
+ var isLocalList: Boolean = false
override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
- viewModel =
- ViewModelProvider(this)[ResultViewModel2::class.java]
+
+ viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
+ syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java]
return super.onCreateView(name, context, attrs)
}
private fun hidePreviewPopupDialog() {
bottomPreviewPopup.dismissSafe(this)
+ lastPopupJob?.cancel()
+ lastPopupJob = null
bottomPreviewPopup = null
bottomPreviewBinding = null
}
- private var bottomPreviewPopup: BottomSheetDialog? = null
+ private var bottomPreviewPopup: Dialog? = null
private var bottomPreviewBinding: BottomResultviewPreviewBinding? = null
private fun showPreviewPopupDialog(): BottomResultviewPreviewBinding {
val ret = (bottomPreviewBinding ?: run {
- val builder =
- BottomSheetDialog(this)
- val binding: BottomResultviewPreviewBinding =
- BottomResultviewPreviewBinding.inflate(builder.layoutInflater, null, false)
+
+ val builder: Dialog
+ val layout: Int
+
+ if (isLayout(PHONE)) {
+ builder =
+ BottomSheetDialog(this)
+ layout = R.layout.bottom_resultview_preview
+ } else {
+ builder =
+ Dialog(this, R.style.DialogHalfFullscreen)
+ layout = R.layout.bottom_resultview_preview_tv
+ // No way to do this in styles :(
+ builder.window?.setGravity(Gravity.CENTER_VERTICAL or Gravity.END)
+ }
+
+ val root = layoutInflater.inflate(layout, null, false)
+ val binding = BottomResultviewPreviewBinding.bind(root)
+
bottomPreviewBinding = binding
- builder.setContentView(binding.root)
+ builder.setContentView(root)
builder.setOnDismissListener {
bottomPreviewPopup = null
bottomPreviewBinding = null
@@ -1058,18 +1177,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
}
-
override fun onCreate(savedInstanceState: Bundle?) {
- app.initClient(this)
+ app.initClient(this, ignoreSSL = false)
+ @OptIn(UnsafeSSL::class)
+ insecureApp.initClient(this, ignoreSSL = true)
+
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val errorFile = filesDir.resolve("last_error")
- if (errorFile.exists() && errorFile.isFile) {
- lastError = errorFile.readText(Charset.defaultCharset())
- errorFile.delete()
- } else {
- lastError = null
- }
+ setLastError(this)
val settingsForProvider = SettingsJson()
settingsForProvider.enableAdult =
@@ -1078,11 +1193,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
MainAPI.settingsForProvider = settingsForProvider
loadThemes(this)
+ enableEdgeToEdgeCompat()
+ setNavigationBarColorCompat(R.attr.primaryGrayBackground)
updateLocale()
super.onCreate(savedInstanceState)
try {
if (isCastApiAvailable()) {
- mSessionManager = CastContext.getSharedInstance(this).sessionManager
+ CastContext.getSharedInstance(this) { it.run() }
+ .addOnSuccessListener { mSessionManager = it.sessionManager }
}
} catch (t: Throwable) {
logError(t)
@@ -1092,51 +1210,63 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
updateTv()
// backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting?
- try {
+ safe {
val appVer = BuildConfig.VERSION_NAME
val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
if (appVer != lastAppAutoBackup) {
setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
- backup()
+ if (lastAppAutoBackup.isEmpty()) return@safe
+
+ safe {
+ backup(this)
+ }
+ safe {
+ // Recompile oat on new version
+ PluginManager.deleteAllOatFiles(this)
+ }
}
- } catch (t: Throwable) {
- logError(t)
}
// just in case, MAIN SHOULD *NEVER* BOOT LOOP CRASH
binding = try {
- if (isTvSettings()) {
+ if (isLayout(TV or EMULATOR)) {
val newLocalBinding = ActivityMainTvBinding.inflate(layoutInflater, null, false)
setContentView(newLocalBinding.root)
- TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline)
- newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
- // println("refocus $oldFocus -> $newFocus")
- try {
- val r = Rect(0,0,0,0)
- newFocus.getDrawingRect(r)
- val x = r.centerX()
- val y = r.centerY()
- val dx = 0 //screenWidth / 2
- val dy = screenHeight / 2
- val r2 = Rect(x-dx,y-dy,x+dx,y+dy)
- newFocus.requestRectangleOnScreen(r2, false)
- // TvFocus.current =TvFocus.current.copy(y=y.toFloat())
- } catch (_ : Throwable) { }
- TvFocus.updateFocusView(newFocus)
- /*var focus = newFocus
- while(focus != null) {
- if(focus is ScrollingView && focus.canScrollVertically()) {
- focus.scrollBy()
- }
- when(focus.parent) {
- is View -> focus = newFocus
- else -> break
- }
- }*/
+ if (isLayout(TV) && ANIMATED_OUTLINE) {
+ TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline)
+ newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener {
+ TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true)
+ }
+ newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
+ TvFocus.updateFocusView(newFocus)
+ }
+ } else {
+ newLocalBinding.focusOutline.isVisible = false
}
- newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener {
- TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true)
+
+ if (isLayout(TV)) {
+ // Put here any button you don't want focusing it to center the view
+ val exceptionButtons = listOf(
+ //R.id.home_preview_play_btt,
+ R.id.home_preview_info_btt,
+ R.id.home_preview_hidden_next_focus,
+ R.id.home_preview_hidden_prev_focus,
+ R.id.result_play_movie_button,
+ R.id.result_play_series_button,
+ R.id.result_resume_series_button,
+ R.id.result_play_trailer_button,
+ R.id.result_bookmark_Button,
+ R.id.result_favorite_Button,
+ R.id.result_subscribe_Button,
+ R.id.result_search_Button,
+ R.id.result_episodes_show_button,
+ )
+
+ newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
+ if (exceptionButtons.contains(newFocus?.id)) return@addOnGlobalFocusChangeListener
+ centerView(newFocus)
+ }
}
ActivityMainBinding.bind(newLocalBinding.root) // this may crash
@@ -1150,7 +1280,46 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
null
}
- changeStatusBarState(isEmulatorSettings())
+ binding?.apply {
+ fixSystemBarsPadding(
+ navView,
+ heightResId = R.dimen.nav_view_height,
+ padTop = false,
+ overlayCutout = false
+ )
+
+ fixSystemBarsPadding(
+ navRailView,
+ widthResId = R.dimen.nav_rail_view_width,
+ padRight = false,
+ padTop = false
+ )
+ }
+
+ // overscan
+ val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx
+ binding?.homeRoot?.setPadding(padding, padding, padding, padding)
+
+ changeStatusBarState(isLayout(EMULATOR))
+
+ /** Biometric stuff for users without accounts **/
+ val noAccounts = settingsManager.getBoolean(
+ getString(R.string.skip_startup_account_select_key),
+ false
+ ) || accounts.count() <= 1
+
+ if (isLayout(PHONE) && isAuthEnabled(this) && noAccounts) {
+ if (deviceHasPasswordPinLock(this)) {
+ startBiometricAuthentication(this, R.string.biometric_authentication_title, false)
+
+ promptInfo?.let { prompt ->
+ biometricPrompt?.authenticate(prompt)
+ }
+
+ // hide background while authenticating, Sorry moms & dads 🙏
+ binding?.navHostFragment?.isInvisible = true
+ }
+ }
// Automatically enable jsdelivr if cant connect to raw.githubusercontent.com
if (this.getKey(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) {
@@ -1159,17 +1328,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
this.setKey(getString(R.string.jsdelivr_proxy_key), false)
} else {
this.setKey(getString(R.string.jsdelivr_proxy_key), true)
- val parentView: View = findViewById(android.R.id.content)
- Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG)
- .let { snackbar ->
- snackbar.setAction(R.string.revert) {
- setKey(getString(R.string.jsdelivr_proxy_key), false)
- }
- snackbar.setBackgroundTint(colorFromAttribute(R.attr.primaryGrayBackground))
- snackbar.setTextColor(colorFromAttribute(R.attr.textColor))
- snackbar.setActionTextColor(colorFromAttribute(R.attr.colorPrimary))
- snackbar.show()
- }
+ showSnackbar(
+ this@MainActivity,
+ R.string.jsdelivr_enabled,
+ Snackbar.LENGTH_LONG,
+ R.string.revert
+ ) { setKey(getString(R.string.jsdelivr_proxy_key), false) }
}
}
}
@@ -1177,12 +1341,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
ioSafe { SafeFile.check(this@MainActivity) }
if (PluginManager.checkSafeModeFile()) {
- normalSafeApiCall {
+ safe {
showToast(R.string.safe_mode_file, Toast.LENGTH_LONG)
}
} else if (lastError == null) {
ioSafe {
- getKey(USER_SELECTED_HOMEPAGE_API)?.let { homeApi ->
+ DataStoreHelper.currentHomePage?.let { homeApi ->
mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi))
} ?: run {
mainPluginsLoadedEvent.invoke(false)
@@ -1194,9 +1358,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
true
)
) {
- PluginManager.updateAllOnlinePluginsAndLoadThem(this@MainActivity)
+ PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(
+ this@MainActivity
+ )
} else {
- loadAllOnlinePlugins(this@MainActivity)
+ ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(this@MainActivity)
}
//Automatically download not existing plugins, using mode specified.
@@ -1207,7 +1373,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
)
) ?: AutoDownloadMode.Disable
if (autoDownloadPlugin != AutoDownloadMode.Disable) {
- PluginManager.downloadNotExistingPluginsAndLoad(
+ PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
this@MainActivity,
autoDownloadPlugin
)
@@ -1215,8 +1381,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
ioSafe {
- PluginManager.loadAllLocalPlugins(this@MainActivity, false)
+ PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(
+ this@MainActivity,
+ false
+ )
}
+
+// Add your channel creation here
+
}
} else {
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
@@ -1235,6 +1407,77 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
builder.show().setDefaultFocus()
}
+
+ fun setUserData(status: Resource?) {
+ if (isLocalList) return
+ bottomPreviewBinding?.apply {
+ when (status) {
+ is Resource.Success -> {
+ resultviewPreviewBookmark.isEnabled = true
+ resultviewPreviewBookmark.setText(status.value.status.stringRes)
+ resultviewPreviewBookmark.setIconResource(status.value.status.iconRes)
+ }
+
+ is Resource.Failure -> {
+ resultviewPreviewBookmark.isEnabled = false
+ resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
+ resultviewPreviewBookmark.text = status.errorString
+ }
+
+ else -> {
+ resultviewPreviewBookmark.isEnabled = false
+ resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
+ resultviewPreviewBookmark.setText(R.string.loading)
+ }
+ }
+ }
+ }
+
+ fun setWatchStatus(state: WatchType?) {
+ if (!isLocalList || state == null) return
+
+ bottomPreviewBinding?.resultviewPreviewBookmark?.apply {
+ setIconResource(state.iconRes)
+ setText(state.stringRes)
+ }
+ }
+
+ fun setSubscribeStatus(state: Boolean?) {
+ bottomPreviewBinding?.resultviewPreviewSubscribe?.apply {
+ if (state != null) {
+ val drawable = if (state) {
+ R.drawable.ic_baseline_notifications_active_24
+ } else {
+ R.drawable.baseline_notifications_none_24
+ }
+ setImageResource(drawable)
+ }
+ isVisible = state != null
+
+ setOnClickListener {
+ viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? ->
+ if (newStatus == null) return@toggleSubscriptionStatus
+
+ val message = if (newStatus) {
+ // Kinda icky to have this here, but it works.
+ SubscriptionWorkManager.enqueuePeriodicWork(context)
+ R.string.subscription_new
+ } else {
+ R.string.subscription_deleted
+ }
+
+ val name = (viewModel.page.value as? Resource.Success)?.value?.title
+ ?: txt(R.string.no_data).asStringNull(context) ?: ""
+ showToast(txt(message, name), Toast.LENGTH_SHORT)
+ }
+ }
+ }
+ }
+
+ observe(viewModel.watchStatus, ::setWatchStatus)
+ observe(syncViewModel.userData, ::setUserData)
+ observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus)
+
observeNullable(viewModel.page) { resource ->
if (resource == null) {
hidePreviewPopupDialog()
@@ -1269,26 +1512,86 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
resultviewPreviewMetaDuration.setText(d.durationText)
resultviewPreviewMetaRating.setText(d.ratingText)
- resultviewPreviewDescription.setText(d.plotText)
- resultviewPreviewPoster.setImage(
- d.posterImage ?: d.posterBackgroundImage
- )
+ resultviewPreviewDescription.setTextHtml(d.plotText)
+ if (isLayout(PHONE)) {
+ resultviewPreviewPoster.loadImage(
+ d.posterImage ?: d.posterBackgroundImage,
+ headers = d.posterHeaders
+ )
+ } else {
+ resultviewPreviewPoster.loadImage(
+ d.posterBackgroundImage ?: d.posterImage,
+ headers = d.posterHeaders
+ )
+ }
- resultviewPreviewPoster.setOnClickListener {
+ setUserData(syncViewModel.userData.value)
+ setWatchStatus(viewModel.watchStatus.value)
+ setSubscribeStatus(viewModel.subscribeStatus.value)
+
+ resultviewPreviewBookmark.setOnClickListener {
//viewModel.updateWatchStatus(WatchType.PLANTOWATCH)
- val value = viewModel.watchStatus.value ?: WatchType.NONE
+ if (isLocalList) {
+ val value = viewModel.watchStatus.value ?: WatchType.NONE
- this@MainActivity.showBottomDialog(
- WatchType.values().map { getString(it.stringRes) }.toList(),
- value.ordinal,
- this@MainActivity.getString(R.string.action_add_to_bookmarks),
- showApply = false,
- {}) {
- viewModel.updateWatchStatus(WatchType.values()[it])
+ this@MainActivity.showBottomDialog(
+ WatchType.entries.map { getString(it.stringRes) }.toList(),
+ value.ordinal,
+ this@MainActivity.getString(R.string.action_add_to_bookmarks),
+ showApply = false,
+ {}) {
+ viewModel.updateWatchStatus(
+ WatchType.entries[it],
+ this@MainActivity
+ )
+ }
+ } else {
+ val value =
+ (syncViewModel.userData.value as? Resource.Success)?.value?.status
+ ?: SyncWatchType.NONE
+
+ this@MainActivity.showBottomDialog(
+ SyncWatchType.entries.map { getString(it.stringRes) }.toList(),
+ value.ordinal,
+ this@MainActivity.getString(R.string.action_add_to_bookmarks),
+ showApply = false,
+ {}) {
+ syncViewModel.setStatus(SyncWatchType.entries[it].internalId)
+ syncViewModel.publishUserData()
+ }
}
}
- if (!isTvSettings()) // dont want this clickable on tv layout
+ observeNullable(viewModel.favoriteStatus) observeFavoriteStatus@{ isFavorite ->
+ resultviewPreviewFavorite.isVisible = isFavorite != null
+ if (isFavorite == null) return@observeFavoriteStatus
+
+ val drawable = if (isFavorite) {
+ R.drawable.ic_baseline_favorite_24
+ } else {
+ R.drawable.ic_baseline_favorite_border_24
+ }
+
+ resultviewPreviewFavorite.setImageResource(drawable)
+ }
+
+ resultviewPreviewFavorite.setOnClickListener {
+ viewModel.toggleFavoriteStatus(this@MainActivity) { newStatus: Boolean? ->
+ if (newStatus == null) return@toggleFavoriteStatus
+
+ val message = if (newStatus) {
+ R.string.favorite_added
+ } else {
+ R.string.favorite_removed
+ }
+
+ val name = (viewModel.page.value as? Resource.Success)?.value?.title
+ ?: txt(R.string.no_data).asStringNull(this@MainActivity) ?: ""
+ showToast(txt(message, name), Toast.LENGTH_SHORT)
+ }
+ }
+
+ if (isLayout(PHONE)) // dont want this clickable on tv layout
resultviewPreviewDescription.setOnClickListener { view ->
view.context?.let { ctx ->
val builder: AlertDialog.Builder =
@@ -1323,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)
+ }
}
}
}
@@ -1341,9 +1653,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
ioSafe {
initAll()
// No duplicates (which can happen by registerMainAPI)
- apis = synchronized(allProviders) {
- allProviders.distinctBy { it }
- }
+ apis = allProviders.distinctBy { it }
}
// val navView: BottomNavigationView = findViewById(R.id.nav_view)
@@ -1362,6 +1672,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery)
}
}
+
+ if (navDestination.matchDestination(R.id.navigation_home)) {
+ attachBackPressedCallback("MainActivity") {
+ showConfirmExitDialog(settingsManager)
+ }
+ } else detachBackPressedCallback("MainActivity")
}
//val navController = findNavController(R.id.nav_host_fragment)
@@ -1387,17 +1703,27 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
navController
)
}
+
}
binding?.navRailView?.apply {
- itemRippleColor = rippleColor
- itemActiveIndicatorColor = rippleColor
+ if (isLayout(PHONE)) {
+ itemRippleColor = rippleColor
+ itemActiveIndicatorColor = rippleColor
+ } else {
+ val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f))
+ val rippleColorTransparent =
+ ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f))
+ itemSpacing = 12.toPx // expandedItemSpacing does not have an attr
+ itemRippleColor = rippleColorTransparent
+ itemActiveIndicatorColor = rippleColor
+ }
setupWithNavController(navController)
- if (isTvSettings()) {
+ /*if (isLayout(TV or EMULATOR)) {
background?.alpha = 200
} else {
background?.alpha = 255
- }
+ }*/
setOnItemSelectedListener { item ->
onNavDestinationSelected(
@@ -1406,6 +1732,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
)
}
+
fun noFocus(view: View) {
view.tag = view.context.getString(R.string.tv_no_focus_tag)
(view as? ViewGroup)?.let {
@@ -1414,7 +1741,132 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
}
}
- noFocus(this)
+ //noFocus(this)
+
+ val navProfileRoot = findViewById(R.id.nav_footer_root)
+
+ if (isLayout(TV or EMULATOR)) {
+ val navProfilePic = findViewById(R.id.nav_footer_profile_pic)
+ val navProfileCard = findViewById(R.id.nav_footer_profile_card)
+
+ navProfileCard?.setOnClickListener {
+ showAccountSelectLinear()
+ }
+
+ val homeViewModel =
+ ViewModelProvider(this@MainActivity)[HomeViewModel::class.java]
+
+ observe(homeViewModel.currentAccount) { currentAccount ->
+ if (currentAccount != null) {
+ navProfilePic?.loadImage(
+ currentAccount.image
+ )
+ navProfileRoot.isVisible = true
+ } else {
+ navProfileRoot.isGone = true
+ }
+ }
+ } else {
+ navProfileRoot.isGone = true
+ }
+ }
+
+ val rail = binding?.navRailView
+ if (rail != null) {
+ binding?.navRailView?.labelVisibilityMode =
+ NavigationRailView.LABEL_VISIBILITY_UNLABELED
+ //val focus = mutableSetOf()
+
+ var prevId: Int? = null
+ var prevView: View? = null
+
+ // The genius engineers at google did not actually
+ // write a nextFocus for the navrail
+ rail.findViewById(R.id.navigation_settings)?.nextFocusDownId =
+ R.id.nav_footer_profile_card
+ for (id in arrayOf(
+ R.id.navigation_home,
+ R.id.navigation_search,
+ R.id.navigation_library,
+ R.id.navigation_downloads,
+ R.id.navigation_settings
+ )) {
+ val view = rail.findViewById(id) ?: continue
+ prevId?.let { view.nextFocusUpId = it }
+ prevView?.nextFocusDownId = id
+
+ prevView = view
+ prevId = id
+ // Uncomment for focus expand
+ /*if (!isLayout(TV)) {
+ view.onFocusChangeListener = null
+ } else {
+ view.onFocusChangeListener =
+ View.OnFocusChangeListener { v, hasFocus ->
+ if (hasFocus) {
+ focus += id
+ binding?.navRailView?.labelVisibilityMode =
+ NavigationRailView.LABEL_VISIBILITY_LABELED
+ binding?.navRailView?.expand()
+ } else {
+ focus -= id
+ v.post {
+ if (focus.isEmpty()) {
+ binding?.navRailView?.labelVisibilityMode =
+ NavigationRailView.LABEL_VISIBILITY_UNLABELED
+ binding?.navRailView?.collapse()
+ }
+ }
+ }
+ }
+ }*/
+ }
+ }
+
+ // Navigation button long click functionality to scroll to top
+ for (view in listOf(binding?.navView, binding?.navRailView)) {
+ view?.findViewById(R.id.navigation_home)?.setOnLongClickListener {
+ val recycler = binding?.root?.findViewById(R.id.home_master_recycler)
+ recycler?.smoothScrollToPosition(0)
+ return@setOnLongClickListener recycler != null
+ }
+
+ view?.findViewById(R.id.navigation_library)?.setOnLongClickListener {
+ val viewPager = binding?.root?.findViewById(R.id.viewpager)
+ ?: return@setOnLongClickListener false
+ try {
+ val children = (viewPager[0] as? RecyclerView)?.children
+ ?: return@setOnLongClickListener false
+ for (child in children) {
+ child.findViewById(R.id.page_recyclerview)
+ ?.smoothScrollToPosition(0)
+ }
+ } catch (_: IndexOutOfBoundsException) {
+ } catch (t: Throwable) {
+ logError(t)
+ }
+ return@setOnLongClickListener true
+ }
+
+ view?.findViewById(R.id.navigation_search)?.setOnLongClickListener {
+ for (recyclerId in arrayOf(
+ R.id.search_master_recycler,
+ R.id.search_autofit_results,
+ R.id.search_history_recycler
+ )) {
+ val recycler = binding?.root?.findViewById(recyclerId)
+ ?: return@setOnLongClickListener false
+ recycler.smoothScrollToPosition(0)
+ }
+ return@setOnLongClickListener true
+ }
+
+ view?.findViewById(R.id.navigation_downloads)?.setOnLongClickListener {
+ val recycler: RecyclerView? = binding?.root?.findViewById(R.id.download_list)
+ ?: binding?.root?.findViewById(R.id.download_child_list)
+ recycler?.smoothScrollToPosition(0)
+ return@setOnLongClickListener recycler != null
+ }
}
loadCache()
@@ -1483,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()
@@ -1509,7 +1961,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (BuildConfig.DEBUG) {
var providersAndroidManifestString = "Current androidmanifest should be:\n"
- synchronized(allProviders) {
+ allProviders.withLock {
for (api in allProviders) {
providersAndroidManifestString += "(USER_SELECTED_HOMEPAGE_API)?.let { homepage ->
+ DataStoreHelper.currentHomePage = homepage
+ removeKey(USER_SELECTED_HOMEPAGE_API)
+ }
+
try {
if (getKey(HAS_DONE_SETUP_KEY, false) != true) {
navController.navigate(R.id.navigation_setup_language)
@@ -1558,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
@@ -1571,6 +2039,24 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
// }
// }
+ attachBackPressedCallback("MainActivityDefault") {
+ setNavigationBarColorCompat(R.attr.primaryGrayBackground)
+ updateLocale()
+ runDefault()
+ }
+
+ // Start the download queue
+ DownloadQueueManager.init(this)
+ }
+
+ /** Biometric stuff **/
+ override fun onAuthenticationSuccess() {
+ // make background (nav host fragment) visible again
+ binding?.navHostFragment?.isInvisible = false
+ }
+
+ override fun onAuthenticationError() {
+ finish()
}
suspend fun checkGithubConnectivity(): Boolean {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt
deleted file mode 100644
index 7be904405..000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.lagradost.cloudstream3
-
-import com.lagradost.cloudstream3.MainActivity.Companion.lastError
-import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.plugins.PluginManager.checkSafeModeFile
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-
-object NativeCrashHandler {
- // external fun triggerNativeCrash()
- /*private external fun initNativeCrashHandler()
- private external fun getSignalStatus(): Int
-
- private fun initSignalPolling() = CoroutineScope(Dispatchers.IO).launch {
-
- //launch {
- // delay(10000)
- // triggerNativeCrash()
- //}
-
- while (true) {
- delay(10_000)
- val signal = getSignalStatus()
- // Signal is initialized to zero
- if (signal == 0) continue
-
- // Do not crash in safe mode!
- if (lastError != null) continue
- if (checkSafeModeFile()) continue
-
- AcraApplication.exceptionHandler?.uncaughtException(
- Thread.currentThread(),
- RuntimeException("Native crash with code: $signal. Try uninstalling extensions.\n")
- )
- }
- }
-
- fun initCrashHandler() {
- try {
- System.loadLibrary("native-lib")
- initNativeCrashHandler()
- } catch (t: Throwable) {
- // Make debug crash.
- if (BuildConfig.DEBUG) throw t
- logError(t)
- return
- }
-
- initSignalPolling()
- }*/
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt b/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt
deleted file mode 100644
index 469554275..000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt
+++ /dev/null
@@ -1,88 +0,0 @@
-package com.lagradost.cloudstream3
-
-import com.lagradost.cloudstream3.mvvm.logError
-import kotlinx.coroutines.*
-
-//https://stackoverflow.com/questions/34697828/parallel-operations-on-kotlin-collections
-/*
-fun Iterable.pmap(
- numThreads: Int = maxOf(Runtime.getRuntime().availableProcessors() - 2, 1),
- exec: ExecutorService = Executors.newFixedThreadPool(numThreads),
- transform: (T) -> R,
-): List {
-
- // default size is just an inlined version of kotlin.collections.collectionSizeOrDefault
- val defaultSize = if (this is Collection<*>) this.size else 10
- val destination = Collections.synchronizedList(ArrayList(defaultSize))
-
- for (item in this) {
- exec.submit { destination.add(transform(item)) }
- }
-
- exec.shutdown()
- exec.awaitTermination(1, TimeUnit.DAYS)
-
- return ArrayList(destination)
-}*/
-
-
-@OptIn(DelicateCoroutinesApi::class)
-suspend fun Map.amap(f: suspend (Map.Entry) -> R): List =
- with(CoroutineScope(GlobalScope.coroutineContext)) {
- map { async { f(it) } }.map { it.await() }
- }
-
-fun Map.apmap(f: suspend (Map.Entry) -> R): List = runBlocking {
- map { async { f(it) } }.map { it.await() }
-}
-
-
-@OptIn(DelicateCoroutinesApi::class)
-suspend fun List.amap(f: suspend (A) -> B): List =
- with(CoroutineScope(GlobalScope.coroutineContext)) {
- map { async { f(it) } }.map { it.await() }
- }
-
-
-fun List.apmap(f: suspend (A) -> B): List = runBlocking {
- map { async { f(it) } }.map { it.await() }
-}
-
-fun List.apmapIndexed(f: suspend (index: Int, A) -> B): List = runBlocking {
- mapIndexed { index, a -> async { f(index, a) } }.map { it.await() }
-}
-
-@OptIn(DelicateCoroutinesApi::class)
-suspend fun List.amapIndexed(f: suspend (index: Int, A) -> B): List =
- with(CoroutineScope(GlobalScope.coroutineContext)) {
- mapIndexed { index, a -> async { f(index, a) } }.map { it.await() }
- }
-
-// run code in parallel
-/*fun argpmap(
- vararg transforms: () -> R,
- numThreads: Int = maxOf(Runtime.getRuntime().availableProcessors() - 2, 1),
- exec: ExecutorService = Executors.newFixedThreadPool(numThreads)
-) {
- for (item in transforms) {
- exec.submit { item.invoke() }
- }
-
- exec.shutdown()
- exec.awaitTermination(1, TimeUnit.DAYS)
-}*/
-
-// built in try catch
-fun argamap(
- vararg transforms: suspend () -> R,
-) = runBlocking {
- transforms.map {
- async {
- try {
- it.invoke()
- } catch (e: Exception) {
- logError(e)
- }
- }
- }.map { it.await() }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt
new file mode 100644
index 000000000..a3c4040b5
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt
@@ -0,0 +1,26 @@
+package com.lagradost.cloudstream3.actions
+
+import android.content.Context
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.txt
+
+class AlwaysAskAction : VideoClickAction() {
+ override val name = txt(R.string.player_settings_always_ask)
+ override val isPlayer = true
+
+ // Only show in settings, not on a video
+ override fun shouldShow(context: Context?, video: ResultEpisode?): Boolean = video == null
+
+ override suspend fun runAction(
+ context: Context?,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ // This is handled specially in ResultViewModel2.kt by detecting the AlwaysAskAction
+ // and showing the player selection dialog instead of executing the action directly
+ throw NotImplementedError("AlwaysAskAction is handled specially by the calling code")
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt
new file mode 100644
index 000000000..ac912cbeb
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt
@@ -0,0 +1,135 @@
+package com.lagradost.cloudstream3.actions
+
+import android.app.Activity
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.ui.result.ResultFragment
+import com.lagradost.cloudstream3.utils.UiText
+import com.lagradost.cloudstream3.utils.txt
+import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import java.io.File
+
+fun updateDurationAndPosition(position: Long, duration: Long) {
+ if (position <= 0 || duration <= 0) return
+ val episode = getKey("last_opened") ?: return
+ DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
+ ResultFragment.updateUI()
+}
+
+/**
+ * Util method that may be helpful for creating intents for apps that support m3u8 files.
+ * All sources are written to a temporary m3u8 file, which is then sent to the app.
+ */
+fun makeTempM3U8Intent(
+ context: Context,
+ intent: Intent,
+ result: LinkLoadingResult
+) {
+ if (result.links.size == 1) {
+ intent.setDataAndType(result.links.first().url.toUri(), "video/*")
+ return
+ }
+
+ intent.apply {
+ addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
+ addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ }
+
+ val outputFile = File.createTempFile("mirrorlist", ".m3u8", context.cacheDir)
+ var text = "#EXTM3U\n#EXT-X-VERSION:3"
+
+ result.links.forEach { link ->
+ text += "\n#EXTINF:0,${link.name}\n${link.url}"
+ }
+
+ //With subtitles it doesn't work for no reason :(
+ /*for (sub in result.subs) {
+ val normalizedName = sub.name.replace("[^a-zA-Z0-9 ]".toRegex(), "")
+ text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${normalizedName}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.languageCode}\",URI=\"${sub.url}\""
+ }*/
+
+ text += "\n#EXT-X-ENDLIST"
+ outputFile.writeText(text)
+
+ intent.setDataAndType(
+ FileProvider.getUriForFile(
+ context,
+ context.applicationContext.packageName + ".provider",
+ outputFile
+ ), "application/x-mpegURL"
+ )
+}
+
+abstract class OpenInAppAction(
+ open val appName: UiText,
+ open val packageName: String,
+ private val intentClass: String? = null,
+ private val action: String = Intent.ACTION_VIEW
+) : VideoClickAction() {
+ override val name: UiText
+ get() = txt(R.string.episode_action_play_in_format, appName)
+
+ override val isPlayer = true
+
+ override fun shouldShow(context: Context?, video: ResultEpisode?) =
+ context?.isAppInstalled(packageName) != false
+
+ override suspend fun runAction(
+ context: Context?,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ if (context == null) return
+ val intent = Intent(action)
+ intent.setPackage(packageName)
+ if (intentClass != null) {
+ intent.component = ComponentName(packageName, intentClass)
+ }
+ putExtra(context, intent, video, result, index)
+ setKey("last_opened", video)
+ launchResult(intent)
+ }
+
+ /**
+ * Before intent is sent, this function is called to put extra data into the intent.
+ * @see VideoClickAction.runAction
+ * */
+ @Throws
+ abstract suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ )
+
+ /**
+ * This function is called when the app is opened again after the intent was sent.
+ * You can use it to for example update duration and position.
+ * @see updateDurationAndPosition
+ */
+ @Throws
+ abstract fun onResult(activity: Activity, intent: Intent?)
+
+ /** Safe version of onResult, we don't trust extension devs to not crash the app */
+ fun onResultSafe(activity: Activity, intent: Intent?) {
+ try {
+ onResult(activity, intent)
+ } catch (t: Throwable) {
+ logError(t)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt
new file mode 100644
index 000000000..a864b5fb7
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt
@@ -0,0 +1,205 @@
+package com.lagradost.cloudstream3.actions
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.core.app.ActivityOptionsCompat
+import com.lagradost.api.Log
+import com.lagradost.cloudstream3.CommonActivity
+import com.lagradost.cloudstream3.ErrorLoadingException
+import com.lagradost.cloudstream3.MainActivity
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage
+import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction
+import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage
+import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage
+import com.lagradost.cloudstream3.actions.temp.MpvExPackage
+import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
+import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
+import com.lagradost.cloudstream3.actions.temp.MpvPackage
+import com.lagradost.cloudstream3.actions.temp.MpvRxPackage
+import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
+import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
+import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
+import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
+import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
+import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
+import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage
+import com.lagradost.cloudstream3.actions.temp.VlcPackage
+import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage
+import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
+import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.UiText
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.util.concurrent.Callable
+import java.util.concurrent.FutureTask
+import kotlin.reflect.jvm.jvmName
+
+object VideoClickActionHolder {
+ val allVideoClickActions = atomicListOf(
+ // Default
+ PlayInBrowserAction(),
+ CopyClipboardAction(),
+ ViewM3U8Action(),
+ PlayMirrorAction(),
+ // main support external apps
+ VlcPackage(),
+ MpvPackage(),
+ MpvExPackage(),
+ NextPlayerPackage(),
+ JustPlayerPackage(),
+ FcastAction(),
+ LibreTorrentPackage(),
+ BiglyBTPackage(),
+ // forks/backup apps
+ VlcNightlyPackage(),
+ WebVideoCastPackage(),
+ MpvYTDLPackage(),
+ MpvKtPackage(),
+ MpvKtPreviewPackage(),
+ OnlyPlayer(),
+ MpvRxPackage(),
+ // Always Ask option
+ AlwaysAskAction(),
+ // added by plugins
+ // ...
+ )
+
+ init {
+ Log.d("VideoClickActionHolder", "allVideoClickActions: ${allVideoClickActions.map { it.uniqueId() }}")
+ }
+
+ private const val ACTION_ID_OFFSET = 1000
+
+ fun makeOptionMap(activity: Activity?, video: ResultEpisode) = allVideoClickActions
+ // We need to have index before filtering
+ .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET }
+ .filter { it.first.shouldShowSafe(activity, video) }
+ .map { it.first.name to it.second }
+
+
+ fun getActionById(id: Int): VideoClickAction? = allVideoClickActions.getOrNull(id - ACTION_ID_OFFSET)
+
+ fun getByUniqueId(uniqueId: String): VideoClickAction? = allVideoClickActions.firstOrNull { it.uniqueId() == uniqueId }
+
+ fun uniqueIdToId(uniqueId: String?): Int? {
+ if (uniqueId == null) return null
+ return allVideoClickActions
+ .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET }
+ .firstOrNull { it.first.uniqueId() == uniqueId }
+ ?.second
+ }
+
+ fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShowSafe(activity, null) }
+}
+
+abstract class VideoClickAction {
+ abstract val name: UiText
+
+ /** if true, the app will show dialog to select source - result.links[index] */
+ open val oneSource : Boolean = false
+
+ /** if true, this action could be selected as default player (one press action) in settings */
+ open val isPlayer: Boolean = false
+
+ /** Which type of sources this action can handle. */
+ open val sourceTypes: Set = ExtractorLinkType.entries.toSet()
+
+ /** Determines which plugin a given provider is from. This is the full path to the plugin. */
+ var sourcePlugin: String? = null
+
+ /** Even if VideoClickAction should not run any UI code, startActivity requires it,
+ * this is a wrapper for runOnUiThread in a suspended safe context that bubble up exceptions */
+ @Throws
+ suspend fun uiThread(callable : Callable) : T? {
+ val future = FutureTask{
+ try {
+ Result.success(callable.call())
+ } catch (t : Throwable) {
+ Result.failure(t)
+ }
+ }
+ CommonActivity.activity?.runOnUiThread(future) ?: throw ErrorLoadingException("No UI Activity, this should never happened")
+ val result = withContext(Dispatchers.IO) {
+ return@withContext future.get()
+ }
+ return result.getOrThrow()
+ }
+
+ /** Internally uses activityResultLauncher,
+ * use this when the activity has a result like watched position */
+ @Throws
+ suspend fun launchResult(intent : Intent?, options : ActivityOptionsCompat? = null) {
+ if (intent == null) {
+ return
+ }
+
+ uiThread {
+ MainActivity.activityResultLauncher?.launch(intent,options)
+ }
+ }
+
+ /** Internally uses startActivity, use this when you don't
+ * have any result that needs to be stored when exiting the activity */
+ @Throws
+ suspend fun launch(intent : Intent?, bundle : Bundle? = null) {
+ if (intent == null) {
+ return
+ }
+
+ uiThread {
+ CommonActivity.activity?.startActivity(intent, bundle)
+ }
+ }
+
+ fun uniqueId() = "$sourcePlugin:${this::class.jvmName}"
+
+ @Throws
+ abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean
+
+ /** Safe version of shouldShow, as we don't trust extension devs to handle exceptions,
+ * however no dev *should* throw in shouldShow */
+ fun shouldShowSafe(context: Context?, video: ResultEpisode?): Boolean {
+ return try {
+ shouldShow(context,video)
+ } catch (t : Throwable) {
+ logError(t)
+ false
+ }
+ }
+
+ /**
+ * This function is called when the action is clicked.
+ * @param context The current activity
+ * @param video The episode/movie that was clicked
+ * @param result The result of the link loading, contains video & subtitle links
+ * @param index if oneSource is true, this is the index of the selected source
+ */
+ @Throws
+ abstract suspend fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?)
+
+ /** Safe version of runAction, as we don't trust extension devs to handle exceptions */
+ fun runActionSafe(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) = ioSafe {
+ try {
+ runAction(context, video, result, index)
+ } catch (_ : NotImplementedError) {
+ CommonActivity.showToast("runAction has not been implemented for ${name.asStringNull(context)}, please contact the extension developer of $sourcePlugin", Toast.LENGTH_LONG)
+ } catch (error : ErrorLoadingException) {
+ CommonActivity.showToast(error.message, Toast.LENGTH_LONG)
+ } catch (_: ActivityNotFoundException) {
+ CommonActivity.showToast(R.string.app_not_found_error, Toast.LENGTH_LONG)
+ } catch (t : Throwable) {
+ logError(t)
+ CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG)
+ }
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt
new file mode 100644
index 000000000..a7401c2ff
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt
@@ -0,0 +1,30 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.txt
+
+/** https://github.com/devgianlu/Aria2Android */
+@Suppress("unused")
+class Aria2Package : OpenInAppAction(
+ appName = txt("Aria2"),
+ packageName = "com.gianlu.aria2android",
+ intentClass = "com.gianlu.aria2android.MainActivity"
+) {
+ override val oneSource: Boolean = true
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ throw NotImplementedError("Aria2Android is missing getIntent, and onNewIntent, meaning it cant handle intents")
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) = Unit
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt
new file mode 100644
index 000000000..3959bb9d3
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt
@@ -0,0 +1,36 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.txt
+
+/** https://github.com/BiglySoftware/BiglyBT-Android */
+class BiglyBTPackage : OpenInAppAction(
+ appName = txt("BiglyBT"),
+ packageName = "com.biglybt.android.client",
+ intentClass = "com.biglybt.android.client.activity.IntentHandler"
+) {
+ // Only torrents are supported by the app
+ override val sourceTypes: Set =
+ setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT)
+
+ override val oneSource: Boolean = true
+
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ intent.data = result.links[index!!].url.toUri()
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) = Unit
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt
new file mode 100644
index 000000000..d414b6117
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt
@@ -0,0 +1,162 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.BuildConfig
+import com.lagradost.cloudstream3.ui.player.ExtractorUri
+import com.lagradost.cloudstream3.ui.player.SubtitleData
+import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
+import com.lagradost.cloudstream3.utils.DrmExtractorLink
+import com.lagradost.cloudstream3.utils.ExtractorLink
+import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.newExtractorLink
+import com.lagradost.cloudstream3.utils.Qualities
+import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
+import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
+import com.lagradost.cloudstream3.utils.txt
+
+/**
+ * If you want to support CloudStream 3 as an external player, then this shows how to play any video link
+ * For basic interactions, just `intent.data = uri` works
+ *
+ * However for more advanced use, CloudStream 3 also supports playlists of MinimalVideoLink and MinimalSubtitleLink with a `String[]` of JSON
+ * These are passed as LINKS_EXTRA and SUBTITLE_EXTRA respectively
+ */
+@Suppress("Unused")
+class CloudStreamPackage : OpenInAppAction(
+ appName = txt("CloudStream"),
+ packageName = BuildConfig.APPLICATION_ID, //"com.lagradost.cloudstream3" or "com.lagradost.cloudstream3.prerelease"
+ intentClass = "com.lagradost.cloudstream3.ui.player.DownloadedPlayerActivity"
+) {
+ override val oneSource: Boolean = false
+
+ companion object {
+ const val SUBTITLE_EXTRA: String = "subs" // Json of an array of MinimalVideoLink
+ const val LINKS_EXTRA: String = "links" // Json of an array of MinimalSubtitleLink
+ const val TITLE_EXTRA: String = "title" // Unused (String)
+ const val ID_EXTRA: String =
+ "id" // Identification number for the video(s), used to store start time (Int)
+ const val POSITION_EXTRA: String = "pos" // Start time in MS (Long)
+ const val DURATION_EXTRA: String = "dur" // Duration time in MS (Long)
+ }
+
+ data class MinimalVideoLink(
+ @JsonProperty("uri")
+ val uri: Uri?,
+ @JsonProperty("url")
+ val url: String?,
+ @JsonProperty("mimeType")
+ val mimeType: String = "video/mp4",
+ @JsonProperty("name")
+ val name: String?,
+ @JsonProperty("headers")
+ var headers: Map = mapOf(),
+ @JsonProperty("quality")
+ val quality: Int?,
+ ) {
+ companion object {
+ fun fromExtractor(link: ExtractorLink): MinimalVideoLink = MinimalVideoLink(
+ uri = null,
+ url = link.url,
+ name = link.name,
+ mimeType = link.type.getMimeType(),
+ headers = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) + link.headers,
+ quality = link.quality
+ )
+ }
+
+ suspend fun toExtractorLink(): Pair =
+ url?.let { url ->
+ newExtractorLink(
+ source = "NONE",
+ name = name ?: "Unknown",
+ url = url,
+ type = ExtractorLinkType.entries.firstOrNull { ty -> ty.getMimeType() == mimeType }
+ ?: ExtractorLinkType.VIDEO) {
+
+ this@newExtractorLink.headers =
+ this@MinimalVideoLink.headers
+
+ this@newExtractorLink.quality =
+ this@MinimalVideoLink.quality ?: Qualities.Unknown.value
+ }
+ } to uri?.let { uri ->
+ ExtractorUri(
+ uri = uri,
+ name = name ?: "Unknown",
+ )
+ }
+ }
+
+
+ data class MinimalSubtitleLink(
+ @JsonProperty("url")
+ val url: String,
+ @JsonProperty("mimeType")
+ val mimeType: String = "text/vtt",
+ @JsonProperty("name")
+ val name: String?,
+ @JsonProperty("headers")
+ var headers: Map = mapOf(),
+ ) {
+ companion object {
+ fun fromSubtitle(sub: SubtitleData): MinimalSubtitleLink = MinimalSubtitleLink(
+ url = sub.url,
+ mimeType = sub.mimeType,
+ name = sub.originalName,
+ headers = sub.headers,
+ )
+ }
+
+ fun toSubtitleData(): SubtitleData = SubtitleData(
+ url = url,
+ nameSuffix = "",
+ mimeType = mimeType,
+ originalName = name ?: "Unknown",
+ headers = headers,
+ origin = SubtitleOrigin.URL,
+ languageCode = fromCodeToLangTagIETF(name) ?:
+ fromLanguageToTagIETF(name, true) ?:
+ name,
+ )
+ }
+
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ intent.apply {
+ val position = getViewPos(video.id)?.position
+ if (position != null)
+ putExtra(POSITION_EXTRA, position)
+
+ putExtra(ID_EXTRA, video.id)
+ putExtra(TITLE_EXTRA, video.name)
+ putExtra(
+ SUBTITLE_EXTRA,
+ result.subs.map { MinimalSubtitleLink.fromSubtitle(it).toJson() }.toTypedArray()
+ )
+ putExtra(
+ LINKS_EXTRA,
+ result.links.filter { it !is ExtractorLinkPlayList && it !is DrmExtractorLink }
+ .map { MinimalVideoLink.fromExtractor(it).toJson() }.toTypedArray()
+ )
+ }
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) {
+ // No results yet
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt
new file mode 100644
index 000000000..7e89d7c8c
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt
@@ -0,0 +1,27 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.content.Context
+import com.lagradost.cloudstream3.actions.VideoClickAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.txt
+import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
+
+class CopyClipboardAction: VideoClickAction() {
+ override val name = txt("Copy to clipboard")
+
+ override val oneSource = true
+
+ override fun shouldShow(context: Context?, video: ResultEpisode?) = true
+
+ override suspend fun runAction(
+ context: Context?,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ if (index == null) return
+ val link = result.links.getOrNull(index) ?: return
+ clipboardHelper(txt(link.name), link.url)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt
new file mode 100644
index 000000000..20eb843c7
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt
@@ -0,0 +1,37 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.txt
+
+/** https://github.com/moneytoo/Player/ */
+class JustPlayerPackage : OpenInAppAction(
+ appName = txt("JustPlayer"),
+ packageName = "com.brouken.player",
+ intentClass = "com.brouken.player.PlayerActivity"
+) {
+ override val sourceTypes: Set =
+ setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH)
+
+ override val oneSource: Boolean = true
+
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ // While JustPlayer has support for subs, it cant add both subs and links at the same time
+ // See https://github.com/moneytoo/Player/blob/49d80eb8de7a7bfc662393fdf114788fed1ebb2e/app/src/main/java/com/brouken/player/PlayerActivity.java#L794
+ intent.data = result.links[index!!].url.toUri()
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) = Unit
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt
new file mode 100644
index 000000000..11d1858c6
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt
@@ -0,0 +1,36 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.txt
+
+/** https://github.com/proninyaroslav/libretorrent */
+class LibreTorrentPackage : OpenInAppAction(
+ appName = txt("LibreTorrent"),
+ packageName = "org.proninyaroslav.libretorrent",
+ intentClass = "org.proninyaroslav.libretorrent.ui.addtorrent.AddTorrentActivity"
+) {
+ // Only torrents are supported by the app
+ override val sourceTypes: Set =
+ setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT)
+
+ override val oneSource: Boolean = true
+
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ intent.data = result.links[index!!].url.toUri()
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) = Unit
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt
new file mode 100644
index 000000000..faae39212
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt
@@ -0,0 +1,68 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.actions.updateDurationAndPosition
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.txt
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+
+class MpvKtPreviewPackage: MpvKtPackage(
+ appName = "mpvKt Preview",
+ packageName = "live.mehiz.mpvkt.preview",
+)
+
+open class MpvKtPackage(
+ appName: String = "mpvKt",
+ packageName: String = "live.mehiz.mpvkt",
+): OpenInAppAction(
+ appName = txt(appName),
+ packageName = packageName,
+ intentClass = "live.mehiz.mpvkt.ui.player.PlayerActivity"
+) {
+ override val oneSource = true
+
+ override val sourceTypes = setOf(
+ ExtractorLinkType.VIDEO,
+ ExtractorLinkType.DASH,
+ ExtractorLinkType.M3U8
+ )
+
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ val link = result.links.getOrNull(index ?: 0) ?: return
+
+ intent.apply {
+ putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
+ setDataAndType(link.url.toUri(), "video/*")
+
+ // m3u8 plays, but changing sources feature is not available
+ // makeTempM3U8Intent(activity, this, result)
+
+ //putExtra("headers", link.headers.flatMap { listOf(it.key, it.value) }.toTypedArray())
+
+ val position = getViewPos(video.id)?.position
+ if (position != null)
+ putExtra("position", position.toInt())
+
+ putExtra("secure_uri", true)
+ }
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) {
+ val position = intent?.getIntExtra("position", -1)?.toLong() ?: -1
+ val duration = intent?.getIntExtra("duration", -1)?.toLong() ?: -1
+ updateDurationAndPosition(position, duration)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt
new file mode 100644
index 000000000..cd49eb994
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt
@@ -0,0 +1,68 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.lagradost.api.Log
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
+import com.lagradost.cloudstream3.actions.updateDurationAndPosition
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.txt
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+
+// https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904
+// https://mpv-android.github.io/mpv-android/intent.html
+
+//https://github.com/marlboro-advance/mpvEx
+class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity")
+
+class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
+ override val sourceTypes = setOf(
+ ExtractorLinkType.VIDEO,
+ ExtractorLinkType.DASH,
+ ExtractorLinkType.M3U8
+ )
+}
+
+open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction(
+ txt(appName),
+ packageName,
+ intentClass
+) {
+ override val oneSource = true // mpv has poor playlist support on TV
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ intent.apply {
+ putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
+ putExtra("title", video.name)
+
+ if (index != null) {
+ setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*")
+ } else {
+ makeTempM3U8Intent(context, this, result)
+ }
+
+ val position = getViewPos(video.id)?.position
+ if (position != null)
+ putExtra("position", position.toInt())
+
+ putExtra("secure_uri", true)
+ }
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) {
+ val position = intent?.getIntExtra("position", -1) ?: -1
+ val duration = intent?.getIntExtra("duration", -1) ?: -1
+ Log.d("MPV", "Position: $position, Duration: $duration")
+ updateDurationAndPosition(position.toLong(), duration.toLong())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt
new file mode 100644
index 000000000..e8bb93a99
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvRxPackage.kt
@@ -0,0 +1,75 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.lagradost.api.Log
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.actions.updateDurationAndPosition
+import com.lagradost.cloudstream3.isEpisodeBased
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
+import com.lagradost.cloudstream3.utils.txt
+
+/** https://github.com/Riteshp2001/mpvRx
+ *
+ * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132
+ * https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56
+ * */
+class MpvRxPackage : OpenInAppAction(
+ appName = txt("mpvRx"),
+ packageName = "app.gyrolet.mpvrx",
+ intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity"
+) {
+ override val oneSource = true
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ intent.apply {
+ putExtra("title", video.name)
+ val link = result.links[index!!]
+ val headers = link.headers
+
+ setData(link.url.toUri())
+ if (headers.isNotEmpty()) {
+ // PlayerActivity expects a flat array: [key1, value1, key2, value2, ...]
+ val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray()
+ intent.putExtra("headers", flat)
+ }
+ /*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146
+ intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray())
+ intent.putExtra(
+ "subs.titles",
+ subs.map { it.name }.toTypedArray(),
+ )
+ intent.putExtra(
+ "subs.langs",
+ subs.map { it.languageCode }.toTypedArray(),
+ )
+ val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri()
+ intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf() )*/
+
+ if (video.tvType.isEpisodeBased()) {
+ video.season?.let { intent.putExtra("introdb_season", it) }
+ video.episode.let { intent.putExtra("introdb_episode", it) }
+ }
+
+ val position = getViewPos(video.id)?.position
+ if (position != null)
+ putExtra("position", position.toInt())
+ }
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) {
+ val position = intent?.getIntExtra("position", -1) ?: -1
+ val duration = intent?.getIntExtra("duration", -1) ?: -1
+ Log.d("MPV", "Position: $position, Duration: $duration")
+ updateDurationAndPosition(position.toLong(), duration.toLong())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt
new file mode 100644
index 000000000..5d0923b81
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt
@@ -0,0 +1,35 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.txt
+
+/** https://github.com/anilbeesetti/nextplayer */
+class NextPlayerPackage : OpenInAppAction(
+ appName = txt("NextPlayer"),
+ packageName = "dev.anilbeesetti.nextplayer",
+ intentClass = "dev.anilbeesetti.nextplayer.feature.player.PlayerActivity"
+) {
+ override val sourceTypes: Set =
+ setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH)
+
+ override val oneSource: Boolean = true
+
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ intent.data = result.links[index!!].url.toUri()
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) = Unit
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt
new file mode 100644
index 000000000..348be440a
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/OnlyPlayer.kt
@@ -0,0 +1,44 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.txt
+
+/** https://github.com/Kindness-Kismet/only_player/tree/main
+ * https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */
+class OnlyPlayer : OpenInAppAction(
+ txt("Only Player"),
+ "one.only.player",
+ intentClass = "one.only.player.feature.player.PlayerActivity"
+) {
+ override val oneSource = true
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ /** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */
+ intent.apply {
+ val link = result.links[index!!]
+ setData(link.url.toUri())
+
+ putExtra("headers", Bundle().apply {
+ for ((key, value) in link.headers) {
+ putExtra(key, value)
+ }
+ })
+ }
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) {
+ /* onResult does not get called */
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt
new file mode 100644
index 000000000..bfd2926bf
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt
@@ -0,0 +1,39 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.actions.VideoClickAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.txt
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+
+class PlayInBrowserAction: VideoClickAction() {
+ override val name = txt(R.string.episode_action_play_in_format, "Browser")
+
+ override val oneSource = true
+
+ override val isPlayer = true
+
+ override val sourceTypes: Set = setOf(
+ ExtractorLinkType.VIDEO,
+ ExtractorLinkType.DASH,
+ ExtractorLinkType.M3U8
+ )
+
+ override fun shouldShow(context: Context?, video: ResultEpisode?) = true
+
+ override suspend fun runAction(
+ context: Context?,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ val link = result.links.getOrNull(index ?: 0) ?: return
+ val i = Intent(Intent.ACTION_VIEW)
+ i.data = link.url.toUri()
+ launch(i)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt
new file mode 100644
index 000000000..56512377b
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt
@@ -0,0 +1,65 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.actions.VideoClickAction
+import com.lagradost.cloudstream3.ui.player.ExtractorUri
+import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
+import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP
+import com.lagradost.cloudstream3.ui.player.SubtitleData
+import com.lagradost.cloudstream3.ui.player.VideoGenerator
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.ExtractorLink
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.UIHelper.navigate
+import com.lagradost.cloudstream3.utils.txt
+
+class PlayMirrorAction : VideoClickAction() {
+ override val name = txt(R.string.episode_action_play_mirror)
+
+ override val oneSource = true
+
+ override val isPlayer = true
+
+ override val sourceTypes: Set = LOADTYPE_INAPP
+
+ override fun shouldShow(context: Context?, video: ResultEpisode?) = true
+
+ override suspend fun runAction(
+ context: Context?,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ //Implemented a generator to handle the single
+ val activity = context as? Activity ?: return
+ val link = index?.let { result.links[it] }
+ val generatorMirror = object : VideoGenerator