diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml
index 931db3bd9bf..f3590067312 100644
--- a/.github/ISSUE_TEMPLATE/application-bug.yml
+++ b/.github/ISSUE_TEMPLATE/application-bug.yml
@@ -80,13 +80,13 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
+ - label: I am sure my issue is related to the app and **NOT some extension**.
+ required: true
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**.
required: true
- - label: If related to a provider, I have checked the site and it works, but not the app.
- required: true
- label: I will fill out all of the requested information in this form.
required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 250734cddb4..b56cdf8ed07 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -2,7 +2,7 @@ blank_issues_enabled: false
contact_links:
- name: Request a new provider or report bug with an existing provider
url: https://github.com/recloudstream
- about: Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
+ about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
- name: Discord
url: https://discord.gg/5Hus6fM
about: Join our discord for faster support on smaller issues.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
index 9c35ba56fc3..e18daebb30c 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -27,9 +27,7 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
- - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
- required: true
- - label: I have written a short but informative title.
- required: true
- - label: I will fill out all of the requested information in this form.
+ - label: My suggestion is **NOT** about adding a new provider
required: true
+ - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
+ required: true
\ No newline at end of file
diff --git a/.github/downloads.jpg b/.github/downloads.jpg
deleted file mode 100644
index ca14a664a2c..00000000000
Binary files a/.github/downloads.jpg and /dev/null differ
diff --git a/.github/home.jpg b/.github/home.jpg
deleted file mode 100644
index 72370d3c98b..00000000000
Binary files a/.github/home.jpg and /dev/null differ
diff --git a/.github/locales.py b/.github/locales.py
new file mode 100644
index 00000000000..6127d9d806e
--- /dev/null
+++ b/.github/locales.py
@@ -0,0 +1,65 @@
+import re
+import glob
+import requests
+import lxml.etree as ET # builtin library doesn't preserve comments
+
+
+SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
+START_MARKER = "/* begin language list */"
+END_MARKER = "/* end language list */"
+XML_NAME = "app/src/main/res/values-b+"
+ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
+INDENT = " "*4
+
+iso_map = requests.get(ISO_MAP_URL, timeout=300).json()
+
+# Load settings file
+src = open(SETTINGS_PATH, "r", encoding='utf-8').read()
+before_src, rest = src.split(START_MARKER)
+rest, after_src = rest.split(END_MARKER)
+
+# Load already added langs
+languages = {}
+for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest):
+ name, iso = lang.groups()
+ languages[iso] = name
+
+# Add not yet added langs
+for folder in glob.glob(f"{XML_NAME}*"):
+ iso = folder[len(XML_NAME):].replace("+", "-")
+ if iso not in languages.keys():
+ entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found
+ languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple
+
+# Create pairs
+pairs = []
+for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name
+ name = languages[iso]
+ pairs.append(f'{INDENT}Pair("{name}", "{iso}"),')
+
+# Update settings file
+open(SETTINGS_PATH, "w+",encoding='utf-8').write(
+ before_src +
+ START_MARKER +
+ "\n" +
+ "\n".join(pairs) +
+ "\n" +
+ END_MARKER +
+ after_src
+)
+
+# Go through each values.xml file and fix escaped \@string
+for file in glob.glob(f"{XML_NAME}*/strings.xml"):
+ try:
+ tree = ET.parse(file)
+ for child in tree.getroot():
+ if not child.text:
+ continue
+ if child.text.startswith("\\@string/"):
+ print(f"[{file}] fixing {child.attrib['name']}")
+ child.text = child.text.replace("\\@string/", "@string/")
+ with open(file, 'wb') as fp:
+ fp.write(b'\n')
+ tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
+ except ET.ParseError as ex:
+ print(f"[{file}] {ex}")
diff --git a/.github/player.jpg b/.github/player.jpg
deleted file mode 100644
index f6959cf31d2..00000000000
Binary files a/.github/player.jpg and /dev/null differ
diff --git a/.github/results.jpg b/.github/results.jpg
deleted file mode 100644
index 4dbc9b8d48a..00000000000
Binary files a/.github/results.jpg and /dev/null differ
diff --git a/.github/search.jpg b/.github/search.jpg
deleted file mode 100644
index 784bec89255..00000000000
Binary files a/.github/search.jpg and /dev/null differ
diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml
index 83430766507..b5960d5d942 100644
--- a/.github/workflows/build_to_archive.yml
+++ b/.github/workflows/build_to_archive.yml
@@ -1,76 +1,92 @@
-name: Archive build
-
-on:
- push:
- branches: [ master ]
- paths-ignore:
- - '*.md'
- - '*.json'
- - '**/wcokey.txt'
- workflow_dispatch:
-
-concurrency:
- group: "Archive-build"
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - name: Generate access token
- id: generate_token
- uses: tibdex/github-app-token@v1
- with:
- app_id: ${{ secrets.GH_APP_ID }}
- private_key: ${{ secrets.GH_APP_KEY }}
- repository: "recloudstream/secrets"
- - name: Generate access token (archive)
- id: generate_archive_token
- uses: tibdex/github-app-token@v1
- with:
- app_id: ${{ secrets.GH_APP_ID }}
- private_key: ${{ secrets.GH_APP_KEY }}
- repository: "recloudstream/cloudstream-archive"
- - uses: actions/checkout@v2
- - name: Set up JDK 11
- uses: actions/setup-java@v2
- with:
- java-version: '11'
- distribution: 'adopt'
- - name: Grant execute permission for gradlew
- run: chmod +x gradlew
- - name: Fetch keystore
- id: fetch_keystore
- run: |
- TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore
- mkdir -p "${TMP_KEYSTORE_FILE_PATH}"
- curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks"
- curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt"
- KEY_PWD="$(cat keystore_password.txt)"
- echo "::add-mask::${KEY_PWD}"
- echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
- - name: Run Gradle
- run: |
- ./gradlew assemblePrerelease
- env:
- SIGNING_KEY_ALIAS: "key0"
- SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
- SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
- - uses: actions/checkout@v3
- with:
- repository: "recloudstream/cloudstream-archive"
- token: ${{ steps.generate_archive_token.outputs.token }}
- path: "archive"
-
- - name: Move build
- run: |
- cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
-
- - name: Push archive
- run: |
- cd $GITHUB_WORKSPACE/archive
- git config --local user.email "actions@github.com"
- git config --local user.name "GitHub Actions"
- git add .
- git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit
- git push --force
\ No newline at end of file
+name: Archive build
+
+on:
+ push:
+ branches: [ master ]
+ paths-ignore:
+ - '*.md'
+ - '*.json'
+ - '**/wcokey.txt'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: "Archive-build"
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate access token
+ id: generate_token
+ uses: tibdex/github-app-token@v2
+ with:
+ app_id: ${{ secrets.GH_APP_ID }}
+ private_key: ${{ secrets.GH_APP_KEY }}
+ repository: "recloudstream/secrets"
+
+ - name: Generate access token (archive)
+ id: generate_archive_token
+ uses: tibdex/github-app-token@v2
+ with:
+ app_id: ${{ secrets.GH_APP_ID }}
+ private_key: ${{ secrets.GH_APP_KEY }}
+ repository: "recloudstream/cloudstream-archive"
+
+ - uses: actions/checkout@v6
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 17
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Fetch keystore
+ id: fetch_keystore
+ run: |
+ TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore
+ mkdir -p "${TMP_KEYSTORE_FILE_PATH}"
+ curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks"
+ curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt"
+ KEY_PWD="$(cat keystore_password.txt)"
+ echo "::add-mask::${KEY_PWD}"
+ echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+
+ - name: Run Gradle
+ run: ./gradlew assemblePrereleaseRelease
+ env:
+ SIGNING_KEY_ALIAS: "key0"
+ SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
+ SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
+ SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
+ SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
+ MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
+
+ - uses: actions/checkout@v6
+ with:
+ repository: "recloudstream/cloudstream-archive"
+ token: ${{ steps.generate_archive_token.outputs.token }}
+ path: "archive"
+
+ - name: Move build
+ run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
+
+ - name: Push archive
+ run: |
+ cd $GITHUB_WORKSPACE/archive
+ git config --local user.email "actions@github.com"
+ git config --local user.name "GitHub Actions"
+ git add .
+ git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit
+ git push --force
diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml
index 3c5caad789f..d67b8a519d7 100644
--- a/.github/workflows/generate_dokka.yml
+++ b/.github/workflows/generate_dokka.yml
@@ -1,64 +1,67 @@
name: Dokka
-# https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency
-concurrency:
- group: "dokka"
- cancel-in-progress: true
-
on:
push:
- branches:
- # choose your default branch
- - master
- - main
+ branches: [ master ]
paths-ignore:
- '*.md'
+permissions:
+ contents: read
+
+concurrency:
+ group: "dokka"
+ cancel-in-progress: true
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/dokka"
+
- name: Checkout
- uses: actions/checkout@master
+ uses: actions/checkout@v6
with:
path: "src"
- name: Checkout dokka
- uses: actions/checkout@master
+ uses: actions/checkout@v6
with:
repository: "recloudstream/dokka"
path: "dokka"
token: ${{ steps.generate_token.outputs.token }}
-
+
- name: Clean old builds
run: |
cd $GITHUB_WORKSPACE/dokka/
- rm -rf "./-cloudstream"
+ rm -rf "./app"
+ rm -rf "./library"
- - name: Setup JDK 11
- uses: actions/setup-java@v1
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
with:
- java-version: 11
+ distribution: temurin
+ java-version: 17
- - name: Setup Android SDK
- uses: android-actions/setup-android@v2
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Generate Dokka
run: |
cd $GITHUB_WORKSPACE/src/
chmod +x gradlew
- ./gradlew app:dokkaHtml
+ ./gradlew docs:dokkaGeneratePublicationHtml
- name: Copy Dokka
- run: |
- cp -r $GITHUB_WORKSPACE/src/app/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
+ run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
- name: Push builds
run: |
diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml
index 28b737b3107..e354d657d50 100644
--- a/.github/workflows/issue_action.yml
+++ b/.github/workflows/issue_action.yml
@@ -4,17 +4,23 @@ on:
issues:
types: [opened]
+permissions:
+ contents: read
+ issues: write
+
jobs:
issue-moderator:
runs-on: ubuntu-latest
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
+
- name: Similarity analysis
+ id: similarity
uses: actions-cool/issues-similarity-analysis@v1
with:
token: ${{ steps.generate_token.outputs.token }}
@@ -24,7 +30,22 @@ jobs:
### Your issue looks similar to these issues:
Please close if duplicate.
comment-body: '${index}. ${similarity} #${number}'
- - uses: actions/checkout@v2
+
+ - name: Label if possible duplicate
+ if: steps.similarity.outputs.similar-issues-found =='true'
+ uses: actions/github-script@v9
+ with:
+ github-token: ${{ steps.generate_token.outputs.token }}
+ script: |
+ github.rest.issues.addLabels({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: ["possible duplicate"]
+ })
+
+ - uses: actions/checkout@v6
+
- name: Automatically close issues that dont follow the issue template
uses: lucasbento/auto-close-issues@v1.0.2
with:
@@ -33,6 +54,7 @@ jobs:
@${issue.user.login}: hello! :wave:
This issue is being automatically closed because it does not follow the issue template."
closed-issues-label: "invalid"
+
- name: Check if issue mentions a provider
id: provider_check
env:
@@ -42,6 +64,7 @@ jobs:
pip3 install httpx
RES="$(python3 ./check_issue.py)"
echo "name=${RES}" >> $GITHUB_OUTPUT
+
- name: Comment if issue mentions a provider
if: steps.provider_check.outputs.name != 'none'
uses: actions-cool/issues-helper@v3
@@ -53,11 +76,23 @@ jobs:
Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM).
Found provider name: `${{ steps.provider_check.outputs.name }}`
+
+ - name: Label if mentions provider
+ if: steps.provider_check.outputs.name != 'none'
+ uses: actions/github-script@v9
+ with:
+ github-token: ${{ steps.generate_token.outputs.token }}
+ script: |
+ github.rest.issues.addLabels({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ labels: ["possible provider issue"]
+ })
+
- name: Add eyes reaction to all issues
uses: actions-cool/emoji-helper@v1.0.0
with:
type: 'issue'
token: ${{ steps.generate_token.outputs.token }}
emoji: 'eyes'
-
-
diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
index 4ce7dba12d0..d9a20a04b2b 100644
--- a/.github/workflows/prerelease.yml
+++ b/.github/workflows/prerelease.yml
@@ -8,29 +8,36 @@ on:
- '*.json'
- '**/wcokey.txt'
-concurrency:
+concurrency:
group: "pre-release"
cancel-in-progress: true
+permissions:
+ contents: write
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- - uses: actions/checkout@v2
- - name: Set up JDK 11
- uses: actions/setup-java@v2
+
+ - uses: actions/checkout@v6
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
with:
- java-version: '11'
- distribution: 'adopt'
+ distribution: temurin
+ java-version: 17
+
- name: Grant execute permission for gradlew
run: chmod +x gradlew
+
- name: Fetch keystore
id: fetch_keystore
run: |
@@ -41,15 +48,24 @@ jobs:
KEY_PWD="$(cat keystore_password.txt)"
echo "::add-mask::${KEY_PWD}"
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+
- name: Run Gradle
- run: |
- ./gradlew assemblePrerelease makeJar androidSourcesJar
+ run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
+ SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
+ SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
+ MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
+
- name: Create pre-release
- uses: "marvinpinto/action-automatic-releases@latest"
+ uses: marvinpinto/action-automatic-releases@latest
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "pre-release"
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 36199cd60ee..675ce3b2f77 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -2,22 +2,35 @@ name: Artifact Build
on: [pull_request]
+permissions:
+ contents: read
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - name: Set up JDK 11
- uses: actions/setup-java@v2
+ - uses: actions/checkout@v6
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
with:
- java-version: '11'
- distribution: 'adopt'
+ distribution: temurin
+ java-version: 17
+
- name: Grant execute permission for gradlew
run: chmod +x gradlew
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v5
+ with:
+ cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache-read-only: false
+
- name: Run Gradle
- run: ./gradlew assemblePrereleaseDebug
+ run: ./gradlew assemblePrereleaseDebug lint
+
- name: Upload Artifact
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v7
with:
name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk"
diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml
new file mode 100644
index 00000000000..0a538d5d4da
--- /dev/null
+++ b/.github/workflows/update_locales.yml
@@ -0,0 +1,46 @@
+name: Fix locale issues
+
+on:
+ push:
+ branches: [ master ]
+ paths:
+ - '**.xml'
+ workflow_dispatch:
+
+concurrency:
+ group: "locale"
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ create:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Generate access token
+ id: generate_token
+ uses: tibdex/github-app-token@v2
+ with:
+ app_id: ${{ secrets.GH_APP_ID }}
+ private_key: ${{ secrets.GH_APP_KEY }}
+ repository: "recloudstream/cloudstream"
+
+ - uses: actions/checkout@v6
+ with:
+ token: ${{ steps.generate_token.outputs.token }}
+
+ - name: Install dependencies
+ run: pip3 install lxml requests
+
+ - name: Edit files
+ run: python3 .github/locales.py
+
+ - name: Commit to the repo
+ run: |
+ git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"
+ git config --local user.name "recloudstream[bot]"
+ git add .
+ # "echo" returns true so the build succeeds, even if no changed files
+ git commit -m 'chore(locales): fix locale issues' || echo
+ git push
diff --git a/.gitignore b/.gitignore
index 2ac6c9695ca..5fc9f0870b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,3 @@
-*.iml
-.gradle
/local.properties
/.idea/caches
/.idea/misc.xml
@@ -11,6 +9,220 @@
.DS_Store
/build
/captures
-.externalNativeBuild
.cxx
+.kotlin/*
+
+# Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode
+# Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode
+
+### Android ###
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
local.properties
+
+# Log/OS Files
+*.log
+
+# Android Studio generated files and folders
+captures/
+.externalNativeBuild/
+.cxx/
+*.apk
+output.json
+
+# IntelliJ
+*.iml
+.idea/
+misc.xml
+deploymentTargetDropDown.xml
+render.experimental.xml
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Android Profiling
+*.hprof
+
+### Android Patch ###
+gen-external-apklibs
+
+# Replacement of .externalNativeBuild directories introduced
+# with Android Studio 3.5.
+
+### Java ###
+# Compiled class file
+*.class
+
+# Log file
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+replay_pid*
+
+### Kotlin ###
+# Compiled class file
+
+# Log file
+
+# BlueJ files
+
+# Mobile Tools for Java (J2ME)
+
+# Package Files #
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+
+### VisualStudioCode ###
+.vscode/*
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+### VisualStudioCode Patch ###
+# Ignore all local history of files
+.history
+.ionide
+
+### AndroidStudio ###
+# Covers files to be ignored for android development using Android Studio.
+
+# Built application files
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle
+
+# Signing files
+.signing/
+
+# Local configuration file (sdk path, etc)
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+
+# Android Studio
+/*/build/
+/*/local.properties
+/*/out
+/*/*/build
+/*/*/production
+.navigation/
+*.ipr
+*~
+*.swp
+
+# Keystore files
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Android Patch
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+
+# NDK
+obj/
+
+# IntelliJ IDEA
+*.iws
+/out/
+
+# User-specific configurations
+.idea/caches/
+.idea/libraries/
+.idea/shelf/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/.name
+.idea/compiler.xml
+.idea/copyright/profiles_settings.xml
+.idea/encodings.xml
+.idea/misc.xml
+.idea/modules.xml
+.idea/scopes/scope_settings.xml
+.idea/dictionaries
+.idea/vcs.xml
+.idea/jsLibraryMappings.xml
+.idea/datasources.xml
+.idea/dataSources.ids
+.idea/sqlDataSources.xml
+.idea/dynamic.xml
+.idea/uiDesigner.xml
+.idea/assetWizardSettings.xml
+.idea/gradle.xml
+.idea/jarRepositories.xml
+.idea/navEditor.xml
+
+# Legacy Eclipse project files
+.classpath
+.project
+.cproject
+.settings/
+
+# Mobile Tools for Java (J2ME)
+
+# Package Files #
+
+# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
+
+## Plugin-specific files:
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Mongo Explorer plugin
+.idea/mongoSettings.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### AndroidStudio Patch ###
+
+!/gradle/wrapper/gradle-wrapper.jar
+
+# End of https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index 1eb497a9359..00000000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-CloudStream
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
deleted file mode 100644
index 7643783a82f..00000000000
--- a/.idea/codeStyles/Project.xml
+++ /dev/null
@@ -1,123 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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 79ee123c2b2..00000000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index 5421743a9c2..00000000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/.idea/discord.xml b/.idea/discord.xml
deleted file mode 100644
index d8e95616687..00000000000
--- a/.idea/discord.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 10c26704e22..00000000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
deleted file mode 100644
index 333d49373bb..00000000000
--- a/.idea/jarRepositories.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1ddfbbc..00000000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 7282979ad72..00000000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "githubPullRequests.ignoredPullRequestBranches": [
- "master"
- ],
- "java.configuration.updateBuildConfiguration": "interactive"
-}
\ No newline at end of file
diff --git a/AI-POLICY.md b/AI-POLICY.md
new file mode 100644
index 00000000000..5409393fb18
--- /dev/null
+++ b/AI-POLICY.md
@@ -0,0 +1,11 @@
+# AI Policy
+
+AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions.
+
+1. Always state any AI usage in pull requests and issues.
+
+2. Always test code before making a pull request. We do not want to test your AI generated code.
+
+3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI.
+
+4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions.
diff --git a/README.md b/README.md
index 0fef9758c26..c2492c5d821 100644
--- a/README.md
+++ b/README.md
@@ -1,52 +1,111 @@
# CloudStream
-**⚠️ Warning: By default this app doesn't provide any video sources, you have to install extensions in order to add functionality to the app.**
-
+**⚠️ Warning: By default, this app doesn't provide any video sources; you have to install extensions to add functionality to the app.**
[](https://discord.gg/5Hus6fM)
-### Features:
+
+## Table of Contents:
++ [About Us:](#about_us)
++ [Installation Steps:](#install_rules)
++ [Contributing:](#contributing)
++ [Issues:](#issues)
+ + [Bugs Reports:](#bug_report)
+ + [Enhancement:](#enhancment)
++ [Extension Development:](#extensions)
++ [Language Support:](#languages)
++ [Further Sources](#contact_and_sources)
+
+
+
+
+## About us:
+
+**CloudStream is a media center that prioritizes and emphasizes complete freedom and flexibility for users and developers.**
+
+CloudStream is an extension-based multimedia player with tracking support. There are extensions to view videos from:
+
++ [Librevox (audio-books)](https://librivox.org/)
++ [Youtube](https://www.youtube.com/)
++ [Twitch](https://www.twitch.tv/)
++ [iptv-org (A collection of publicly available IPTV (Internet Protocol television) channels from all over the world.)](https://github.com/iptv-org/iptv)
++ [nginx](https://nginx.org/)
++ And more...
+
+
+**Please don't create illegal extensions or use any that host any copyrighted media.** For more details about our stance on the DMCA and EUCD, you can read about it on our organization: [reCloudStream](https://github.com/recloudstream)
+
+#### Important Copyright Note:
+
+Our documentation is unmaintained and open to contributions; therefore, apps and sources, extensions in recommended sources, and recommended apps are not officially moderated or endorsed by CloudStream; if you or another copyright owner identify an extension that breaches your copyright, please let us know.
+
+
+#### Features:
+ **AdFree**, No ads whatsoever
+ No tracking/analytics
+ Bookmarks
-+ Download and stream movies, tv-shows and anime
++ Phone and TV support
+ Chromecast
++ Extension system for personal customization
+
+
+
+
+## Installation:
-### Screenshots:
+Our documentation provides the steps to install and configure CloudStream for your streaming needs.
-


-
+[Getting Started With CloudStream:](https://recloudstream.github.io/csdocs/)
+
+
+
+## Contributing:
+We **happily** accept any contributions to our project. To find out where you can start contributing towards the project, please look [at our issues tab](/cloudstream/issues)
+
+
+
+
+
+### Issues:
+While we **actively** accept issues and pull requests, we do require you fill out an [template](https://github.com/recloudstream/cloudstream/issues/new/choose) for issues. These include the following:
+
+
+
+- [Bug Report Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=bug&projects=&template=application-bug.yml)
+ - For bug reports, we want as much info as possible, including your downloaded version of CloudeStream, device and updated version (if possible, current API),
+ expected behavior of the program, and the actual behavior that the program did, most importantly we require clear, reproducible steps of the bug. If your bug can't be reproduced, it is unlikely we'll work on your issue.
+
+
+
+- [Feature Request Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yml)
+ - Before adding a feature request, please check to see if a feature request already has been requested.
+
+
+### Extensions:
+
+**Further details on creating extensions for CloudStream are found in our documentation.**
+
+[Guide: For Extension Developers](https://recloudstream.github.io/csdocs/devs/gettingstarted/)
+
+
+
+## Further Sources:
+
+As well as providing clear install steps, our [website](https://dweb.link/ipns/cloudstream.on.fleek.co/) includes a wide variety of other tools, such as:
+- [Troubleshooting](https://recloudstream.github.io/csdocs/troubleshooting/)
+- [Further CloudStream Repositories](https://recloudstream.github.io/csdocs/repositories/)
+- Set-Up for other devices, such as:
+ - [Android TV](https://recloudstream.github.io/csdocs/other-devices/tv/)
+ - [Windows](https://recloudstream.github.io/csdocs/other-devices/windows/)
+ - [Linux](https://recloudstream.github.io/csdocs/other-devices/linux/)
+- And more...
+
+
### Supported languages:
+
+Even if you can't contribute to the code or documentation, we always look for those who can contribute to translation and language support. Your contribution is exceptionally appreciated; you can check our translation from the figure below.
+
-
-
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fa1cabaa4c9..ae530192998 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,37 +1,95 @@
-import com.android.build.gradle.api.BaseVariantOutput
-import org.jetbrains.dokka.gradle.DokkaTask
-import java.io.ByteArrayOutputStream
-import java.net.URL
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
+import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
+import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier
+import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
plugins {
- id("com.android.application")
- id("kotlin-android")
- id("kotlin-kapt")
- id("kotlin-android-extensions")
- id("org.jetbrains.dokka")
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.dokka)
}
-val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
-val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
-
-fun String.execute() = ByteArrayOutputStream().use { baot ->
- if (project.exec {
- workingDir = projectDir
- commandLine = this@execute.split(Regex("\\s"))
- standardOutput = baot
- }.exitValue == 0)
- String(baot.toByteArray()).trim()
- else null
+val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
+
+abstract class GenerateGitHashTask : DefaultTask() {
+
+ @get:InputFile
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val headFile: RegularFileProperty
+
+ @get:InputDirectory
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val headsDir: DirectoryProperty
+
+ @get:OutputDirectory
+ abstract val outputDir: DirectoryProperty
+
+ @TaskAction
+ fun generate() {
+ val head = headFile.get().asFile
+
+ val hash = try {
+ if (head.exists()) {
+ // Read the commit hash from .git/HEAD
+ val headContent = head.readText().trim()
+ if (headContent.startsWith("ref:")) {
+ val refPath = headContent.substring(5) // e.g., refs/heads/main
+ val commitFile = File(head.parentFile, refPath)
+ if (commitFile.exists()) commitFile.readText().trim() else ""
+ } else headContent // If it's a detached HEAD (commit hash directly)
+ } else "" // If .git/HEAD doesn't exist
+ } catch (_: Throwable) {
+ "" // Just set to an empty string if any exception occurs
+ }.take(7) // Get the short commit hash
+
+ val outFile = outputDir.file("git-hash.txt").get().asFile
+ outFile.parentFile.mkdirs()
+ outFile.writeText(hash)
+ }
+}
+
+val generateGitHash = tasks.register("generateGitHash") {
+ val gitDir = layout.projectDirectory.dir("../.git")
+
+ headFile.set(gitDir.file("HEAD"))
+ headsDir.set(gitDir.dir("refs/heads"))
+
+ outputDir.set(layout.buildDirectory.dir("generated/git"))
}
android {
+ @Suppress("UnstableApiUsage")
testOptions {
unitTests.isReturnDefaultValues = true
}
+
+ // Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
+ dependenciesInfo {
+ // Disables dependency metadata when building APKs.
+ includeInApk = false
+ // Disables dependency metadata when building Android App Bundles.
+ includeInBundle = false
+ }
+
+ androidComponents {
+ onVariants { variant ->
+ variant.sources.assets?.addGeneratedSourceDirectory(
+ generateGitHash,
+ GenerateGitHashTask::outputDir
+ )
+ }
+ }
+
signingConfigs {
- create("prerelease") {
- if (prereleaseStoreFile != null) {
- storeFile = file(prereleaseStoreFile)
+ // We just use SIGNING_KEY_ALIAS here since it won't change
+ // so won't kill the configuration cache.
+ if (System.getenv("SIGNING_KEY_ALIAS") != null) {
+ create("prerelease") {
+ val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
+ val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
+
+ storeFile = prereleaseStoreFile?.let { file(it) }
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
@@ -39,34 +97,36 @@ android {
}
}
- compileSdk = 33
- buildToolsVersion = "30.0.3"
+ compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
applicationId = "com.lagradost.cloudstream3"
- minSdk = 21
- targetSdk = 33
-
- versionCode = 55
- versionName = "3.3.0"
-
- resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
+ minSdk = libs.versions.minSdk.get().toInt()
+ targetSdk = libs.versions.targetSdk.get().toInt()
+ versionCode = 68
+ versionName = "4.7.0"
- resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
+ manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
- resValue("bool", "is_prerelease", "false")
+ // Reads local.properties
+ val localProperties = gradleLocalProperties(rootDir, project.providers)
+ buildConfigField(
+ "long",
+ "BUILD_DATE",
+ "${System.currentTimeMillis()}"
+ )
buildConfigField(
"String",
- "BUILDDATE",
- "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));"
+ "SIMKL_CLIENT_ID",
+ "\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\""
+ )
+ buildConfigField(
+ "String",
+ "SIMKL_CLIENT_SECRET",
+ "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
)
-
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
-
- kapt {
- includeCompileClasspath = true
- }
}
buildTypes {
@@ -74,180 +134,203 @@ android {
isDebuggable = false
isMinifyEnabled = false
isShrinkResources = false
- proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
}
debug {
isDebuggable = true
applicationIdSuffix = ".debug"
- proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
}
}
+
flavorDimensions.add("state")
productFlavors {
create("stable") {
dimension = "state"
- resValue("bool", "is_prerelease", "false")
}
create("prerelease") {
dimension = "state"
- resValue("bool", "is_prerelease", "true")
- buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease"
- signingConfig = signingConfigs.getByName("prerelease")
+ if (signingConfigs.names.contains("prerelease")) {
+ signingConfig = signingConfigs.getByName("prerelease")
+ } else {
+ logger.warn("No prerelease signing config!")
+ }
versionNameSuffix = "-PRE"
versionCode = (System.currentTimeMillis() / 60000).toInt()
}
}
+
compileOptions {
isCoreLibraryDesugaringEnabled = true
-
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.toVersion(javaTarget.target)
+ targetCompatibility = JavaVersion.toVersion(javaTarget.target)
}
- kotlinOptions {
- jvmTarget = "1.8"
- freeCompilerArgs = listOf("-Xjvm-default=compatibility")
+
+ java {
+ // Use Java 17 toolchain even if a higher JDK runs the build.
+ // We still use Java 8 for now which higher JDKs have deprecated.
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get()))
+ }
}
+
lint {
- abortOnError = false
checkReleaseBuilds = false
}
- namespace = "com.lagradost.cloudstream3"
-}
-
-repositories {
- maven("https://jitpack.io")
-}
-
-dependencies {
- implementation("com.google.android.mediahome:video:1.0.0")
- implementation("androidx.test.ext:junit-ktx:1.1.3")
- testImplementation("org.json:json:20180813")
- implementation("androidx.core:core-ktx:1.8.0")
- implementation("androidx.appcompat:appcompat:1.4.2") // need target 32 for 1.5.0
-
- // dont change this to 1.6.0 it looks ugly af
- implementation("com.google.android.material:material:1.5.0")
- implementation("androidx.constraintlayout:constraintlayout:2.1.4")
- implementation("androidx.navigation:navigation-fragment-ktx:2.5.1")
- implementation("androidx.navigation:navigation-ui-ktx:2.5.1")
- implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1")
- implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1")
- testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.3")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
-
- //implementation("io.karn:khttp-android:0.1.2") //okhttp instead
-// implementation("org.jsoup:jsoup:1.13.1")
- implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1")
-
- implementation("androidx.preference:preference-ktx:1.2.0")
-
- implementation("com.github.bumptech.glide:glide:4.13.1")
- kapt("com.github.bumptech.glide:compiler:4.13.1")
- implementation("com.github.bumptech.glide:okhttp3-integration:4.13.0")
-
- implementation("jp.wasabeef:glide-transformations:4.3.0")
-
- implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
-
- // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09")
-
- // Exoplayer
- implementation("com.google.android.exoplayer:exoplayer:2.18.2")
- implementation("com.google.android.exoplayer:extension-cast:2.18.2")
- implementation("com.google.android.exoplayer:extension-mediasession:2.18.2")
- implementation("com.google.android.exoplayer:extension-okhttp:2.18.2")
-
- //implementation("com.google.android.exoplayer:extension-leanback:2.14.0")
-
- // Bug reports
- implementation("ch.acra:acra-core:5.8.4")
- implementation("ch.acra:acra-toast:5.8.4")
-
- compileOnly("com.google.auto.service:auto-service-annotations:1.0")
- //either for java sources:
- annotationProcessor("com.google.auto.service:auto-service:1.0")
- //or for kotlin sources (requires kapt gradle plugin):
- kapt("com.google.auto.service:auto-service:1.0")
-
- // subtitle color picker
- implementation("com.jaredrummler:colorpicker:1.1.0")
-
- //run JS
- // do not upgrade to 1.7.14, since in 1.7.14 Rhino uses the `SourceVersion` class, which is not
- // available on Android (even when using desugaring), and `NoClassDefFoundError` is thrown
- implementation("org.mozilla:rhino:1.7.13")
-
- // TorrentStream
- //implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
-
- // Downloading
- implementation("androidx.work:work-runtime:2.7.1")
- implementation("androidx.work:work-runtime-ktx:2.7.1")
-
- // Networking
-// implementation("com.squareup.okhttp3:okhttp:4.9.2")
-// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
- implementation("com.github.Blatzar:NiceHttp:0.4.1")
- // To fix SSL fuckery on android 9
- implementation("org.conscrypt:conscrypt-android:2.2.1")
- // Util to skip the URI file fuckery 🙏
- implementation("com.github.tachiyomiorg:unifile:17bec43")
-
- // API because cba maintaining it myself
- implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0")
-
- implementation("com.github.discord:OverlappingPanels:0.1.3")
- // debugImplementation because LeakCanary should only run in debug builds.
- // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
-
- // for shimmer when loading
- implementation("com.facebook.shimmer:shimmer:0.5.0")
+ buildFeatures {
+ buildConfig = true
+ viewBinding = true
+ }
- implementation("androidx.tvprovider:tvprovider:1.0.0")
+ packaging {
+ jniLibs {
+ // Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23).
+ // Note: This may increase app startup time slightly.
+ useLegacyPackaging = true
+ }
+ }
- // used for subtitle decoding https://github.com/albfernandez/juniversalchardet
- implementation("com.github.albfernandez:juniversalchardet:2.4.0")
+ namespace = "com.lagradost.cloudstream3"
+}
- // slow af yt
- //implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT")
+dependencies {
+ // Testing
+ testImplementation(libs.junit)
+ testImplementation(libs.json)
+ androidTestImplementation(libs.core)
+ implementation(libs.junit.ktx)
+ androidTestImplementation(libs.ext.junit)
+ androidTestImplementation(libs.espresso.core)
+
+ // Android Core & Lifecycle
+ implementation(libs.core.ktx)
+ implementation(libs.activity.ktx)
+ implementation(libs.annotation)
+ implementation(libs.appcompat)
+ implementation(libs.fragment.ktx)
+ implementation(libs.bundles.lifecycle)
+ implementation(libs.bundles.navigation)
+ implementation(libs.kotlinx.collections.immutable)
+
+ // Design & UI
+ implementation(libs.preference.ktx)
+ implementation(libs.material)
+ implementation(libs.constraintlayout)
+
+ // Coil Image Loading
+ implementation(libs.bundles.coil)
+
+ // Media 3 (ExoPlayer)
+ implementation(libs.bundles.media3)
+ implementation(libs.video)
+
+ // FFmpeg Decoding
+ implementation(libs.bundles.nextlib)
+
+ // Anime-db for filler
+ implementation(libs.anime.db)
+
+ // PlayBack
+ implementation(libs.colorpicker) // Subtitle Color Picker
+ implementation(libs.newpipeextractor) // For Trailers
+ implementation(libs.juniversalchardet) // Subtitle Decoding
+
+ // UI Stuff
+ implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
+ implementation(libs.palette.ktx) // Palette for Images -> Colors
+ implementation(libs.tvprovider)
+ implementation(libs.overlappingpanels) // Gestures
+ implementation(libs.biometric) // Fingerprint Authentication
+ implementation(libs.previewseekbar.media3) // SeekBar Preview
+ implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
+
+ // Extensions & Other Libs
+ implementation(libs.jsoup) // HTML Parser
+ implementation(libs.rhino) // Run JavaScript
+ implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance
+ implementation(libs.safefile) // To Prevent the URI File Fu*kery
+ coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
+ implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
+ implementation(libs.jackson.module.kotlin) // JSON Parser
+ implementation(libs.zipline)
+
+ // Torrent Support
+ implementation(libs.torrentserver)
+
+ // Downloading & Networking
+ implementation(libs.work.runtime.ktx)
+ implementation(libs.nicehttp) // HTTP Lib
+
+ implementation(project(":library"))
+}
- // newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L190
- implementation("com.github.TeamNewPipe:NewPipeExtractor:9ffdd0948b2ecd82655f5ff2a3e127b2b7695d5b")
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6")
+tasks.register("androidSourcesJar") {
+ archiveClassifier.set("sources")
+ from(android.sourceSets.getByName("main").java.directories) // Full Sources
+}
- // Library/extensions searching with Levenshtein distance
- implementation("me.xdrop:fuzzywuzzy:1.4.0")
+tasks.register("copyJar") {
+ dependsOn("build", ":library:jvmJar")
+ from(
+ "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
+ "../library/build/libs"
+ )
+ into("build/app-classes")
+ include("classes.jar", "library-jvm*.jar")
+ // Remove the version
+ rename("library-jvm.*.jar", "library-jvm.jar")
}
-tasks.register("androidSourcesJar", Jar::class) {
- archiveClassifier.set("sources")
- from(android.sourceSets.getByName("main").java.srcDirs) //full sources
+// Merge the app classes and the library classes into classes.jar
+tasks.register("makeJar") {
+ // Duplicates cause hard to catch errors, better to fail at compile time.
+ duplicatesStrategy = DuplicatesStrategy.FAIL
+ dependsOn(tasks.getByName("copyJar"))
+ from(
+ zipTree("build/app-classes/classes.jar"),
+ zipTree("build/app-classes/library-jvm.jar")
+ )
+ destinationDirectory.set(layout.buildDirectory)
+ archiveBaseName = "classes"
}
-// this is used by the gradlew plugin
-tasks.register("makeJar", Copy::class) {
- from("build/intermediates/compile_app_classes_jar/prereleaseDebug")
- into("build")
- include("classes.jar")
- dependsOn("build")
+tasks.withType {
+ compilerOptions {
+ jvmTarget.set(javaTarget)
+ jvmDefault.set(JvmDefaultMode.ENABLE)
+ freeCompilerArgs.add("-Xannotation-default-target=param-property")
+ optIn.addAll(
+ "com.lagradost.cloudstream3.InternalAPI",
+ "com.lagradost.cloudstream3.Prerelease",
+ )
+ }
}
-tasks.withType().configureEach {
- moduleName.set("Cloudstream")
+dokka {
+ moduleName = "App"
dokkaSourceSets {
- named("main") {
- sourceLink {
- // Unix based directory relative path to the root of the project (where you execute gradle respectively).
- localDirectory.set(file("src/main/java"))
+ configureEach {
+ suppress = name != "prereleaseDebug"
+ analysisPlatform = KotlinPlatform.JVM
+ displayName = "JVM"
+ documentedVisibilities(
+ VisibilityModifier.Public,
+ VisibilityModifier.Protected
+ )
- // URL showing where the source code can be accessed through the web browser
- remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java"))
- // Suffix which is used to append the line number to the URL. Use #L for GitHub
- remoteLineSuffix.set("#L")
+ sourceLink {
+ localDirectory = file("..")
+ remoteUrl("https://github.com/recloudstream/cloudstream/tree/master")
+ remoteLineSuffix = "#L"
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/lint.xml b/app/lint.xml
new file mode 100644
index 00000000000..b2f5e8f2bc3
--- /dev/null
+++ b/app/lint.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
index 2a3e13e5695..4c5cdea5bee 100644
--- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
@@ -1,173 +1,149 @@
package com.lagradost.cloudstream3
+import android.app.Activity
+import android.os.Bundle
+import android.os.PersistableBundle
+import android.view.LayoutInflater
+import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.utils.Qualities
+import androidx.viewbinding.ViewBinding
+import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
+import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
+import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
+import com.lagradost.cloudstream3.databinding.FragmentLibraryTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
+import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentResultBinding
+import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentSearchBinding
+import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding
+import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
+import com.lagradost.cloudstream3.databinding.HomepageParentBinding
+import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding
+import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding
+import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
+import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding
+import com.lagradost.cloudstream3.databinding.RepositoryItemBinding
+import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding
+import com.lagradost.cloudstream3.databinding.SearchResultGridBinding
+import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
+import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding
import com.lagradost.cloudstream3.utils.SubtitleHelper
+import com.lagradost.cloudstream3.utils.TestingUtils
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
+
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
+class TestApplication : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
+ super.onCreate(savedInstanceState, persistentState)
+ }
+}
+
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
- //@Test
- //fun useAppContext() {
- // // Context of the app under test.
- // val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- // assertEquals("com.lagradost.cloudstream3", appContext.packageName)
- //}
-
- private fun getAllProviders(): List {
- return APIHolder.allProviders //.filter { !it.usesWebView }
+ private fun getAllProviders(): Array {
+ println("Providers: ${APIHolder.allProviders.size}")
+ return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView }
}
- private suspend fun loadLinks(api: MainAPI, url: String?): Boolean {
- Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
- if (url == null) return true
- var linksLoaded = 0
- try {
- val success = api.loadLinks(url, false, {}) { link ->
- Assert.assertTrue(
- "Api ${api.name} returns link with invalid Quality",
- Qualities.values().map { it.value }.contains(link.quality)
- )
- Assert.assertTrue(
- "Api ${api.name} returns link with invalid url ${link.url}",
- link.url.length > 4
- )
- linksLoaded++
- }
- if (success) {
- return linksLoaded > 0
- }
- Assert.assertTrue("Api ${api.name} has returns false on .loadLinks", success)
- } catch (e: Exception) {
- if (e.cause is NotImplementedError) {
- Assert.fail("Provider has not implemented .loadLinks")
- }
- logError(e)
- }
- return true
+ @Test
+ fun providersExist() {
+ Assert.assertTrue(getAllProviders().isNotEmpty())
+ println("Done providersExist")
}
- private suspend fun testSingleProviderApi(api: MainAPI): Boolean {
- val searchQueries = listOf("over", "iron", "guy")
- var correctResponses = 0
- var searchResult: List? = null
- for (query in searchQueries) {
- val response = try {
- api.search(query)
- } catch (e: Exception) {
- if (e.cause is NotImplementedError) {
- Assert.fail("Provider has not implemented .search")
- }
- logError(e)
- null
- }
- if (!response.isNullOrEmpty()) {
- correctResponses++
- if (searchResult == null) {
- searchResult = response
- }
- }
+ @Throws
+ private inline fun testAllLayouts(
+ activity: Activity,
+ vararg layouts: Int
+ ) {
+
+ val bind = T::class.java.methods.first { it.name == "bind" }
+ val inflater = LayoutInflater.from(activity)
+ for (layout in layouts) {
+ val root = inflater.inflate(layout, null, false)
+ bind.invoke(null, root)
}
+ }
- if (correctResponses == 0 || searchResult == null) {
- System.err.println("Api ${api.name} did not return any valid search responses")
- return false
- }
+ @Test
+ @Throws
+ fun layoutTest() {
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ scenario.onActivity { activity: MainActivity ->
+ // FragmentHomeHeadBinding and FragmentHomeHeadTvBinding CANT be the same
+ //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv)
+ //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv)
- try {
- var validResults = false
- for (result in searchResult) {
- Assert.assertEquals(
- "Invalid apiName on response on ${api.name}",
- result.apiName,
- api.name
- )
- val load = api.load(result.url) ?: continue
- Assert.assertEquals(
- "Invalid apiName on load on ${api.name}",
- load.apiName,
- result.apiName
- )
- Assert.assertTrue(
- "Api ${api.name} on load does not contain any of the supportedTypes",
- api.supportedTypes.contains(load.type)
- )
- when (load) {
- is AnimeLoadResponse -> {
- val gotNoEpisodes =
- load.episodes.keys.isEmpty() || load.episodes.keys.any { load.episodes[it].isNullOrEmpty() }
-
- if (gotNoEpisodes) {
- println("Api ${api.name} got no episodes on ${load.url}")
- continue
- }
-
- val url = (load.episodes[load.episodes.keys.first()])?.first()?.data
- validResults = loadLinks(api, url)
- if (!validResults) continue
- }
- is MovieLoadResponse -> {
- val gotNoEpisodes = load.dataUrl.isBlank()
- if (gotNoEpisodes) {
- println("Api ${api.name} got no movie on ${load.url}")
- continue
- }
-
- validResults = loadLinks(api, load.dataUrl)
- if (!validResults) continue
- }
- is TvSeriesLoadResponse -> {
- val gotNoEpisodes = load.episodes.isEmpty()
- if (gotNoEpisodes) {
- println("Api ${api.name} got no episodes on ${load.url}")
- continue
- }
-
- validResults = loadLinks(api, load.episodes.first().data)
- if (!validResults) continue
- }
- }
- break
- }
- if(!validResults) {
- System.err.println("Api ${api.name} did not load on any")
- }
+ // main cant be tested
+ // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv)
+ // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv)
+ //testAllLayouts(activity, R.layout.activity_main_tv)
+
+ testAllLayouts(activity, R.layout.bottom_resultview_preview,R.layout.bottom_resultview_preview_tv)
+
+ testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
+ testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
- return validResults
- } catch (e: Exception) {
- if (e.cause is NotImplementedError) {
- Assert.fail("Provider has not implemented .load")
+ // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv)
+ // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv)
+
+ testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
+ testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
+ testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
+
+ testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item)
+ testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item)
+
+ testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item)
+ testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item)
+
+ testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home)
+ testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home)
+
+ testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search)
+ testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search)
+
+ testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid)
+ //testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) ??? fails ???
+
+ testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded)
+ testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded)
+
+
+ // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
+ // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
+
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+
+ testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
+ testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
}
- logError(e)
- return false
}
}
@Test
- fun providersExist() {
- Assert.assertTrue(getAllProviders().isNotEmpty())
- println("Done providersExist")
- }
-
- @Test
+ @Throws(AssertionError::class)
fun providerCorrectData() {
- val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
- Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
+ val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag }
+ Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty())
for (api in getAllProviders()) {
Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE")
Assert.assertTrue("Api does not contain a name", api.name != "NONE")
Assert.assertTrue(
"Api ${api.name} does not contain a valid language code",
- isoNames.contains(api.lang)
+ langTagsIETF.contains(api.lang)
)
Assert.assertTrue(
"Api ${api.name} does not contain any supported types",
@@ -180,66 +156,20 @@ class ExampleInstrumentedTest {
@Test
fun providerCorrectHomepage() {
runBlocking {
- getAllProviders().amap { api ->
- if (api.hasMainPage) {
- try {
- val homepage = api.getMainPage()
- when {
- homepage == null -> {
- System.err.println("Homepage provider ${api.name} did not correctly load homepage!")
- }
- homepage.items.isEmpty() -> {
- System.err.println("Homepage provider ${api.name} does not contain any items!")
- }
- homepage.items.any { it.list.isEmpty() } -> {
- System.err.println ("Homepage provider ${api.name} does not have any items on result!")
- }
- }
- } catch (e: Exception) {
- if (e.cause is NotImplementedError) {
- Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
- }
- logError(e)
- }
- }
+ getAllProviders().toList().amap { api ->
+ TestingUtils.testHomepage(api, TestingUtils.Logger())
}
}
println("Done providerCorrectHomepage")
}
-// @Test
-// fun testSingleProvider() {
-// testSingleProviderApi(ThenosProvider())
-// }
-
@Test
- fun providerCorrect() {
+ fun testAllProvidersCorrect() {
runBlocking {
- val invalidProvider = ArrayList>()
- val providers = getAllProviders()
- providers.amap { api ->
- try {
- println("Trying $api")
- if (testSingleProviderApi(api)) {
- println("Success $api")
- } else {
- System.err.println("Error $api")
- invalidProvider.add(Pair(api, null))
- }
- } catch (e: Exception) {
- logError(e)
- invalidProvider.add(Pair(api, e))
- }
- }
- if(invalidProvider.isEmpty()) {
- println("No Invalid providers! :D")
- } else {
- println("Invalid providers are: ")
- for (provider in invalidProvider) {
- println("${provider.first}")
- }
- }
+ TestingUtils.getDeferredProviderTests(
+ this,
+ getAllProviders(),
+ ) { _, _ -> }
}
- println("Done providerCorrect")
}
}
diff --git a/app/src/debug/res/drawable-v24/ic_banner_background.xml b/app/src/debug/res/drawable-v24/ic_banner_background.xml
index 7b05b711181..caed023d55f 100644
--- a/app/src/debug/res/drawable-v24/ic_banner_background.xml
+++ b/app/src/debug/res/drawable-v24/ic_banner_background.xml
@@ -25,9 +25,8 @@
android:endY="245.72"
android:endX="292.58"
android:type="linear">
-
-
-
+
+
@@ -40,9 +39,8 @@
android:endY="245.72"
android:endX="248.76"
android:type="linear">
-
-
-
+
+
@@ -55,46 +53,45 @@
android:endY="245.69"
android:endX="210.03"
android:type="linear">
-
-
-
+
+
+ android:fillColor="#39A11D"/>
+ android:fillColor="#39A11D"/>
+ android:fillColor="#39A11D"/>
+ android:fillColor="#39A11D"/>
+ android:fillColor="#39A11D"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
+ android:fillColor="#68C671"/>
@@ -104,9 +101,9 @@
android:endY="252.3"
android:endX="373.57"
android:type="linear">
-
-
-
+
+
+
@@ -117,9 +114,9 @@
android:startX="400.11"
android:endX="900"
android:type="linear">
-
-
-
+
+
+
@@ -132,9 +129,9 @@
android:endY="252.3"
android:endX="373.57"
android:type="linear">
-
-
-
+
+
+
@@ -145,9 +142,9 @@
android:startX="700.11"
android:endX="900.57"
android:type="linear">
-
-
-
+
+
+
@@ -158,9 +155,9 @@
android:startX="400.11"
android:endX="800.57"
android:type="linear">
-
-
-
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 871c4f69836..ee4c978f2be 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,16 +6,63 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+ tools:targetApi="${target_sdk_version}">
+ android:supportsPictureInPicture="true"
+ android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
+ android:launchMode="singleTask"
+ tools:ignore="DiscouragedApi">
@@ -79,25 +125,65 @@
+
+
+
+
+
+
+
+
+
+
+
+ android:supportsPictureInPicture="true" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -114,7 +200,14 @@
+
+
+
+
+
+
+
@@ -138,7 +231,7 @@
-
+
@@ -151,15 +244,11 @@
-
-
-
+ android:exported="false">
+
@@ -167,14 +256,28 @@
+
+
+
+
+
-
-
\ No newline at end of file
+
+
diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
index 198f0f4c2b3..bbe7d97debc 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
@@ -1,208 +1,78 @@
package com.lagradost.cloudstream3
-import android.app.Activity
-import android.app.Application
-import android.content.Context
-import android.content.ContextWrapper
-import android.content.Intent
-import android.widget.Toast
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentActivity
-import com.google.auto.service.AutoService
-import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
-import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
-import com.lagradost.cloudstream3.plugins.PluginManager
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
-import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
-import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
-import com.lagradost.cloudstream3.utils.DataStore.getKey
-import com.lagradost.cloudstream3.utils.DataStore.getKeys
-import com.lagradost.cloudstream3.utils.DataStore.removeKey
-import com.lagradost.cloudstream3.utils.DataStore.removeKeys
-import com.lagradost.cloudstream3.utils.DataStore.setKey
-import kotlinx.coroutines.runBlocking
-import org.acra.ACRA
-import org.acra.ReportField
-import org.acra.config.CoreConfiguration
-import org.acra.data.CrashReportData
-import org.acra.data.StringFormat
-import org.acra.ktx.initAcra
-import org.acra.sender.ReportSender
-import org.acra.sender.ReportSenderFactory
-import java.io.File
-import java.io.FileNotFoundException
-import java.io.PrintStream
-import java.lang.Exception
-import java.lang.ref.WeakReference
-import kotlin.concurrent.thread
-import kotlin.system.exitProcess
-
-
-class CustomReportSender : ReportSender {
- // Sends all your crashes to google forms
- override fun send(context: Context, errorContent: CrashReportData) {
- println("Sending report")
- val url =
- "https://docs.google.com/forms/u/0/d/e/1FAIpQLSe9Vff8oHGMRXcjgCXZwkjvx3eBdNpn4DzjO0FkcWEU1gEQpA/formResponse"
- val data = mapOf(
- "entry.1586460852" to errorContent.toJSON()
- )
-
- thread { // to not run it on main thread
- runBlocking {
- suspendSafeApiCall {
- val post = app.post(url, data = data)
- println("Report response: $post")
- }
- }
- }
-
- runOnMainThread { // to run it on main looper
- normalSafeApiCall {
- Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show()
- }
- }
- }
+/**
+ * Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
+ * Use CloudStreamApp instead.
+ */
+@Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
+ level = DeprecationLevel.WARNING
+)
+class AcraApplication {
+ companion object {
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
+ level = DeprecationLevel.WARNING
+ )
+ val context get() = CloudStreamApp.context
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun removeKeys(folder: String): Int? =
+ CloudStreamApp.removeKeys(folder)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun setKey(path: String, value: T) =
+ CloudStreamApp.setKey(path, value)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
+ level = DeprecationLevel.WARNING
+ )
+ fun setKey(folder: String, path: String, value: T) =
+ CloudStreamApp.setKey(folder, path, value)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
+ level = DeprecationLevel.WARNING
+ )
+ inline fun getKey(path: String, defVal: T?): T? =
+ CloudStreamApp.getKey(path, defVal)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
+ level = DeprecationLevel.WARNING
+ )
+ inline fun getKey(path: String): T? =
+ CloudStreamApp.getKey(path)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
+ level = DeprecationLevel.WARNING
+ )
+ inline fun getKey(folder: String, path: String): T? =
+ CloudStreamApp.getKey(folder, path)
+
+ @Deprecated(
+ message = "AcraApplication is deprecated, use CloudStreamApp instead",
+ replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
+ level = DeprecationLevel.WARNING
+ )
+ inline fun getKey(folder: String, path: String, defVal: T?): T? =
+ CloudStreamApp.getKey(folder, path, defVal)
+ }
}
-
-@AutoService(ReportSenderFactory::class)
-class CustomSenderFactory : ReportSenderFactory {
- override fun create(context: Context, config: CoreConfiguration): ReportSender {
- return CustomReportSender()
- }
-
- override fun enabled(config: CoreConfiguration): Boolean {
- return true
- }
-}
-
-class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
- Thread.UncaughtExceptionHandler {
- override fun uncaughtException(thread: Thread, error: Throwable) {
- ACRA.errorReporter.handleException(error)
- try {
- PrintStream(errorFile).use { ps ->
- ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}"))
- ps.println(
- String.format(
- "Fatal exception on thread %s (%d)",
- thread.name,
- thread.id
- )
- )
- error.printStackTrace(ps)
- }
- } catch (ignored: FileNotFoundException) {
- }
- try {
- onError.invoke()
- } catch (ignored: Exception) {
- }
- exitProcess(1)
- }
-
-}
-
-class AcraApplication : Application() {
- override fun onCreate() {
- super.onCreate()
- Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) {
- val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
- startActivity(Intent.makeRestartActivityTask(intent!!.component))
- })
- }
-
- override fun attachBaseContext(base: Context?) {
- super.attachBaseContext(base)
- context = base
-
- initAcra {
- //core configuration:
- buildConfigClass = BuildConfig::class.java
- reportFormat = StringFormat.JSON
-
- reportContent = arrayOf(
- ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
- ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
- ReportField.STACK_TRACE
- )
-
- // removed this due to bug when starting the app, moved it to when it actually crashes
- //each plugin you chose above can be configured in a block like this:
- /*toast {
- text = getString(R.string.acra_report_toast)
- //opening this block automatically enables the plugin.
- }*/
- }
- }
-
- companion object {
- /** Use to get activity from Context */
- tailrec fun Context.getActivity(): Activity? = this as? Activity
- ?: (this as? ContextWrapper)?.baseContext?.getActivity()
-
- private var _context: WeakReference? = null
- var context
- get() = _context?.get()
- private set(value) {
- _context = WeakReference(value)
- }
-
- fun removeKeys(folder: String): Int? {
- return context?.removeKeys(folder)
- }
-
- fun setKey(path: String, value: T) {
- context?.setKey(path, value)
- }
-
- fun setKey(folder: String, path: String, value: T) {
- context?.setKey(folder, path, value)
- }
-
- inline fun getKey(path: String, defVal: T?): T? {
- return context?.getKey(path, defVal)
- }
-
- inline fun getKey(path: String): T? {
- return context?.getKey(path)
- }
-
- inline fun getKey(folder: String, path: String): T? {
- return context?.getKey(folder, path)
- }
-
- inline fun getKey(folder: String, path: String, defVal: T?): T? {
- return context?.getKey(folder, path, defVal)
- }
-
- fun getKeys(folder: String): List? {
- return context?.getKeys(folder)
- }
-
- fun removeKey(folder: String, path: String) {
- context?.removeKey(folder, path)
- }
-
- fun removeKey(path: String) {
- context?.removeKey(path)
- }
-
- /**
- * If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails.
- * */
- fun openBrowser(url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null) {
- context?.openBrowser(url, fallbackWebview, fragment)
- }
-
- /** Will fallback to webview if in TV layout */
- fun openBrowser(url: String, activity: FragmentActivity?) {
- openBrowser(
- url,
- isTvSettings(),
- activity?.supportFragmentManager?.fragments?.lastOrNull()
- )
- }
-
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt
new file mode 100644
index 00000000000..a9cd9c01edd
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt
@@ -0,0 +1,181 @@
+package com.lagradost.cloudstream3
+
+import android.app.Activity
+import android.app.Application
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.Intent
+import android.os.Build
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import coil3.ImageLoader
+import coil3.PlatformContext
+import coil3.SingletonImageLoader
+import com.lagradost.api.setContext
+import com.lagradost.cloudstream3.BuildConfig
+import com.lagradost.cloudstream3.mvvm.safe
+import com.lagradost.cloudstream3.mvvm.safeAsync
+import com.lagradost.cloudstream3.plugins.PluginManager
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
+import com.lagradost.cloudstream3.utils.AppDebug
+import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
+import com.lagradost.cloudstream3.utils.DataStore.getKey
+import com.lagradost.cloudstream3.utils.DataStore.getKeys
+import com.lagradost.cloudstream3.utils.DataStore.removeKey
+import com.lagradost.cloudstream3.utils.DataStore.removeKeys
+import com.lagradost.cloudstream3.utils.DataStore.setKey
+import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader
+import kotlinx.coroutines.runBlocking
+import java.io.File
+import java.io.FileNotFoundException
+import java.io.PrintStream
+import java.lang.ref.WeakReference
+import java.util.Locale
+import kotlin.concurrent.thread
+import kotlin.system.exitProcess
+
+class ExceptionHandler(
+ val errorFile: File,
+ val onError: (() -> Unit)
+) : Thread.UncaughtExceptionHandler {
+
+ override fun uncaughtException(thread: Thread, error: Throwable) {
+ try {
+ val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
+ thread.threadId()
+ } else {
+ @Suppress("DEPRECATION")
+ thread.id
+ }
+
+ PrintStream(errorFile).use { ps ->
+ ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
+ ps.println("Fatal exception on thread ${thread.name} ($threadId)")
+ error.printStackTrace(ps)
+ }
+ } catch (_: FileNotFoundException) {
+ }
+ try {
+ onError()
+ } catch (_: Exception) {
+ }
+ exitProcess(1)
+ }
+}
+
+class CloudStreamApp : Application(), SingletonImageLoader.Factory {
+
+ override fun onCreate() {
+ super.onCreate()
+ // If we want to initialize Coil as early as possible, maybe when
+ // loading an image or GIF in a splash screen activity.
+ // buildImageLoader(applicationContext)
+
+ ExceptionHandler(filesDir.resolve("last_error")) {
+ val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
+ startActivity(Intent.makeRestartActivityTask(intent!!.component))
+ }.also {
+ exceptionHandler = it
+ Thread.setDefaultUncaughtExceptionHandler(it)
+ }
+
+ AppDebug.isDebug = BuildConfig.DEBUG
+ }
+
+ override fun attachBaseContext(base: Context?) {
+ super.attachBaseContext(base)
+ context = base
+ }
+
+ override fun newImageLoader(context: PlatformContext): ImageLoader {
+ // Coil module will be initialized globally when first loadImage() is invoked.
+ return buildImageLoader(applicationContext)
+ }
+
+ companion object {
+ var exceptionHandler: ExceptionHandler? = null
+
+ /** Use to get Activity from Context. */
+ tailrec fun Context.getActivity(): Activity? {
+ return when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.getActivity()
+ else -> null
+ }
+ }
+
+ private var _context: WeakReference? = null
+ var context
+ get() = _context?.get()
+ private set(value) {
+ _context = WeakReference(value)
+ setContext(WeakReference(value))
+ }
+
+ fun getKeyClass(path: String, valueType: Class): T? {
+ return context?.getKey(path, valueType)
+ }
+
+ fun setKeyClass(path: String, value: T) {
+ context?.setKey(path, value)
+ }
+
+ fun removeKeys(folder: String): Int? {
+ return context?.removeKeys(folder)
+ }
+
+ fun setKey(path: String, value: T) {
+ context?.setKey(path, value)
+ }
+
+ fun setKey(folder: String, path: String, value: T) {
+ context?.setKey(folder, path, value)
+ }
+
+ inline fun getKey(path: String, defVal: T?): T? {
+ return context?.getKey(path, defVal)
+ }
+
+ inline fun getKey(path: String): T? {
+ return context?.getKey(path)
+ }
+
+ inline fun getKey(folder: String, path: String): T? {
+ return context?.getKey(folder, path)
+ }
+
+ inline fun getKey(folder: String, path: String, defVal: T?): T? {
+ return context?.getKey(folder, path, defVal)
+ }
+
+ fun getKeys(folder: String): List? {
+ return context?.getKeys(folder)
+ }
+
+ fun removeKey(folder: String, path: String) {
+ context?.removeKey(folder, path)
+ }
+
+ fun removeKey(path: String) {
+ context?.removeKey(path)
+ }
+
+ /** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */
+ fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) {
+ context?.openBrowser(url, fallbackWebView, fragment)
+ }
+
+ /** Will fall back to WebView if in TV or emulator layout. */
+ fun openBrowser(url: String, activity: FragmentActivity?) {
+ openBrowser(
+ url,
+ isLayout(TV or EMULATOR),
+ activity?.supportFragmentManager?.fragments?.lastOrNull()
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
index b7415811570..ed0aaf9b761 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
@@ -1,15 +1,23 @@
package com.lagradost.cloudstream3
-import android.Manifest
+import android.annotation.SuppressLint
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.pm.PackageManager
+import android.content.res.Configuration
import android.content.res.Resources
+import android.Manifest
import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.util.DisplayMetrics
import android.util.Log
-import android.view.*
-import android.widget.TextView
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.View
+import android.view.View.NO_ID
+import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
@@ -18,44 +26,126 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
+import androidx.core.view.children
+import androidx.core.view.isNotEmpty
import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession
-import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
+import com.google.android.material.chip.ChipGroup
+import com.google.android.material.navigationrail.NavigationRailView
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.actions.VideoClickActionHolder
+import com.lagradost.cloudstream3.databinding.ToastBinding
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.ui.player.PlayerEventType
-import com.lagradost.cloudstream3.ui.result.ResultFragment
-import com.lagradost.cloudstream3.ui.result.UiText
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
-import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.syncproviders.AccountManager
+import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
+import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
+import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
+import com.lagradost.cloudstream3.ui.player.Torrent
+import com.lagradost.cloudstream3.ui.result.ActorAdaptor
+import com.lagradost.cloudstream3.ui.result.EpisodeAdapter
+import com.lagradost.cloudstream3.ui.result.ImageAdapter
+import com.lagradost.cloudstream3.ui.search.SearchAdapter
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
+import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter
+import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
+import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Event
-import com.lagradost.cloudstream3.utils.UIHelper
-import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
-import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
+import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
import com.lagradost.cloudstream3.utils.UIHelper.toPx
+import com.lagradost.cloudstream3.utils.UiText
+import java.lang.ref.WeakReference
+import java.util.Locale
+import kotlin.math.max
+import kotlin.math.min
import org.schabi.newpipe.extractor.NewPipe
-import java.util.*
+
+enum class FocusDirection {
+ Start,
+ End,
+ Up,
+ Down,
+}
object CommonActivity {
+
+ private var _activity: WeakReference? = null
+ var activity
+ get() = _activity?.get()
+ private set(value) {
+ _activity = WeakReference(value)
+ }
+
+ @MainThread
+ fun setActivityInstance(newActivity: Activity?) {
+ activity = newActivity
+ }
+
@MainThread
fun Activity?.getCastSession(): CastSession? {
return (this as MainActivity?)?.mSessionManager?.currentCastSession
}
+ val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
+
+ // screenWidth and screenHeight does always
+ // refer to the screen while in landscape mode
+ val screenWidth: Int
+ get() {
+ return max(displayMetrics.widthPixels, displayMetrics.heightPixels)
+ }
+ val screenHeight: Int
+ get() {
+ return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
+ }
+ val screenWidthWithOrientation: Int
+ get() {
+ return displayMetrics.widthPixels
+ }
+ val screenHeightWithOrientation: Int
+ get() {
+ return displayMetrics.heightPixels
+ }
- var canEnterPipMode: Boolean = false
- var canShowPipMode: Boolean = false
+ var isPipDesired: Boolean = false
var isInPIPMode: Boolean = false
val onColorSelectedEvent = Event>()
val onDialogDismissedEvent = Event()
- var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair) -> Boolean)? = null
+ var appliedTheme: Int = 0
+ var appliedColor: Int = 0
+ private var currentToast: Toast? = null
- var currentToast: Toast? = null
+ fun showToast(@StringRes message: Int, duration: Int? = null) {
+ val act = activity ?: return
+ act.runOnUiThread {
+ showToast(act, act.getString(message), duration)
+ }
+ }
+
+ fun showToast(message: String?, duration: Int? = null) {
+ val act = activity ?: return
+ act.runOnUiThread {
+ showToast(act, message, duration)
+ }
+ }
+
+ fun showToast(message: UiText?, duration: Int? = null) {
+ val act = activity ?: return
+ if (message == null) return
+ act.runOnUiThread {
+ showToast(act, message.asString(act), duration)
+ }
+ }
+
+ @MainThread
fun showToast(act: Activity?, text: UiText, duration: Int) {
if (act == null) return
text.asStringNull(act)?.let {
@@ -86,42 +176,50 @@ object CommonActivity {
} catch (e: Exception) {
logError(e)
}
- try {
- val inflater =
- act.getSystemService(AppCompatActivity.LAYOUT_INFLATER_SERVICE) as LayoutInflater
- val layout: View = inflater.inflate(
- R.layout.toast,
- act.findViewById(R.id.toast_layout_root) as ViewGroup?
- )
-
- val text = layout.findViewById(R.id.text) as TextView
- text.text = message.trim()
+ try {
+ val binding = ToastBinding.inflate(act.layoutInflater)
+ binding.text.text = message.trim()
+ // custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11)
val toast = Toast(act)
- toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
toast.duration = duration ?: Toast.LENGTH_SHORT
- toast.view = layout
- //https://github.com/PureWriter/ToastCompat
- toast.show()
+ toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
+ @Suppress("DEPRECATION")
+ toast.view =
+ binding.root // FIXME Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
currentToast = toast
+ toast.show()
+
+ val handler = Handler(Looper.getMainLooper())
+ val ref = WeakReference(toast)
+
+ /* Clean up activity leak */
+ handler.postDelayed({
+ if (ref.get() == currentToast) {
+ currentToast = null
+ }
+ }, 10_000)
+
} catch (e: Exception) {
logError(e)
}
}
/**
- * Not all languages can be fetched from locale with a code.
- * This map allows sidestepping the default Locale(languageCode)
- * when setting the app language.
- **/
- val appLanguageExceptions = hashMapOf(
- "zh_TW" to Locale.TRADITIONAL_CHINESE
- )
-
- fun setLocale(context: Context?, languageCode: String?) {
- if (context == null || languageCode == null) return
- val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
+ * Set locale
+ * @param languageTag shall a IETF BCP 47 conformant tag.
+ * Check [com.lagradost.cloudstream3.utils.SubtitleHelper].
+ *
+ * See locales on:
+ * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json
+ * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
+ * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml
+ * https://iso639-3.sil.org/code_tables/639/data/all
+ */
+ fun setLocale(context: Context?, languageTag: String?) {
+ if (context == null || languageTag == null) return
+ val locale = Locale.forLanguageTag(languageTag)
val resources: Resources = context.resources
val config = resources.configuration
Locale.setDefault(locale)
@@ -129,7 +227,12 @@ object CommonActivity {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
context.createConfigurationContext(config)
- resources.updateConfiguration(config, resources.displayMetrics)
+
+ @Suppress("DEPRECATION")
+ resources.updateConfiguration(
+ config,
+ resources.displayMetrics
+ ) // FIXME this should be replaced
}
fun Context.updateLocale() {
@@ -138,43 +241,38 @@ object CommonActivity {
setLocale(this, localeCode)
}
- fun init(act: ComponentActivity?) {
- if (act == null) return
- //https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
- //https://developer.android.com/guide/topics/ui/picture-in-picture
- canShowPipMode =
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // OS SUPPORT
- act.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
- act.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
-
- act.updateLocale()
- act.updateTv()
+ fun init(act: Activity) {
+ setActivityInstance(act)
+ ioSafe { Torrent.deleteAllFiles() }
+ val componentActivity = activity as? ComponentActivity ?: return
+
+ componentActivity.updateLocale()
+ componentActivity.updateTv()
+ AccountManager.initMainAPI()
NewPipe.init(DownloaderTestImpl.getInstance())
- for (resumeApp in resumeApps) {
- resumeApp.launcher =
- act.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- val resultCode = result.resultCode
- val data = result.data
- if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
- val pos = resumeApp.getPosition(data)
- val dur = resumeApp.getDuration(data)
- if (dur > 0L && pos > 0L)
- DataStoreHelper.setViewPos(getKey(resumeApp.lastId), pos, dur)
- removeKey(resumeApp.lastId)
- ResultFragment.updateUI()
- }
+ MainActivity.activityResultLauncher =
+ componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == AppCompatActivity.RESULT_OK) {
+ val actionUid =
+ getKey("last_click_action") ?: return@registerForActivityResult
+ Log.d(TAG, "Loading action $actionUid result handler")
+ val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction
+ ?: return@registerForActivityResult
+ action.onResultSafe(act, result.data)
+ removeKey("last_click_action")
+ removeKey("last_opened")
}
- }
+ }
// Ask for notification permissions on Android 13
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
- act,
+ componentActivity,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
- val requestPermissionLauncher = act.registerForActivityResult(
+ val requestPermissionLauncher = componentActivity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Log.d(TAG, "Notification permission: $isGranted")
@@ -185,17 +283,22 @@ object CommonActivity {
}
}
+ /** Enters pip mode if it is both possible and desired to do so*/
private fun Activity.enterPIPMode() {
- if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
+ if (!isPipDesired || !this.isPIPPossible()) return
+
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
enterPictureInPictureMode(PictureInPictureParams.Builder().build())
- } catch (e: Exception) {
+ } catch (_: Exception) {
+ // Use fallback just in case
+ @Suppress("DEPRECATION")
enterPictureInPictureMode()
}
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ @Suppress("DEPRECATION")
enterPictureInPictureMode()
}
}
@@ -204,9 +307,32 @@ object CommonActivity {
}
}
- fun onUserLeaveHint(act: Activity?) {
- if (canEnterPipMode && canShowPipMode) {
- act?.enterPIPMode()
+ fun onUserLeaveHint(act: Activity) {
+ // On Android 12 and later we use setAutoEnterEnabled() instead.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return
+ act.enterPIPMode()
+ }
+
+ fun updateTheme(act: Activity) {
+ val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
+ if (settingsManager
+ .getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
+ && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ ) {
+ loadThemes(act)
+ }
+ }
+
+ private fun mapSystemTheme(act: Activity): Int {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val currentNightMode =
+ act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
+ return when (currentNightMode) {
+ Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme
+ else -> R.style.AppTheme // Night mode is active, we're using dark theme
+ }
+ } else {
+ return R.style.AppTheme
}
}
@@ -216,24 +342,33 @@ object CommonActivity {
val currentTheme =
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
+ "System" -> mapSystemTheme(act)
"Black" -> R.style.AppTheme
"Light" -> R.style.LightMode
"Amoled" -> R.style.AmoledMode
"AmoledLight" -> R.style.AmoledModeLight
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.MonetMode else R.style.AppTheme
+
+ "Dracula" -> R.style.DraculaMode
+ "Lavender" -> R.style.LavenderMode
+ "SilentBlue" -> R.style.SilentBlueMode
+
else -> R.style.AppTheme
}
val currentOverlayTheme =
when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) {
"Normal" -> R.style.OverlayPrimaryColorNormal
+ "DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow
"CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink
+ "Orange" -> R.style.OverlayPrimaryColorOrange
"DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen
"Maroon" -> R.style.OverlayPrimaryColorMaroon
"NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue
"Grey" -> R.style.OverlayPrimaryColorGrey
"White" -> R.style.OverlayPrimaryColorWhite
+ "CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue
"Brown" -> R.style.OverlayPrimaryColorBrown
"Purple" -> R.style.OverlayPrimaryColorPurple
"Green" -> R.style.OverlayPrimaryColorGreen
@@ -242,211 +377,228 @@ object CommonActivity {
"Banana" -> R.style.OverlayPrimaryColorBanana
"Party" -> R.style.OverlayPrimaryColorParty
"Pink" -> R.style.OverlayPrimaryColorPink
+ "Lavender" -> R.style.OverlayPrimaryColorLavender
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
+
"Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal
+
else -> R.style.OverlayPrimaryColorNormal
}
+
act.theme.applyStyle(currentTheme, true)
act.theme.applyStyle(currentOverlayTheme, true)
-
+ appliedTheme = currentTheme
+ appliedColor = currentOverlayTheme
+ act.updateTv()
+ if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true)
act.theme.applyStyle(
R.style.LoadedStyle,
true
) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW
}
- private fun getNextFocus(
- act: Activity?,
+ /** because we want closes find, aka when multiple have the same id, we go to parent
+ until the correct one is found */
+ private fun localLook(from: View, id: Int): View? {
+ if (id == NO_ID) return null
+ var currentLook: View = from
+ // limit to 15 look depth
+ for (i in 0..15) {
+ currentLook.findViewById(id)?.let { return it }
+ currentLook = (currentLook.parent as? View) ?: break
+ }
+ return null
+ }
+ /*var currentLook: View = view
+ while (true) {
+ val tmpNext = currentLook.findViewById(nextId)
+ if (tmpNext != null) {
+ next = tmpNext
+ break
+ }
+ currentLook = currentLook.parent as? View ?: break
+ }*/
+
+ private fun View.hasContent(): Boolean {
+ return isShown && when (this) {
+ is ViewGroup -> this.isNotEmpty()
+ else -> true
+ }
+ }
+
+ /** skips the initial stage of searching for an id using the view, see getNextFocus for specification */
+ fun continueGetNextFocus(
+ root: Any?,
+ view: View,
+ direction: FocusDirection,
+ nextId: Int,
+ depth: Int = 0
+ ): View? {
+ if (nextId == NO_ID) return null
+
+ // do an initial search for the view, in case the localLook is too deep we can use this as
+ // an early break and backup view
+ var next =
+ when (root) {
+ is Activity -> root.findViewById(nextId)
+ is View -> root.rootView.findViewById(nextId)
+ else -> null
+ } ?: return null
+
+ next = localLook(view, nextId) ?: next
+ val shown = next.hasContent()
+
+ // if cant focus but visible then break and let android decide
+ // the exception if is the view is a parent and has children that wants focus
+ val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
+ parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty()
+ } ?: false
+ if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
+
+ // if not shown then continue because we will "skip" over views to get to a replacement
+ if (!shown) {
+ // we don't want a while true loop, so we let android decide if we find a recursive view
+ if (next == view) return null
+ return getNextFocus(root, next, direction, depth + 1)
+ }
+
+ (when (next) {
+ is ChipGroup -> {
+ next.children.firstOrNull { it.isFocusable && it.isShown }
+ }
+
+ is NavigationRailView -> {
+ next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home)
+ }
+
+ else -> null
+ })?.let {
+ return it
+ }
+
+ // nothing wrong with the view found, return it
+ return next
+ }
+
+ /** recursively looks for a next focus up to a depth of 10,
+ * this is used to override the normal shit focus system
+ * because this application has a lot of invisible views that messes with some tv devices*/
+ fun getNextFocus(
+ root: Any?,
view: View?,
direction: FocusDirection,
depth: Int = 0
- ): Int? {
- if (view == null || depth >= 10 || act == null) {
+ ): View? {
+ // if input is invalid let android decide + depth test to not crash if loop is found
+ if (view == null || depth >= 10 || root == null) {
return null
}
- val nextId = when (direction) {
- FocusDirection.Left -> {
- view.nextFocusLeftId
+ var nextId = when (direction) {
+ FocusDirection.Start -> {
+ if (view.isRtl())
+ view.nextFocusRightId
+ else
+ view.nextFocusLeftId
}
+
FocusDirection.Up -> {
view.nextFocusUpId
}
- FocusDirection.Right -> {
- view.nextFocusRightId
+
+ FocusDirection.End -> {
+ if (view.isRtl())
+ view.nextFocusLeftId
+ else
+ view.nextFocusRightId
}
+
FocusDirection.Down -> {
view.nextFocusDownId
}
}
- return if (nextId != -1) {
- val next = act.findViewById(nextId)
- //println("NAME: ${next.accessibilityClassName} | ${next?.isShown}" )
-
- if (next?.isShown == false) {
- getNextFocus(act, next, direction, depth + 1)
- } else {
- if (depth == 0) {
- null
- } else {
- nextId
- }
- }
- } else {
- null
+ if (nextId == NO_ID) {
+ // if not specified then use forward id
+ nextId = view.nextFocusForwardId
+ // if view is still not found to next focus then return and let android decide
+ if (nextId == NO_ID)
+ return null
}
+ return continueGetNextFocus(root, view, direction, nextId, depth)
}
- enum class FocusDirection {
- Left,
- Right,
- Up,
- Down,
- }
- fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
- //println("Keycode: $keyCode")
- //showToast(
- // this,
- // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
- // Toast.LENGTH_LONG
- //)
-
- // Tested keycodes on remote:
- // KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
- // KeyEvent.KEYCODE_MEDIA_REWIND
- // KeyEvent.KEYCODE_MENU
- // KeyEvent.KEYCODE_MEDIA_NEXT
- // KeyEvent.KEYCODE_MEDIA_PREVIOUS
- // KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
-
- // 149 keycode_numpad 5
- when (keyCode) {
- KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
- PlayerEventType.SeekForward
- }
- KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
- PlayerEventType.SeekBack
- }
- KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
- PlayerEventType.NextEpisode
- }
- KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> {
- PlayerEventType.PrevEpisode
- }
- KeyEvent.KEYCODE_MEDIA_PAUSE -> {
- PlayerEventType.Pause
- }
- KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
- PlayerEventType.Play
- }
- KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
- PlayerEventType.Lock
- }
- KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
- PlayerEventType.ToggleHide
- }
- KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
- PlayerEventType.ToggleMute
- }
- KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
- PlayerEventType.ShowMirrors
- }
- // OpenSubtitles shortcut
- KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
- PlayerEventType.SearchSubtitlesOnline
- }
- KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
- PlayerEventType.ShowSpeed
- }
- KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
- PlayerEventType.Resize
- }
- KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
- PlayerEventType.SkipOp
- }
- KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
- PlayerEventType.SkipCurrentChapter
- }
- KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
- PlayerEventType.PlayPauseToggle
- }
- else -> null
- }?.let { playerEvent ->
- playerEventListener?.invoke(playerEvent)
- }
-
- //when (keyCode) {
- // KeyEvent.KEYCODE_DPAD_CENTER -> {
- // println("DPAD PRESSED")
- // }
- //}
+ fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
+ return null
}
+ /** overrides focus and custom key events */
fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? {
if (act == null) return null
+ val currentFocus = act.currentFocus
+
event?.keyCode?.let { keyCode ->
- when (event.action) {
- KeyEvent.ACTION_DOWN -> {
- if (act.currentFocus != null) {
- val next = when (keyCode) {
- KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
- act,
- act.currentFocus,
- FocusDirection.Left
- )
- KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
- act,
- act.currentFocus,
- FocusDirection.Right
- )
- KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
- act,
- act.currentFocus,
- FocusDirection.Up
- )
- KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
- act,
- act.currentFocus,
- FocusDirection.Down
- )
-
- else -> null
- }
-
- if (next != null && next != -1) {
- val nextView = act.findViewById(next)
- if (nextView != null) {
- nextView.requestFocus()
- keyEventListener?.invoke(Pair(event, true))
- return true
- }
- }
-
- when (keyCode) {
- KeyEvent.KEYCODE_DPAD_CENTER -> {
- if (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) {
- UIHelper.showInputMethod(act.currentFocus?.findFocus())
- }
- }
- }
- }
- //println("Keycode: $keyCode")
- //showToast(
- // this,
- // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
- // Toast.LENGTH_LONG
- //)
- }
+ if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let
+ val nextView = when (keyCode) {
+ KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
+ act,
+ currentFocus,
+ FocusDirection.Start
+ )
+
+ KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
+ act,
+ currentFocus,
+ FocusDirection.End
+ )
+
+ KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
+ act,
+ currentFocus,
+ FocusDirection.Up
+ )
+
+ KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
+ act,
+ currentFocus,
+ FocusDirection.Down
+ )
+
+ else -> null
+ }
+
+ // println("NEXT FOCUS : $nextView")
+ if (nextView != null) {
+ nextView.requestFocus()
+ keyEventListener?.invoke(Pair(event, true))
+ return true
}
+
+ // TODO: Figure out why removing the check for SearchAutoComplete seems
+ // to break focus on TV as it shouldn't need to be used.
+ @SuppressLint("RestrictedApi")
+ if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
+ (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
+ ) {
+ showInputMethod(act.currentFocus?.findFocus())
+ }
+
+ //println("Keycode: $keyCode")
+ //showToast(
+ // this,
+ // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
+ // Toast.LENGTH_LONG
+ //)
}
+ // if someone else want to override the focus then don't handle the event as it is already
+ // consumed. used in video player
if (keyEventListener?.invoke(Pair(event, false)) == true) {
return true
}
return null
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
index 379a91e4c4a..8da7ca384b5 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
@@ -2,6 +2,7 @@ package com.lagradost.cloudstream3
import okhttp3.OkHttpClient
import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
@@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit
class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() {
- private val client: OkHttpClient
+ private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build()
override fun execute(request: Request): Response {
val httpMethod: String = request.httpMethod()
val url: String = request.url()
@@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
val dataToSend: ByteArray? = request.dataToSend()
var requestBody: RequestBody? = null
if (dataToSend != null) {
- requestBody = RequestBody.create(null, dataToSend)
+ requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size)
}
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.method(httpMethod, requestBody).url(url)
@@ -50,7 +51,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
companion object {
private const val USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
private var instance: DownloaderTestImpl? = null
/**
@@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
return instance
}
}
-
- init {
- client = builder.readTimeout(30, TimeUnit.SECONDS).build()
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt
deleted file mode 100644
index 1bcdf422e13..00000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/MainAPI.kt
+++ /dev/null
@@ -1,1517 +0,0 @@
-package com.lagradost.cloudstream3
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.net.Uri
-import android.util.Base64.encodeToString
-import androidx.annotation.WorkerThread
-import androidx.preference.PreferenceManager
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.databind.json.JsonMapper
-import com.fasterxml.jackson.module.kotlin.KotlinModule
-import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
-import com.lagradost.cloudstream3.ui.player.SubtitleData
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
-import com.lagradost.cloudstream3.utils.AppUtils.toJson
-import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import okhttp3.Interceptor
-import java.text.SimpleDateFormat
-import java.util.*
-import kotlin.math.absoluteValue
-
-const val USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
-
-//val baseHeader = mapOf("User-Agent" to USER_AGENT)
-val mapper = JsonMapper.builder().addModule(KotlinModule())
- .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!!
-
-/**
- * Defines the constant for the all languages preference, if this is set then it is
- * the equivalent of all languages being set
- **/
-const val AllLanguagesName = "universal"
-
-object APIHolder {
- val unixTime: Long
- get() = System.currentTimeMillis() / 1000L
- val unixTimeMS: Long
- get() = System.currentTimeMillis()
-
- private const val defProvider = 0
-
- // ConcurrentModificationException is possible!!!
- val allProviders = threadSafeListOf()
-
- fun initAll() {
- for (api in allProviders) {
- api.init()
- }
- apiMap = null
- }
-
- fun String.capitalize(): String {
- return this.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
- }
-
- var apis: List = threadSafeListOf()
- var apiMap: Map? = null
-
- fun addPluginMapping(plugin: MainAPI) {
- apis = apis + plugin
- initMap(true)
- }
-
- fun removePluginMapping(plugin: MainAPI) {
- apis = apis.filter { it != plugin }
- initMap(true)
- }
-
- private fun initMap(forcedUpdate: Boolean = false) {
- if (apiMap == null || forcedUpdate)
- apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap()
- }
-
- fun getApiFromNameNull(apiName: String?): MainAPI? {
- if (apiName == null) return null
- synchronized(allProviders) {
- initMap()
- return apiMap?.get(apiName)?.let { apis.getOrNull(it) }
- // Leave the ?. null check, it can crash regardless
- ?: allProviders.firstOrNull { it?.name == apiName }
- }
- }
-
- fun getApiFromUrlNull(url: String?): MainAPI? {
- if (url == null) return null
- synchronized(allProviders) {
- allProviders.forEach { api ->
- if (url.startsWith(api.mainUrl)) return api
- }
- }
- return null
- }
-
- private fun getLoadResponseIdFromUrl(url: String, apiName: String): Int {
- return url.replace(getApiFromNameNull(apiName)?.mainUrl ?: "", "").replace("/", "")
- .hashCode()
- }
-
- fun LoadResponse.getId(): Int {
- return getLoadResponseIdFromUrl(url, apiName)
- }
-
- /**
- * Gets the website captcha token
- * discovered originally by https://github.com/ahmedgamal17
- * optimized by https://github.com/justfoolingaround
- *
- * @param url the main url, likely the same website you found the key from.
- * @param key used to fill https://www.google.com/recaptcha/api.js?render=....
- *
- * @param referer the referer for the google.com/recaptcha/api.js... request, optional.
- * */
-
- // Try document.select("script[src*=https://www.google.com/recaptcha/api.js?render=]").attr("src").substringAfter("render=")
- // To get the key
- suspend fun getCaptchaToken(url: String, key: String, referer: String? = null): String? {
- try {
- val uri = Uri.parse(url)
- val domain = encodeToString(
- (uri.scheme + "://" + uri.host + ":443").encodeToByteArray(),
- 0
- ).replace("\n", "").replace("=", ".")
-
- val vToken =
- app.get(
- "https://www.google.com/recaptcha/api.js?render=$key",
- referer = referer,
- cacheTime = 0
- )
- .text
- .substringAfter("releases/")
- .substringBefore("/")
- val recapToken =
- app.get("https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=cs3&k=$key&co=$domain&v=$vToken")
- .document
- .selectFirst("#recaptcha-token")?.attr("value")
- if (recapToken != null) {
- return app.post(
- "https://www.google.com/recaptcha/api2/reload?k=$key",
- data = mapOf(
- "v" to vToken,
- "k" to key,
- "c" to recapToken,
- "co" to domain,
- "sa" to "",
- "reason" to "q"
- ), cacheTime = 0
- ).text
- .substringAfter("rresp\",\"")
- .substringBefore("\"")
- }
- } catch (e: Exception) {
- logError(e)
- }
- return null
- }
-
- fun Context.getApiSettings(): HashSet {
- //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
-
- val hashSet = HashSet()
- val activeLangs = getApiProviderLangSettings()
- val hasUniversal = activeLangs.contains(AllLanguagesName)
- hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) }
- .map { it.name })
-
- /*val set = settingsManager.getStringSet(
- this.getString(R.string.search_providers_list_key),
- hashSet
- )?.toHashSet() ?: hashSet
-
- val list = HashSet()
- for (name in set) {
- val api = getApiFromNameNull(name) ?: continue
- if (activeLangs.contains(api.lang)) {
- list.add(name)
- }
- }*/
- //if (list.isEmpty()) return hashSet
- //return list
- return hashSet
- }
-
- fun Context.getApiDubstatusSettings(): HashSet {
- val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val hashSet = HashSet()
- hashSet.addAll(DubStatus.values())
- val list = settingsManager.getStringSet(
- this.getString(R.string.display_sub_key),
- hashSet.map { it.name }.toMutableSet()
- ) ?: return hashSet
-
- val names = DubStatus.values().map { it.name }.toHashSet()
- //if(realSet.isEmpty()) return hashSet
-
- return list.filter { names.contains(it) }.map { DubStatus.valueOf(it) }.toHashSet()
- }
-
- fun Context.getApiProviderLangSettings(): HashSet {
- val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val hashSet = hashSetOf(AllLanguagesName) // def is all languages
-// hashSet.add("en") // def is only en
- val list = settingsManager.getStringSet(
- this.getString(R.string.provider_lang_key),
- hashSet
- )
-
- if (list.isNullOrEmpty()) return hashSet
- return list.toHashSet()
- }
-
- fun Context.getApiTypeSettings(): HashSet {
- val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val hashSet = HashSet()
- hashSet.addAll(TvType.values())
- val list = settingsManager.getStringSet(
- this.getString(R.string.search_types_list_key),
- hashSet.map { it.name }.toMutableSet()
- )
-
- if (list.isNullOrEmpty()) return hashSet
-
- val names = TvType.values().map { it.name }.toHashSet()
- val realSet = list.filter { names.contains(it) }.map { TvType.valueOf(it) }.toHashSet()
- if (realSet.isEmpty()) return hashSet
-
- return realSet
- }
-
- fun Context.updateHasTrailers() {
- LoadResponse.isTrailersEnabled = getHasTrailers()
- }
-
- private fun Context.getHasTrailers(): Boolean {
- if (isTvSettings()) return false
- val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true)
- }
-
- fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List {
- // We are getting the weirdest crash ever done:
- // java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType
- // Trying fixing using classloader fuckery
- val oldLoader = Thread.currentThread().contextClassLoader
- Thread.currentThread().contextClassLoader = TvType::class.java.classLoader
-
- val default = TvType.values()
- .sorted()
- .filter { it != TvType.NSFW }
- .map { it.ordinal }
-
- Thread.currentThread().contextClassLoader = oldLoader
-
- val defaultSet = default.map { it.toString() }.toSet()
- val currentPrefMedia = try {
- PreferenceManager.getDefaultSharedPreferences(this)
- .getStringSet(this.getString(R.string.prefer_media_type_key), defaultSet)
- ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null }
- } catch (e: Throwable) {
- null
- } ?: default
- val langs = this.getApiProviderLangSettings()
- val hasUniversal = langs.contains(AllLanguagesName)
- val allApis = apis.filter { hasUniversal || langs.contains(it.lang) }
- .filter { api -> api.hasMainPage || !hasHomePageIsRequired }
- return if (currentPrefMedia.isEmpty()) {
- allApis
- } else {
- // Filter API depending on preferred media type
- allApis.filter { api -> api.supportedTypes.any { currentPrefMedia.contains(it.ordinal) } }
- }
- }
-
- fun Context.filterSearchResultByFilmQuality(data: List): List {
- // Filter results omitting entries with certain quality
- if (data.isNotEmpty()) {
- val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this)
- ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf())
- ?.mapNotNull { entry ->
- entry.toIntOrNull() ?: return@mapNotNull null
- } ?: listOf()
- if (filteredSearchQuality.isNotEmpty()) {
- return data.filter { item ->
- val searchQualVal = item.quality?.ordinal ?: -1
- //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}")
- !filteredSearchQuality.contains(searchQualVal)
- }
- }
- }
- return data
- }
-
- fun Context.filterHomePageListByFilmQuality(data: HomePageList): HomePageList {
- // Filter results omitting entries with certain quality
- if (data.list.isNotEmpty()) {
- val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this)
- ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf())
- ?.mapNotNull { entry ->
- entry.toIntOrNull() ?: return@mapNotNull null
- } ?: listOf()
- if (filteredSearchQuality.isNotEmpty()) {
- return HomePageList(
- name = data.name,
- isHorizontalImages = data.isHorizontalImages,
- list = data.list.filter { item ->
- val searchQualVal = item.quality?.ordinal ?: -1
- //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}")
- !filteredSearchQuality.contains(searchQualVal)
- }
- )
- }
- }
- return data
- }
-}
-
-
-/*
-0 = Site not good
-1 = All good
-2 = Slow, heavy traffic
-3 = restricted, must donate 30 benenes to use
- */
-const val PROVIDER_STATUS_KEY = "PROVIDER_STATUS_KEY"
-const val PROVIDER_STATUS_BETA_ONLY = 3
-const val PROVIDER_STATUS_SLOW = 2
-const val PROVIDER_STATUS_OK = 1
-const val PROVIDER_STATUS_DOWN = 0
-
-data class ProvidersInfoJson(
- @JsonProperty("name") var name: String,
- @JsonProperty("url") var url: String,
- @JsonProperty("credentials") var credentials: String? = null,
- @JsonProperty("status") var status: Int,
-)
-
-data class SettingsJson(
- @JsonProperty("enableAdult") var enableAdult: Boolean = false,
-)
-
-
-data class MainPageData(
- val name: String,
- val data: String,
- val horizontalImages: Boolean = false
-)
-
-data class MainPageRequest(
- val name: String,
- val data: String,
- val horizontalImages: Boolean,
- //TODO genre selection or smth
-)
-
-fun mainPage(url: String, name: String, horizontalImages: Boolean = false): MainPageData {
- return MainPageData(name = name, data = url, horizontalImages = horizontalImages)
-}
-
-fun mainPageOf(vararg elements: MainPageData): List {
- return elements.toList()
-}
-
-/** return list of MainPageData with url to name, make for more readable code */
-fun mainPageOf(vararg elements: Pair): List {
- return elements.map { (url, name) -> MainPageData(name = name, data = url) }
-}
-
-fun newHomePageResponse(
- name: String,
- list: List,
- hasNext: Boolean? = null,
-): HomePageResponse {
- return HomePageResponse(
- listOf(HomePageList(name, list)),
- hasNext = hasNext ?: list.isNotEmpty()
- )
-}
-
-fun newHomePageResponse(
- data: MainPageRequest,
- list: List,
- hasNext: Boolean? = null,
-): HomePageResponse {
- return HomePageResponse(
- listOf(HomePageList(data.name, list, data.horizontalImages)),
- hasNext = hasNext ?: list.isNotEmpty()
- )
-}
-
-fun newHomePageResponse(list: HomePageList, hasNext: Boolean? = null): HomePageResponse {
- return HomePageResponse(listOf(list), hasNext = hasNext ?: list.list.isNotEmpty())
-}
-
-fun newHomePageResponse(list: List, hasNext: Boolean? = null): HomePageResponse {
- return HomePageResponse(list, hasNext = hasNext ?: list.any { it.list.isNotEmpty() })
-}
-
-/**Every provider will **not** have try catch built in, so handle exceptions when calling these functions*/
-abstract class MainAPI {
- companion object {
- var overrideData: HashMap? = null
- var settingsForProvider: SettingsJson = SettingsJson()
- }
-
- fun init() {
- overrideData?.get(this.javaClass.simpleName)?.let { data ->
- overrideWithNewData(data)
- }
- }
-
- fun overrideWithNewData(data: ProvidersInfoJson) {
- if (!canBeOverridden) return
- this.name = data.name
- if (data.url.isNotBlank() && data.url != "NONE")
- this.mainUrl = data.url
- this.storedCredentials = data.credentials
- }
-
- open var name = "NONE"
- open var mainUrl = "NONE"
- open var storedCredentials: String? = null
- open var canBeOverridden: Boolean = true
-
- /** if this is turned on then it will request the homepage one after the other,
- used to delay if they block many request at the same time*/
- open var sequentialMainPage: Boolean = false
-
- /** in milliseconds, this can be used to add more delay between homepage requests
- * on first load if sequentialMainPage is turned on */
- open var sequentialMainPageDelay: Long = 0L
-
- /** in milliseconds, this can be used to add more delay between homepage requests when scrolling */
- open var sequentialMainPageScrollDelay: Long = 0L
-
- /** used to keep track when last homepage request was in unixtime ms */
- var lastHomepageRequest: Long = 0L
-
- open var lang = "en" // ISO_639_1 check SubtitleHelper
-
- /**If link is stored in the "data" string, so links can be instantly loaded*/
- open val instantLinkLoading = false
-
- /**Set false if links require referer or for some reason cant be played on a chromecast*/
- open val hasChromecastSupport = true
-
- /**If all links are encrypted then set this to false*/
- open val hasDownloadSupport = true
-
- /**Used for testing and can be used to disable the providers if WebView is not available*/
- open val usesWebView = false
-
- /** Determines which plugin a given provider is from */
- var sourcePlugin: String? = null
-
- open val hasMainPage = false
- open val hasQuickSearch = false
-
- open val supportedTypes = setOf(
- TvType.Movie,
- TvType.TvSeries,
- TvType.Cartoon,
- TvType.Anime,
- TvType.OVA,
- )
-
- open val vpnStatus = VPNStatus.None
- open val providerType = ProviderType.DirectProvider
-
- //emptyList() //
- open val mainPage = listOf(MainPageData("", "", false))
-
- @WorkerThread
- open suspend fun getMainPage(
- page: Int,
- request: MainPageRequest,
- ): HomePageResponse? {
- throw NotImplementedError()
- }
-
- @WorkerThread
- open suspend fun search(query: String): List? {
- throw NotImplementedError()
- }
-
- @WorkerThread
- open suspend fun quickSearch(query: String): List? {
- throw NotImplementedError()
- }
-
- @WorkerThread
- /**
- * Based on data from search() or getMainPage() it generates a LoadResponse,
- * basically opening the info page from a link.
- * */
- open suspend fun load(url: String): LoadResponse? {
- throw NotImplementedError()
- }
-
- /**
- * Largely redundant feature for most providers.
- *
- * This job runs in the background when a link is playing in exoplayer.
- * First implemented to do polling for sflix to keep the link from getting expired.
- *
- * This function might be updated to include exoplayer timestamps etc in the future
- * if the need arises.
- * */
- @WorkerThread
- open suspend fun extractorVerifierJob(extractorData: String?) {
- throw NotImplementedError()
- }
-
- /**Callback is fired once a link is found, will return true if method is executed successfully*/
- @WorkerThread
- open suspend fun loadLinks(
- data: String,
- isCasting: Boolean,
- subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
- ): Boolean {
- throw NotImplementedError()
- }
-
- /** An okhttp interceptor for used in OkHttpDataSource */
- open fun getVideoInterceptor(extractorLink: ExtractorLink): Interceptor? {
- return null
- }
-}
-
-/** Might need a different implementation for desktop*/
-@SuppressLint("NewApi")
-fun base64Decode(string: String): String {
- return String(base64DecodeArray(string), Charsets.ISO_8859_1)
-}
-
-@SuppressLint("NewApi")
-fun base64DecodeArray(string: String): ByteArray {
- return try {
- android.util.Base64.decode(string, android.util.Base64.DEFAULT)
- } catch (e: Exception) {
- Base64.getDecoder().decode(string)
- }
-}
-
-@SuppressLint("NewApi")
-fun base64Encode(array: ByteArray): String {
- return try {
- String(android.util.Base64.encode(array, android.util.Base64.NO_WRAP), Charsets.ISO_8859_1)
- } catch (e: Exception) {
- String(Base64.getEncoder().encode(array))
- }
-}
-
-class ErrorLoadingException(message: String? = null) : Exception(message)
-
-fun MainAPI.fixUrlNull(url: String?): String? {
- if (url.isNullOrEmpty()) {
- return null
- }
- return fixUrl(url)
-}
-
-fun MainAPI.fixUrl(url: String): String {
- if (url.startsWith("http") ||
- // Do not fix JSON objects when passed as urls.
- url.startsWith("{\"")
- ) {
- return url
- }
- if (url.isEmpty()) {
- return ""
- }
-
- val startsWithNoHttp = url.startsWith("//")
- if (startsWithNoHttp) {
- return "https:$url"
- } else {
- if (url.startsWith('/')) {
- return mainUrl + url
- }
- return "$mainUrl/$url"
- }
-}
-
-fun sortUrls(urls: Set): List {
- return urls.sortedBy { t -> -t.quality }
-}
-
-fun sortSubs(subs: Set): List {
- return subs.sortedBy { it.name }
-}
-
-fun capitalizeString(str: String): String {
- return capitalizeStringNullable(str) ?: str
-}
-
-fun capitalizeStringNullable(str: String?): String? {
- if (str == null)
- return null
- return try {
- str.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
- } catch (e: Exception) {
- str
- }
-}
-
-fun fixTitle(str: String): String {
- return str.split(" ").joinToString(" ") {
- it.lowercase()
- .replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it }
- }
-}
-
-/** https://www.imdb.com/title/tt2861424/ -> tt2861424 */
-fun imdbUrlToId(url: String): String? {
- return Regex("/title/(tt[0-9]*)").find(url)?.groupValues?.get(1)
- ?: Regex("tt[0-9]{5,}").find(url)?.groupValues?.get(0)
-}
-
-fun imdbUrlToIdNullable(url: String?): String? {
- if (url == null) return null
- return imdbUrlToId(url)
-}
-
-enum class ProviderType {
- // When data is fetched from a 3rd party site like imdb
- MetaProvider,
-
- // When all data is from the site
- DirectProvider,
-}
-
-enum class VPNStatus {
- None,
- MightBeNeeded,
- Torrent,
-}
-
-enum class ShowStatus {
- Completed,
- Ongoing,
-}
-
-enum class DubStatus(val id: Int) {
- None(-1),
- Dubbed(1),
- Subbed(0),
-}
-
-enum class TvType(value: Int?) {
- Movie(1),
- AnimeMovie(2),
- TvSeries(3),
- Cartoon(4),
- Anime(5),
- OVA(6),
- Torrent(7),
- Documentary(8),
- AsianDrama(9),
- Live(10),
- NSFW(11),
- Others(12)
-}
-
-// IN CASE OF FUTURE ANIME MOVIE OR SMTH
-fun TvType.isMovieType(): Boolean {
- return this == TvType.Movie || this == TvType.AnimeMovie || this == TvType.Torrent || this == TvType.Live
-}
-
-fun TvType.isLiveStream(): Boolean {
- return this == TvType.Live
-}
-
-// returns if the type has an anime opening
-fun TvType.isAnimeOp(): Boolean {
- return this == TvType.Anime || this == TvType.OVA
-}
-
-data class SubtitleFile(val lang: String, val url: String)
-
-data class HomePageResponse(
- val items: List,
- val hasNext: Boolean = false
-)
-
-data class HomePageList(
- val name: String,
- var list: List,
- val isHorizontalImages: Boolean = false
-)
-
-enum class SearchQuality(value: Int?) {
- //https://en.wikipedia.org/wiki/Pirated_movie_release_types
- Cam(1),
- CamRip(2),
- HdCam(3),
- Telesync(4), // TS
- WorkPrint(5),
- Telecine(6), // TC
- HQ(7),
- HD(8),
- HDR(9), // high dynamic range
- BlueRay(10),
- DVD(11),
- SD(12),
- FourK(13),
- UHD(14),
- SDR(15), // standard dynamic range
- WebRip(16)
-}
-
-/**Add anything to here if you find a site that uses some specific naming convention*/
-fun getQualityFromString(string: String?): SearchQuality? {
- val check = (string ?: return null).trim().lowercase().replace(" ", "")
-
- return when (check) {
- "cam" -> SearchQuality.Cam
- "camrip" -> SearchQuality.CamRip
- "hdcam" -> SearchQuality.HdCam
- "hdtc" -> SearchQuality.HdCam
- "hdts" -> SearchQuality.HdCam
- "highquality" -> SearchQuality.HQ
- "hq" -> SearchQuality.HQ
- "highdefinition" -> SearchQuality.HD
- "hdrip" -> SearchQuality.HD
- "hd" -> SearchQuality.HD
- "hdtv" -> SearchQuality.HD
- "rip" -> SearchQuality.CamRip
- "telecine" -> SearchQuality.Telecine
- "tc" -> SearchQuality.Telecine
- "telesync" -> SearchQuality.Telesync
- "ts" -> SearchQuality.Telesync
- "dvd" -> SearchQuality.DVD
- "dvdrip" -> SearchQuality.DVD
- "dvdscr" -> SearchQuality.DVD
- "blueray" -> SearchQuality.BlueRay
- "bluray" -> SearchQuality.BlueRay
- "blu" -> SearchQuality.BlueRay
- "fhd" -> SearchQuality.HD
- "br" -> SearchQuality.BlueRay
- "standard" -> SearchQuality.SD
- "sd" -> SearchQuality.SD
- "4k" -> SearchQuality.FourK
- "uhd" -> SearchQuality.UHD // may also be 4k or 8k
- "blue" -> SearchQuality.BlueRay
- "wp" -> SearchQuality.WorkPrint
- "workprint" -> SearchQuality.WorkPrint
- "webrip" -> SearchQuality.WebRip
- "webdl" -> SearchQuality.WebRip
- "web" -> SearchQuality.WebRip
- "hdr" -> SearchQuality.HDR
- "sdr" -> SearchQuality.SDR
- else -> null
- }
-}
-
-interface SearchResponse {
- val name: String
- val url: String
- val apiName: String
- var type: TvType?
- var posterUrl: String?
- var posterHeaders: Map?
- var id: Int?
- var quality: SearchQuality?
-}
-
-fun MainAPI.newMovieSearchResponse(
- name: String,
- url: String,
- type: TvType = TvType.Movie,
- fix: Boolean = true,
- initializer: MovieSearchResponse.() -> Unit = { },
-): MovieSearchResponse {
- val builder = MovieSearchResponse(name, if (fix) fixUrl(url) else url, this.name, type)
- builder.initializer()
-
- return builder
-}
-
-fun MainAPI.newTvSeriesSearchResponse(
- name: String,
- url: String,
- type: TvType = TvType.TvSeries,
- fix: Boolean = true,
- initializer: TvSeriesSearchResponse.() -> Unit = { },
-): TvSeriesSearchResponse {
- val builder = TvSeriesSearchResponse(name, if (fix) fixUrl(url) else url, this.name, type)
- builder.initializer()
-
- return builder
-}
-
-
-fun MainAPI.newAnimeSearchResponse(
- name: String,
- url: String,
- type: TvType = TvType.Anime,
- fix: Boolean = true,
- initializer: AnimeSearchResponse.() -> Unit = { },
-): AnimeSearchResponse {
- val builder = AnimeSearchResponse(name, if (fix) fixUrl(url) else url, this.name, type)
- builder.initializer()
-
- return builder
-}
-
-fun SearchResponse.addQuality(quality: String) {
- this.quality = getQualityFromString(quality)
-}
-
-fun SearchResponse.addPoster(url: String?, headers: Map? = null) {
- this.posterUrl = url
- this.posterHeaders = headers
-}
-
-fun LoadResponse.addPoster(url: String?, headers: Map? = null) {
- this.posterUrl = url
- this.posterHeaders = headers
-}
-
-enum class ActorRole {
- Main,
- Supporting,
- Background,
-}
-
-data class Actor(
- val name: String,
- val image: String? = null,
-)
-
-data class ActorData(
- val actor: Actor,
- val role: ActorRole? = null,
- val roleString: String? = null,
- val voiceActor: Actor? = null,
-)
-
-data class AnimeSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType? = null,
-
- override var posterUrl: String? = null,
- var year: Int? = null,
- var dubStatus: EnumSet? = null,
-
- var otherName: String? = null,
- var episodes: MutableMap = mutableMapOf(),
-
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
-) : SearchResponse
-
-fun AnimeSearchResponse.addDubStatus(status: DubStatus, episodes: Int? = null) {
- this.dubStatus = dubStatus?.also { it.add(status) } ?: EnumSet.of(status)
- if (this.type?.isMovieType() != true)
- if (episodes != null && episodes > 0)
- this.episodes[status] = episodes
-}
-
-fun AnimeSearchResponse.addDubStatus(isDub: Boolean, episodes: Int? = null) {
- addDubStatus(if (isDub) DubStatus.Dubbed else DubStatus.Subbed, episodes)
-}
-
-fun AnimeSearchResponse.addDub(episodes: Int?) {
- if (episodes == null || episodes <= 0) return
- addDubStatus(DubStatus.Dubbed, episodes)
-}
-
-fun AnimeSearchResponse.addSub(episodes: Int?) {
- if (episodes == null || episodes <= 0) return
- addDubStatus(DubStatus.Subbed, episodes)
-}
-
-fun AnimeSearchResponse.addDubStatus(
- dubExist: Boolean,
- subExist: Boolean,
- dubEpisodes: Int? = null,
- subEpisodes: Int? = null
-) {
- if (dubExist)
- addDubStatus(DubStatus.Dubbed, dubEpisodes)
-
- if (subExist)
- addDubStatus(DubStatus.Subbed, subEpisodes)
-}
-
-fun AnimeSearchResponse.addDubStatus(status: String, episodes: Int? = null) {
- if (status.contains("(dub)", ignoreCase = true)) {
- addDubStatus(DubStatus.Dubbed, episodes)
- } else if (status.contains("(sub)", ignoreCase = true)) {
- addDubStatus(DubStatus.Subbed, episodes)
- }
-}
-
-data class TorrentSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType?,
-
- override var posterUrl: String?,
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
-) : SearchResponse
-
-data class MovieSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType? = null,
-
- override var posterUrl: String? = null,
- var year: Int? = null,
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
-) : SearchResponse
-
-data class LiveSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType? = null,
-
- override var posterUrl: String? = null,
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
- val lang: String? = null,
-) : SearchResponse
-
-data class TvSeriesSearchResponse(
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType? = null,
-
- override var posterUrl: String? = null,
- val year: Int? = null,
- val episodes: Int? = null,
- override var id: Int? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
-) : SearchResponse
-
-data class TrailerData(
- val extractorUrl: String,
- val referer: String?,
- val raw: Boolean,
- //var mirros: List,
- //var subtitles: List = emptyList(),
-)
-
-interface LoadResponse {
- var name: String
- var url: String
- var apiName: String
- var type: TvType
- var posterUrl: String?
- var year: Int?
- var plot: String?
- var rating: Int? // 0-10000
- var tags: List?
- var duration: Int? // in minutes
- var trailers: MutableList
-
- var recommendations: List?
- var actors: List?
- var comingSoon: Boolean
- var syncData: MutableMap
- var posterHeaders: Map?
- var backgroundPosterUrl: String?
-
- companion object {
- private val malIdPrefix = malApi.idPrefix
- private val aniListIdPrefix = aniListApi.idPrefix
- var isTrailersEnabled = true
-
- fun LoadResponse.isMovie(): Boolean {
- return this.type.isMovieType()
- }
-
- @JvmName("addActorNames")
- fun LoadResponse.addActors(actors: List?) {
- this.actors = actors?.map { ActorData(Actor(it)) }
- }
-
- @JvmName("addActors")
- fun LoadResponse.addActors(actors: List>?) {
- this.actors = actors?.map { (actor, role) -> ActorData(actor, roleString = role) }
- }
-
- @JvmName("addActorsRole")
- fun LoadResponse.addActors(actors: List>?) {
- this.actors = actors?.map { (actor, role) -> ActorData(actor, role = role) }
- }
-
- @JvmName("addActorsOnly")
- fun LoadResponse.addActors(actors: List?) {
- this.actors = actors?.map { actor -> ActorData(actor) }
- }
-
- fun LoadResponse.getMalId(): String? {
- return this.syncData[malIdPrefix]
- }
-
- fun LoadResponse.getAniListId(): String? {
- return this.syncData[aniListIdPrefix]
- }
-
- fun LoadResponse.addMalId(id: Int?) {
- this.syncData[malIdPrefix] = (id ?: return).toString()
- }
-
- fun LoadResponse.addAniListId(id: Int?) {
- this.syncData[aniListIdPrefix] = (id ?: return).toString()
- }
-
- fun LoadResponse.addImdbUrl(url: String?) {
- addImdbId(imdbUrlToIdNullable(url))
- }
-
- /**better to call addTrailer with mutible trailers directly instead of calling this multiple times*/
- suspend fun LoadResponse.addTrailer(
- trailerUrl: String?,
- referer: String? = null,
- addRaw: Boolean = false
- ) {
- if (!isTrailersEnabled || trailerUrl.isNullOrBlank()) return
- this.trailers.add(TrailerData(trailerUrl, referer, addRaw))
- /*val links = arrayListOf()
- val subs = arrayListOf()
- if (!loadExtractor(
- trailerUrl,
- referer,
- { subs.add(it) },
- { links.add(it) }) && addRaw
- ) {
- this.trailers.add(
- TrailerData(
- listOf(
- ExtractorLink(
- "",
- "Trailer",
- trailerUrl,
- referer ?: "",
- Qualities.Unknown.value,
- trailerUrl.contains(".m3u8")
- )
- ), listOf()
- )
- )
- } else {
- this.trailers.add(TrailerData(links, subs))
- }*/
- }
-
- /*
- fun LoadResponse.addTrailer(newTrailers: List) {
- trailers.addAll(newTrailers.map { TrailerData(listOf(it)) })
- }*/
-
- suspend fun LoadResponse.addTrailer(
- trailerUrls: List?,
- referer: String? = null,
- addRaw: Boolean = false
- ) {
- if (!isTrailersEnabled || trailerUrls == null) return
- trailers.addAll(trailerUrls.map { TrailerData(it, referer, addRaw) })
- /*val trailers = trailerUrls.filter { it.isNotBlank() }.amap { trailerUrl ->
- val links = arrayListOf()
- val subs = arrayListOf()
- if (!loadExtractor(
- trailerUrl,
- referer,
- { subs.add(it) },
- { links.add(it) }) && addRaw
- ) {
- arrayListOf(
- ExtractorLink(
- "",
- "Trailer",
- trailerUrl,
- referer ?: "",
- Qualities.Unknown.value,
- trailerUrl.contains(".m3u8")
- )
- ) to arrayListOf()
- } else {
- links to subs
- }
- }.map { (links, subs) -> TrailerData(links, subs) }
- this.trailers.addAll(trailers)*/
- }
-
- fun LoadResponse.addImdbId(id: String?) {
- // TODO add imdb sync
- }
-
- fun LoadResponse.addTrackId(id: String?) {
- // TODO add trackt sync
- }
-
- fun LoadResponse.addkitsuId(id: String?) {
- // TODO add kitsu sync
- }
-
- fun LoadResponse.addTMDbId(id: String?) {
- // TODO add TMDb sync
- }
-
- fun LoadResponse.addRating(text: String?) {
- addRating(text.toRatingInt())
- }
-
- fun LoadResponse.addRating(value: Int?) {
- if ((value ?: return) < 0 || value > 10000) {
- return
- }
- this.rating = value
- }
-
- fun LoadResponse.addDuration(input: String?) {
- this.duration = getDurationFromString(input) ?: this.duration
- }
- }
-}
-
-fun getDurationFromString(input: String?): Int? {
- val cleanInput = input?.trim()?.replace(" ", "") ?: return null
- //Use first as sometimes the text passes on the 2 other Regex, but failed to provide accurate return value
- Regex("(\\d+\\shr)|(\\d+\\shour)|(\\d+\\smin)|(\\d+\\ssec)").findAll(input).let { values ->
- var seconds = 0
- values.forEach {
- val time_text = it.value
- if (time_text.isNotBlank()) {
- val time = time_text.filter { s -> s.isDigit() }.trim().toInt()
- val scale = time_text.filter { s -> !s.isDigit() }.trim()
- //println("Scale: $scale")
- val timeval = when (scale) {
- "hr", "hour" -> time * 60 * 60
- "min" -> time * 60
- "sec" -> time
- else -> 0
- }
- seconds += timeval
- }
- }
- if (seconds > 0) {
- return seconds / 60
- }
- }
- Regex("([0-9]*)h.*?([0-9]*)m").find(cleanInput)?.groupValues?.let { values ->
- if (values.size == 3) {
- val hours = values[1].toIntOrNull()
- val minutes = values[2].toIntOrNull()
- if (minutes != null && hours != null) {
- return hours * 60 + minutes
- }
- }
- }
- Regex("([0-9]*)m").find(cleanInput)?.groupValues?.let { values ->
- if (values.size == 2) {
- val return_value = values[1].toIntOrNull()
- if (return_value != null) {
- return return_value
- }
- }
- }
- return null
-}
-
-fun LoadResponse?.isEpisodeBased(): Boolean {
- if (this == null) return false
- return this is EpisodeResponse && this.type.isEpisodeBased()
-}
-
-fun LoadResponse?.isAnimeBased(): Boolean {
- if (this == null) return false
- return (this.type == TvType.Anime || this.type == TvType.OVA) // && (this is AnimeLoadResponse)
-}
-
-fun TvType?.isEpisodeBased(): Boolean {
- if (this == null) return false
- return (this == TvType.TvSeries || this == TvType.Anime)
-}
-
-
-data class NextAiring(
- val episode: Int,
- val unixTime: Long,
-)
-
-/**
- * @param season To be mapped with episode season, not shown in UI if displaySeason is defined
- * @param name To be shown next to the season like "Season $displaySeason $name" but if displaySeason is null then "$name"
- * @param displaySeason What to be displayed next to the season name, if null then the name is the only thing shown.
- * */
-data class SeasonData(
- val season: Int,
- val name: String? = null,
- val displaySeason: Int? = null, // will use season if null
-)
-
-interface EpisodeResponse {
- var showStatus: ShowStatus?
- var nextAiring: NextAiring?
- var seasonNames: List?
-}
-
-@JvmName("addSeasonNamesString")
-fun EpisodeResponse.addSeasonNames(names: List) {
- this.seasonNames = if (names.isEmpty()) null else names.mapIndexed { index, s ->
- SeasonData(
- season = index + 1,
- s
- )
- }
-}
-
-@JvmName("addSeasonNamesSeasonData")
-fun EpisodeResponse.addSeasonNames(names: List) {
- this.seasonNames = names.ifEmpty { null }
-}
-
-data class TorrentLoadResponse(
- override var name: String,
- override var url: String,
- override var apiName: String,
- var magnet: String?,
- var torrent: String?,
- override var plot: String?,
- override var type: TvType = TvType.Torrent,
- override var posterUrl: String? = null,
- override var year: Int? = null,
- override var rating: Int? = null,
- override var tags: List? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse
-
-data class AnimeLoadResponse(
- var engName: String? = null,
- var japName: String? = null,
- override var name: String,
- override var url: String,
- override var apiName: String,
- override var type: TvType,
-
- override var posterUrl: String? = null,
- override var year: Int? = null,
-
- var episodes: MutableMap> = mutableMapOf(),
- override var showStatus: ShowStatus? = null,
-
- override var plot: String? = null,
- override var tags: List? = null,
- var synonyms: List? = null,
-
- override var rating: Int? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var nextAiring: NextAiring? = null,
- override var seasonNames: List? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse, EpisodeResponse
-
-/**
- * If episodes already exist appends the list.
- * */
-fun AnimeLoadResponse.addEpisodes(status: DubStatus, episodes: List?) {
- if (episodes.isNullOrEmpty()) return
- this.episodes[status] = (this.episodes[status] ?: emptyList()) + episodes
-}
-
-suspend fun MainAPI.newAnimeLoadResponse(
- name: String,
- url: String,
- type: TvType,
- comingSoonIfNone: Boolean = true,
- initializer: suspend AnimeLoadResponse.() -> Unit = { },
-): AnimeLoadResponse {
- val builder = AnimeLoadResponse(name = name, url = url, apiName = this.name, type = type)
- builder.initializer()
- if (comingSoonIfNone) {
- builder.comingSoon = true
- for (key in builder.episodes.keys)
- if (!builder.episodes[key].isNullOrEmpty()) {
- builder.comingSoon = false
- break
- }
- }
- return builder
-}
-
-data class LiveStreamLoadResponse(
- override var name: String,
- override var url: String,
- override var apiName: String,
- var dataUrl: String,
-
- override var posterUrl: String? = null,
- override var year: Int? = null,
- override var plot: String? = null,
-
- override var type: TvType = TvType.Live,
- override var rating: Int? = null,
- override var tags: List? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse
-
-data class MovieLoadResponse(
- override var name: String,
- override var url: String,
- override var apiName: String,
- override var type: TvType,
- var dataUrl: String,
-
- override var posterUrl: String? = null,
- override var year: Int? = null,
- override var plot: String? = null,
-
- override var rating: Int? = null,
- override var tags: List? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse
-
-suspend fun MainAPI.newMovieLoadResponse(
- name: String,
- url: String,
- type: TvType,
- data: T?,
- initializer: suspend MovieLoadResponse.() -> Unit = { }
-): MovieLoadResponse {
- // just in case
- if (data is String) return newMovieLoadResponse(
- name,
- url,
- type,
- dataUrl = data,
- initializer = initializer
- )
- val dataUrl = data?.toJson() ?: ""
- val builder = MovieLoadResponse(
- name = name,
- url = url,
- apiName = this.name,
- type = type,
- dataUrl = dataUrl,
- comingSoon = dataUrl.isBlank()
- )
- builder.initializer()
- return builder
-}
-
-suspend fun MainAPI.newMovieLoadResponse(
- name: String,
- url: String,
- type: TvType,
- dataUrl: String,
- initializer: suspend MovieLoadResponse.() -> Unit = { }
-): MovieLoadResponse {
- val builder = MovieLoadResponse(
- name = name,
- url = url,
- apiName = this.name,
- type = type,
- dataUrl = dataUrl,
- comingSoon = dataUrl.isBlank()
- )
- builder.initializer()
- return builder
-}
-
-data class Episode(
- var data: String,
- var name: String? = null,
- var season: Int? = null,
- var episode: Int? = null,
- var posterUrl: String? = null,
- var rating: Int? = null,
- var description: String? = null,
- var date: Long? = null,
-)
-
-fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") {
- try {
- this.date = SimpleDateFormat(format)?.parse(date ?: return)?.time
- } catch (e: Exception) {
- logError(e)
- }
-}
-
-fun Episode.addDate(date: Date?) {
- this.date = date?.time
-}
-
-fun MainAPI.newEpisode(
- url: String,
- initializer: Episode.() -> Unit = { },
- fix: Boolean = true,
-): Episode {
- val builder = Episode(
- data = if (fix) fixUrl(url) else url
- )
- builder.initializer()
- return builder
-}
-
-fun MainAPI.newEpisode(
- data: T,
- initializer: Episode.() -> Unit = { }
-): Episode {
- if (data is String) return newEpisode(
- url = data,
- initializer = initializer
- ) // just in case java is wack
-
- val builder = Episode(
- data = data?.toJson() ?: throw ErrorLoadingException("invalid newEpisode")
- )
- builder.initializer()
- return builder
-}
-
-data class TvSeriesLoadResponse(
- override var name: String,
- override var url: String,
- override var apiName: String,
- override var type: TvType,
- var episodes: List,
-
- override var posterUrl: String? = null,
- override var year: Int? = null,
- override var plot: String? = null,
-
- override var showStatus: ShowStatus? = null,
- override var rating: Int? = null,
- override var tags: List? = null,
- override var duration: Int? = null,
- override var trailers: MutableList = mutableListOf(),
- override var recommendations: List? = null,
- override var actors: List? = null,
- override var comingSoon: Boolean = false,
- override var syncData: MutableMap = mutableMapOf(),
- override var posterHeaders: Map? = null,
- override var nextAiring: NextAiring? = null,
- override var seasonNames: List? = null,
- override var backgroundPosterUrl: String? = null,
-) : LoadResponse, EpisodeResponse
-
-suspend fun MainAPI.newTvSeriesLoadResponse(
- name: String,
- url: String,
- type: TvType,
- episodes: List,
- initializer: suspend TvSeriesLoadResponse.() -> Unit = { }
-): TvSeriesLoadResponse {
- val builder = TvSeriesLoadResponse(
- name = name,
- url = url,
- apiName = this.name,
- type = type,
- episodes = episodes,
- comingSoon = episodes.isEmpty(),
- )
- builder.initializer()
- return builder
-}
-
-fun fetchUrls(text: String?): List {
- if (text.isNullOrEmpty()) {
- return listOf()
- }
- val linkRegex =
- Regex("""(https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))""")
- return linkRegex.findAll(text).map { it.value.trim().removeSurrounding("\"") }.toList()
-}
-
-fun String?.toRatingInt(): Int? =
- this?.replace(" ", "")?.trim()?.toDoubleOrNull()?.absoluteValue?.times(1000f)?.toInt()
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
index 9a591580b53..8a98bd2972e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
@@ -1,21 +1,44 @@
package com.lagradost.cloudstream3
-import android.content.ComponentName
+import android.animation.ValueAnimator
+import android.annotation.SuppressLint
+import android.app.Dialog
+import android.content.Context
import android.content.Intent
+import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.content.res.Configuration
+import android.graphics.Rect
import android.os.Bundle
+import android.util.AttributeSet
import android.util.Log
+import android.view.Gravity
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
import android.view.WindowManager
+import android.widget.CheckBox
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IdRes
+import androidx.annotation.MainThread
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
+import androidx.cardview.widget.CardView
+import androidx.core.content.edit
+import androidx.core.net.toUri
+import androidx.core.view.children
+import androidx.core.view.get
+import androidx.core.view.isGone
+import androidx.core.view.isInvisible
import androidx.core.view.isVisible
+import androidx.core.view.marginStart
import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
@@ -24,202 +47,192 @@ import androidx.navigation.NavOptions
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
-import com.google.android.gms.cast.framework.*
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.LinearSnapHelper
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewpager2.widget.ViewPager2
+import com.google.android.gms.cast.framework.CastContext
+import com.google.android.gms.cast.framework.Session
+import com.google.android.gms.cast.framework.SessionManager
+import com.google.android.gms.cast.framework.SessionManagerListener
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.navigationrail.NavigationRailView
+import com.google.android.material.snackbar.Snackbar
+import com.google.common.collect.Comparators.min
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.APIHolder.apis
-import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.initAll
-import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
-import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
-import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.loadThemes
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint
+import com.lagradost.cloudstream3.CommonActivity.screenHeight
+import com.lagradost.cloudstream3.CommonActivity.setActivityInstance
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.CommonActivity.updateLocale
+import com.lagradost.cloudstream3.CommonActivity.updateTheme
+import com.lagradost.cloudstream3.actions.temp.fcast.FcastManager
+import com.lagradost.cloudstream3.databinding.ActivityMainBinding
+import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding
+import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
+import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
+import com.lagradost.cloudstream3.mvvm.safe
+import com.lagradost.cloudstream3.mvvm.observe
+import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.network.initClient
import com.lagradost.cloudstream3.plugins.PluginManager
-import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins
+import com.lagradost.cloudstream3.plugins.PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths
+import com.lagradost.cloudstream3.services.SubscriptionWorkManager
+import com.lagradost.cloudstream3.syncproviders.AccountManager
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi
+import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.APIRepository
+import com.lagradost.cloudstream3.ui.SyncWatchType
+import com.lagradost.cloudstream3.ui.WatchType
+import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
import com.lagradost.cloudstream3.ui.home.HomeViewModel
+import com.lagradost.cloudstream3.ui.library.LibraryViewModel
+import com.lagradost.cloudstream3.ui.player.BasicLink
+import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
+import com.lagradost.cloudstream3.ui.player.LinkGenerator
+import com.lagradost.cloudstream3.ui.result.LinearListLayout
+import com.lagradost.cloudstream3.ui.result.ResultViewModel2
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
+import com.lagradost.cloudstream3.ui.result.SyncViewModel
import com.lagradost.cloudstream3.ui.search.SearchFragment
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY
import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions
-import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable
-import com.lagradost.cloudstream3.utils.AppUtils.loadCache
-import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
-import com.lagradost.cloudstream3.utils.AppUtils.loadResult
-import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
+import com.lagradost.cloudstream3.utils.ApkInstaller
+import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
+import com.lagradost.cloudstream3.utils.AppContextUtils.html
+import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
+import com.lagradost.cloudstream3.utils.AppContextUtils.isLtr
+import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable
+import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
+import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
+import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
+import com.lagradost.cloudstream3.utils.BackupUtils.backup
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
+import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.setKey
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
import com.lagradost.cloudstream3.utils.Event
-import com.lagradost.cloudstream3.utils.IOnBackPressed
-import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
+import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
+import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate
+import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
+import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
+import com.lagradost.cloudstream3.utils.TvChannelUtils
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
-import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
+import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
+import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
+import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
+import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
+import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
-import com.lagradost.nicehttp.Requests
-import com.lagradost.nicehttp.ResponseParser
-import kotlinx.android.synthetic.main.activity_main.*
-import kotlinx.android.synthetic.main.fragment_result_swipe.*
+import com.lagradost.cloudstream3.utils.setText
+import com.lagradost.cloudstream3.utils.setTextHtml
+import com.lagradost.cloudstream3.utils.txt
+import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
+import java.lang.ref.WeakReference
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.Charset
-import kotlin.reflect.KClass
-
-
-//https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
-//https://wiki.videolan.org/Android_Player_Intents/
-
-//https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904
-//https://mpv-android.github.io/mpv-android/intent.html
-
-// https://www.webvideocaster.com/integrations
-
-//https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225
-
-const val VLC_PACKAGE = "org.videolan.vlc"
-const val MPV_PACKAGE = "is.xyz.mpv"
-const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo"
-
-val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity")
-val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity")
-
-//TODO REFACTOR AF
-open class ResultResume(
- val packageString: String,
- val action: String = Intent.ACTION_VIEW,
- val position: String? = null,
- val duration: String? = null,
- var launcher: ActivityResultLauncher? = null,
-) {
- val defaultTime = -1L
-
- val lastId get() = "${packageString}_last_open_id"
- suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) {
- val intent = Intent(action)
-
- if (id != null)
- setKey(lastId, id)
- else
- removeKey(lastId)
-
- intent.setPackage(packageString)
- callback.invoke(intent)
- launcher?.launch(intent)
- }
-
- open fun getPosition(intent: Intent?): Long {
- return defaultTime
- }
-
- open fun getDuration(intent: Intent?): Long {
- return defaultTime
- }
-}
-
-val VLC = object : ResultResume(
- VLC_PACKAGE,
- "org.videolan.vlc.player.result",
- "extra_position",
- "extra_duration",
-) {
- override fun getPosition(intent: Intent?): Long {
- return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime
- }
-
- override fun getDuration(intent: Intent?): Long {
- return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime
- }
-}
-
-val MPV = object : ResultResume(
- MPV_PACKAGE,
- //"is.xyz.mpv.MPVActivity.result", // resume not working :pensive:
- position = "position",
- duration = "duration",
-) {
- override fun getPosition(intent: Intent?): Long {
- return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong() ?: defaultTime
- }
-
- override fun getDuration(intent: Intent?): Long {
- return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong() ?: defaultTime
- }
-}
-
-val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE)
+import kotlin.math.abs
+import kotlin.math.absoluteValue
+import kotlin.system.exitProcess
+import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+
+class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
+ companion object {
+ var activityResultLauncher: ActivityResultLauncher? = null
-val resumeApps = arrayOf(
- VLC, MPV, WEB_VIDEO
-)
+ const val TAG = "MAINACT"
+ const val ANIMATED_OUTLINE: Boolean = false
+ var lastError: String? = null
-// Short name for requests client to make it nicer to use
+ /** Update lastError variable based on error file, to check if app crashed.
+ * Can be called multiple times without changing the lastError variable changing.
+ **/
+ fun setLastError(context: Context) {
+ if (lastError != null) return
+
+ val errorFile = context.filesDir.resolve("last_error")
+ if (errorFile.exists() && errorFile.isFile) {
+ lastError = errorFile.readText(Charset.defaultCharset())
+ errorFile.delete()
+ } else {
+ lastError = null
+ }
+ }
-var app = Requests(responseParser = object : ResponseParser {
- val mapper: ObjectMapper = jacksonObjectMapper().configure(
- DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
- false
- )
+ private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
+ const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
- override fun parse(text: String, kClass: KClass): T {
- return mapper.readValue(text, kClass.java)
- }
+ /**
+ * Transient files to delete on application exit.
+ * Deletes files on onDestroy().
+ */
+ private var filesToDelete: Set
+ // This needs to be persistent because the application may exit without calling onDestroy.
+ get() = getKey>(FILE_DELETE_KEY) ?: setOf()
+ private set(value) = setKey(FILE_DELETE_KEY, value)
- override fun parseSafe(text: String, kClass: KClass): T? {
- return try {
- mapper.readValue(text, kClass.java)
- } catch (e: Exception) {
- null
+ /**
+ * Add file to delete on Exit.
+ */
+ fun deleteFileOnExit(file: File) {
+ filesToDelete = filesToDelete + file.path
}
- }
-
- override fun writeValueAsString(obj: Any): String {
- return mapper.writeValueAsString(obj)
- }
-}).apply {
- defaultHeaders = mapOf("user-agent" to USER_AGENT)
-}
-
-class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
- companion object {
- const val TAG = "MAINACT"
/**
* Setting this will automatically enter the query in the search
@@ -228,7 +241,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
*
* This is a very bad solution but I was unable to find a better one.
**/
- private var nextSearchQuery: String? = null
+ var nextSearchQuery: String? = null
/**
* Fires every time a new batch of plugins have been loaded, no guarantee about how often this is run and on which thread
@@ -241,6 +254,24 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
Event() // homepage api, used to speed up time to load for homepage
val afterRepositoryLoadedEvent = Event()
+ // kinda shitty solution, but cant com main->home otherwise for popups
+ val bookmarksUpdatedEvent = Event()
+
+ /**
+ * Used by DataStoreHelper to fully reload home when switching accounts
+ */
+ val reloadHomeEvent = Event()
+
+ /**
+ * Used by DataStoreHelper to fully reload library when switching accounts
+ */
+ val reloadLibraryEvent = Event()
+
+ /**
+ * Used by DataStoreHelper to fully reload Navigation Rail header picture
+ */
+ val reloadAccountEvent = Event()
+
/**
* @return true if the str has launched an app task (be it successful or not)
* @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
@@ -248,11 +279,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
fun handleAppIntentUrl(
activity: FragmentActivity?,
str: String?,
- isWebview: Boolean
+ isWebview: Boolean,
+ extraArgs: Bundle? = null
): Boolean =
with(activity) {
+ // TODO MUCH BETTER HANDLING
+
// Invalid URIs can crash
- fun safeURI(uri: String) = normalSafeApiCall { URI(uri) }
+ fun safeURI(uri: String) = safe { URI(uri) }
if (str != null && this != null) {
if (str.startsWith("https://cs.repo")) {
@@ -260,30 +294,30 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
println("Repository url: $realUrl")
loadRepository(realUrl)
return true
- } else if (str.contains(appString)) {
- for (api in OAuth2Apis) {
- if (str.contains("/${api.redirectUrl}")) {
+ } else if (str.contains(APP_STRING)) {
+ for (api in AccountManager.allApis) {
+ if (api.isValidRedirectUrl(str)) {
ioSafe {
Log.i(TAG, "handleAppIntent $str")
- val isSuccessful = api.handleRedirect(str)
-
- if (isSuccessful) {
- Log.i(TAG, "authenticated ${api.name}")
- } else {
- Log.i(TAG, "failed to authenticate ${api.name}")
- }
-
- this@with.runOnUiThread {
- try {
- showToast(
- this@with,
- getString(if (isSuccessful) R.string.authenticated_user else R.string.authenticated_user_fail).format(
- api.name
- )
- )
- } catch (e: Exception) {
- logError(e) // format might fail
+ try {
+ val isSuccessful = api.login(str)
+ if (isSuccessful) {
+ Log.i(TAG, "authenticated ${api.name}")
+ } else {
+ Log.i(TAG, "failed to authenticate ${api.name}")
}
+ showToast(
+ if (isSuccessful) {
+ txt(R.string.authenticated_user, api.name)
+ } else {
+ txt(R.string.authenticated_user_fail, api.name)
+ }
+ )
+ } catch (t: Throwable) {
+ logError(t)
+ showToast(
+ txt(R.string.authenticated_user_fail, api.name)
+ )
}
}
return true
@@ -291,20 +325,50 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
// This specific intent is used for the gradle deployWithAdb
// https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46
- if (str == "$appString:") {
- PluginManager.hotReloadAllLocalPlugins(activity)
+ if (str == "$APP_STRING:") {
+ ioSafe {
+ PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(
+ activity
+ )
+ }
}
- } else if (safeURI(str)?.scheme == appStringRepo) {
- val url = str.replaceFirst(appStringRepo, "https")
+ } else if (safeURI(str)?.scheme == APP_STRING_REPO) {
+ val url = str.replaceFirst(APP_STRING_REPO, "https")
loadRepository(url)
return true
- } else if (safeURI(str)?.scheme == appStringSearch) {
+ } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) {
+ val query = str.substringAfter("$APP_STRING_SEARCH://")
nextSearchQuery =
- URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8")
- nav_view.selectedItemId = R.id.navigation_search
- } else if (safeURI(str)?.scheme == appStringResumeWatching) {
+ try {
+ URLDecoder.decode(query, "UTF-8")
+ } catch (t: Throwable) {
+ logError(t)
+ query
+ }
+ // Use both navigation views to support both layouts.
+ // It might be better to use the QuickSearch.
+ activity?.findViewById(R.id.nav_view)?.selectedItemId =
+ R.id.navigation_search
+ activity?.findViewById(R.id.nav_rail_view)?.selectedItemId =
+ R.id.navigation_search
+ } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
+ val uri = str.toUri()
+ val name = uri.getQueryParameter("name")
+ val url = URLDecoder.decode(uri.authority, "UTF-8")
+
+ navigate(
+ R.id.global_to_navigation_player,
+ GeneratorPlayer.newInstance(
+ LinkGenerator(
+ listOf(BasicLink(url, name)),
+ extract = true,
+ id = url.hashCode()
+ ), 0
+ )
+ )
+ } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
val id =
- str.substringAfter("$appStringResumeWatching://").toIntOrNull()
+ str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull()
?: return false
ioSafe {
val resumeWatchingCard =
@@ -315,15 +379,41 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
START_ACTION_RESUME_LATEST
)
}
+ } else if (str.startsWith(APP_STRING_SHARE)) {
+ try {
+ val data = str.substringAfter("$APP_STRING_SHARE:")
+ val parts = data.split("?", limit = 2)
+ loadResult(
+ String(base64DecodeArray(parts[1]), Charsets.UTF_8),
+ String(base64DecodeArray(parts[0]), Charsets.UTF_8),
+ ""
+ )
+ return true
+ } catch (e: Exception) {
+ showToast("Invalid Uri", Toast.LENGTH_SHORT)
+ return false
+ }
} else if (!isWebview) {
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
this.navigate(R.id.navigation_downloads)
return true
} else {
- for (api in apis) {
- if (str.startsWith(api.mainUrl)) {
- loadResult(str, api.name)
- return true
+ val apiName = extraArgs?.getString(API_NAME_EXTRA_KEY)
+ ?.takeIf { it.isNotBlank() }
+ // if provided, try to match the api name instead of the api url
+ // this is in order to also support providers that use JSON dataUrls
+ // for example
+ if (apiName != null) {
+ loadResult(str, apiName, "")
+ return true
+ }
+
+ synchronized(apis) {
+ for (api in apis) {
+ if (str.startsWith(api.mainUrl)) {
+ loadResult(str, api.name, "")
+ return true
+ }
}
}
}
@@ -331,6 +421,54 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
return false
}
+
+
+ fun centerView(view: View?) {
+ if (view == null) return
+ try {
+ Log.v(TAG, "centerView: $view")
+ val r = Rect(0, 0, 0, 0)
+ view.getDrawingRect(r)
+ val x = r.centerX()
+ val y = r.centerY()
+ val dx = r.width() / 2 //screenWidth / 2
+ val dy = screenHeight / 2
+ val r2 = Rect(x - dx, y - dy, x + dx, y + dy)
+ view.requestRectangleOnScreen(r2, false)
+ // TvFocus.current =TvFocus.current.copy(y=y.toFloat())
+ } catch (_: Throwable) {
+ }
+ }
+ }
+
+
+ var lastPopup: SearchResponse? = null
+ var lastPopupJob: Job? = null
+ fun loadPopup(result: SearchResponse, load: Boolean = true) {
+ lastPopup = result
+ val syncName = syncViewModel.syncName(result.apiName)
+
+ // based on apiName we decide on if it is a local list or not, this is because
+ // we want to show a bit of extra UI to sync apis
+ if (result is SyncAPI.LibraryItem && syncName != null) {
+ isLocalList = false
+ syncViewModel.setSync(syncName, result.syncId)
+ syncViewModel.updateMetaAndUser()
+ } else {
+ isLocalList = true
+ syncViewModel.clear()
+ }
+
+ lastPopupJob?.cancel()
+ lastPopupJob = if (load) {
+ viewModel.load(
+ this, result.url, result.apiName, false, if (getApiDubstatusSettings()
+ .contains(DubStatus.Dubbed)
+ ) DubStatus.Dubbed else DubStatus.Subbed, null
+ )
+ } else {
+ viewModel.loadSmall(result)
+ }
}
override fun onColorSelected(dialogId: Int, color: Int) {
@@ -344,6 +482,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateLocale() // android fucks me by chaining lang when rotating the phone
+ updateTheme(this) // Update if system theme
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
@@ -354,7 +493,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
this.hideKeyboard()
// Fucks up anime info layout since that has its own layout
- cast_mini_controller_holder?.isVisible =
+ binding?.castMiniControllerHolder?.isVisible =
!listOf(
R.id.navigation_results_phone,
R.id.navigation_results_tv,
@@ -364,9 +503,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
val isNavVisible = listOf(
R.id.navigation_home,
R.id.navigation_search,
+ R.id.navigation_library,
R.id.navigation_downloads,
R.id.navigation_settings,
R.id.navigation_download_child,
+ R.id.navigation_download_queue,
R.id.navigation_subtitles,
R.id.navigation_chrome_subtitles,
R.id.navigation_settings_player,
@@ -377,26 +518,93 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
R.id.navigation_settings_general,
R.id.navigation_settings_extensions,
R.id.navigation_settings_plugins,
+ R.id.navigation_test_providers,
).contains(destination.id)
- val landscape = when (resources.configuration.orientation) {
- Configuration.ORIENTATION_LANDSCAPE -> {
- true
+
+ /*val dontPush = listOf(
+ R.id.navigation_home,
+ R.id.navigation_search,
+ R.id.navigation_results_phone,
+ R.id.navigation_results_tv,
+ R.id.navigation_player,
+ R.id.navigation_quick_search,
+ ).contains(destination.id)
+
+ binding?.navHostFragment?.apply {
+ val params = layoutParams as ConstraintLayout.LayoutParams
+ val push =
+ if (!dontPush && isLayout(TV or EMULATOR)) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0
+
+ if (!this.isLtr()) {
+ params.setMargins(
+ params.leftMargin,
+ params.topMargin,
+ push,
+ params.bottomMargin
+ )
+ } else {
+ params.setMargins(
+ push,
+ params.topMargin,
+ params.rightMargin,
+ params.bottomMargin
+ )
}
- Configuration.ORIENTATION_PORTRAIT -> {
- false
+
+ layoutParams = params
+ }*/
+
+ binding?.apply {
+ navRailView.isVisible = isNavVisible && isLandscape()
+ navView.isVisible = isNavVisible && !isLandscape()
+ navHostFragment.apply {
+ val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
+ layoutParams =
+ (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
+ marginStart =
+ if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
+ }
}
- else -> {
- false
+
+ /**
+ * We need to make sure if we return to a sub-fragment,
+ * the correct navigation item is selected so that it does not
+ * highlight the wrong one in UI.
+ */
+ when (destination.id) {
+ in listOf(
+ R.id.navigation_downloads,
+ R.id.navigation_download_child,
+ R.id.navigation_download_queue
+ ) -> {
+ navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
+ navView.menu.findItem(R.id.navigation_downloads).isChecked = true
+ }
+
+ in listOf(
+ R.id.navigation_settings,
+ R.id.navigation_subtitles,
+ R.id.navigation_chrome_subtitles,
+ R.id.navigation_settings_player,
+ R.id.navigation_settings_updates,
+ R.id.navigation_settings_ui,
+ R.id.navigation_settings_account,
+ R.id.navigation_settings_providers,
+ R.id.navigation_settings_general,
+ R.id.navigation_settings_extensions,
+ R.id.navigation_settings_plugins,
+ R.id.navigation_test_providers
+ ) -> {
+ navRailView.menu.findItem(R.id.navigation_settings).isChecked = true
+ navView.menu.findItem(R.id.navigation_settings).isChecked = true
+ }
}
}
-
- nav_view?.isVisible = isNavVisible && !landscape
- nav_rail_view?.isVisible = isNavVisible && landscape
}
//private var mCastSession: CastSession? = null
- lateinit var mSessionManager: SessionManager
+ var mSessionManager: SessionManager? = null
private val mSessionManagerListener: SessionManagerListener by lazy { SessionManagerListenerImpl() }
private inner class SessionManagerListenerImpl : SessionManagerListener {
@@ -433,10 +641,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onResume() {
super.onResume()
afterPluginsLoadedEvent += ::onAllPluginsLoaded
+ setActivityInstance(this)
try {
if (isCastApiAvailable()) {
- //mCastSession = mSessionManager.currentCastSession
- mSessionManager.addSessionManagerListener(mSessionManagerListener)
+ mSessionManager?.addSessionManagerListener(mSessionManagerListener)
}
} catch (e: Exception) {
logError(e)
@@ -445,9 +653,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onPause() {
super.onPause()
+
+ // Start any delayed updates
+ if (ApkInstaller.delayedInstaller?.startInstallation() == true) {
+ Toast.makeText(this, R.string.update_started, Toast.LENGTH_LONG).show()
+ }
try {
if (isCastApiAvailable()) {
- mSessionManager.removeSessionManagerListener(mSessionManagerListener)
+ mSessionManager?.removeSessionManagerListener(mSessionManagerListener)
//mCastSession = null
}
} catch (e: Exception) {
@@ -455,19 +668,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
}
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean =
+ CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
- override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
- CommonActivity.dispatchKeyEvent(this, event)?.let {
- return it
- }
- return super.dispatchKeyEvent(event)
- }
-
- override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
- CommonActivity.onKeyDown(this, keyCode, event)
-
- return super.onKeyDown(keyCode, event)
- }
+ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean =
+ CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event)
override fun onUserLeaveHint() {
@@ -475,54 +680,57 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
onUserLeaveHint(this)
}
- private fun showConfirmExitDialog() {
- val builder: AlertDialog.Builder = AlertDialog.Builder(this)
- builder.setTitle(R.string.confirm_exit_dialog)
- builder.apply {
- setPositiveButton(R.string.yes) { _, _ -> super.onBackPressed() }
- setNegativeButton(R.string.no) { _, _ -> }
- }
- builder.show()
- }
-
- private fun backPressed() {
- this.window?.navigationBarColor =
- this.colorFromAttribute(R.attr.primaryGrayBackground)
- this.updateLocale()
- this.updateLocale()
+ @SuppressLint("ApplySharedPref") // commit since the op needs to be synchronous
+ private fun showConfirmExitDialog(settingsManager: SharedPreferences) {
+ val confirmBeforeExit = settingsManager.getInt(getString(R.string.confirm_exit_key), -1)
- val navHostFragment =
- supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment
- val navController = navHostFragment?.navController
- val isAtHome =
- navController?.currentDestination?.matchDestination(R.id.navigation_home) == true
-
- if (isAtHome && isTrueTvSettings()) {
- showConfirmExitDialog()
- } else {
- super.onBackPressed()
+ if (confirmBeforeExit == 1 || (confirmBeforeExit == -1 && isLayout(PHONE))) {
+ // finish() causes a bug on some TVs where player
+ // may keep playing after closing the app.
+ if (isLayout(TV)) exitProcess(0) else finish()
+ return
}
- }
- override fun onBackPressed() {
- ((supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.childFragmentManager?.primaryNavigationFragment as? IOnBackPressed)?.onBackPressed()
- ?.let { runNormal ->
- if (runNormal) backPressed()
- } ?: run {
- backPressed()
- }
+ val dialogView = layoutInflater.inflate(R.layout.confirm_exit_dialog, null)
+ val dontShowAgainCheck: CheckBox = dialogView.findViewById(R.id.checkboxDontShowAgain)
+ val builder: AlertDialog.Builder = AlertDialog.Builder(this)
+ builder.setView(dialogView)
+ .setTitle(R.string.confirm_exit_dialog)
+ .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ }
+ .setPositiveButton(R.string.yes) { _, _ ->
+ if (dontShowAgainCheck.isChecked) {
+ settingsManager.edit(commit = true) {
+ putInt(getString(R.string.confirm_exit_key), 1)
+ }
+ }
+ // finish() causes a bug on some TVs where player
+ // may keep playing after closing the app.
+ if (isLayout(TV)) exitProcess(0) else finish()
+ }
+
+ builder.show().setDefaultFocus()
}
override fun onDestroy() {
+ filesToDelete.forEach { path ->
+ val result = File(path).deleteRecursively()
+ if (result) {
+ Log.d(TAG, "Deleted temporary file: $path")
+ } else {
+ Log.d(TAG, "Failed to delete temporary file: $path")
+ }
+ }
+ filesToDelete = setOf()
val broadcastIntent = Intent()
broadcastIntent.action = "restart_service"
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
this.sendBroadcast(broadcastIntent)
afterPluginsLoadedEvent -= ::onAllPluginsLoaded
+ detachBackPressedCallback("MainActivityDefault")
super.onDestroy()
}
- override fun onNewIntent(intent: Intent?) {
+ override fun onNewIntent(intent: Intent) {
handleAppIntent(intent)
super.onNewIntent(intent)
}
@@ -531,13 +739,55 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (intent == null) return
val str = intent.dataString
loadCache()
- handleAppIntentUrl(this, str, false)
+
+ handleAppIntentUrl(this, str, false, intent.extras)
}
private fun NavDestination.matchDestination(@IdRes destId: Int): Boolean =
hierarchy.any { it.id == destId }
+ private var lastNavTime = 0L
private fun onNavDestinationSelected(item: MenuItem, navController: NavController): Boolean {
+ val currentTime = System.currentTimeMillis()
+ // safeDebounce: Check if a previous tap happened within the last 400ms
+ if (currentTime - lastNavTime < 400) return false
+ lastNavTime = currentTime
+
+ val destinationId = item.itemId
+
+ // Check if we are already at the selected destination
+ if (navController.currentDestination?.id == destinationId) return false
+
+ // Make all nav buttons focus on this specific view when nextFocusRightId
+ val targetView = when (destinationId) {
+ // Please note that if R.id.navigation_home is readded, then it will only take affect when
+ // navigation to home for the second time as onNavDestinationSelected will not get called
+ // when first loading up the app
+
+ // R.id.navigation_home -> R.id.home_preview_change_api
+ R.id.navigation_search -> R.id.main_search
+ R.id.navigation_library -> R.id.main_search
+ R.id.navigation_downloads -> R.id.download_appbar
+ else -> null
+ }
+ if (targetView != null && isLayout(TV or EMULATOR)) {
+ val fromView = binding?.navRailView
+ if (fromView != null) {
+ fromView.nextFocusRightId = targetView
+
+ for (focusView in arrayOf(
+ R.id.navigation_downloads,
+ R.id.navigation_home,
+ R.id.navigation_search,
+ R.id.navigation_library,
+ R.id.navigation_settings,
+ )) {
+ fromView.findViewById(focusView)?.nextFocusRightId = targetView
+ }
+ }
+ }
+
+
val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true)
.setEnterAnim(R.anim.enter_anim)
.setExitAnim(R.anim.exit_anim)
@@ -550,56 +800,395 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
saveState = true
)
}
- val options = builder.build()
return try {
- navController.navigate(item.itemId, null, options)
- navController.currentDestination?.matchDestination(item.itemId) == true
+ navController.navigate(destinationId, null, builder.build())
+ navController.currentDestination?.matchDestination(destinationId) == true
} catch (e: IllegalArgumentException) {
+ Log.e("NavigationError", "Failed to navigate: ${e.message}")
false
}
}
+
private val pluginsLock = Mutex()
private fun onAllPluginsLoaded(success: Boolean = false) {
ioSafe {
pluginsLock.withLock {
- // Load cloned sites after plugins have been loaded since clones depend on plugins.
- try {
- getKey>(USER_PROVIDER_API)?.let { list ->
- list.forEach { custom ->
- allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass }
- ?.let {
- allProviders.add(it.javaClass.newInstance().apply {
- name = custom.name
- lang = custom.lang
- mainUrl = custom.url.trimEnd('/')
- canBeOverridden = false
- })
- }
+ synchronized(allProviders) {
+ // Load cloned sites after plugins have been loaded since clones depend on plugins.
+ try {
+ getKey>(USER_PROVIDER_API)?.let { list ->
+ list.forEach { custom ->
+ allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass }
+ ?.let {
+ allProviders.add(
+ it.javaClass.getDeclaredConstructor().newInstance()
+ .apply {
+ name = custom.name
+ lang = custom.lang
+ mainUrl = custom.url.trimEnd('/')
+ canBeOverridden = false
+ })
+ }
+ }
}
+ // it.hashCode() is not enough to make sure they are distinct
+ apis =
+ allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name }
+ APIHolder.apiMap = null
+ } catch (e: Exception) {
+ logError(e)
}
- // it.hashCode() is not enough to make sure they are distinct
- apis =
- allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name }
- APIHolder.apiMap = null
- } catch (e: Exception) {
- logError(e)
}
}
}
}
+ lateinit var viewModel: ResultViewModel2
+ lateinit var syncViewModel: SyncViewModel
+ private var libraryViewModel: LibraryViewModel? = null
+
+ /** kinda dirty, however it signals that we should use the watch status as sync or not*/
+ var isLocalList: Boolean = false
+ override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
+
+ viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
+ syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java]
+
+ return super.onCreateView(name, context, attrs)
+ }
+
+ private fun hidePreviewPopupDialog() {
+ bottomPreviewPopup.dismissSafe(this)
+ lastPopupJob?.cancel()
+ lastPopupJob = null
+ bottomPreviewPopup = null
+ bottomPreviewBinding = null
+ }
+
+ private var bottomPreviewPopup: Dialog? = null
+ private var bottomPreviewBinding: BottomResultviewPreviewBinding? = null
+ private fun showPreviewPopupDialog(): BottomResultviewPreviewBinding {
+ val ret = (bottomPreviewBinding ?: run {
+
+ val builder: Dialog
+ val layout: Int
+
+ if (isLayout(PHONE)) {
+ builder =
+ BottomSheetDialog(this)
+ layout = R.layout.bottom_resultview_preview
+ } else {
+ builder =
+ Dialog(this, R.style.DialogHalfFullscreen)
+ layout = R.layout.bottom_resultview_preview_tv
+ // No way to do this in styles :(
+ builder.window?.setGravity(Gravity.CENTER_VERTICAL or Gravity.END)
+ }
+
+ val root = layoutInflater.inflate(layout, null, false)
+ val binding = BottomResultviewPreviewBinding.bind(root)
+
+ bottomPreviewBinding = binding
+ builder.setContentView(root)
+ builder.setOnDismissListener {
+ bottomPreviewPopup = null
+ bottomPreviewBinding = null
+ viewModel.clear()
+ }
+ builder.setCanceledOnTouchOutside(true)
+ builder.show()
+ bottomPreviewPopup = builder
+ binding
+ })
+
+ return ret
+ }
+
+ var binding: ActivityMainBinding? = null
+
+ object TvFocus {
+ data class FocusTarget(
+ val width: Int,
+ val height: Int,
+ val x: Float,
+ val y: Float,
+ ) {
+ companion object {
+ fun lerp(a: FocusTarget, b: FocusTarget, lerp: Float): FocusTarget {
+ val ilerp = 1 - lerp
+ return FocusTarget(
+ width = (a.width * ilerp + b.width * lerp).toInt(),
+ height = (a.height * ilerp + b.height * lerp).toInt(),
+ x = a.x * ilerp + b.x * lerp,
+ y = a.y * ilerp + b.y * lerp
+ )
+ }
+ }
+ }
+
+ var last: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f)
+ var current: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f)
+
+ var focusOutline: WeakReference = WeakReference(null)
+ var lastFocus: WeakReference = WeakReference(null)
+ private val layoutListener: View.OnLayoutChangeListener =
+ View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ // shitty fix for layouts
+ lastFocus.get()?.apply {
+ updateFocusView(
+ this, same = true
+ )
+ postDelayed({
+ updateFocusView(
+ lastFocus.get(), same = false
+ )
+ }, 300)
+ }
+ }
+ private val attachListener: View.OnAttachStateChangeListener =
+ object : View.OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(v: View) {
+ updateFocusView(v)
+ }
+
+ override fun onViewDetachedFromWindow(v: View) {
+ // removes the focus view but not the listener as updateFocusView(null) will remove the listener
+ focusOutline.get()?.isVisible = false
+ }
+ }
+ /*private val scrollListener = object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ current = current.copy(x = current.x + dx, y = current.y + dy)
+ setTargetPosition(current)
+ }
+ }*/
+
+ private fun setTargetPosition(target: FocusTarget) {
+ focusOutline.get()?.apply {
+ layoutParams = layoutParams?.apply {
+ width = target.width
+ height = target.height
+ }
+
+ translationX = target.x
+ translationY = target.y
+ bringToFront()
+ }
+ }
+
+ private var animator: ValueAnimator? = null
+
+ /** if this is enabled it will keep the focus unmoving
+ * during listview move */
+ private const val NO_MOVE_LIST: Boolean = false
+
+ /** If this is enabled then it will try to move the
+ * listview focus to the left instead of center */
+ private const val LEFTMOST_MOVE_LIST: Boolean = true
+
+ private val reflectedScroll by lazy {
+ try {
+ RecyclerView::class.java.declaredMethods.firstOrNull {
+ it.name == "scrollStep"
+ }?.also { it.isAccessible = true }
+ } catch (t: Throwable) {
+ null
+ }
+ }
+
+ @MainThread
+ fun updateFocusView(newFocus: View?, same: Boolean = false) {
+ val focusOutline = focusOutline.get() ?: return
+ val lastView = lastFocus.get()
+ val exactlyTheSame = lastView == newFocus && newFocus != null
+ if (!exactlyTheSame) {
+ lastView?.removeOnLayoutChangeListener(layoutListener)
+ lastView?.removeOnAttachStateChangeListener(attachListener)
+ (lastView?.parent as? RecyclerView)?.apply {
+ removeOnLayoutChangeListener(layoutListener)
+ //removeOnScrollListener(scrollListener)
+ }
+ }
+
+ val wasGone = focusOutline.isGone
+
+ val visible =
+ newFocus != null && newFocus.measuredHeight > 0 && newFocus.measuredWidth > 0 && newFocus.isShown && newFocus.tag != "tv_no_focus_tag"
+ focusOutline.isVisible = visible
+
+ if (newFocus != null) {
+ lastFocus = WeakReference(newFocus)
+ val parent = newFocus.parent
+ var targetDx = 0
+ if (parent is RecyclerView) {
+ val layoutManager = parent.layoutManager
+ if (layoutManager is LinearListLayout && layoutManager.orientation == LinearLayoutManager.HORIZONTAL) {
+ val dx =
+ LinearSnapHelper().calculateDistanceToFinalSnap(layoutManager, newFocus)
+ ?.get(0)
+
+ if (dx != null) {
+ val rdx = if (LEFTMOST_MOVE_LIST) {
+ // this makes the item the leftmost in ltr, instead of center
+ val diff =
+ ((layoutManager.width - layoutManager.paddingStart - newFocus.measuredWidth) / 2) - newFocus.marginStart
+ dx + if (parent.isRtl()) {
+ -diff
+ } else {
+ diff
+ }
+ } else {
+ if (dx > 0) dx else 0
+ }
+
+ if (!NO_MOVE_LIST) {
+ parent.smoothScrollBy(rdx, 0)
+ } else {
+ val smoothScroll = reflectedScroll
+ if (smoothScroll == null) {
+ parent.smoothScrollBy(rdx, 0)
+ } else {
+ try {
+ // this is very fucked but because it is a protected method to
+ // be able to compute the scroll I use reflection, scroll, then
+ // scroll back, then smooth scroll and set the no move
+ val out = IntArray(2)
+ smoothScroll.invoke(parent, rdx, 0, out)
+ val scrolledX = out[0]
+ if (abs(scrolledX) <= 0) { // newFocus.measuredWidth*2
+ smoothScroll.invoke(parent, -rdx, 0, out)
+ parent.smoothScrollBy(scrolledX, 0)
+ if (NO_MOVE_LIST) targetDx = scrolledX
+ }
+ } catch (t: Throwable) {
+ parent.smoothScrollBy(rdx, 0)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val out = IntArray(2)
+ newFocus.getLocationInWindow(out)
+ val (screenX, screenY) = out
+ var (x, y) = screenX.toFloat() to screenY.toFloat()
+ val (currentX, currentY) = focusOutline.translationX to focusOutline.translationY
+
+ if (!newFocus.isLtr()) {
+ x = x - focusOutline.rootView.width + newFocus.measuredWidth
+ }
+ x -= targetDx
+
+ // out of bounds = 0,0
+ if (screenX == 0 && screenY == 0) {
+ focusOutline.isVisible = false
+ }
+ if (!exactlyTheSame) {
+ (newFocus.parent as? RecyclerView)?.apply {
+ addOnLayoutChangeListener(layoutListener)
+ //addOnScrollListener(scrollListener)
+ }
+ newFocus.addOnLayoutChangeListener(layoutListener)
+ newFocus.addOnAttachStateChangeListener(attachListener)
+ }
+ val start = FocusTarget(
+ x = currentX,
+ y = currentY,
+ width = focusOutline.measuredWidth,
+ height = focusOutline.measuredHeight
+ )
+ val end = FocusTarget(
+ x = x,
+ y = y,
+ width = newFocus.measuredWidth,
+ height = newFocus.measuredHeight
+ )
+
+ // if they are the same within then snap, aka scrolling
+ val deltaMinX = min(end.width / 2, 60.toPx)
+ val deltaMinY = min(end.height / 2, 60.toPx)
+ if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMinX && (start.y - end.y).absoluteValue < deltaMinY) {
+ animator?.cancel()
+ last = start
+ current = end
+ setTargetPosition(end)
+ return
+ }
+
+ // if running then "reuse"
+ if (animator?.isRunning == true) {
+ current = end
+ return
+ } else {
+ animator?.cancel()
+ }
+
+
+ last = start
+ current = end
+
+ // if previously gone, then tp
+ if (wasGone) {
+ setTargetPosition(current)
+ return
+ }
+
+ // animate between a and b
+ animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply {
+ startDelay = 0
+ duration = 200
+ addUpdateListener { animation ->
+ val animatedValue = animation.animatedValue as Float
+ val target = FocusTarget.lerp(last, current, minOf(animatedValue, 1.0f))
+ setTargetPosition(target)
+ }
+ start()
+ }
+
+ // post check
+ if (!same) {
+ newFocus.postDelayed({
+ updateFocusView(lastFocus.get(), same = true)
+ }, 200)
+ }
+
+ /*
+
+ the following is working, but somewhat bad code code
+
+ if (!wasGone) {
+ (focusOutline.parent as? ViewGroup)?.let {
+ TransitionManager.endTransitions(it)
+ TransitionManager.beginDelayedTransition(
+ it,
+ TransitionSet().addTransition(ChangeBounds())
+ .addTransition(ChangeTransform())
+ .setDuration(100)
+ )
+ }
+ }
+
+ focusOutline.layoutParams = focusOutline.layoutParams?.apply {
+ width = newFocus.measuredWidth
+ height = newFocus.measuredHeight
+ }
+ focusOutline.translationX = x.toFloat()
+ focusOutline.translationY = y.toFloat()*/
+ }
+ }
+ }
override fun onCreate(savedInstanceState: Bundle?) {
- app.initClient(this)
+ app.initClient(this, ignoreSSL = false)
+ @OptIn(UnsafeSSL::class)
+ insecureApp.initClient(this, ignoreSSL = true)
+
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
- val errorFile = filesDir.resolve("last_error")
- var lastError: String? = null
- if (errorFile.exists() && errorFile.isFile) {
- lastError = errorFile.readText(Charset.defaultCharset())
- errorFile.delete()
- }
+ setLastError(this)
val settingsForProvider = SettingsJson()
settingsForProvider.enableAdult =
@@ -608,29 +1197,160 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
MainAPI.settingsForProvider = settingsForProvider
loadThemes(this)
+ enableEdgeToEdgeCompat()
+ setNavigationBarColorCompat(R.attr.primaryGrayBackground)
updateLocale()
super.onCreate(savedInstanceState)
try {
if (isCastApiAvailable()) {
- mSessionManager = CastContext.getSharedInstance(this).sessionManager
+ CastContext.getSharedInstance(this) { it.run() }
+ .addOnSuccessListener { mSessionManager = it.sessionManager }
}
- } catch (e: Exception) {
- logError(e)
+ } catch (t: Throwable) {
+ logError(t)
}
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN)
+ updateTv()
+
+ // backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting?
+ safe {
+ val appVer = BuildConfig.VERSION_NAME
+ val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
+ if (appVer != lastAppAutoBackup) {
+ setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
+ if (lastAppAutoBackup.isEmpty()) return@safe
+
+ safe {
+ backup(this)
+ }
+ safe {
+ // Recompile oat on new version
+ PluginManager.deleteAllOatFiles(this)
+ }
+ }
+ }
- if (isTvSettings()) {
- setContentView(R.layout.activity_main_tv)
- } else {
- setContentView(R.layout.activity_main)
+ // just in case, MAIN SHOULD *NEVER* BOOT LOOP CRASH
+ binding = try {
+ if (isLayout(TV or EMULATOR)) {
+ val newLocalBinding = ActivityMainTvBinding.inflate(layoutInflater, null, false)
+ setContentView(newLocalBinding.root)
+
+ if (isLayout(TV) && ANIMATED_OUTLINE) {
+ TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline)
+ newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener {
+ TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true)
+ }
+ newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
+ TvFocus.updateFocusView(newFocus)
+ }
+ } else {
+ newLocalBinding.focusOutline.isVisible = false
+ }
+
+ if (isLayout(TV)) {
+ // Put here any button you don't want focusing it to center the view
+ val exceptionButtons = listOf(
+ //R.id.home_preview_play_btt,
+ R.id.home_preview_info_btt,
+ R.id.home_preview_hidden_next_focus,
+ R.id.home_preview_hidden_prev_focus,
+ R.id.result_play_movie_button,
+ R.id.result_play_series_button,
+ R.id.result_resume_series_button,
+ R.id.result_play_trailer_button,
+ R.id.result_bookmark_Button,
+ R.id.result_favorite_Button,
+ R.id.result_subscribe_Button,
+ R.id.result_search_Button,
+ R.id.result_episodes_show_button,
+ )
+
+ newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
+ if (exceptionButtons.contains(newFocus?.id)) return@addOnGlobalFocusChangeListener
+ centerView(newFocus)
+ }
+ }
+
+ ActivityMainBinding.bind(newLocalBinding.root) // this may crash
+ } else {
+ val newLocalBinding = ActivityMainBinding.inflate(layoutInflater, null, false)
+ setContentView(newLocalBinding.root)
+ newLocalBinding
+ }
+ } catch (t: Throwable) {
+ showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG)
+ null
+ }
+
+ binding?.apply {
+ fixSystemBarsPadding(
+ navView,
+ heightResId = R.dimen.nav_view_height,
+ padTop = false,
+ overlayCutout = false
+ )
+
+ fixSystemBarsPadding(
+ navRailView,
+ widthResId = R.dimen.nav_rail_view_width,
+ padRight = false,
+ padTop = false
+ )
}
- changeStatusBarState(isEmulatorSettings())
+ // overscan
+ val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx
+ binding?.homeRoot?.setPadding(padding, padding, padding, padding)
+
+ changeStatusBarState(isLayout(EMULATOR))
+
+ /** Biometric stuff for users without accounts **/
+ val noAccounts = settingsManager.getBoolean(
+ getString(R.string.skip_startup_account_select_key),
+ false
+ ) || accounts.count() <= 1
+
+ if (isLayout(PHONE) && isAuthEnabled(this) && noAccounts) {
+ if (deviceHasPasswordPinLock(this)) {
+ startBiometricAuthentication(this, R.string.biometric_authentication_title, false)
- if (lastError == null) {
+ promptInfo?.let { prompt ->
+ biometricPrompt?.authenticate(prompt)
+ }
+
+ // hide background while authenticating, Sorry moms & dads 🙏
+ binding?.navHostFragment?.isInvisible = true
+ }
+ }
+
+ // Automatically enable jsdelivr if cant connect to raw.githubusercontent.com
+ if (this.getKey(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) {
+ main {
+ if (checkGithubConnectivity()) {
+ this.setKey(getString(R.string.jsdelivr_proxy_key), false)
+ } else {
+ this.setKey(getString(R.string.jsdelivr_proxy_key), true)
+ showSnackbar(
+ this@MainActivity,
+ R.string.jsdelivr_enabled,
+ Snackbar.LENGTH_LONG,
+ R.string.revert
+ ) { setKey(getString(R.string.jsdelivr_proxy_key), false) }
+ }
+ }
+ }
+
+ ioSafe { SafeFile.check(this@MainActivity) }
+
+ if (PluginManager.checkSafeModeFile()) {
+ safe {
+ showToast(R.string.safe_mode_file, Toast.LENGTH_LONG)
+ }
+ } else if (lastError == null) {
ioSafe {
- getKey(USER_SELECTED_HOMEPAGE_API)?.let { homeApi ->
+ DataStoreHelper.currentHomePage?.let { homeApi ->
mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi))
} ?: run {
mainPluginsLoadedEvent.invoke(false)
@@ -642,24 +1362,37 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
true
)
) {
- PluginManager.updateAllOnlinePluginsAndLoadThem(this@MainActivity)
+ PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(
+ this@MainActivity
+ )
} else {
- loadAllOnlinePlugins(this@MainActivity)
+ ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(this@MainActivity)
}
- //Automatically download not existing plugins
- if (settingsManager.getBoolean(
+ //Automatically download not existing plugins, using mode specified.
+ val autoDownloadPlugin = AutoDownloadMode.getEnum(
+ settingsManager.getInt(
getString(R.string.auto_download_plugins_key),
- false
+ 0
+ )
+ ) ?: AutoDownloadMode.Disable
+ if (autoDownloadPlugin != AutoDownloadMode.Disable) {
+ PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
+ this@MainActivity,
+ autoDownloadPlugin
)
- ) {
- PluginManager.downloadNotExistingPluginsAndLoad(this@MainActivity)
}
}
ioSafe {
- PluginManager.loadAllLocalPlugins(this@MainActivity, false)
+ PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(
+ this@MainActivity,
+ false
+ )
}
+
+// Add your channel creation here
+
}
} else {
val builder: AlertDialog.Builder = AlertDialog.Builder(this)
@@ -675,10 +1408,216 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
setNegativeButton("Ok") { _, _ -> }
}
- builder.show()
+ builder.show().setDefaultFocus()
}
+ fun setUserData(status: Resource?) {
+ if (isLocalList) return
+ bottomPreviewBinding?.apply {
+ when (status) {
+ is Resource.Success -> {
+ resultviewPreviewBookmark.isEnabled = true
+ resultviewPreviewBookmark.setText(status.value.status.stringRes)
+ resultviewPreviewBookmark.setIconResource(status.value.status.iconRes)
+ }
+
+ is Resource.Failure -> {
+ resultviewPreviewBookmark.isEnabled = false
+ resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
+ resultviewPreviewBookmark.text = status.errorString
+ }
+
+ else -> {
+ resultviewPreviewBookmark.isEnabled = false
+ resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
+ resultviewPreviewBookmark.setText(R.string.loading)
+ }
+ }
+ }
+ }
+
+ fun setWatchStatus(state: WatchType?) {
+ if (!isLocalList || state == null) return
+
+ bottomPreviewBinding?.resultviewPreviewBookmark?.apply {
+ setIconResource(state.iconRes)
+ setText(state.stringRes)
+ }
+ }
+
+ fun setSubscribeStatus(state: Boolean?) {
+ bottomPreviewBinding?.resultviewPreviewSubscribe?.apply {
+ if (state != null) {
+ val drawable = if (state) {
+ R.drawable.ic_baseline_notifications_active_24
+ } else {
+ R.drawable.baseline_notifications_none_24
+ }
+ setImageResource(drawable)
+ }
+ isVisible = state != null
+
+ setOnClickListener {
+ viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? ->
+ if (newStatus == null) return@toggleSubscriptionStatus
+
+ val message = if (newStatus) {
+ // Kinda icky to have this here, but it works.
+ SubscriptionWorkManager.enqueuePeriodicWork(context)
+ R.string.subscription_new
+ } else {
+ R.string.subscription_deleted
+ }
+
+ val name = (viewModel.page.value as? Resource.Success)?.value?.title
+ ?: txt(R.string.no_data).asStringNull(context) ?: ""
+ showToast(txt(message, name), Toast.LENGTH_SHORT)
+ }
+ }
+ }
+ }
+
+ observe(viewModel.watchStatus, ::setWatchStatus)
+ observe(syncViewModel.userData, ::setUserData)
+ observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus)
+
+ observeNullable(viewModel.page) { resource ->
+ if (resource == null) {
+ hidePreviewPopupDialog()
+ return@observeNullable
+ }
+ when (resource) {
+ is Resource.Failure -> {
+ showToast(R.string.error)
+ viewModel.clear()
+ hidePreviewPopupDialog()
+ }
+
+ is Resource.Loading -> {
+ showPreviewPopupDialog().apply {
+ resultviewPreviewLoading.isVisible = true
+ resultviewPreviewResult.isVisible = false
+ resultviewPreviewLoadingShimmer.startShimmer()
+ }
+ }
+
+ is Resource.Success -> {
+ val d = resource.value
+ showPreviewPopupDialog().apply {
+ resultviewPreviewLoading.isVisible = false
+ resultviewPreviewResult.isVisible = true
+ resultviewPreviewLoadingShimmer.stopShimmer()
+
+ resultviewPreviewTitle.text = d.title
+
+ resultviewPreviewMetaType.setText(d.typeText)
+ resultviewPreviewMetaYear.setText(d.yearText)
+ resultviewPreviewMetaDuration.setText(d.durationText)
+ resultviewPreviewMetaRating.setText(d.ratingText)
+
+ resultviewPreviewDescription.setTextHtml(d.plotText)
+ if (isLayout(PHONE)) {
+ resultviewPreviewPoster.loadImage(
+ d.posterImage ?: d.posterBackgroundImage,
+ headers = d.posterHeaders
+ )
+ } else {
+ resultviewPreviewPoster.loadImage(
+ d.posterBackgroundImage ?: d.posterImage,
+ headers = d.posterHeaders
+ )
+ }
+
+ setUserData(syncViewModel.userData.value)
+ setWatchStatus(viewModel.watchStatus.value)
+ setSubscribeStatus(viewModel.subscribeStatus.value)
+
+ resultviewPreviewBookmark.setOnClickListener {
+ //viewModel.updateWatchStatus(WatchType.PLANTOWATCH)
+ if (isLocalList) {
+ val value = viewModel.watchStatus.value ?: WatchType.NONE
+
+ this@MainActivity.showBottomDialog(
+ WatchType.entries.map { getString(it.stringRes) }.toList(),
+ value.ordinal,
+ this@MainActivity.getString(R.string.action_add_to_bookmarks),
+ showApply = false,
+ {}) {
+ viewModel.updateWatchStatus(
+ WatchType.entries[it],
+ this@MainActivity
+ )
+ }
+ } else {
+ val value =
+ (syncViewModel.userData.value as? Resource.Success)?.value?.status
+ ?: SyncWatchType.NONE
+
+ this@MainActivity.showBottomDialog(
+ SyncWatchType.entries.map { getString(it.stringRes) }.toList(),
+ value.ordinal,
+ this@MainActivity.getString(R.string.action_add_to_bookmarks),
+ showApply = false,
+ {}) {
+ syncViewModel.setStatus(SyncWatchType.entries[it].internalId)
+ syncViewModel.publishUserData()
+ }
+ }
+ }
+
+ observeNullable(viewModel.favoriteStatus) observeFavoriteStatus@{ isFavorite ->
+ resultviewPreviewFavorite.isVisible = isFavorite != null
+ if (isFavorite == null) return@observeFavoriteStatus
+
+ val drawable = if (isFavorite) {
+ R.drawable.ic_baseline_favorite_24
+ } else {
+ R.drawable.ic_baseline_favorite_border_24
+ }
+
+ resultviewPreviewFavorite.setImageResource(drawable)
+ }
+
+ resultviewPreviewFavorite.setOnClickListener {
+ viewModel.toggleFavoriteStatus(this@MainActivity) { newStatus: Boolean? ->
+ if (newStatus == null) return@toggleFavoriteStatus
+
+ val message = if (newStatus) {
+ R.string.favorite_added
+ } else {
+ R.string.favorite_removed
+ }
+
+ val name = (viewModel.page.value as? Resource.Success)?.value?.title
+ ?: txt(R.string.no_data).asStringNull(this@MainActivity) ?: ""
+ showToast(txt(message, name), Toast.LENGTH_SHORT)
+ }
+ }
+
+ if (isLayout(PHONE)) // dont want this clickable on tv layout
+ resultviewPreviewDescription.setOnClickListener { view ->
+ view.context?.let { ctx ->
+ val builder: AlertDialog.Builder =
+ AlertDialog.Builder(ctx, R.style.AlertDialogCustom)
+ builder.setMessage(d.plotText.asString(ctx).html())
+ .setTitle(d.plotHeaderText.asString(ctx))
+ .show()
+ }
+ }
+
+ resultviewPreviewMoreInfo.setOnClickListener {
+ viewModel.clear()
+ hidePreviewPopupDialog()
+ lastPopup?.let {
+ loadSearchResult(it)
+ }
+ }
+ }
+ }
+ }
+ }
+
// ioSafe {
// val plugins =
// RepositoryParser.getRepoPlugins("https://raw.githubusercontent.com/recloudstream/TestPlugin/master/repo.json")
@@ -691,15 +1630,24 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
// init accounts
ioSafe {
- for (api in accountManagers) {
- api.init()
- }
+ // we need to run this after we init all apis, otherwise currentSyncApi will fuck itself
+ this@MainActivity.runOnUiThread {
+ // Change library icon with logo of current api in sync
+ libraryViewModel =
+ ViewModelProvider(this@MainActivity)[LibraryViewModel::class.java]
+ libraryViewModel?.currentApiName?.observe(this@MainActivity) {
+ val syncAPI = libraryViewModel?.currentSyncApi
+ Log.i("SYNC_API", "${syncAPI?.name}, ${syncAPI?.idPrefix}")
+ val icon = if (syncAPI?.idPrefix == localListApi.idPrefix) {
+ R.drawable.library_icon_selector
+ } else {
+ syncAPI?.icon ?: R.drawable.library_icon_selector
+ }
- inAppAuths.amap { api ->
- try {
- api.initialize()
- } catch (e: Exception) {
- logError(e)
+ binding?.apply {
+ navRailView.menu.findItem(R.id.navigation_library)?.setIcon(icon)
+ navView.menu.findItem(R.id.navigation_library)?.setIcon(icon)
+ }
}
}
}
@@ -709,7 +1657,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
ioSafe {
initAll()
// No duplicates (which can happen by registerMainAPI)
- apis = allProviders.distinctBy { it }
+ apis = synchronized(allProviders) {
+ allProviders.distinctBy { it }
+ }
}
// val navView: BottomNavigationView = findViewById(R.id.nav_view)
@@ -722,12 +1672,18 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? ->
// Intercept search and add a query
+ updateNavBar(navDestination)
if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) {
bundle?.apply {
this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery)
- nextSearchQuery = null
}
}
+
+ if (navDestination.matchDestination(R.id.navigation_home)) {
+ attachBackPressedCallback("MainActivity") {
+ showConfirmExitDialog(settingsManager)
+ }
+ } else detachBackPressedCallback("MainActivity")
}
//val navController = findNavController(R.id.nav_host_fragment)
@@ -740,24 +1696,183 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
.setPopExitAnim(R.anim.nav_pop_exit)
.setPopUpTo(navController.graph.startDestination, false)
.build()*/
- nav_view?.setupWithNavController(navController)
- val nav_rail = findViewById(R.id.nav_rail_view)
- nav_rail?.setupWithNavController(navController)
-
- nav_rail?.setOnItemSelectedListener { item ->
- onNavDestinationSelected(
- item,
- navController
- )
+
+ val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.colorPrimary, 0.1f))
+
+ binding?.navView?.apply {
+ itemRippleColor = rippleColor
+ itemActiveIndicatorColor = rippleColor
+ setupWithNavController(navController)
+ setOnItemSelectedListener { item ->
+ onNavDestinationSelected(
+ item,
+ navController
+ )
+ }
+
}
- nav_view?.setOnItemSelectedListener { item ->
- onNavDestinationSelected(
- item,
- navController
- )
+
+ binding?.navRailView?.apply {
+ if (isLayout(PHONE)) {
+ itemRippleColor = rippleColor
+ itemActiveIndicatorColor = rippleColor
+ } else {
+ val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f))
+ val rippleColorTransparent =
+ ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f))
+ itemSpacing = 12.toPx // expandedItemSpacing does not have an attr
+ itemRippleColor = rippleColorTransparent
+ itemActiveIndicatorColor = rippleColor
+ }
+ setupWithNavController(navController)
+ /*if (isLayout(TV or EMULATOR)) {
+ background?.alpha = 200
+ } else {
+ background?.alpha = 255
+ }*/
+
+ setOnItemSelectedListener { item ->
+ onNavDestinationSelected(
+ item,
+ navController
+ )
+ }
+
+
+ fun noFocus(view: View) {
+ view.tag = view.context.getString(R.string.tv_no_focus_tag)
+ (view as? ViewGroup)?.let {
+ for (child in it.children) {
+ noFocus(child)
+ }
+ }
+ }
+ //noFocus(this)
+
+ val navProfileRoot = findViewById(R.id.nav_footer_root)
+
+ if (isLayout(TV or EMULATOR)) {
+ val navProfilePic = findViewById(R.id.nav_footer_profile_pic)
+ val navProfileCard = findViewById(R.id.nav_footer_profile_card)
+
+ navProfileCard?.setOnClickListener {
+ showAccountSelectLinear()
+ }
+
+ val homeViewModel =
+ ViewModelProvider(this@MainActivity)[HomeViewModel::class.java]
+
+ observe(homeViewModel.currentAccount) { currentAccount ->
+ if (currentAccount != null) {
+ navProfilePic?.loadImage(
+ currentAccount.image
+ )
+ navProfileRoot.isVisible = true
+ } else {
+ navProfileRoot.isGone = true
+ }
+ }
+ } else {
+ navProfileRoot.isGone = true
+ }
}
- navController.addOnDestinationChangedListener { _, destination, _ ->
- updateNavBar(destination)
+
+ val rail = binding?.navRailView
+ if (rail != null) {
+ binding?.navRailView?.labelVisibilityMode =
+ NavigationRailView.LABEL_VISIBILITY_UNLABELED
+ //val focus = mutableSetOf()
+
+ var prevId: Int? = null
+ var prevView: View? = null
+
+ // The genius engineers at google did not actually
+ // write a nextFocus for the navrail
+ rail.findViewById(R.id.navigation_settings)?.nextFocusDownId =
+ R.id.nav_footer_profile_card
+ for (id in arrayOf(
+ R.id.navigation_home,
+ R.id.navigation_search,
+ R.id.navigation_library,
+ R.id.navigation_downloads,
+ R.id.navigation_settings
+ )) {
+ val view = rail.findViewById(id) ?: continue
+ prevId?.let { view.nextFocusUpId = it }
+ prevView?.nextFocusDownId = id
+
+ prevView = view
+ prevId = id
+ // Uncomment for focus expand
+ /*if (!isLayout(TV)) {
+ view.onFocusChangeListener = null
+ } else {
+ view.onFocusChangeListener =
+ View.OnFocusChangeListener { v, hasFocus ->
+ if (hasFocus) {
+ focus += id
+ binding?.navRailView?.labelVisibilityMode =
+ NavigationRailView.LABEL_VISIBILITY_LABELED
+ binding?.navRailView?.expand()
+ } else {
+ focus -= id
+ v.post {
+ if (focus.isEmpty()) {
+ binding?.navRailView?.labelVisibilityMode =
+ NavigationRailView.LABEL_VISIBILITY_UNLABELED
+ binding?.navRailView?.collapse()
+ }
+ }
+ }
+ }
+ }*/
+ }
+ }
+
+ // Navigation button long click functionality to scroll to top
+ for (view in listOf(binding?.navView, binding?.navRailView)) {
+ view?.findViewById(R.id.navigation_home)?.setOnLongClickListener {
+ val recycler = binding?.root?.findViewById(R.id.home_master_recycler)
+ recycler?.smoothScrollToPosition(0)
+ return@setOnLongClickListener recycler != null
+ }
+
+ view?.findViewById(R.id.navigation_library)?.setOnLongClickListener {
+ val viewPager = binding?.root?.findViewById(R.id.viewpager)
+ ?: return@setOnLongClickListener false
+ try {
+ val children = (viewPager[0] as? RecyclerView)?.children
+ ?: return@setOnLongClickListener false
+ for (child in children) {
+ child.findViewById(R.id.page_recyclerview)
+ ?.smoothScrollToPosition(0)
+ }
+ } catch (_: IndexOutOfBoundsException) {
+ } catch (t: Throwable) {
+ logError(t)
+ }
+ return@setOnLongClickListener true
+ }
+
+ view?.findViewById(R.id.navigation_search)?.setOnLongClickListener {
+ for (recyclerId in arrayOf(
+ R.id.search_master_recycler,
+ R.id.search_autofit_results,
+ R.id.search_history_recycler
+ )) {
+ val recycler = binding?.root?.findViewById(recyclerId)
+ ?: return@setOnLongClickListener false
+ recycler.smoothScrollToPosition(0)
+ }
+ return@setOnLongClickListener true
+ }
+
+ view?.findViewById(R.id.navigation_downloads)?.setOnLongClickListener {
+ val recycler: RecyclerView? = binding?.root?.findViewById(R.id.download_list)
+ ?: binding?.root?.findViewById(R.id.download_child_list)
+ recycler?.smoothScrollToPosition(0)
+ return@setOnLongClickListener recycler != null
+ }
}
loadCache()
@@ -780,17 +1895,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
true
}*/
- val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.colorPrimary, 0.1f))
- nav_view?.itemRippleColor = rippleColor
- nav_rail?.itemRippleColor = rippleColor
- nav_rail?.itemActiveIndicatorColor = rippleColor
- nav_view?.itemActiveIndicatorColor = rippleColor
if (!checkWrite()) {
requestRW()
if (checkWrite()) return
}
- CastButtonFactory.setUpMediaRouteButton(this, media_route_button)
+ //CastButtonFactory.setUpMediaRouteButton(this, media_route_button)
// THIS IS CURRENTLY REMOVED BECAUSE HIGHER VERS OF ANDROID NEEDS A NOTIFICATION
//if (!VideoDownloadManager.isMyServiceRunning(this, VideoDownloadKeepAliveService::class.java)) {
@@ -831,7 +1941,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
fun buildMediaQueueItem(video: String): MediaQueueItem {
// val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO)
//movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream")
- val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString())
+ val mediaInfo = MediaInfo.Builder(video.toUri().toString())
.setStreamType(MediaInfo.STREAM_TYPE_NONE)
.setContentType(MimeTypes.IMAGE_JPEG)
// .setMetadata(movieMetadata).build()
@@ -857,14 +1967,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (BuildConfig.DEBUG) {
var providersAndroidManifestString = "Current androidmanifest should be:\n"
- for (api in allProviders) {
- providersAndroidManifestString += "\n"
+ synchronized(allProviders) {
+ for (api in allProviders) {
+ providersAndroidManifestString += "\n"
+ }
}
-
println(providersAndroidManifestString)
}
@@ -874,13 +1985,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
runAutoUpdate()
}
+ FcastManager().init(this, false)
+
APIRepository.dubStatusActive = getApiDubstatusSettings()
try {
// this ensures that no unnecessary space is taken
loadCache()
File(filesDir, "exoplayer").deleteRecursively() // old cache
- File(cacheDir, "exoplayer").deleteOnExit() // current cache
+ deleteFileOnExit(File(cacheDir, "exoplayer")) // current cache
} catch (e: Exception) {
logError(e)
}
@@ -890,6 +2003,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
migrateResumeWatching()
}
+ main {
+ val channelId =
+ TvChannelUtils.getChannelId(this@MainActivity, getString(R.string.app_name))
+ if (channelId == null) {
+ Log.d("TvChannel", "Channel not found, creating")
+ TvChannelUtils.createTvChannel(this@MainActivity)
+ } else {
+ Log.d("TvChannel", "Channel ID: $channelId")
+ }
+ }
+
+ getKey(USER_SELECTED_HOMEPAGE_API)?.let { homepage ->
+ DataStoreHelper.currentHomePage = homepage
+ removeKey(USER_SELECTED_HOMEPAGE_API)
+ }
+
try {
if (getKey(HAS_DONE_SETUP_KEY, false) != true) {
navController.navigate(R.id.navigation_setup_language)
@@ -905,17 +2034,45 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
} catch (e: Exception) {
logError(e)
- } finally {
- setKey(HAS_DONE_SETUP_KEY, true)
}
// Used to check current focus for TV
// main {
// while (true) {
-// delay(1000)
+// delay(5000)
// println("Current focus: $currentFocus")
+// showToast(this, currentFocus.toString(), Toast.LENGTH_LONG)
// }
// }
+ attachBackPressedCallback("MainActivityDefault") {
+ setNavigationBarColorCompat(R.attr.primaryGrayBackground)
+ updateLocale()
+ runDefault()
+ }
+
+ // Start the download queue
+ DownloadQueueManager.init(this)
+ }
+
+ /** Biometric stuff **/
+ override fun onAuthenticationSuccess() {
+ // make background (nav host fragment) visible again
+ binding?.navHostFragment?.isInvisible = false
+ }
+
+ override fun onAuthenticationError() {
+ finish()
+ }
+
+ suspend fun checkGithubConnectivity(): Boolean {
+ return try {
+ app.get(
+ "https://raw.githubusercontent.com/recloudstream/.github/master/connectivitycheck",
+ timeout = 5
+ ).text.trim() == "ok"
+ } catch (t: Throwable) {
+ false
+ }
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt b/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt
deleted file mode 100644
index 4695542752a..00000000000
--- a/app/src/main/java/com/lagradost/cloudstream3/ParCollections.kt
+++ /dev/null
@@ -1,88 +0,0 @@
-package com.lagradost.cloudstream3
-
-import com.lagradost.cloudstream3.mvvm.logError
-import kotlinx.coroutines.*
-
-//https://stackoverflow.com/questions/34697828/parallel-operations-on-kotlin-collections
-/*
-fun Iterable.pmap(
- numThreads: Int = maxOf(Runtime.getRuntime().availableProcessors() - 2, 1),
- exec: ExecutorService = Executors.newFixedThreadPool(numThreads),
- transform: (T) -> R,
-): List {
-
- // default size is just an inlined version of kotlin.collections.collectionSizeOrDefault
- val defaultSize = if (this is Collection<*>) this.size else 10
- val destination = Collections.synchronizedList(ArrayList(defaultSize))
-
- for (item in this) {
- exec.submit { destination.add(transform(item)) }
- }
-
- exec.shutdown()
- exec.awaitTermination(1, TimeUnit.DAYS)
-
- return ArrayList(destination)
-}*/
-
-
-@OptIn(DelicateCoroutinesApi::class)
-suspend fun Map.amap(f: suspend (Map.Entry) -> R): List =
- with(CoroutineScope(GlobalScope.coroutineContext)) {
- map { async { f(it) } }.map { it.await() }
- }
-
-fun Map.apmap(f: suspend (Map.Entry) -> R): List = runBlocking {
- map { async { f(it) } }.map { it.await() }
-}
-
-
-@OptIn(DelicateCoroutinesApi::class)
-suspend fun List.amap(f: suspend (A) -> B): List =
- with(CoroutineScope(GlobalScope.coroutineContext)) {
- map { async { f(it) } }.map { it.await() }
- }
-
-
-fun List.apmap(f: suspend (A) -> B): List = runBlocking {
- map { async { f(it) } }.map { it.await() }
-}
-
-fun List.apmapIndexed(f: suspend (index: Int, A) -> B): List = runBlocking {
- mapIndexed { index, a -> async { f(index, a) } }.map { it.await() }
-}
-
-@OptIn(DelicateCoroutinesApi::class)
-suspend fun List.amapIndexed(f: suspend (index: Int, A) -> B): List =
- with(CoroutineScope(GlobalScope.coroutineContext)) {
- mapIndexed { index, a -> async { f(index, a) } }.map { it.await() }
- }
-
-// run code in parallel
-/*fun argpmap(
- vararg transforms: () -> R,
- numThreads: Int = maxOf(Runtime.getRuntime().availableProcessors() - 2, 1),
- exec: ExecutorService = Executors.newFixedThreadPool(numThreads)
-) {
- for (item in transforms) {
- exec.submit { item.invoke() }
- }
-
- exec.shutdown()
- exec.awaitTermination(1, TimeUnit.DAYS)
-}*/
-
-// built in try catch
-fun argamap(
- vararg transforms: suspend () -> R,
-) = runBlocking {
- transforms.map {
- async {
- try {
- it.invoke()
- } catch (e: Exception) {
- logError(e)
- }
- }
- }.map { it.await() }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt
new file mode 100644
index 00000000000..a3c4040b5cf
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt
@@ -0,0 +1,26 @@
+package com.lagradost.cloudstream3.actions
+
+import android.content.Context
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.txt
+
+class AlwaysAskAction : VideoClickAction() {
+ override val name = txt(R.string.player_settings_always_ask)
+ override val isPlayer = true
+
+ // Only show in settings, not on a video
+ override fun shouldShow(context: Context?, video: ResultEpisode?): Boolean = video == null
+
+ override suspend fun runAction(
+ context: Context?,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ // This is handled specially in ResultViewModel2.kt by detecting the AlwaysAskAction
+ // and showing the player selection dialog instead of executing the action directly
+ throw NotImplementedError("AlwaysAskAction is handled specially by the calling code")
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt
new file mode 100644
index 00000000000..ac912cbeb41
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt
@@ -0,0 +1,135 @@
+package com.lagradost.cloudstream3.actions
+
+import android.app.Activity
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
+import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.ui.result.ResultFragment
+import com.lagradost.cloudstream3.utils.UiText
+import com.lagradost.cloudstream3.utils.txt
+import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import java.io.File
+
+fun updateDurationAndPosition(position: Long, duration: Long) {
+ if (position <= 0 || duration <= 0) return
+ val episode = getKey("last_opened") ?: return
+ DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
+ ResultFragment.updateUI()
+}
+
+/**
+ * Util method that may be helpful for creating intents for apps that support m3u8 files.
+ * All sources are written to a temporary m3u8 file, which is then sent to the app.
+ */
+fun makeTempM3U8Intent(
+ context: Context,
+ intent: Intent,
+ result: LinkLoadingResult
+) {
+ if (result.links.size == 1) {
+ intent.setDataAndType(result.links.first().url.toUri(), "video/*")
+ return
+ }
+
+ intent.apply {
+ addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
+ addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ }
+
+ val outputFile = File.createTempFile("mirrorlist", ".m3u8", context.cacheDir)
+ var text = "#EXTM3U\n#EXT-X-VERSION:3"
+
+ result.links.forEach { link ->
+ text += "\n#EXTINF:0,${link.name}\n${link.url}"
+ }
+
+ //With subtitles it doesn't work for no reason :(
+ /*for (sub in result.subs) {
+ val normalizedName = sub.name.replace("[^a-zA-Z0-9 ]".toRegex(), "")
+ text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${normalizedName}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.languageCode}\",URI=\"${sub.url}\""
+ }*/
+
+ text += "\n#EXT-X-ENDLIST"
+ outputFile.writeText(text)
+
+ intent.setDataAndType(
+ FileProvider.getUriForFile(
+ context,
+ context.applicationContext.packageName + ".provider",
+ outputFile
+ ), "application/x-mpegURL"
+ )
+}
+
+abstract class OpenInAppAction(
+ open val appName: UiText,
+ open val packageName: String,
+ private val intentClass: String? = null,
+ private val action: String = Intent.ACTION_VIEW
+) : VideoClickAction() {
+ override val name: UiText
+ get() = txt(R.string.episode_action_play_in_format, appName)
+
+ override val isPlayer = true
+
+ override fun shouldShow(context: Context?, video: ResultEpisode?) =
+ context?.isAppInstalled(packageName) != false
+
+ override suspend fun runAction(
+ context: Context?,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ if (context == null) return
+ val intent = Intent(action)
+ intent.setPackage(packageName)
+ if (intentClass != null) {
+ intent.component = ComponentName(packageName, intentClass)
+ }
+ putExtra(context, intent, video, result, index)
+ setKey("last_opened", video)
+ launchResult(intent)
+ }
+
+ /**
+ * Before intent is sent, this function is called to put extra data into the intent.
+ * @see VideoClickAction.runAction
+ * */
+ @Throws
+ abstract suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ )
+
+ /**
+ * This function is called when the app is opened again after the intent was sent.
+ * You can use it to for example update duration and position.
+ * @see updateDurationAndPosition
+ */
+ @Throws
+ abstract fun onResult(activity: Activity, intent: Intent?)
+
+ /** Safe version of onResult, we don't trust extension devs to not crash the app */
+ fun onResultSafe(activity: Activity, intent: Intent?) {
+ try {
+ onResult(activity, intent)
+ } catch (t: Throwable) {
+ logError(t)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt
new file mode 100644
index 00000000000..4843b7617a2
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt
@@ -0,0 +1,201 @@
+package com.lagradost.cloudstream3.actions
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.core.app.ActivityOptionsCompat
+import com.lagradost.api.Log
+import com.lagradost.cloudstream3.CommonActivity
+import com.lagradost.cloudstream3.ErrorLoadingException
+import com.lagradost.cloudstream3.MainActivity
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage
+import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction
+import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage
+import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage
+import com.lagradost.cloudstream3.actions.temp.MpvExPackage
+import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
+import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
+import com.lagradost.cloudstream3.actions.temp.MpvPackage
+import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
+import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
+import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
+import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
+import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
+import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage
+import com.lagradost.cloudstream3.actions.temp.VlcPackage
+import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage
+import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
+import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.UiText
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.util.concurrent.Callable
+import java.util.concurrent.FutureTask
+import kotlin.reflect.jvm.jvmName
+
+object VideoClickActionHolder {
+ val allVideoClickActions = threadSafeListOf(
+ // Default
+ PlayInBrowserAction(),
+ CopyClipboardAction(),
+ ViewM3U8Action(),
+ PlayMirrorAction(),
+ // main support external apps
+ VlcPackage(),
+ MpvPackage(),
+ MpvExPackage(),
+ NextPlayerPackage(),
+ JustPlayerPackage(),
+ FcastAction(),
+ LibreTorrentPackage(),
+ BiglyBTPackage(),
+ // forks/backup apps
+ VlcNightlyPackage(),
+ WebVideoCastPackage(),
+ MpvYTDLPackage(),
+ MpvKtPackage(),
+ MpvKtPreviewPackage(),
+ // Always Ask option
+ AlwaysAskAction(),
+ // added by plugins
+ // ...
+ )
+
+ init {
+ Log.d("VideoClickActionHolder", "allVideoClickActions: ${allVideoClickActions.map { it.uniqueId() }}")
+ }
+
+ private const val ACTION_ID_OFFSET = 1000
+
+ fun makeOptionMap(activity: Activity?, video: ResultEpisode) = allVideoClickActions
+ // We need to have index before filtering
+ .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET }
+ .filter { it.first.shouldShowSafe(activity, video) }
+ .map { it.first.name to it.second }
+
+
+ fun getActionById(id: Int): VideoClickAction? = allVideoClickActions.getOrNull(id - ACTION_ID_OFFSET)
+
+ fun getByUniqueId(uniqueId: String): VideoClickAction? = allVideoClickActions.firstOrNull { it.uniqueId() == uniqueId }
+
+ fun uniqueIdToId(uniqueId: String?): Int? {
+ if (uniqueId == null) return null
+ return allVideoClickActions
+ .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET }
+ .firstOrNull { it.first.uniqueId() == uniqueId }
+ ?.second
+ }
+
+ fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShowSafe(activity, null) }
+}
+
+abstract class VideoClickAction {
+ abstract val name: UiText
+
+ /** if true, the app will show dialog to select source - result.links[index] */
+ open val oneSource : Boolean = false
+
+ /** if true, this action could be selected as default player (one press action) in settings */
+ open val isPlayer: Boolean = false
+
+ /** Which type of sources this action can handle. */
+ open val sourceTypes: Set = ExtractorLinkType.entries.toSet()
+
+ /** Determines which plugin a given provider is from. This is the full path to the plugin. */
+ var sourcePlugin: String? = null
+
+ /** Even if VideoClickAction should not run any UI code, startActivity requires it,
+ * this is a wrapper for runOnUiThread in a suspended safe context that bubble up exceptions */
+ @Throws
+ suspend fun uiThread(callable : Callable) : T? {
+ val future = FutureTask{
+ try {
+ Result.success(callable.call())
+ } catch (t : Throwable) {
+ Result.failure(t)
+ }
+ }
+ CommonActivity.activity?.runOnUiThread(future) ?: throw ErrorLoadingException("No UI Activity, this should never happened")
+ val result = withContext(Dispatchers.IO) {
+ return@withContext future.get()
+ }
+ return result.getOrThrow()
+ }
+
+ /** Internally uses activityResultLauncher,
+ * use this when the activity has a result like watched position */
+ @Throws
+ suspend fun launchResult(intent : Intent?, options : ActivityOptionsCompat? = null) {
+ if (intent == null) {
+ return
+ }
+
+ uiThread {
+ MainActivity.activityResultLauncher?.launch(intent,options)
+ }
+ }
+
+ /** Internally uses startActivity, use this when you don't
+ * have any result that needs to be stored when exiting the activity */
+ @Throws
+ suspend fun launch(intent : Intent?, bundle : Bundle? = null) {
+ if (intent == null) {
+ return
+ }
+
+ uiThread {
+ CommonActivity.activity?.startActivity(intent, bundle)
+ }
+ }
+
+ fun uniqueId() = "$sourcePlugin:${this::class.jvmName}"
+
+ @Throws
+ abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean
+
+ /** Safe version of shouldShow, as we don't trust extension devs to handle exceptions,
+ * however no dev *should* throw in shouldShow */
+ fun shouldShowSafe(context: Context?, video: ResultEpisode?): Boolean {
+ return try {
+ shouldShow(context,video)
+ } catch (t : Throwable) {
+ logError(t)
+ false
+ }
+ }
+
+ /**
+ * This function is called when the action is clicked.
+ * @param context The current activity
+ * @param video The episode/movie that was clicked
+ * @param result The result of the link loading, contains video & subtitle links
+ * @param index if oneSource is true, this is the index of the selected source
+ */
+ @Throws
+ abstract suspend fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?)
+
+ /** Safe version of runAction, as we don't trust extension devs to handle exceptions */
+ fun runActionSafe(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) = ioSafe {
+ try {
+ runAction(context, video, result, index)
+ } catch (_ : NotImplementedError) {
+ CommonActivity.showToast("runAction has not been implemented for ${name.asStringNull(context)}, please contact the extension developer of $sourcePlugin", Toast.LENGTH_LONG)
+ } catch (error : ErrorLoadingException) {
+ CommonActivity.showToast(error.message, Toast.LENGTH_LONG)
+ } catch (_: ActivityNotFoundException) {
+ CommonActivity.showToast(R.string.app_not_found_error, Toast.LENGTH_LONG)
+ } catch (t : Throwable) {
+ logError(t)
+ CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG)
+ }
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt
new file mode 100644
index 00000000000..a7401c2ff70
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt
@@ -0,0 +1,30 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.txt
+
+/** https://github.com/devgianlu/Aria2Android */
+@Suppress("unused")
+class Aria2Package : OpenInAppAction(
+ appName = txt("Aria2"),
+ packageName = "com.gianlu.aria2android",
+ intentClass = "com.gianlu.aria2android.MainActivity"
+) {
+ override val oneSource: Boolean = true
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ throw NotImplementedError("Aria2Android is missing getIntent, and onNewIntent, meaning it cant handle intents")
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) = Unit
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt
new file mode 100644
index 00000000000..3959bb9d324
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt
@@ -0,0 +1,36 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.txt
+
+/** https://github.com/BiglySoftware/BiglyBT-Android */
+class BiglyBTPackage : OpenInAppAction(
+ appName = txt("BiglyBT"),
+ packageName = "com.biglybt.android.client",
+ intentClass = "com.biglybt.android.client.activity.IntentHandler"
+) {
+ // Only torrents are supported by the app
+ override val sourceTypes: Set =
+ setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT)
+
+ override val oneSource: Boolean = true
+
+ override suspend fun putExtra(
+ context: Context,
+ intent: Intent,
+ video: ResultEpisode,
+ result: LinkLoadingResult,
+ index: Int?
+ ) {
+ intent.data = result.links[index!!].url.toUri()
+ }
+
+ override fun onResult(activity: Activity, intent: Intent?) = Unit
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt
new file mode 100644
index 00000000000..d414b611783
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt
@@ -0,0 +1,162 @@
+package com.lagradost.cloudstream3.actions.temp
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.cloudstream3.actions.OpenInAppAction
+import com.lagradost.cloudstream3.BuildConfig
+import com.lagradost.cloudstream3.ui.player.ExtractorUri
+import com.lagradost.cloudstream3.ui.player.SubtitleData
+import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
+import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
+import com.lagradost.cloudstream3.ui.result.ResultEpisode
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
+import com.lagradost.cloudstream3.utils.DrmExtractorLink
+import com.lagradost.cloudstream3.utils.ExtractorLink
+import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.newExtractorLink
+import com.lagradost.cloudstream3.utils.Qualities
+import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
+import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
+import com.lagradost.cloudstream3.utils.txt
+
+/**
+ * If you want to support CloudStream 3 as an external player, then this shows how to play any video link
+ * For basic interactions, just `intent.data = uri` works
+ *
+ * However for more advanced use, CloudStream 3 also supports playlists of MinimalVideoLink and MinimalSubtitleLink with a `String[]` of JSON
+ * These are passed as LINKS_EXTRA and SUBTITLE_EXTRA respectively
+ */
+@Suppress("Unused")
+class CloudStreamPackage : OpenInAppAction(
+ appName = txt("CloudStream"),
+ packageName = BuildConfig.APPLICATION_ID, //"com.lagradost.cloudstream3" or "com.lagradost.cloudstream3.prerelease"
+ intentClass = "com.lagradost.cloudstream3.ui.player.DownloadedPlayerActivity"
+) {
+ override val oneSource: Boolean = false
+
+ companion object {
+ const val SUBTITLE_EXTRA: String = "subs" // Json of an array of MinimalVideoLink
+ const val LINKS_EXTRA: String = "links" // Json of an array of MinimalSubtitleLink
+ const val TITLE_EXTRA: String = "title" // Unused (String)
+ const val ID_EXTRA: String =
+ "id" // Identification number for the video(s), used to store start time (Int)
+ const val POSITION_EXTRA: String = "pos" // Start time in MS (Long)
+ const val DURATION_EXTRA: String = "dur" // Duration time in MS (Long)
+ }
+
+ data class MinimalVideoLink(
+ @JsonProperty("uri")
+ val uri: Uri?,
+ @JsonProperty("url")
+ val url: String?,
+ @JsonProperty("mimeType")
+ val mimeType: String = "video/mp4",
+ @JsonProperty("name")
+ val name: String?,
+ @JsonProperty("headers")
+ var headers: Map = mapOf(),
+ @JsonProperty("quality")
+ val quality: Int?,
+ ) {
+ companion object {
+ fun fromExtractor(link: ExtractorLink): MinimalVideoLink = MinimalVideoLink(
+ uri = null,
+ url = link.url,
+ name = link.name,
+ mimeType = link.type.getMimeType(),
+ headers = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) + link.headers,
+ quality = link.quality
+ )
+ }
+
+ suspend fun toExtractorLink(): Pair =
+ url?.let { url ->
+ newExtractorLink(
+ source = "NONE",
+ name = name ?: "Unknown",
+ url = url,
+ type = ExtractorLinkType.entries.firstOrNull { ty -> ty.getMimeType() == mimeType }
+ ?: ExtractorLinkType.VIDEO) {
+
+ this@newExtractorLink.headers =
+ this@MinimalVideoLink.headers
+
+ this@newExtractorLink.quality =
+ this@MinimalVideoLink.quality ?: Qualities.Unknown.value
+ }
+ } to uri?.let { uri ->
+ ExtractorUri(
+ uri = uri,
+ name = name ?: "Unknown",
+ )
+ }
+ }
+
+
+ data class MinimalSubtitleLink(
+ @JsonProperty("url")
+ val url: String,
+ @JsonProperty("mimeType")
+ val mimeType: String = "text/vtt",
+ @JsonProperty("name")
+ val name: String?,
+ @JsonProperty("headers")
+ var headers: Map