Compare commits

..

1 commit

Author SHA1 Message Date
firelight
693f69f25f
Feat: Expandable navigation rail on focus 2025-08-21 03:51:52 +02:00
592 changed files with 21382 additions and 35996 deletions

30
.github/locales.py vendored
View file

@ -1,13 +1,14 @@
import re import re
import glob import glob
import requests import requests
import os
import lxml.etree as ET # builtin library doesn't preserve comments import lxml.etree as ET # builtin library doesn't preserve comments
SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt" SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt"
START_MARKER = "/* begin language list */" START_MARKER = "/* begin language list */"
END_MARKER = "/* end language list */" END_MARKER = "/* end language list */"
XML_NAME = "app/src/main/res/values-b+" XML_NAME = "app/src/main/res/values-"
ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json"
INDENT = " "*4 INDENT = " "*4
@ -20,29 +21,29 @@ rest, after_src = rest.split(END_MARKER)
# Load already added langs # Load already added langs
languages = {} languages = {}
for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): for lang in re.finditer(r'Triple\("(.*)", "(.*)", "(.*)"\)', rest):
name, iso = lang.groups() flag, name, iso = lang.groups()
languages[iso] = name languages[iso] = (flag, name)
# Add not yet added langs # Add not yet added langs
for folder in glob.glob(f"{XML_NAME}*"): for folder in glob.glob(f"{XML_NAME}*"):
iso = folder[len(XML_NAME):].replace("+", "-") iso = folder[len(XML_NAME):]
if iso not in languages.keys(): if iso not in languages.keys():
entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found entry = iso_map.get(iso.lower(),{'nativeName':iso})
languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple languages[iso] = ("", entry['nativeName'].split(',')[0])
# Create pairs # Create triples
pairs = [] triples = []
for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name for iso in sorted(languages.keys()):
name = languages[iso] flag, name = languages[iso]
pairs.append(f'{INDENT}Pair("{name}", "{iso}"),') triples.append(f'{INDENT}Triple("{flag}", "{name}", "{iso}"),')
# Update settings file # Update settings file
open(SETTINGS_PATH, "w+",encoding='utf-8').write( open(SETTINGS_PATH, "w+",encoding='utf-8').write(
before_src + before_src +
START_MARKER + START_MARKER +
"\n" + "\n" +
"\n".join(pairs) + "\n".join(triples) +
"\n" + "\n" +
END_MARKER + END_MARKER +
after_src after_src
@ -61,5 +62,8 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
with open(file, 'wb') as fp: with open(file, 'wb') as fp:
fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n') fp.write(b'<?xml version="1.0" encoding="utf-8"?>\n')
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
# Remove trailing new line to be consistent with weblate
fp.seek(-1, os.SEEK_END)
fp.truncate()
except ET.ParseError as ex: except ET.ParseError as ex:
print(f"[{file}] {ex}") print(f"[{file}] {ex}")

View file

@ -9,9 +9,6 @@ on:
- '**/wcokey.txt' - '**/wcokey.txt'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
concurrency: concurrency:
group: "Archive-build" group: "Archive-build"
cancel-in-progress: true cancel-in-progress: true
@ -27,7 +24,6 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets" repository: "recloudstream/secrets"
- name: Generate access token (archive) - name: Generate access token (archive)
id: generate_archive_token id: generate_archive_token
uses: tibdex/github-app-token@v2 uses: tibdex/github-app-token@v2
@ -35,18 +31,14 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream-archive" repository: "recloudstream/cloudstream-archive"
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: temurin java-version: '17'
java-version: 17 distribution: 'adopt'
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Fetch keystore - name: Fetch keystore
id: fetch_keystore id: fetch_keystore
run: | run: |
@ -57,33 +49,24 @@ jobs:
KEY_PWD="$(cat keystore_password.txt)" KEY_PWD="$(cat keystore_password.txt)"
echo "::add-mask::${KEY_PWD}" echo "::add-mask::${KEY_PWD}"
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT 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 - name: Run Gradle
run: ./gradlew assemblePrereleaseRelease run: |
./gradlew assemblePrerelease
env: env:
SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_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_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} - uses: actions/checkout@v4
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
MAL_KEY: ${{ secrets.MAL_KEY }}
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
- uses: actions/checkout@v6
with: with:
repository: "recloudstream/cloudstream-archive" repository: "recloudstream/cloudstream-archive"
token: ${{ steps.generate_archive_token.outputs.token }} token: ${{ steps.generate_archive_token.outputs.token }}
path: "archive" path: "archive"
- name: Move build - name: Move build
run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk" run: |
cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk"
- name: Push archive - name: Push archive
run: | run: |

View file

@ -1,18 +1,19 @@
name: Dokka name: Dokka
on: # https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#concurrency
push:
branches: [ master ]
paths-ignore:
- '*.md'
permissions:
contents: read
concurrency: concurrency:
group: "dokka" group: "dokka"
cancel-in-progress: true cancel-in-progress: true
on:
push:
branches:
# choose your default branch
- master
- main
paths-ignore:
- '*.md'
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -24,14 +25,13 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/dokka" repository: "recloudstream/dokka"
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@master
with: with:
path: "src" path: "src"
- name: Checkout dokka - name: Checkout dokka
uses: actions/checkout@v6 uses: actions/checkout@master
with: with:
repository: "recloudstream/dokka" repository: "recloudstream/dokka"
path: "dokka" path: "dokka"
@ -44,15 +44,13 @@ jobs:
rm -rf "./library" rm -rf "./library"
- name: Setup JDK 17 - name: Setup JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: temurin
java-version: 17 java-version: 17
distribution: 'adopt'
- name: Setup Gradle - name: Setup Android SDK
uses: gradle/actions/setup-gradle@v5 uses: android-actions/setup-android@v3
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Generate Dokka - name: Generate Dokka
run: | run: |
@ -61,7 +59,8 @@ jobs:
./gradlew docs:dokkaGeneratePublicationHtml ./gradlew docs:dokkaGeneratePublicationHtml
- name: Copy Dokka - name: Copy Dokka
run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ run: |
cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/
- name: Push builds - name: Push builds
run: | run: |

88
.github/workflows/issue_action.yml vendored Normal file
View file

@ -0,0 +1,88 @@
name: Issue automatic actions
on:
issues:
types: [opened]
jobs:
issue-moderator:
runs-on: ubuntu-latest
steps:
- name: Generate access token
id: generate_token
uses: tibdex/github-app-token@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 }}
filter-threshold: 0.60
title-excludes: ''
comment-title: |
### Your issue looks similar to these issues:
Please close if duplicate.
comment-body: '${index}. ${similarity} #${number}'
- name: Label if possible duplicate
if: steps.similarity.outputs.similar-issues-found =='true'
uses: actions/github-script@v7
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@v4
- name: Automatically close issues that dont follow the issue template
uses: lucasbento/auto-close-issues@v1.0.2
with:
github-token: ${{ steps.generate_token.outputs.token }}
issue-close-message: |
@${issue.user.login}: hello! :wave:
This issue is being automatically closed because it does not follow the issue template."
closed-issues-label: "invalid"
- name: Check if issue mentions a provider
id: provider_check
env:
GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}"
run: |
wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py"
pip3 install httpx
RES="$(python3 ./check_issue.py)"
echo "name=${RES}" >> $GITHUB_OUTPUT
- name: Comment if issue mentions a provider
if: steps.provider_check.outputs.name != 'none'
uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment'
token: ${{ steps.generate_token.outputs.token }}
body: |
Hello ${{ github.event.issue.user.login }}.
Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM).
Found provider name: `${{ steps.provider_check.outputs.name }}`
- name: Label if mentions provider
if: steps.provider_check.outputs.name != 'none'
uses: actions/github-script@v7
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'

View file

@ -12,9 +12,6 @@ concurrency:
group: "pre-release" group: "pre-release"
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: write
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -26,18 +23,14 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets" repository: "recloudstream/secrets"
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: temurin java-version: '17'
java-version: 17 distribution: 'adopt'
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Fetch keystore - name: Fetch keystore
id: fetch_keystore id: fetch_keystore
run: | run: |
@ -48,27 +41,19 @@ jobs:
KEY_PWD="$(cat keystore_password.txt)" KEY_PWD="$(cat keystore_password.txt)"
echo "::add-mask::${KEY_PWD}" echo "::add-mask::${KEY_PWD}"
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT 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 - name: Run Gradle
run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar run: |
./gradlew assemblePrerelease build androidSourcesJar
./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
env: env:
SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_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_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
MDL_API_KEY: ${{ secrets.MDL_API_KEY }} MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
MAL_KEY: ${{ secrets.MAL_KEY }}
ANILIST_KEY: ${{ secrets.ANILIST_KEY }}
- name: Create pre-release - name: Create pre-release
uses: marvinpinto/action-automatic-releases@latest uses: "marvinpinto/action-automatic-releases@latest"
with: with:
repo_token: "${{ secrets.GITHUB_TOKEN }}" repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: "pre-release" automatic_release_tag: "pre-release"

View file

@ -2,35 +2,22 @@ name: Artifact Build
on: [pull_request] on: [pull_request]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: temurin java-version: '17'
java-version: 17 distribution: 'adopt'
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x 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 - name: Run Gradle
run: ./gradlew assemblePrereleaseDebug lint check run: ./gradlew assemblePrereleaseDebug
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: pull-request-build name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk" path: "app/build/outputs/apk/prerelease/debug/*.apk"

View file

@ -1,19 +1,17 @@
name: Fix locale issues name: Fix locale issues
on: on:
workflow_dispatch:
push: push:
branches: [ master ]
paths: paths:
- '**.xml' - '**.xml'
workflow_dispatch: branches:
- master
concurrency: concurrency:
group: "locale" group: "locale"
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
create: create:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -25,17 +23,15 @@ jobs:
app_id: ${{ secrets.GH_APP_ID }} app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream" repository: "recloudstream/cloudstream"
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with: with:
token: ${{ steps.generate_token.outputs.token }} token: ${{ steps.generate_token.outputs.token }}
- name: Install dependencies - name: Install dependencies
run: pip3 install lxml requests run: |
pip3 install lxml
- name: Edit files - name: Edit files
run: python3 .github/locales.py run: |
python3 .github/locales.py
- name: Commit to the repo - name: Commit to the repo
run: | run: |
git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com"

View file

@ -1,11 +0,0 @@
# 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.

View file

@ -1,96 +1,53 @@
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform
import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier 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.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
plugins { plugins {
alias(libs.plugins.android.application) id("com.android.application")
alias(libs.plugins.dokka) id("kotlin-android")
alias(libs.plugins.kotlin.serialization) id("org.jetbrains.dokka")
} }
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
abstract class GenerateGitHashTask : DefaultTask() { fun getGitCommitHash(): String {
return try {
val headFile = file("${project.rootDir}/.git/HEAD")
@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 // Read the commit hash from .git/HEAD
val headContent = head.readText().trim() if (headFile.exists()) {
val headContent = headFile.readText().trim()
if (headContent.startsWith("ref:")) { if (headContent.startsWith("ref:")) {
val refPath = headContent.substring(5) // e.g., refs/heads/main val refPath = headContent.substring(5) // e.g., refs/heads/main
val commitFile = File(head.parentFile, refPath) val commitFile = file("${project.rootDir}/.git/$refPath")
if (commitFile.exists()) commitFile.readText().trim() else "" if (commitFile.exists()) commitFile.readText().trim() else ""
} else headContent // If it's a detached HEAD (commit hash directly) } else headContent // If it's a detached HEAD (commit hash directly)
} else "" // If .git/HEAD doesn't exist } else {
"" // If .git/HEAD doesn't exist
}.take(7) // Return the short commit hash
} catch (_: Throwable) { } catch (_: Throwable) {
"" // Just set to an empty string if any exception occurs "" // Just return 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<GenerateGitHashTask>("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 { android {
@Suppress("UnstableApiUsage") @Suppress("UnstableApiUsage")
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
} }
// Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491 viewBinding {
dependenciesInfo { enable = true
// 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 { signingConfigs {
// We just use SIGNING_KEY_ALIAS here since it won't change if (prereleaseStoreFile != null) {
// so won't kill the configuration cache.
if (System.getenv("SIGNING_KEY_ALIAS") != null) {
create("prerelease") { create("prerelease") {
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" storeFile = file(prereleaseStoreFile)
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
storeFile = prereleaseStoreFile?.let { file(it) }
storePassword = System.getenv("SIGNING_STORE_PASSWORD") storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD") keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
@ -104,10 +61,12 @@ android {
applicationId = "com.lagradost.cloudstream3" applicationId = "com.lagradost.cloudstream3"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt() versionCode = 66
versionName = libs.versions.versionName.get() versionName = "4.5.4"
manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", getGitCommitHash())
resValue("bool", "is_prerelease", "false")
// Reads local.properties // Reads local.properties
val localProperties = gradleLocalProperties(rootDir, project.providers) val localProperties = gradleLocalProperties(rootDir, project.providers)
@ -127,16 +86,6 @@ android {
"SIMKL_CLIENT_SECRET", "SIMKL_CLIENT_SECRET",
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
) )
buildConfigField(
"String",
"MAL_KEY",
"\"" + (System.getenv("MAL_KEY") ?: localProperties["mal.key"]) + "\""
)
buildConfigField(
"String",
"ANILIST_KEY",
"\"" + (System.getenv("ANILIST_KEY") ?: localProperties["anilist.key"]) + "\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@ -164,9 +113,12 @@ android {
productFlavors { productFlavors {
create("stable") { create("stable") {
dimension = "state" dimension = "state"
resValue("bool", "is_prerelease", "false")
} }
create("prerelease") { create("prerelease") {
dimension = "state" dimension = "state"
resValue("bool", "is_prerelease", "true")
buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease" applicationIdSuffix = ".prerelease"
if (signingConfigs.names.contains("prerelease")) { if (signingConfigs.names.contains("prerelease")) {
signingConfig = signingConfigs.getByName("prerelease") signingConfig = signingConfigs.getByName("prerelease")
@ -184,29 +136,13 @@ android {
targetCompatibility = JavaVersion.toVersion(javaTarget.target) targetCompatibility = JavaVersion.toVersion(javaTarget.target)
} }
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 { lint {
abortOnError = false
checkReleaseBuilds = false checkReleaseBuilds = false
} }
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
viewBinding = true
}
packaging {
jniLibs {
// Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23).
// Note: This may increase app startup time slightly.
useLegacyPackaging = true
}
} }
namespace = "com.lagradost.cloudstream3" namespace = "com.lagradost.cloudstream3"
@ -217,46 +153,43 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.json) testImplementation(libs.json)
androidTestImplementation(libs.core) androidTestImplementation(libs.core)
androidTestImplementation(libs.espresso.core) implementation(libs.junit.ktx)
androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.instancio.core) androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.junit.ktx)
androidTestImplementation(libs.kotlin.test)
// Android Core & Lifecycle // Android Core & Lifecycle
implementation(libs.core.ktx) implementation(libs.core.ktx)
implementation(libs.activity.ktx)
implementation(libs.annotation)
implementation(libs.appcompat) implementation(libs.appcompat)
implementation(libs.fragment.ktx) implementation(libs.bundles.navigationKtx)
implementation(libs.bundles.lifecycle) implementation(libs.lifecycle.livedata.ktx)
implementation(libs.bundles.navigation) implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kotlinx.serialization.json) // JSON Parser
// Design & UI // Design & UI
implementation(libs.preference.ktx) implementation(libs.preference.ktx)
implementation(libs.material) implementation(libs.material)
implementation(libs.constraintlayout) implementation(libs.constraintlayout)
implementation(libs.swiperefreshlayout)
// Coil Image Loading // Coil Image Loading
implementation(libs.bundles.coil) implementation(libs.coil)
implementation(libs.coil.network.okhttp)
// Media 3 (ExoPlayer) // Media 3 (ExoPlayer)
implementation(libs.bundles.media3) implementation(libs.bundles.media3)
implementation(libs.video) implementation(libs.video)
// FFmpeg Decoding
implementation(libs.bundles.nextlib)
// Anime-db for filler
implementation(libs.anime.db)
// PlayBack // PlayBack
implementation(libs.colorpicker) // Subtitle Color Picker implementation(libs.colorpicker) // Subtitle Color Picker
implementation(libs.newpipeextractor) // For Trailers implementation(libs.newpipeextractor) // For Trailers
implementation(libs.juniversalchardet) // Subtitle Decoding implementation(libs.juniversalchardet) // Subtitle Decoding
// FFmpeg Decoding
implementation(libs.bundles.nextlibMedia3)
// Crash Reports (AcraApplication.kt)
implementation(libs.acra.core)
implementation(libs.acra.toast)
// UI Stuff // UI Stuff
implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton) implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton)
implementation(libs.palette.ktx) // Palette for Images -> Colors implementation(libs.palette.ktx) // Palette for Images -> Colors
@ -267,34 +200,50 @@ dependencies {
implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV
// Extensions & Other Libs // Extensions & Other Libs
implementation(libs.jsoup) // HTML Parser
implementation(libs.rhino) // Run JavaScript implementation(libs.rhino) // Run JavaScript
implementation(libs.quickjs)
implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance
implementation(libs.safefile) // To Prevent the URI File Fu*kery implementation(libs.safefile) // To Prevent the URI File Fu*kery
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor 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.conscrypt.android) {
implementation(libs.jackson.module.kotlin) // JSON Parser version {
implementation(libs.zipline) strictly("2.5.2")
}
// Deprecated; will be removed once extensions have time to migrate from using it because("2.5.3 crashes everything for everyone.")
implementation("me.xdrop:fuzzywuzzy:1.4.0") } // To Fix SSL Fu*kery on Android 9
implementation(libs.jackson.module.kotlin) {
version {
strictly("2.13.1")
}
because("Don't Bump Jackson above 2.13.1, Crashes on Android TV's and FireSticks that have Min API Level 25 or Less.")
} // JSON Parser
// Torrent Support // Torrent Support
implementation(libs.torrentserver) implementation(libs.torrentserver)
// Downloading & Networking // Downloading & Networking
implementation(libs.work.runtime)
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
implementation(libs.nicehttp) // HTTP Lib implementation(libs.nicehttp) // HTTP Lib
implementation(project(":library")) implementation(project(":library") {
// There does not seem to be a good way of getting the android flavor.
val isDebug = gradle.startParameter.taskRequests.any { task ->
task.args.any { arg ->
arg.contains("debug", true)
}
}
this.extra.set("isDebug", isDebug)
})
} }
tasks.register<Jar>("androidSourcesJar") { tasks.register<Jar>("androidSourcesJar") {
archiveClassifier.set("sources") archiveClassifier.set("sources")
from(android.sourceSets.getByName("main").java.directories) // Full Sources from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
} }
tasks.register<Copy>("copyJar") { tasks.register<Copy>("copyJar") {
dependsOn("build", ":library:jvmJar")
from( from(
"build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar",
"../library/build/libs" "../library/build/libs"
@ -321,23 +270,15 @@ tasks.register<Jar>("makeJar") {
tasks.withType<KotlinJvmCompile> { tasks.withType<KotlinJvmCompile> {
compilerOptions { compilerOptions {
jvmTarget.set(javaTarget) jvmTarget.set(javaTarget)
jvmDefault.set(JvmDefaultMode.ENABLE) freeCompilerArgs.add("-Xjvm-default=all-compatibility")
freeCompilerArgs.add("-Xannotation-default-target=param-property")
optIn.addAll(
"com.lagradost.cloudstream3.InternalAPI",
"com.lagradost.cloudstream3.Prerelease",
"kotlin.uuid.ExperimentalUuidApi",
)
} }
} }
dokka { dokka {
moduleName = "App" moduleName = "App"
dokkaSourceSets { dokkaSourceSets {
configureEach { main {
suppress = name != "prereleaseDebug"
analysisPlatform = KotlinPlatform.JVM analysisPlatform = KotlinPlatform.JVM
displayName = "JVM"
documentedVisibilities( documentedVisibilities(
VisibilityModifier.Public, VisibilityModifier.Public,
VisibilityModifier.Protected VisibilityModifier.Protected

View file

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- ByteOrderMark has errors in values-b+ja/strings.xml, but it's handled by weblate so we don't really care. -->
<issue id="ByteOrderMark" severity="ignore" />
<!-- We don't care about MissingTranslation since it's handled by weblate. -->
<issue id="MissingTranslation" severity="ignore" />
<!-- We only care about the source language here. -->
<issue id="StringFormatInvalid">
<ignore path="**/res/values-*/**" />
</issue>
</lint>

View file

@ -136,14 +136,14 @@ class ExampleInstrumentedTest {
@Test @Test
@Throws(AssertionError::class) @Throws(AssertionError::class)
fun providerCorrectData() { fun providerCorrectData() {
val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag } val isoNames = SubtitleHelper.languages.map { it.ISO_639_1 }
Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty()) Assert.assertFalse("ISO does not contain any languages", isoNames.isNullOrEmpty())
for (api in getAllProviders()) { for (api in getAllProviders()) {
Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE") 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 does not contain a name", api.name != "NONE")
Assert.assertTrue( Assert.assertTrue(
"Api ${api.name} does not contain a valid language code", "Api ${api.name} does not contain a valid language code",
langTagsIETF.contains(api.lang) isoNames.contains(api.lang)
) )
Assert.assertTrue( Assert.assertTrue(
"Api ${api.name} does not contain any supported types", "Api ${api.name} does not contain any supported types",

View file

@ -1,134 +0,0 @@
package com.lagradost.cloudstream3
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import dalvik.system.DexFile
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.serializer
import kotlinx.serialization.serializerOrNull
import org.instancio.Instancio
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.reflect.KClass
import kotlin.reflect.jvm.jvmName
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@RunWith(AndroidJUnit4::class)
class SerializationClassTester {
// Same as app, or using app reference
val jacksonMapper = mapper
val kotlinxMapper = json
@Test
fun isIdenticalSerialization() {
val serializableClasses = findSerializableClasses("com.lagradost")
println("Number of serializable classes: ${serializableClasses.size}")
serializableClasses.forEach { kClass ->
val instance = Instancio.create(kClass.java)
val jacksonJson = jacksonMapper.writeValueAsString(instance)
val kotlinxJson = serializeWithKotlinx(kClass, instance)
assertEquals(
jacksonJson,
kotlinxJson,
"""
Serialization mismatch for:
${kClass.qualifiedName}
Jackson:
$jacksonJson
Kotlinx:
$kotlinxJson
""".trimIndent()
)
println("Identical serialization for: ${kClass.jvmName}")
}
}
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
@Test
fun isIdenticalDeserialization() {
val serializableClasses = findSerializableClasses("com.lagradost")
println("Number of serializable classes: ${serializableClasses.size}")
serializableClasses.forEach { kClass ->
val instance = Instancio.create(kClass.java)
// Convert to JSON to get example JSON object
// We prefer jackson here because the app may have many jackson JSON strings in local storage
val originalJson = jacksonMapper.writeValueAsString(instance)
// Create an object from the JSON using kotlinx
val serializer =
kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass)
assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!")
val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson)
// Create an object from the JSON using jackson
val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java)
// Deep inspect both object using the mapper toJson function.
// This deep equality check can be performed using other methods, but this just works.
val jacksonJson = mapperDecoded.toJson()
val kotlinxJson = kotlinxDecoded.toJson()
assertEquals(
jacksonJson,
kotlinxJson,
"""
Serialization mismatch for:
${kClass.qualifiedName}
Jackson:
$jacksonJson
Kotlinx:
$kotlinxJson
""".trimIndent()
)
println("Identical deserialization for: ${kClass.jvmName}")
}
}
// DEX files are the best solution to read all our classes dynamically.
// classgraph could be used instead, but it only gives results on the JVM, not Android.
@Suppress("DEPRECATION")
private fun findSerializableClasses(packageName: String): List<KClass<*>> {
val context = InstrumentationRegistry
.getInstrumentation()
.targetContext
val dexFile = DexFile(context.packageCodePath)
return dexFile.entries()
.toList()
.filter { it.startsWith(packageName) }
.mapNotNull {
runCatching { Class.forName(it).kotlin }.getOrNull()
}.filter { kClass ->
// Not possible to use .hasAnnotation() on newer Android versions.
kClass.java.annotations.any {
it is Serializable
}
}
}
@OptIn(InternalSerializationApi::class)
@Suppress("UNCHECKED_CAST")
private fun serializeWithKotlinx(
kClass: KClass<*>,
value: Any
): String {
val serializer = kClass.serializer() as KSerializer<Any>
return kotlinxMapper.encodeToString(serializer, value)
}
}

View file

@ -1,157 +0,0 @@
package com.lagradost.cloudstream3.utils.serializers
import android.net.Uri
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KeepGeneratedSerializer
import kotlinx.serialization.Serializable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = NonEmptyData.Serializer::class)
data class NonEmptyData(
val title: String = "",
val tags: List<String> = emptyList(),
val meta: Map<String, String> = emptyMap(),
val name: String = "hello",
) {
object Serializer : NonEmptySerializer<NonEmptyData>(NonEmptyData.generatedSerializer())
}
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = WriteOnlyData.Serializer::class)
data class WriteOnlyData(
val fieldA: String = "",
val fieldB: String = "",
) {
object Serializer : WriteOnlySerializer<WriteOnlyData>(
WriteOnlyData.generatedSerializer(),
setOf("fieldB"),
)
}
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = MultiWriteOnly.Serializer::class)
data class MultiWriteOnly(
val fieldA: String = "",
val fieldB: String = "",
val fieldC: String = "",
) {
object Serializer : WriteOnlySerializer<MultiWriteOnly>(
MultiWriteOnly.generatedSerializer(),
setOf("fieldB", "fieldC"),
)
}
@Serializable
data class UriData(
@Serializable(with = UriSerializer::class)
val uri: Uri = Uri.EMPTY,
)
class SerializerTest {
@Test
fun nonEmptySerializerOmitsEmptyStrings() {
val data = NonEmptyData(title = "", name = "hello")
val result = data.toJson()
assertFalse(result.contains("title"))
assertTrue(result.contains("name"))
}
@Test
fun nonEmptySerializerOmitsEmptyLists() {
val data = NonEmptyData(tags = emptyList(), name = "hello")
val result = data.toJson()
assertFalse(result.contains("tags"))
}
@Test
fun nonEmptySerializerOmitsEmptyMaps() {
val data = NonEmptyData(meta = emptyMap(), name = "hello")
val result = data.toJson()
assertFalse(result.contains("meta"))
}
@Test
fun nonEmptySerializerKeepsNonEmptyFields() {
val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v"))
val result = data.toJson()
assertTrue(result.contains("title"))
assertTrue(result.contains("tags"))
assertTrue(result.contains("meta"))
}
@Test
fun nonEmptySerializerDoesNotAffectDeserialization() {
val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}"""
val result = parseJson<NonEmptyData>(input)
assertEquals("hello", result.title)
assertEquals(listOf("a"), result.tags)
assertEquals(mapOf("k" to "v"), result.meta)
assertEquals("world", result.name)
}
@Test
fun writeOnlySerializerOmitsFieldOnSerialize() {
val data = WriteOnlyData(fieldA = "hello", fieldB = "secret")
val result = data.toJson()
assertTrue(result.contains("fieldA"))
assertFalse(result.contains("fieldB"))
}
@Test
fun writeOnlySerializerDeserializesNormally() {
val input = """{"fieldA":"hello","fieldB":"secret"}"""
val result = parseJson<WriteOnlyData>(input)
assertEquals("hello", result.fieldA)
assertEquals("secret", result.fieldB)
}
@Test
fun writeOnlySerializerDeserializesMissingAsDefault() {
val input = """{"fieldA":"hello"}"""
val result = parseJson<WriteOnlyData>(input)
assertEquals("hello", result.fieldA)
assertEquals("", result.fieldB)
}
@Test
fun writeOnlySerializerHandlesMultipleKeys() {
val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2")
val result = data.toJson()
assertTrue(result.contains("fieldA"))
assertFalse(result.contains("fieldB"))
assertFalse(result.contains("fieldC"))
}
@Test
fun uriSerializerSerializesUriToString() {
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
val result = data.toJson()
assertTrue(result.contains("https://example.com/path?query=1"))
}
@Test
fun uriSerializerDeserializesStringToUri() {
val input = """{"uri":"https://example.com/path?query=1"}"""
val result = parseJson<UriData>(input)
assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri)
}
@Test
fun uriSerializerRoundtripsCorrectly() {
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
val encoded = data.toJson()
val decoded = parseJson<UriData>(encoded)
assertEquals(data.uri, decoded.uri)
}
}

View file

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="go.torrServer.gojni" /> <!-- torrServer has a different api level -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- I dont remember, probs has to do with downloads --> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- I dont remember, probs has to do with downloads -->
<uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this --> <uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- Downloads -->
@ -16,53 +18,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <!-- We can use this directly as CS3 is not on Play Store --> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <!-- We can use this directly as CS3 is not on Play Store -->
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA" /> <!-- We can use to read the tv channel list -->
<!-- Required for OpenInAppAction and getting arbitrary Aniyomi packages --> <!-- Required for OpenInAppAction and getting arbitrary Aniyomi packages -->
<uses-permission <uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES" android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" /> tools:ignore="QueryAllPackagesPermission" />
<queries>
<!--
QUERY_ALL_PACKAGES does not work on some devices running Android 11+ (like Google TV 14),
so we must explicitly specify the packages and intent patterns we query to ensure visibility.
-->
<!-- For external video players -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/x-mpegURL" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="application/vnd.apple.mpegurl" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="magnet" />
</intent>
<!-- Common players supported in actions/temp -->
<package android:name="org.videolan.vlc" />
<package android:name="org.videolan.vlc.debug" />
<package android:name="is.xyz.mpv" />
<package android:name="is.xyz.mpv.ytdl" />
<package android:name="app.marlboroadvance.mpvex" />
<package android:name="live.mehiz.mpvkt" />
<package android:name="live.mehiz.mpvkt.preview" />
<package android:name="com.brouken.player" />
<package android:name="dev.anilbeesetti.nextplayer" />
<package android:name="com.instantbits.cast.webvideo" />
<package android:name="com.gianlu.aria2android" />
<!-- Torrent clients -->
<package android:name="org.proninyaroslav.libretorrent" />
<package android:name="com.biglybt.android.client" />
</queries>
<!-- Fixes android tv fuckery --> <!-- Fixes android tv fuckery -->
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
@ -74,8 +35,9 @@
<!-- Without the large heap Exoplayer buffering gets reset due to OOM. --> <!-- Without the large heap Exoplayer buffering gets reset due to OOM. -->
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android--> <!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
<application <application
android:name=".CloudStreamApp" android:name=".AcraApplication"
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:appCategory="video" android:appCategory="video"
android:banner="@mipmap/ic_banner" android:banner="@mipmap/ic_banner"
android:fullBackupContent="@xml/backup_descriptor" android:fullBackupContent="@xml/backup_descriptor"
@ -83,12 +45,11 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
android:pageSizeCompat="enabled"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="${target_sdk_version}"> tools:targetApi="35">
<meta-data <meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
@ -149,31 +110,14 @@
android:launchMode="singleTask" android:launchMode="singleTask"
is a bit experimental, it makes loading repositories from browser still stay on the same page is a bit experimental, it makes loading repositories from browser still stay on the same page
no idea about side effects no idea about side effects
Not exported to prevent bypassing the AccountSelectActivity
--> -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
android:exported="false" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:resizeableActivity="true" android:resizeableActivity="true"
android:supportsPictureInPicture="true" /> android:supportsPictureInPicture="true">
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- cloudstreamplayer://encodedUrl?name=Dune --> <!-- cloudstreamplayer://encodedUrl?name=Dune -->
<intent-filter> <intent-filter>
@ -200,14 +144,7 @@
<data android:scheme="cloudstreamrepo" /> <data android:scheme="cloudstreamrepo" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="csshare" />
</intent-filter>
<!-- Allow searching with intents: cloudstreamsearch://Your%20Name --> <!-- Allow searching with intents: cloudstreamsearch://Your%20Name -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -231,7 +168,7 @@
<data android:scheme="cloudstreamcontinuewatching" /> <data android:scheme="cloudstreamcontinuewatching" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="false"> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -244,6 +181,21 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.account.AccountSelectActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="true">
<intent-filter android:exported="true">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<receiver <receiver
android:name=".receivers.VideoDownloadRestartReceiver" android:name=".receivers.VideoDownloadRestartReceiver"
android:enabled="false" android:enabled="false"
@ -259,12 +211,6 @@
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"
android:exported="false" /> android:exported="false" />
<service
android:name=".services.DownloadQueueService"
android:enabled="true"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 --> <!-- Necessary for WorkManager services: https://stackoverflow.com/a/77186316 -->
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"

View file

@ -0,0 +1,28 @@
#include <jni.h>
#include <csignal>
#include <android/log.h>
#define TAG "CloudStream Crash Handler"
volatile sig_atomic_t gSignalStatus = 0;
void handleNativeCrash(int signal) {
gSignalStatus = signal;
}
extern "C" JNIEXPORT void JNICALL
Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) {
#define REGISTER_SIGNAL(X) signal(X, handleNativeCrash);
REGISTER_SIGNAL(SIGSEGV)
#undef REGISTER_SIGNAL
}
//extern "C" JNIEXPORT void JNICALL
//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) {
// int *p = nullptr;
// *p = 0;
//}
extern "C" JNIEXPORT int JNICALL
Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) {
//__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus);
return gSignalStatus;
}

View file

@ -1,78 +1,233 @@
package com.lagradost.cloudstream3 package com.lagradost.cloudstream3
/** import android.app.Activity
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins. import android.app.Application
* Use CloudStreamApp instead. import android.content.Context
*/ import android.content.ContextWrapper
@Deprecated( import android.content.Intent
message = "AcraApplication is deprecated, use CloudStreamApp instead", import android.widget.Toast
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), import androidx.fragment.app.Fragment
level = DeprecationLevel.WARNING import androidx.fragment.app.FragmentActivity
import coil3.PlatformContext
import coil3.SingletonImageLoader
import com.lagradost.api.setContext
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.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
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.ref.WeakReference
import java.util.Locale
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/d/e/$id/formResponse"
val data = mapOf(
"entry.$entry" to errorContent.toJSON()
) )
class AcraApplication {
thread { // to not run it on main thread
runBlocking {
safeAsync {
app.post(url, data = data)
//println("Report response: $post")
}
}
}
runOnMainThread { // to run it on main looper
safe {
Toast.makeText(context, R.string.acra_report_toast, Toast.LENGTH_SHORT).show()
}
}*/
}
}
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("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
error.printStackTrace(ps)
}
} catch (ignored: FileNotFoundException) {
}
try {
onError.invoke()
} catch (ignored: Exception) {
}
exitProcess(1)
}
}
class AcraApplication : Application(), SingletonImageLoader.Factory {
override fun onCreate() {
super.onCreate()
// if we want to initialise coil at earliest
// (maybe when loading an image or gif using in splash screen activity)
//ImageLoader.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)
}
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
context = base
initAcra {
//core configuration:
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
reportContent = listOf(
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
ReportField.STACK_TRACE,
)
// removed this due to bug when starting the app, moved it to when it actually crashes
//each plugin you chose above can be configured in a block like this:
/*toast {
text = getString(R.string.acra_report_toast)
//opening this block automatically enables the plugin.
}*/
}
}
override fun newImageLoader(context: PlatformContext): coil3.ImageLoader {
// Coil Module will be initialized & setSafe globally when first loadImage() is invoked
return ImageLoader.buildImageLoader(applicationContext)
}
companion object { companion object {
var exceptionHandler: ExceptionHandler? = null
@Deprecated( /** Use to get activity from Context */
message = "AcraApplication is deprecated, use CloudStreamApp instead", tailrec fun Context.getActivity(): Activity? {
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), return when (this) {
level = DeprecationLevel.WARNING is Activity -> this
) is ContextWrapper -> baseContext.getActivity()
val context get() = CloudStreamApp.context else -> null
}
@Deprecated( }
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), private var _context: WeakReference<Context>? = null
level = DeprecationLevel.WARNING var context
) get() = _context?.get()
fun removeKeys(folder: String): Int? = private set(value) {
CloudStreamApp.removeKeys(folder) _context = WeakReference(value)
setContext(WeakReference(value))
@Deprecated( }
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
level = DeprecationLevel.WARNING return context?.getKey(path, valueType)
) }
fun <T> setKey(path: String, value: T) =
CloudStreamApp.setKey(path, value) fun <T : Any> setKeyClass(path: String, value: T) {
context?.setKey(path, value)
@Deprecated( }
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"), fun removeKeys(folder: String): Int? {
level = DeprecationLevel.WARNING return context?.removeKeys(folder)
) }
fun <T> setKey(folder: String, path: String, value: T) =
CloudStreamApp.setKey(folder, path, value) fun <T> setKey(path: String, value: T) {
context?.setKey(path, value)
@Deprecated( }
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"), fun <T> setKey(folder: String, path: String, value: T) {
level = DeprecationLevel.WARNING context?.setKey(folder, path, value)
) }
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
CloudStreamApp.getKey(path, defVal) inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
return context?.getKey(path, defVal)
@Deprecated( }
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"), inline fun <reified T : Any> getKey(path: String): T? {
level = DeprecationLevel.WARNING return context?.getKey(path)
) }
inline fun <reified T : Any> getKey(path: String): T? =
CloudStreamApp.getKey(path) inline fun <reified T : Any> getKey(folder: String, path: String): T? {
return context?.getKey(folder, path)
@Deprecated( }
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"), inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
level = DeprecationLevel.WARNING return context?.getKey(folder, path, defVal)
) }
inline fun <reified T : Any> getKey(folder: String, path: String): T? =
CloudStreamApp.getKey(folder, path) fun getKeys(folder: String): List<String>? {
return context?.getKeys(folder)
@Deprecated( }
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"), fun removeKey(folder: String, path: String) {
level = DeprecationLevel.WARNING context?.removeKey(folder, path)
) }
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
CloudStreamApp.getKey(folder, path, defVal) 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,
isLayout(TV or EMULATOR),
activity?.supportFragmentManager?.fragments?.lastOrNull()
)
}
} }
} }

View file

@ -1,181 +0,0 @@
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<Context>? = null
var context
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
setContext(WeakReference(value))
}
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
return context?.getKey(path, valueType)
}
fun <T : Any> setKeyClass(path: String, value: T) {
context?.setKey(path, value)
}
fun removeKeys(folder: String): Int? {
return context?.removeKeys(folder)
}
fun <T> setKey(path: String, value: T) {
context?.setKey(path, value)
}
fun <T> setKey(folder: String, path: String, value: T) {
context?.setKey(folder, path, value)
}
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
return context?.getKey(path, defVal)
}
inline fun <reified T : Any> getKey(path: String): T? {
return context?.getKey(path)
}
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
return context?.getKey(folder, path)
}
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
return context?.getKey(folder, path, defVal)
}
fun getKeys(folder: String): List<String>? {
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()
)
}
}
}

View file

@ -1,16 +1,13 @@
package com.lagradost.cloudstream3 package com.lagradost.cloudstream3
import android.annotation.SuppressLint import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.Resources import android.content.res.Resources
import android.Manifest
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
@ -27,41 +24,35 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isNotEmpty
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.CastSession
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView import com.google.android.material.navigationrail.NavigationRailView
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import com.lagradost.cloudstream3.databinding.ToastBinding import com.lagradost.cloudstream3.databinding.ToastBinding
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter import com.lagradost.cloudstream3.ui.player.PlayerEventType
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.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.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv 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.AppContextUtils.isRtl
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod 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.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.UiText import org.schabi.newpipe.extractor.NewPipe
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.Locale import java.util.Locale
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import org.schabi.newpipe.extractor.NewPipe
enum class FocusDirection { enum class FocusDirection {
Start, Start,
@ -110,15 +101,15 @@ object CommonActivity {
return displayMetrics.heightPixels return displayMetrics.heightPixels
} }
var isPipDesired: Boolean = false var canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false
var isInPIPMode: Boolean = false var isInPIPMode: Boolean = false
val onColorSelectedEvent = Event<Pair<Int, Int>>() val onColorSelectedEvent = Event<Pair<Int, Int>>()
val onDialogDismissedEvent = Event<Int>() val onDialogDismissedEvent = Event<Int>()
var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
var appliedTheme: Int = 0
var appliedColor: Int = 0
private var currentToast: Toast? = null private var currentToast: Toast? = null
@ -191,35 +182,23 @@ object CommonActivity {
currentToast = toast currentToast = toast
toast.show() 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) { } catch (e: Exception) {
logError(e) logError(e)
} }
} }
/** /**
* Set locale * Not all languages can be fetched from locale with a code.
* @param languageTag shall a IETF BCP 47 conformant tag. * This map allows sidestepping the default Locale(languageCode)
* Check [com.lagradost.cloudstream3.utils.SubtitleHelper]. * when setting the app language.
* **/
* See locales on: val appLanguageExceptions = hashMapOf(
* https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json "zh-rTW" to Locale.TRADITIONAL_CHINESE
* 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?, languageCode: String?) {
*/ if (context == null || languageCode == null) return
fun setLocale(context: Context?, languageTag: String?) { val locale = appLanguageExceptions[languageCode] ?: Locale(languageCode)
if (context == null || languageTag == null) return
val locale = Locale.forLanguageTag(languageTag)
val resources: Resources = context.resources val resources: Resources = context.resources
val config = resources.configuration val config = resources.configuration
Locale.setDefault(locale) Locale.setDefault(locale)
@ -227,7 +206,6 @@ object CommonActivity {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
context.createConfigurationContext(config) context.createConfigurationContext(config)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
resources.updateConfiguration( resources.updateConfiguration(
config, config,
@ -244,8 +222,16 @@ object CommonActivity {
fun init(act: Activity) { fun init(act: Activity) {
setActivityInstance(act) setActivityInstance(act)
ioSafe { Torrent.deleteAllFiles() } ioSafe { Torrent.deleteAllFiles() }
val componentActivity = activity as? ComponentActivity ?: return val componentActivity = activity as? ComponentActivity ?: 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
componentActivity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
componentActivity.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
componentActivity.updateLocale() componentActivity.updateLocale()
componentActivity.updateTv() componentActivity.updateTv()
AccountManager.initMainAPI() AccountManager.initMainAPI()
@ -261,7 +247,7 @@ object CommonActivity {
?: return@registerForActivityResult ?: return@registerForActivityResult
action.onResultSafe(act, result.data) action.onResultSafe(act, result.data)
removeKey("last_click_action") removeKey("last_click_action")
removeKey("last_opened") removeKey("last_opened_id")
} }
} }
@ -283,15 +269,13 @@ object CommonActivity {
} }
} }
/** Enters pip mode if it is both possible and desired to do so*/
private fun Activity.enterPIPMode() { private fun Activity.enterPIPMode() {
if (!isPipDesired || !this.isPIPPossible()) return if (!shouldShowPIPMode(canEnterPipMode) || !canShowPipMode) return
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try { try {
enterPictureInPictureMode(PictureInPictureParams.Builder().build()) enterPictureInPictureMode(PictureInPictureParams.Builder().build())
} catch (_: Exception) { } catch (e: Exception) {
// Use fallback just in case // Use fallback just in case
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
enterPictureInPictureMode() enterPictureInPictureMode()
@ -307,10 +291,10 @@ object CommonActivity {
} }
} }
fun onUserLeaveHint(act: Activity) { fun onUserLeaveHint(act: Activity?) {
// On Android 12 and later we use setAutoEnterEnabled() instead. if (canEnterPipMode && canShowPipMode) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return act?.enterPIPMode()
act.enterPIPMode() }
} }
fun updateTheme(act: Activity) { fun updateTheme(act: Activity) {
@ -350,10 +334,6 @@ object CommonActivity {
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.MonetMode else R.style.AppTheme R.style.MonetMode else R.style.AppTheme
"Dracula" -> R.style.DraculaMode
"Lavender" -> R.style.LavenderMode
"SilentBlue" -> R.style.SilentBlueMode
else -> R.style.AppTheme else -> R.style.AppTheme
} }
@ -389,8 +369,6 @@ object CommonActivity {
act.theme.applyStyle(currentTheme, true) act.theme.applyStyle(currentTheme, true)
act.theme.applyStyle(currentOverlayTheme, true) act.theme.applyStyle(currentOverlayTheme, true)
appliedTheme = currentTheme
appliedColor = currentOverlayTheme
act.updateTv() act.updateTv()
if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true) if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true)
act.theme.applyStyle( act.theme.applyStyle(
@ -423,7 +401,8 @@ object CommonActivity {
private fun View.hasContent(): Boolean { private fun View.hasContent(): Boolean {
return isShown && when (this) { return isShown && when (this) {
is ViewGroup -> this.isNotEmpty() //is RecyclerView -> this.childCount > 0
is ViewGroup -> this.childCount > 0
else -> true else -> true
} }
} }
@ -453,7 +432,7 @@ object CommonActivity {
// if cant focus but visible then break and let android decide // 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 // the exception if is the view is a parent and has children that wants focus
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent -> val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty() parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
} ?: false } ?: false
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
@ -532,7 +511,87 @@ object CommonActivity {
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? { fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
// 149 keycode_numpad 5
val playerEvent = 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, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> {
PlayerEventType.NextEpisode
}
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
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 -> return null
}
val listener = playerEventListener
if (listener != null) {
listener.invoke(playerEvent)
return true
}
return null return null
//when (keyCode) {
// KeyEvent.KEYCODE_DPAD_CENTER -> {
// println("DPAD PRESSED")
// }
//}
} }
/** overrides focus and custom key events */ /** overrides focus and custom key events */
@ -569,7 +628,6 @@ object CommonActivity {
else -> null else -> null
} }
// println("NEXT FOCUS : $nextView") // println("NEXT FOCUS : $nextView")
if (nextView != null) { if (nextView != null) {
nextView.requestFocus() nextView.requestFocus()
@ -577,15 +635,10 @@ object CommonActivity {
return true return true
} }
// TODO: Figure out why removing the check for SearchAutoComplete seems if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
// to break focus on TV as it shouldn't need to be used.
// Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote)
// send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button.
@SuppressLint("RestrictedApi")
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
) { ) {
showInputMethod(act.currentFocus?.findFocus()) UIHelper.showInputMethod(act.currentFocus?.findFocus())
} }
//println("Keycode: $keyCode") //println("Keycode: $keyCode")
@ -594,6 +647,7 @@ object CommonActivity {
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG // Toast.LENGTH_LONG
//) //)
} }
// if someone else want to override the focus then don't handle the event as it is already // if someone else want to override the focus then don't handle the event as it is already

View file

@ -0,0 +1,11 @@
package com.lagradost.cloudstream3
import android.view.LayoutInflater
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.ui.HeaderViewDecoration
fun setHeaderDecoration(view: RecyclerView, @LayoutRes headerViewRes: Int) {
val headerView = LayoutInflater.from(view.context).inflate(headerViewRes, null)
view.addItemDecoration(HeaderViewDecoration(headerView))
}

View file

@ -9,6 +9,7 @@ import android.content.SharedPreferences
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log import android.util.Log
@ -23,14 +24,14 @@ import android.widget.CheckBox
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView import androidx.cardview.widget.CardView
import androidx.core.content.edit import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.net.toUri
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.get import androidx.core.view.get
import androidx.core.view.isGone import androidx.core.view.isGone
@ -64,9 +65,9 @@ import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.initAll import com.lagradost.cloudstream3.APIHolder.initAll
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.loadThemes
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
@ -97,7 +98,6 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STR
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO 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_RESUME_WATCHING
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH 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.AccountManager.Companion.localListApi
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.APIRepository
@ -119,7 +119,6 @@ import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV 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.isLayout
import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
@ -157,20 +156,17 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar 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.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite 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.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.getResourceColor
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.requestRW 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.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
@ -188,9 +184,7 @@ import java.nio.charset.Charset
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.system.exitProcess import kotlin.system.exitProcess
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
companion object { companion object {
@ -200,21 +194,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
const val ANIMATED_OUTLINE: Boolean = false const val ANIMATED_OUTLINE: Boolean = false
var lastError: String? = null var lastError: String? = null
/** Update lastError variable based on error file, to check if app crashed.
* Can be called multiple times without changing the lastError variable changing.
**/
fun setLastError(context: Context) {
if (lastError != null) return
val errorFile = context.filesDir.resolve("last_error")
if (errorFile.exists() && errorFile.isFile) {
lastError = errorFile.readText(Charset.defaultCharset())
errorFile.delete()
} else {
lastError = null
}
}
private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY"
@ -276,6 +255,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
* @return true if the str has launched an app task (be it successful or not) * @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. * @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
* */ * */
@Suppress("DEPRECATION_ERROR")
fun handleAppIntentUrl( fun handleAppIntentUrl(
activity: FragmentActivity?, activity: FragmentActivity?,
str: String?, str: String?,
@ -352,7 +332,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
activity?.findViewById<NavigationRailView>(R.id.nav_rail_view)?.selectedItemId = activity?.findViewById<NavigationRailView>(R.id.nav_rail_view)?.selectedItemId =
R.id.navigation_search R.id.navigation_search
} else if (safeURI(str)?.scheme == APP_STRING_PLAYER) { } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
val uri = str.toUri() val uri = Uri.parse(str)
val name = uri.getQueryParameter("name") val name = uri.getQueryParameter("name")
val url = URLDecoder.decode(uri.authority, "UTF-8") val url = URLDecoder.decode(uri.authority, "UTF-8")
@ -362,8 +342,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
LinkGenerator( LinkGenerator(
listOf(BasicLink(url, name)), listOf(BasicLink(url, name)),
extract = true, extract = true,
id = url.hashCode() )
), 0
) )
) )
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
@ -379,20 +358,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
START_ACTION_RESUME_LATEST 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) { } else if (!isWebview) {
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) { if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
this.navigate(R.id.navigation_downloads) this.navigate(R.id.navigation_downloads)
@ -408,39 +373,22 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
return true return true
} }
val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull() synchronized(apis) {
if (matchedApi != null) { for (api in apis) {
loadResult(str, matchedApi.name, "") if (str.startsWith(api.mainUrl)) {
loadResult(str, api.name, "")
return true return true
} }
} }
} }
} }
}
}
return false 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 lastPopup: SearchResponse? = null
var lastPopupJob: Job? = null
fun loadPopup(result: SearchResponse, load: Boolean = true) { fun loadPopup(result: SearchResponse, load: Boolean = true) {
lastPopup = result lastPopup = result
val syncName = syncViewModel.syncName(result.apiName) val syncName = syncViewModel.syncName(result.apiName)
@ -456,8 +404,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
syncViewModel.clear() syncViewModel.clear()
} }
lastPopupJob?.cancel() if (load) {
lastPopupJob = if (load) {
viewModel.load( viewModel.load(
this, result.url, result.apiName, false, if (getApiDubstatusSettings() this, result.url, result.apiName, false, if (getApiDubstatusSettings()
.contains(DubStatus.Dubbed) .contains(DubStatus.Dubbed)
@ -504,7 +451,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
R.id.navigation_downloads, R.id.navigation_downloads,
R.id.navigation_settings, R.id.navigation_settings,
R.id.navigation_download_child, R.id.navigation_download_child,
R.id.navigation_download_queue,
R.id.navigation_subtitles, R.id.navigation_subtitles,
R.id.navigation_chrome_subtitles, R.id.navigation_chrome_subtitles,
R.id.navigation_settings_player, R.id.navigation_settings_player,
@ -519,7 +465,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
).contains(destination.id) ).contains(destination.id)
/*val dontPush = listOf( val dontPush = listOf(
R.id.navigation_home, R.id.navigation_home,
R.id.navigation_search, R.id.navigation_search,
R.id.navigation_results_phone, R.id.navigation_results_phone,
@ -550,19 +496,25 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
} }
layoutParams = params layoutParams = params
}*/ }
val landscape = when (resources.configuration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
true
}
Configuration.ORIENTATION_PORTRAIT -> {
isLayout(TV or EMULATOR)
}
else -> {
false
}
}
binding?.apply { binding?.apply {
navRailView.isVisible = isNavVisible && isLandscape() navRailView.isVisible = isNavVisible && landscape
navView.isVisible = isNavVisible && !isLandscape() navView.isVisible = isNavVisible && !landscape
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
}
}
/** /**
* We need to make sure if we return to a sub-fragment, * We need to make sure if we return to a sub-fragment,
@ -570,11 +522,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
* highlight the wrong one in UI. * highlight the wrong one in UI.
*/ */
when (destination.id) { when (destination.id) {
in listOf( in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
R.id.navigation_downloads,
R.id.navigation_download_child,
R.id.navigation_download_queue
) -> {
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
navView.menu.findItem(R.id.navigation_downloads).isChecked = true navView.menu.findItem(R.id.navigation_downloads).isChecked = true
} }
@ -696,9 +644,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
.setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ } .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ }
.setPositiveButton(R.string.yes) { _, _ -> .setPositiveButton(R.string.yes) { _, _ ->
if (dontShowAgainCheck.isChecked) { if (dontShowAgainCheck.isChecked) {
settingsManager.edit(commit = true) { settingsManager.edit().putInt(getString(R.string.confirm_exit_key), 1).commit()
putInt(getString(R.string.confirm_exit_key), 1)
}
} }
// finish() causes a bug on some TVs where player // finish() causes a bug on some TVs where player
// may keep playing after closing the app. // may keep playing after closing the app.
@ -723,11 +669,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
this.sendBroadcast(broadcastIntent) this.sendBroadcast(broadcastIntent)
afterPluginsLoadedEvent -= ::onAllPluginsLoaded afterPluginsLoadedEvent -= ::onAllPluginsLoaded
detachBackPressedCallback("MainActivityDefault")
super.onDestroy() super.onDestroy()
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent?) {
handleAppIntent(intent) handleAppIntent(intent)
super.onNewIntent(intent) super.onNewIntent(intent)
} }
@ -736,7 +681,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (intent == null) return if (intent == null) return
val str = intent.dataString val str = intent.dataString
loadCache() loadCache()
handleAppIntentUrl(this, str, false, intent.extras) handleAppIntentUrl(this, str, false, intent.extras)
} }
@ -806,11 +750,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
} }
} }
private val pluginsLock = Mutex() private val pluginsLock = Mutex()
private fun onAllPluginsLoaded(success: Boolean = false) { private fun onAllPluginsLoaded(success: Boolean = false) {
ioSafe { ioSafe {
pluginsLock.withLock { pluginsLock.withLock {
allProviders.withLock { synchronized(allProviders) {
// Load cloned sites after plugins have been loaded since clones depend on plugins. // Load cloned sites after plugins have been loaded since clones depend on plugins.
try { try {
getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list -> getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list ->
@ -856,8 +801,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
private fun hidePreviewPopupDialog() { private fun hidePreviewPopupDialog() {
bottomPreviewPopup.dismissSafe(this) bottomPreviewPopup.dismissSafe(this)
lastPopupJob?.cancel()
lastPopupJob = null
bottomPreviewPopup = null bottomPreviewPopup = null
bottomPreviewBinding = null bottomPreviewBinding = null
} }
@ -1177,14 +1120,35 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
} }
} }
override fun onCreate(savedInstanceState: Bundle?) { private fun centerView(view: View?) {
app.initClient(this, ignoreSSL = false) if (view == null) return
@OptIn(UnsafeSSL::class) try {
insecureApp.initClient(this, ignoreSSL = true) 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) {
}
}
@Suppress("DEPRECATION_ERROR")
override fun onCreate(savedInstanceState: Bundle?) {
app.initClient(this)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
setLastError(this) val errorFile = filesDir.resolve("last_error")
if (errorFile.exists() && errorFile.isFile) {
lastError = errorFile.readText(Charset.defaultCharset())
errorFile.delete()
} else {
lastError = null
}
val settingsForProvider = SettingsJson() val settingsForProvider = SettingsJson()
settingsForProvider.enableAdult = settingsForProvider.enableAdult =
@ -1193,8 +1157,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
MainAPI.settingsForProvider = settingsForProvider MainAPI.settingsForProvider = settingsForProvider
loadThemes(this) loadThemes(this)
enableEdgeToEdgeCompat()
setNavigationBarColorCompat(R.attr.primaryGrayBackground)
updateLocale() updateLocale()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
try { try {
@ -1215,8 +1177,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: "" val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
if (appVer != lastAppAutoBackup) { if (appVer != lastAppAutoBackup) {
setKey("VERSION_NAME", BuildConfig.VERSION_NAME) setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
if (lastAppAutoBackup.isEmpty()) return@safe
safe { safe {
backup(this) backup(this)
} }
@ -1248,7 +1208,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (isLayout(TV)) { if (isLayout(TV)) {
// Put here any button you don't want focusing it to center the view // Put here any button you don't want focusing it to center the view
val exceptionButtons = listOf( val exceptionButtons = listOf(
//R.id.home_preview_play_btt, R.id.home_preview_play_btt,
R.id.home_preview_info_btt, R.id.home_preview_info_btt,
R.id.home_preview_hidden_next_focus, R.id.home_preview_hidden_next_focus,
R.id.home_preview_hidden_prev_focus, R.id.home_preview_hidden_prev_focus,
@ -1280,22 +1240,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
null null
} }
binding?.apply {
fixSystemBarsPadding(
navView,
heightResId = R.dimen.nav_view_height,
padTop = false,
overlayCutout = false
)
fixSystemBarsPadding(
navRailView,
widthResId = R.dimen.nav_rail_view_width,
padRight = false,
padTop = false
)
}
// overscan // overscan
val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx
binding?.homeRoot?.setPadding(padding, padding, padding, padding) binding?.homeRoot?.setPadding(padding, padding, padding, padding)
@ -1386,9 +1330,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
false false
) )
} }
// Add your channel creation here
} }
} else { } else {
val builder: AlertDialog.Builder = AlertDialog.Builder(this) val builder: AlertDialog.Builder = AlertDialog.Builder(this)
@ -1653,7 +1594,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
ioSafe { ioSafe {
initAll() initAll()
// No duplicates (which can happen by registerMainAPI) // 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) // val navView: BottomNavigationView = findViewById(R.id.nav_view)
@ -1676,6 +1619,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (navDestination.matchDestination(R.id.navigation_home)) { if (navDestination.matchDestination(R.id.navigation_home)) {
attachBackPressedCallback("MainActivity") { attachBackPressedCallback("MainActivity") {
showConfirmExitDialog(settingsManager) showConfirmExitDialog(settingsManager)
@Suppress("DEPRECATION")
window?.navigationBarColor =
colorFromAttribute(R.attr.primaryGrayBackground)
updateLocale()
} }
} else detachBackPressedCallback("MainActivity") } else detachBackPressedCallback("MainActivity")
} }
@ -1707,23 +1654,14 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
} }
binding?.navRailView?.apply { binding?.navRailView?.apply {
if (isLayout(PHONE)) {
itemRippleColor = rippleColor itemRippleColor = rippleColor
itemActiveIndicatorColor = 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) setupWithNavController(navController)
/*if (isLayout(TV or EMULATOR)) { if (isLayout(TV or EMULATOR)) {
background?.alpha = 200 background?.alpha = 200
} else { } else {
background?.alpha = 255 background?.alpha = 255
}*/ }
setOnItemSelectedListener { item -> setOnItemSelectedListener { item ->
onNavDestinationSelected( onNavDestinationSelected(
@ -1772,54 +1710,31 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
} }
val rail = binding?.navRailView val rail = binding?.navRailView
if (rail != null) { if (rail != null && isLayout(TV)) {
binding?.navRailView?.labelVisibilityMode = val focus = mutableSetOf<Int>()
NavigationRailView.LABEL_VISIBILITY_UNLABELED
//val focus = mutableSetOf<Int>()
var prevId: Int? = null
var prevView: View? = null
// The genius engineers at google did not actually
// write a nextFocus for the navrail
rail.findViewById<View?>(R.id.navigation_settings)?.nextFocusDownId =
R.id.nav_footer_profile_card
for (id in arrayOf( for (id in arrayOf(
R.id.navigation_home, R.id.navigation_home,
R.id.navigation_search,
R.id.navigation_library, R.id.navigation_library,
R.id.navigation_search,
R.id.navigation_downloads, R.id.navigation_downloads,
R.id.navigation_settings R.id.navigation_settings
)) { )) {
val view = rail.findViewById<View?>(id) ?: continue rail.findViewById<View?>(id)?.onFocusChangeListener =
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 -> View.OnFocusChangeListener { v, hasFocus ->
if (hasFocus) { if (hasFocus) {
focus += id focus += id
binding?.navRailView?.labelVisibilityMode = binding?.navRailView?.labelVisibilityMode = NavigationRailView.LABEL_VISIBILITY_LABELED
NavigationRailView.LABEL_VISIBILITY_LABELED
binding?.navRailView?.expand() binding?.navRailView?.expand()
} else { } else {
focus -= id focus -= id
v.post { v.post {
if(focus.isEmpty()) { if(focus.isEmpty()) {
binding?.navRailView?.labelVisibilityMode = binding?.navRailView?.labelVisibilityMode = NavigationRailView.LABEL_VISIBILITY_UNLABELED
NavigationRailView.LABEL_VISIBILITY_UNLABELED
binding?.navRailView?.collapse() binding?.navRailView?.collapse()
} }
} }
} }
} }
}*/
} }
} }
@ -1935,7 +1850,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
fun buildMediaQueueItem(video: String): MediaQueueItem { fun buildMediaQueueItem(video: String): MediaQueueItem {
// val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO) // val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO)
//movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream") //movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream")
val mediaInfo = MediaInfo.Builder(video.toUri().toString()) val mediaInfo = MediaInfo.Builder(Uri.parse(video).toString())
.setStreamType(MediaInfo.STREAM_TYPE_NONE) .setStreamType(MediaInfo.STREAM_TYPE_NONE)
.setContentType(MimeTypes.IMAGE_JPEG) .setContentType(MimeTypes.IMAGE_JPEG)
// .setMetadata(movieMetadata).build() // .setMetadata(movieMetadata).build()
@ -1961,7 +1876,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
var providersAndroidManifestString = "Current androidmanifest should be:\n" var providersAndroidManifestString = "Current androidmanifest should be:\n"
allProviders.withLock { synchronized(allProviders) {
for (api in allProviders) { for (api in allProviders) {
providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${ providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${
api.mainUrl.removePrefix( api.mainUrl.removePrefix(
@ -1997,17 +1912,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
migrateResumeWatching() 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<String>(USER_SELECTED_HOMEPAGE_API)?.let { homepage -> getKey<String>(USER_SELECTED_HOMEPAGE_API)?.let { homepage ->
DataStoreHelper.currentHomePage = homepage DataStoreHelper.currentHomePage = homepage
removeKey(USER_SELECTED_HOMEPAGE_API) removeKey(USER_SELECTED_HOMEPAGE_API)
@ -2039,14 +1943,23 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
// } // }
// } // }
attachBackPressedCallback("MainActivityDefault") { onBackPressedDispatcher.addCallback(
setNavigationBarColorCompat(R.attr.primaryGrayBackground) this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
@Suppress("DEPRECATION")
window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground)
updateLocale() updateLocale()
runDefault()
}
// Start the download queue // If we don't disable we end up in a loop with default behavior calling
DownloadQueueManager.init(this) // this callback as well, so we disable it, run default behavior,
// then re-enable this callback so it can be used for next back press.
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
)
} }
/** Biometric stuff **/ /** Biometric stuff **/

View file

@ -6,8 +6,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
@ -21,8 +21,7 @@ import java.io.File
fun updateDurationAndPosition(position: Long, duration: Long) { fun updateDurationAndPosition(position: Long, duration: Long) {
if (position <= 0 || duration <= 0) return if (position <= 0 || duration <= 0) return
val episode = getKey<ResultEpisode>("last_opened") ?: return DataStoreHelper.setViewPos(getKey("last_opened_id"), position, duration)
DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null)
ResultFragment.updateUI() ResultFragment.updateUI()
} }
@ -99,7 +98,7 @@ abstract class OpenInAppAction(
intent.component = ComponentName(packageName, intentClass) intent.component = ComponentName(packageName, intentClass)
} }
putExtra(context, intent, video, result, index) putExtra(context, intent, video, result, index)
setKey("last_opened", video) setKey("last_opened_id", video.id)
launchResult(intent) launchResult(intent)
} }

View file

@ -16,16 +16,12 @@ import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage
import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction
import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage
import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage 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.MpvKtPackage
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
import com.lagradost.cloudstream3.actions.temp.MpvPackage import com.lagradost.cloudstream3.actions.temp.MpvPackage
import com.lagradost.cloudstream3.actions.temp.MpvRxPackage
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage
import com.lagradost.cloudstream3.actions.temp.VlcPackage import com.lagradost.cloudstream3.actions.temp.VlcPackage
@ -34,8 +30,8 @@ import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -45,16 +41,14 @@ import java.util.concurrent.FutureTask
import kotlin.reflect.jvm.jvmName import kotlin.reflect.jvm.jvmName
object VideoClickActionHolder { object VideoClickActionHolder {
val allVideoClickActions = atomicListOf( val allVideoClickActions = threadSafeListOf(
// Default // Default
PlayInBrowserAction(), PlayInBrowserAction(),
CopyClipboardAction(), CopyClipboardAction(),
ViewM3U8Action(), ViewM3U8Action(),
PlayMirrorAction(),
// main support external apps // main support external apps
VlcPackage(), VlcPackage(),
MpvPackage(), MpvPackage(),
MpvExPackage(),
NextPlayerPackage(), NextPlayerPackage(),
JustPlayerPackage(), JustPlayerPackage(),
FcastAction(), FcastAction(),
@ -66,8 +60,6 @@ object VideoClickActionHolder {
MpvYTDLPackage(), MpvYTDLPackage(),
MpvKtPackage(), MpvKtPackage(),
MpvKtPreviewPackage(), MpvKtPreviewPackage(),
OnlyPlayer(),
MpvRxPackage(),
// Always Ask option // Always Ask option
AlwaysAskAction(), AlwaysAskAction(),
// added by plugins // added by plugins

View file

@ -5,8 +5,8 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.ExtractorUri
import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.player.SubtitleOrigin import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
@ -18,10 +18,8 @@ import com.lagradost.cloudstream3.utils.DrmExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.newExtractorLink
import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF import com.lagradost.cloudstream3.utils.newExtractorLink
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
/** /**
@ -124,9 +122,7 @@ class CloudStreamPackage : OpenInAppAction(
originalName = name ?: "Unknown", originalName = name ?: "Unknown",
headers = headers, headers = headers,
origin = SubtitleOrigin.URL, origin = SubtitleOrigin.URL,
languageCode = fromCodeToLangTagIETF(name) ?: languageCode = null,
fromLanguageToTagIETF(name, true) ?:
name,
) )
} }

View file

@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.updateDurationAndPosition import com.lagradost.cloudstream3.actions.updateDurationAndPosition
@ -44,7 +45,7 @@ open class MpvKtPackage(
intent.apply { intent.apply {
putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray())
setDataAndType(link.url.toUri(), "video/*") setDataAndType(Uri.parse(link.url), "video/*")
// m3u8 plays, but changing sources feature is not available // m3u8 plays, but changing sources feature is not available
// makeTempM3U8Intent(activity, this, result) // makeTempM3U8Intent(activity, this, result)

View file

@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.api.Log import com.lagradost.api.Log
import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.OpenInAppAction
@ -17,9 +18,6 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
// https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904 // https://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://mpv-android.github.io/mpv-android/intent.html
//https://github.com/marlboro-advance/mpvEx
class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity")
class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
override val sourceTypes = setOf( override val sourceTypes = setOf(
ExtractorLinkType.VIDEO, ExtractorLinkType.VIDEO,
@ -28,10 +26,10 @@ class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") {
) )
} }
open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv"): OpenInAppAction(
txt(appName), txt(appName),
packageName, packageName,
intentClass "is.xyz.mpv.MPVActivity"
) { ) {
override val oneSource = true // mpv has poor playlist support on TV override val oneSource = true // mpv has poor playlist support on TV
override suspend fun putExtra( override suspend fun putExtra(
@ -46,7 +44,7 @@ open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv
putExtra("title", video.name) putExtra("title", video.name)
if (index != null) { if (index != null) {
setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") setDataAndType(Uri.parse(result.links.getOrNull(index)?.url ?: return), "video/*")
} else { } else {
makeTempM3U8Intent(context, this, result) makeTempM3U8Intent(context, this, result)
} }

View file

@ -1,75 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.lagradost.api.Log
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/Riteshp2001/mpvRx
*
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56
* */
class MpvRxPackage : OpenInAppAction(
appName = txt("mpvRx"),
packageName = "app.gyrolet.mpvrx",
intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity"
) {
override val oneSource = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
intent.apply {
putExtra("title", video.name)
val link = result.links[index!!]
val headers = link.headers
setData(link.url.toUri())
if (headers.isNotEmpty()) {
// PlayerActivity expects a flat array: [key1, value1, key2, value2, ...]
val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray()
intent.putExtra("headers", flat)
}
/*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146
intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray())
intent.putExtra(
"subs.titles",
subs.map { it.name }.toTypedArray(),
)
intent.putExtra(
"subs.langs",
subs.map { it.languageCode }.toTypedArray(),
)
val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri()
intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf<Uri>() )*/
if (video.tvType.isEpisodeBased()) {
video.season?.let { intent.putExtra("introdb_season", it) }
video.episode.let { intent.putExtra("introdb_episode", it) }
}
val position = getViewPos(video.id)?.position
if (position != null)
putExtra("position", position.toInt())
}
}
override fun onResult(activity: Activity, intent: Intent?) {
val position = intent?.getIntExtra("position", -1) ?: -1
val duration = intent?.getIntExtra("duration", -1) ?: -1
Log.d("MPV", "Position: $position, Duration: $duration")
updateDurationAndPosition(position.toLong(), duration.toLong())
}
}

View file

@ -1,44 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.net.toUri
import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.txt
/** https://github.com/Kindness-Kismet/only_player/tree/main
* https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */
class OnlyPlayer : OpenInAppAction(
txt("Only Player"),
"one.only.player",
intentClass = "one.only.player.feature.player.PlayerActivity"
) {
override val oneSource = true
override suspend fun putExtra(
context: Context,
intent: Intent,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
/** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */
intent.apply {
val link = result.links[index!!]
setData(link.url.toUri())
putExtra("headers", Bundle().apply {
for ((key, value) in link.headers) {
putExtra(key, value)
}
})
}
}
override fun onResult(activity: Activity, intent: Intent?) {
/* onResult does not get called */
}
}

View file

@ -2,7 +2,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.net.toUri import android.net.Uri
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
@ -33,7 +33,7 @@ class PlayInBrowserAction: VideoClickAction() {
) { ) {
val link = result.links.getOrNull(index ?: 0) ?: return val link = result.links.getOrNull(index ?: 0) ?: return
val i = Intent(Intent.ACTION_VIEW) val i = Intent(Intent.ACTION_VIEW)
i.data = link.url.toUri() i.data = Uri.parse(link.url)
launch(i) launch(i)
} }
} }

View file

@ -1,65 +0,0 @@
package com.lagradost.cloudstream3.actions.temp
import android.app.Activity
import android.content.Context
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.ui.player.ExtractorUri
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.player.VideoGenerator
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.txt
class PlayMirrorAction : VideoClickAction() {
override val name = txt(R.string.episode_action_play_mirror)
override val oneSource = true
override val isPlayer = true
override val sourceTypes: Set<ExtractorLinkType> = LOADTYPE_INAPP
override fun shouldShow(context: Context?, video: ResultEpisode?) = true
override suspend fun runAction(
context: Context?,
video: ResultEpisode,
result: LinkLoadingResult,
index: Int?
) {
//Implemented a generator to handle the single
val activity = context as? Activity ?: return
val link = index?.let { result.links[it] }
val generatorMirror = object : VideoGenerator<ResultEpisode>(listOf(video)) {
override val hasCache: Boolean = false
override val canSkipLoading: Boolean = false
override fun getId(index: Int): Int = video.id
override suspend fun generateLinks(
clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit,
offset: Int,
isCasting: Boolean
): Boolean {
index?.let { callback(link to null) }
result.subs.forEach { subtitle -> subtitleCallback(subtitle) }
return true
}
}
activity.navigate(
R.id.global_to_navigation_player,
GeneratorPlayer.newInstance(
generatorMirror, 0, result.syncData
)
)
}
}

View file

@ -6,7 +6,7 @@ import android.content.Intent
import android.os.Build import android.os.Build
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.api.Log import com.lagradost.api.Log
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.OpenInAppAction
import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.actions.makeTempM3U8Intent
import com.lagradost.cloudstream3.actions.updateDurationAndPosition import com.lagradost.cloudstream3.actions.updateDurationAndPosition

View file

@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.actions.temp
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.core.net.toUri import androidx.core.net.toUri
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
@ -37,7 +38,7 @@ class WebVideoCastPackage: OpenInAppAction(
val link = result.links[index ?: 0] val link = result.links[index ?: 0]
intent.apply { intent.apply {
setDataAndType(link.url.toUri(), "video/*") setDataAndType(Uri.parse(link.url), "video/*")
val title = video.name ?: video.headerName val title = video.name ?: video.headerName

View file

@ -1,7 +1,7 @@
package com.lagradost.cloudstream3.actions.temp.fcast package com.lagradost.cloudstream3.actions.temp.fcast
import android.content.Context import android.content.Context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickAction

View file

@ -7,7 +7,6 @@ import android.net.nsd.NsdServiceInfo
import android.os.Build import android.os.Build
import android.os.ext.SdkExtensions import android.os.ext.SdkExtensions
import android.util.Log import android.util.Log
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
class FcastManager { class FcastManager {
@ -73,25 +72,18 @@ class FcastManager {
} }
override fun onServiceFound(serviceInfo: NsdServiceInfo?) { override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
// Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback if (serviceInfo == null) return
safe {
if (serviceInfo == null) return@safe
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(
Build.VERSION_CODES.TIRAMISU Build.VERSION_CODES.TIRAMISU) >= 7) {
) >= 7 nsdManager?.registerServiceInfoCallback(serviceInfo,
) {
nsdManager?.registerServiceInfoCallback(
serviceInfo,
Runnable::run, Runnable::run,
object : NsdManager.ServiceInfoCallback { object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
Log.e(tag, "Service registration failed: $errorCode") Log.e(tag, "Service registration failed: $errorCode")
} }
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
Log.d( Log.d(tag,
tag,
"Service updated: ${serviceInfo.serviceName}," + "Service updated: ${serviceInfo.serviceName}," +
"Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}"
) )
@ -100,24 +92,18 @@ class FcastManager {
_currentDevices.add(PublicDeviceInfo(serviceInfo)) _currentDevices.add(PublicDeviceInfo(serviceInfo))
} }
} }
override fun onServiceLost() { override fun onServiceLost() {
Log.d(tag, "Service lost: ${serviceInfo.serviceName},") Log.d(tag, "Service lost: ${serviceInfo.serviceName},")
synchronized(_currentDevices) { synchronized(_currentDevices) {
_currentDevices.removeIf { it.rawName == serviceInfo.serviceName } _currentDevices.removeIf { it.rawName == serviceInfo.serviceName }
} }
} }
override fun onServiceInfoCallbackUnregistered() {} override fun onServiceInfoCallbackUnregistered() {}
}) })
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
nsdManager?.resolveService(serviceInfo, object : ResolveListener { nsdManager?.resolveService(serviceInfo, object : ResolveListener {
override fun onResolveFailed( override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {}
serviceInfo: NsdServiceInfo?,
errorCode: Int
) {
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
if (serviceInfo == null) return if (serviceInfo == null) return
@ -134,7 +120,6 @@ class FcastManager {
}) })
} }
} }
}
override fun onServiceLost(serviceInfo: NsdServiceInfo?) { override fun onServiceLost(serviceInfo: NsdServiceInfo?) {
if (serviceInfo == null) return if (serviceInfo == null) return
@ -183,8 +168,7 @@ class PublicDeviceInfo(serviceInfo: NsdServiceInfo) {
val host: String? = if ( val host: String? = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
SdkExtensions.getExtensionVersion( SdkExtensions.getExtensionVersion(
Build.VERSION_CODES.TIRAMISU Build.VERSION_CODES.TIRAMISU) >= 7
) >= 7
) { ) {
serviceInfo.hostAddresses.firstOrNull()?.hostAddress serviceInfo.hostAddresses.firstOrNull()?.hostAddress
} else { } else {

View file

@ -1,68 +1,16 @@
package com.lagradost.cloudstream3.mvvm package com.lagradost.cloudstream3.mvvm
import android.view.View
import androidx.activity.ComponentActivity
import androidx.core.view.doOnAttach
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.ui.BaseFragment
/** NOTE: Only one observer at a time per value */ /** NOTE: Only one observer at a time per value */
fun <T> ComponentActivity.observe(liveData: LiveData<T>, action: (T) -> Unit) { fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
observeNullable(liveData) { t -> t?.run(action) }
}
/** NOTE: Only one observer at a time per value */
fun <T> ComponentActivity.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
liveData.removeObservers(this) liveData.removeObservers(this)
liveData.observe(this, action) liveData.observe(this) { it?.let { t -> action(t) } }
} }
/** NOTE: Only one observer at a time per value */ /** NOTE: Only one observer at a time per value */
fun <T, V : ViewBinding> BaseFragment<V>.observe(liveData: LiveData<T>, action: (T) -> Unit) { fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
observeNullable(liveData) { t -> t?.run(action) }
}
/**
* Attaches an observable to the root binding, instead of the fragment. This is more efficient as
* it will not call observe if the view is in the background.
*
* NOTE: Only one observer at a time per value
* */
fun <T, V : ViewBinding> BaseFragment<V>.observeNullable(
liveData: LiveData<T>, action: (T?) -> Unit
) {
val root = this.binding?.root
if (root == null) {
liveData.removeObservers(this) liveData.removeObservers(this)
liveData.observe(this, action) liveData.observe(this) { action(it) }
} else {
root.doOnAttach { view ->
// On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case
val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable
liveData.removeObservers(owner)
liveData.observe(owner, action)
}
}
}
/** NOTE: Only one observer at a time per value */
fun <T> View.observe(liveData: LiveData<T>, action: (T) -> Unit) {
observeNullable(liveData) { t -> t?.run(action) }
}
/** NOTE: Only one observer at a time per value */
fun <T> View.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
doOnAttach { view ->
// On attach should make findViewTreeLifecycleOwner non-null
val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner()
if(owner == null) {
debugException { "Expected non-null findViewTreeLifecycleOwner" }
return@doOnAttach
}
liveData.removeObservers(owner)
liveData.observe(owner, action)
}
} }

View file

@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.network
import android.content.Context import android.content.Context
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.lagradost.cloudstream3.Prerelease
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safe
@ -16,26 +15,11 @@ import org.conscrypt.Conscrypt
import java.io.File import java.io.File
import java.security.Security import java.security.Security
// Backwards compatible constructor, mark as deprecated later
fun Requests.initClient(context: Context) { fun Requests.initClient(context: Context) {
this.baseClient = buildDefaultClient(context) this.baseClient = buildDefaultClient(context)
} }
/** Only use ignoreSSL if you know what you are doing*/
@Prerelease
fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) {
this.baseClient = buildDefaultClient(context, ignoreSSL)
}
// Backwards compatible constructor, mark as deprecated later
fun buildDefaultClient(context: Context): OkHttpClient { fun buildDefaultClient(context: Context): OkHttpClient {
return buildDefaultClient(context, false)
}
/** Only use ignoreSSL if you know what you are doing*/
@Prerelease
fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
@ -43,11 +27,7 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
val baseClient = OkHttpClient.Builder() val baseClient = OkHttpClient.Builder()
.followRedirects(true) .followRedirects(true)
.followSslRedirects(true) .followSslRedirects(true)
.apply { .ignoreAllSSLErrors()
if (ignoreSSL) {
ignoreAllSSLErrors()
}
}
.cache( .cache(
// Note that you need to add a ResponseInterceptor to make this 100% active. // Note that you need to add a ResponseInterceptor to make this 100% active.
// The server response dictates if and when stuff should be cached. // The server response dictates if and when stuff should be cached.
@ -72,6 +52,11 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
return baseClient return baseClient
} }
//val Request.cookies: Map<String, String>
// get() {
// return this.headers.getCookies("Cookie")
// }
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
/** /**

View file

@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction
import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.actions.VideoClickActionHolder
import kotlin.Throws import kotlin.Throws
abstract class Plugin : BasePlugin() { abstract class Plugin : BasePlugin() {
/** /**
* Called when your Plugin is loaded * Called when your Plugin is loaded
@ -25,8 +26,10 @@ abstract class Plugin : BasePlugin() {
fun registerVideoClickAction(element: VideoClickAction) { fun registerVideoClickAction(element: VideoClickAction) {
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
element.sourcePlugin = this.filename element.sourcePlugin = this.filename
synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.add(element) VideoClickActionHolder.allVideoClickActions.add(element)
} }
}
/** /**
* This will contain your resources if you specified requiresResources in gradle * This will contain your resources if you specified requiresResources in gradle

View file

@ -13,7 +13,6 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.annotation.WorkerThread
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@ -21,17 +20,15 @@ import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.removePluginMapping import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.AllLanguagesName
import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.AutoDownloadMode
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.InternalAPI
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
@ -46,7 +43,6 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
@ -55,7 +51,7 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import dalvik.system.PathClassLoader import dalvik.system.PathClassLoader
@ -80,7 +76,6 @@ data class PluginData(
@JsonProperty("filePath") val filePath: String, @JsonProperty("filePath") val filePath: String,
@JsonProperty("version") val version: Int, @JsonProperty("version") val version: Int,
) { ) {
@WorkerThread
fun toSitePlugin(): SitePlugin { fun toSitePlugin(): SitePlugin {
return SitePlugin( return SitePlugin(
this.filePath, this.filePath,
@ -95,9 +90,7 @@ data class PluginData(
null, null,
null, null,
null, null,
File(this.filePath).length(), File(this.filePath).length()
// No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute.
null
) )
} }
} }
@ -265,8 +258,12 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName") @Suppress("FunctionName", "DEPRECATION_ERROR")
@InternalAPI @Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Throws @Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -307,7 +304,6 @@ object PluginManager {
downloadPlugin( downloadPlugin(
activity, activity,
pluginData.onlineData.second.url, pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName, pluginData.savedData.internalName,
File(pluginData.savedData.filePath), File(pluginData.savedData.filePath),
true true
@ -343,8 +339,12 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName") @Suppress("FunctionName", "DEPRECATION_ERROR")
@InternalAPI @Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Throws @Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
activity: Activity, activity: Activity,
@ -419,7 +419,6 @@ object PluginManager {
downloadPlugin( downloadPlugin(
activity, activity,
pluginData.onlineData.second.url, pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName, pluginData.savedData.internalName,
pluginData.onlineData.first, pluginData.onlineData.first,
!pluginData.isDisabled !pluginData.isDisabled
@ -454,8 +453,12 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName") @Suppress("FunctionName", "DEPRECATION_ERROR")
@InternalAPI @Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Throws @Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -476,9 +479,13 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName") @Suppress("FunctionName", "DEPRECATION_ERROR")
@InternalAPI
@Throws @Throws
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -497,8 +504,12 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName") @Suppress("FunctionName", "DEPRECATION_ERROR")
@InternalAPI @Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
@Throws @Throws
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -561,11 +572,6 @@ object PluginManager {
afterPluginsLoadedEvent.invoke(forceReload) afterPluginsLoadedEvent.invoke(forceReload)
} }
/** @return true if safe mode is enabled in any possible way. */
fun isSafeMode(): Boolean {
return checkSafeModeFile() || lastError != null
}
/** /**
* This can be used to override any extension loading to fix crashes! * This can be used to override any extension loading to fix crashes!
* @return true if safe mode file is present * @return true if safe mode file is present
@ -610,7 +616,7 @@ object PluginManager {
return false return false
} }
InputStreamReader(stream).use { reader -> InputStreamReader(stream).use { reader ->
manifest = parseJson<BasePlugin.Manifest>(reader.readText()) manifest = parseJson(reader, BasePlugin.Manifest::class.java)
} }
} }
@ -651,15 +657,9 @@ object PluginManager {
context.resources.configuration context.resources.configuration
) )
} }
synchronized(plugins) {
plugins[filePath] = pluginInstance plugins[filePath] = pluginInstance
}
synchronized(classLoaders) {
classLoaders[loader] = pluginInstance classLoaders[loader] = pluginInstance
}
synchronized(urlPlugins) {
urlPlugins[data.url ?: filePath] = pluginInstance urlPlugins[data.url ?: filePath] = pluginInstance
}
if (pluginInstance is Plugin) { if (pluginInstance is Plugin) {
pluginInstance.load(context) pluginInstance.load(context)
} else { } else {
@ -695,34 +695,26 @@ object PluginManager {
} }
// remove all registered apis // remove all registered apis
synchronized(APIHolder.apis) {
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
removePluginMapping(it) removePluginMapping(it)
} }
}
APIHolder.allProviders.withLock { synchronized(APIHolder.allProviders) {
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename } APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
} }
extractorApis.withLock { extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
synchronized(VideoClickActionHolder.allVideoClickActions) {
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
} }
VideoClickActionHolder.allVideoClickActions.withLock {
VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename }
}
synchronized(classLoaders) {
classLoaders.values.removeIf { v -> v == plugin } classLoaders.values.removeIf { v -> v == plugin }
}
synchronized(plugins) {
plugins.remove(absolutePath) plugins.remove(absolutePath)
}
synchronized(urlPlugins) {
urlPlugins.values.removeIf { v -> v == plugin } urlPlugins.values.removeIf { v -> v == plugin }
} }
}
/** /**
* Spits out a unique and safe filename based on name. * Spits out a unique and safe filename based on name.
@ -751,27 +743,25 @@ object PluginManager {
suspend fun downloadPlugin( suspend fun downloadPlugin(
activity: Activity, activity: Activity,
pluginUrl: String, pluginUrl: String,
pluginHash: String?,
internalName: String, internalName: String,
repositoryUrl: String, repositoryUrl: String,
loadPlugin: Boolean loadPlugin: Boolean
): Boolean { ): Boolean {
val file = getPluginPath(activity, internalName, repositoryUrl) val file = getPluginPath(activity, internalName, repositoryUrl)
return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin) return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
} }
suspend fun downloadPlugin( suspend fun downloadPlugin(
activity: Activity, activity: Activity,
pluginUrl: String, pluginUrl: String,
pluginHash: String?,
internalName: String, internalName: String,
file: File, file: File,
loadPlugin: Boolean, loadPlugin: Boolean
): Boolean { ): Boolean {
try { try {
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
val data = PluginData( val data = PluginData(
internalName, internalName,
@ -818,9 +808,13 @@ object PluginManager {
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
*/ */
@Suppress("FunctionName") @Suppress("FunctionName", "DEPRECATION_ERROR")
@InternalAPI
@Throws @Throws
@Deprecated(
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
replaceWith = ReplaceWith("loadPlugin"),
level = DeprecationLevel.ERROR
)
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) { suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
assertNonRecursiveCallstack() assertNonRecursiveCallstack()
@ -859,7 +853,6 @@ object PluginManager {
if (downloadPlugin( if (downloadPlugin(
activity, activity,
pluginData.onlineData.second.url, pluginData.onlineData.second.url,
pluginData.onlineData.second.fileHash,
pluginData.savedData.internalName, pluginData.savedData.internalName,
existingFile, existingFile,
true true

View file

@ -1,11 +1,10 @@
package com.lagradost.cloudstream3.plugins package com.lagradost.cloudstream3.plugins
import android.content.Context import android.content.Context
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
@ -19,12 +18,10 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.io.BufferedInputStream
import java.io.File import java.io.File
import java.nio.file.AtomicMoveNotSupportedException import java.io.InputStream
import java.nio.file.Files import java.io.OutputStream
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.util.concurrent.atomic.AtomicInteger
/** /**
* Comes with the app, always available in the app, non removable. * Comes with the app, always available in the app, non removable.
@ -65,12 +62,10 @@ data class SitePlugin(
@JsonProperty("repositoryUrl") val repositoryUrl: String?, @JsonProperty("repositoryUrl") val repositoryUrl: String?,
// These types are yet to be mapped and used, ignore for now // These types are yet to be mapped and used, ignore for now
@JsonProperty("tvTypes") val tvTypes: List<String>?, @JsonProperty("tvTypes") val tvTypes: List<String>?,
// Most often a language tag like "en" or "zh-TW"
@JsonProperty("language") val language: String?, @JsonProperty("language") val language: String?,
@JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("iconUrl") val iconUrl: String?,
// Automatically generated by the gradle plugin // Automatically generated by the gradle plugin
@JsonProperty("fileSize") val fileSize: Long?, @JsonProperty("fileSize") val fileSize: Long?,
@JsonProperty("fileHash") val fileHash: String?,
) )
@ -79,26 +74,7 @@ object RepositoryManager {
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy { val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
getKey("PREBUILT_REPOSITORIES") ?: emptyArray() getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
} }
private val GH_REGEX = private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
/** Returns a SHA-256 string of the file content.
* Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/
@WorkerThread
fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { fis ->
val buffer = ByteArray(8192)
var read = fis.read(buffer)
while (read != -1) {
digest.update(buffer, 0, read)
read = fis.read(buffer)
}
}
return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) }
}
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
fun convertRawGitUrl(url: String): String { fun convertRawGitUrl(url: String): String {
@ -163,52 +139,21 @@ object RepositoryManager {
}.flatten() }.flatten()
} }
suspend fun downloadPluginToFile( suspend fun downloadPluginToFile(
context: Context,
pluginUrl: String, pluginUrl: String,
file: File, file: File
expectedFileHash: String?
): File? { ): File? {
return safeAsync { return safeAsync {
val parentDir = file.parentFile ?: return@safeAsync null file.mkdirs()
parentDir.mkdirs()
// Prevent corrupting the plugin file if the operation fails // Overwrite if exists
val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir) if (file.exists()) {
file.delete()
}
file.createNewFile()
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
write(body.byteStream(), file.outputStream())
body.byteStream().use { body ->
tempFile.outputStream().use { fileSteam ->
body.copyTo(fileSteam)
}
}
if (expectedFileHash != null) {
val downloadHash = sha256(tempFile)
if (expectedFileHash != downloadHash) {
tempFile.delete()
throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.")
}
}
// We prefer the operation to be atomic
try {
Files.move(
tempFile.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
tempFile.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
}
file file
} }
} }
@ -256,4 +201,13 @@ object RepositoryManager {
PluginManager.deleteRepositoryData(file.absolutePath) PluginManager.deleteRepositoryData(file.absolutePath)
} }
private fun write(stream: InputStream, output: OutputStream) {
val input = BufferedInputStream(stream)
val dataBuffer = ByteArray(512)
var readBytes: Int
while (input.read(dataBuffer).also { readBytes = it } != -1) {
output.write(dataBuffer, 0, readBytes)
}
}
} }

View file

@ -2,9 +2,9 @@ package com.lagradost.cloudstream3.plugins
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import java.security.MessageDigest import java.security.MessageDigest
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
@ -12,37 +12,52 @@ import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
object VotingApi { object VotingApi { // please do not cheat the votes lol
private const val LOGKEY = "VotingApi" private const val LOGKEY = "VotingApi"
private const val API_DOMAIN = "https://api.countify.xyz"
private fun transformUrl(url: String): String = private const val API_DOMAIN = "https://counterapi.com/api"
private fun transformUrl(url: String): String = // dont touch or all votes get reset
MessageDigest MessageDigest
.getInstance("SHA-256") .getInstance("SHA-256")
.digest("${url}#funny-salt".toByteArray()) .digest("${url}#funny-salt".toByteArray())
.fold("") { str, it -> str + "%02x".format(it) } .fold("") { str, it -> str + "%02x".format(it) }
suspend fun SitePlugin.getVotes(): Int = getVotes(url) suspend fun SitePlugin.getVotes(): Int {
fun SitePlugin.hasVoted(): Boolean = hasVoted(url) return getVotes(url)
suspend fun SitePlugin.vote(): Int = vote(url) }
fun SitePlugin.canVote(): Boolean = canVote(this.url)
fun SitePlugin.hasVoted(): Boolean {
return hasVoted(url)
}
suspend fun SitePlugin.vote(): Int {
return vote(url)
}
fun SitePlugin.canVote(): Boolean {
return canVote(this.url)
}
// Plugin url to Int
private val votesCache = mutableMapOf<String, Int>() private val votesCache = mutableMapOf<String, Int>()
private fun getRepository(pluginUrl: String) = pluginUrl
.split("/")
.drop(2)
.take(3)
.joinToString("-")
private suspend fun readVote(pluginUrl: String): Int { private suspend fun readVote(pluginUrl: String): Int {
val id = transformUrl(pluginUrl) val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
val url = "$API_DOMAIN/get-total/$id" Log.d(LOGKEY, "Requesting: $url")
Log.d(LOGKEY, "Requesting GET: $url") return app.get(url).parsedSafe<Result>()?.value ?: 0
return app.get(url).parsedSafe<CountifyResult>()?.count ?: 0
} }
private suspend fun writeVote(pluginUrl: String): Boolean { private suspend fun writeVote(pluginUrl: String): Boolean {
val id = transformUrl(pluginUrl) val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
val url = "$API_DOMAIN/increment/$id" Log.d(LOGKEY, "Requesting: $url")
Log.d(LOGKEY, "Requesting POST: $url") return app.get(url).parsedSafe<Result>()?.value != null
return app.post(url, emptyMap<String, String>())
.parsedSafe<CountifyResult>()?.count != null
} }
suspend fun getVotes(pluginUrl: String): Int = suspend fun getVotes(pluginUrl: String): Int =
@ -53,35 +68,31 @@ object VotingApi {
fun hasVoted(pluginUrl: String) = fun hasVoted(pluginUrl: String) =
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
fun canVote(pluginUrl: String): Boolean = fun canVote(pluginUrl: String): Boolean {
PluginManager.urlPlugins.contains(pluginUrl) return PluginManager.urlPlugins.contains(pluginUrl)
}
private val voteLock = Mutex() private val voteLock = Mutex()
suspend fun vote(pluginUrl: String): Int { suspend fun vote(pluginUrl: String): Int {
// Prevent multiple requests at the same time.
voteLock.withLock { voteLock.withLock {
if (!canVote(pluginUrl)) { if (!canVote(pluginUrl)) {
main { main {
Toast.makeText( Toast.makeText(context, R.string.extension_install_first, Toast.LENGTH_SHORT)
context, .show()
R.string.extension_install_first,
Toast.LENGTH_SHORT
).show()
} }
return getVotes(pluginUrl) return getVotes(pluginUrl)
} }
if (hasVoted(pluginUrl)) { if (hasVoted(pluginUrl)) {
main { main {
Toast.makeText( Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
context, .show()
R.string.already_voted,
Toast.LENGTH_SHORT
).show()
} }
return getVotes(pluginUrl) return getVotes(pluginUrl)
} }
if (writeVote(pluginUrl)) { if (writeVote(pluginUrl)) {
setKey("cs3-votes/${transformUrl(pluginUrl)}", true) setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
@ -91,8 +102,7 @@ object VotingApi {
} }
} }
private data class CountifyResult( private data class Result(
val id: String? = null, val value: Int?
val count: Int? = null
) )
} }

View file

@ -1,279 +0,0 @@
package com.lagradost.cloudstream3.services
import android.Manifest
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Build.VERSION.SDK_INT
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.MainActivity.Companion.setLastError
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.debugWarning
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.system.measureTimeMillis
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class DownloadQueueService : Service() {
companion object {
const val TAG = "DownloadQueueService"
const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue"
const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service"
const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification."
const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique
@Volatile
var isRunning = false
fun getIntent(
context: Context,
): Intent {
return Intent(context, DownloadQueueService::class.java)
}
private val _downloadInstances: MutableStateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
MutableStateFlow(emptyList())
/** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances.
* Completed or failed instances are automatically removed by the download queue service.
*
*/
val downloadInstances: StateFlow<List<VideoDownloadManager.EpisodeDownloadInstance>> =
_downloadInstances
private val totalDownloadFlow =
downloadInstances.combine(DownloadQueueManager.queue) { instances, queue ->
instances to queue
}
.combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads ->
Triple(instances, queue, currentDownloads)
}
}
private val baseNotification by lazy {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent =
PendingIntentCompat.getActivity(this, 0, intent, 0, false)
val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0)
val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0)
NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID)
.setOngoing(true) // Make it persistent
.setAutoCancel(false)
.setColorized(false)
.setOnlyAlertOnce(true)
.setSilent(true)
.setShowWhen(false)
// If low priority then the notification might not show :(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(this.colorFromAttribute(R.attr.colorPrimary))
.setContentText(activeDownloads)
.setSubText(activeQueue)
.setContentIntent(pendingIntent)
.setSmallIcon(R.drawable.download_icon_load)
}
private fun updateNotification(context: Context, downloads: Int, queued: Int) {
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) return
val activeDownloads =
resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads)
val activeQueue =
resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued)
val newNotification = baseNotification
.setContentText(activeDownloads)
.setSubText(activeQueue)
.build()
safe {
NotificationManagerCompat.from(context)
.notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification)
}
}
// We always need to listen to events, even before the download is launched.
// Stopping link loading is an event which can trigger before downloading.
val downloadEventListener = { event: Pair<Int, VideoDownloadManager.DownloadActionType> ->
when (event.second) {
VideoDownloadManager.DownloadActionType.Stop -> {
removeKey(KEY_RESUME_PACKAGES, event.first.toString())
removeKey(KEY_RESUME_IN_QUEUE, event.first.toString())
DownloadQueueManager.cancelDownload(event.first)
}
else -> {}
}
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
override fun onCreate() {
isRunning = true
val context: Context = this // To make code more readable
Log.d(TAG, "Download queue service started.")
this.createNotificationChannel(
DOWNLOAD_QUEUE_CHANNEL_ID,
DOWNLOAD_QUEUE_CHANNEL_NAME,
DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION
)
if (SDK_INT >= 29) {
startForeground(
DOWNLOAD_QUEUE_NOTIFICATION_ID,
baseNotification.build(),
FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build())
}
downloadEvent += downloadEventListener
val queueJob = ioSafe {
// Ensure this is up to date to prevent race conditions with MainActivity launches
setLastError(context)
// Early return, to prevent waiting for plugins in safe mode
if (lastError != null) return@ioSafe
// Try to ensure all plugins are loaded before starting the downloader.
// To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough
val timeout = 15.seconds
val timeTaken = withTimeoutOrNull(timeout) {
measureTimeMillis {
while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) {
delay(100.milliseconds)
}
}
}
debugWarning({ timeTaken == null || timeTaken > 3_000 }, {
"Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms"
})
debugAssert({ timeTaken == null }, { "Downloader startup should not time out" })
totalDownloadFlow
.debounce { (instances, queue) ->
// Filter away incorrect transient queue states.
// For example when we pop the queue and add a download instance there exists a transient state where
// there is no queue and no download instances (leading to an early exit)
if (instances.isEmpty() && queue.isEmpty()) {
500.milliseconds
} else {
0.milliseconds
}
}
.takeWhile { (instances, queue) ->
// Stop if destroyed
isRunning
// Run as long as there is a queue to process
&& (instances.isNotEmpty() || queue.isNotEmpty())
// Run as long as there are no app crashes
&& lastError == null
}
.collect { (_, queue, currentDownloads) ->
// Remove completed or failed
val newInstances = _downloadInstances.updateAndGet { currentInstances ->
currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled }
}
val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context)
val currentInstanceCount = newInstances.size
val newDownloads = minOf(
// Cannot exceed the max downloads
maxOf(0, maxDownloads - currentInstanceCount),
// Cannot start more downloads than the queue size
queue.size
)
// Cant start multiple downloads at once. If this is rerun it may start too many downloads.
if (newDownloads > 0) {
_downloadInstances.update { instances ->
val downloadInstance = DownloadQueueManager.popQueue(context)
if (downloadInstance != null) {
downloadInstance.startDownload()
instances + downloadInstance
} else {
instances
}
}
}
// The downloads actually displayed to the user with a notification
val currentVisualDownloads =
currentDownloads.size + newInstances.count {
currentDownloads.contains(it.downloadQueueWrapper.id)
.not()
}
// Just the queue
val currentVisualQueue = queue.size
updateNotification(context, currentVisualDownloads, currentVisualQueue)
}
}
// Stop self regardless of job outcome
queueJob.invokeOnCompletion { throwable ->
if (throwable != null) {
logError(throwable)
}
safe {
stopSelf()
}
}
}
override fun onDestroy() {
Log.d(TAG, "Download queue service stopped.")
downloadEvent -= downloadEventListener
isRunning = false
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY // We want the service restarted if its killed
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onTimeout(reason: Int) {
stopSelf()
Log.e(TAG, "Service stopped due to timeout: $reason")
}
}

View file

@ -22,7 +22,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.VideoDownloadManager.getImageBitmapFromUrl
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
.build() .build()
) )
} }
@Suppress("DEPRECATION_ERROR")
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
try { try {
// println("Update subscriptions!") // println("Update subscriptions!")

View file

@ -2,13 +2,12 @@ package com.lagradost.cloudstream3.services
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** Handle notification actions such as pause/resume downloads */
class VideoDownloadService : Service() { class VideoDownloadService : Service() {
private val downloadScope = CoroutineScope(Dispatchers.Default) private val downloadScope = CoroutineScope(Dispatchers.Default)
@ -43,3 +42,19 @@ class VideoDownloadService : Service() {
super.onDestroy() super.onDestroy()
} }
} }
// override fun onHandleIntent(intent: Intent?) {
// if (intent != null) {
// val id = intent.getIntExtra("id", -1)
// val type = intent.getStringExtra("type")
// if (id != -1 && type != null) {
// val state = when (type) {
// "resume" -> VideoDownloadManager.DownloadActionType.Resume
// "pause" -> VideoDownloadManager.DownloadActionType.Pause
// "stop" -> VideoDownloadManager.DownloadActionType.Stop
// else -> return
// }
// VideoDownloadManager.downloadEvent.invoke(Pair(id, state))
// }
// }
// }
//}

View file

@ -1,11 +1,10 @@
package com.lagradost.cloudstream3.syncproviders package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi
import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.LocalList
import com.lagradost.cloudstream3.syncproviders.providers.MALApi import com.lagradost.cloudstream3.syncproviders.providers.MALApi
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
@ -13,14 +12,12 @@ import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
abstract class AccountManager { abstract class AccountManager {
companion object { companion object {
const val NONE_ID: Int = -1 const val NONE_ID: Int = -1
val malApi = MALApi() val malApi = MALApi()
val kitsuApi = KitsuApi()
val aniListApi = AniListApi() val aniListApi = AniListApi()
val simklApi = SimklApi() val simklApi = SimklApi()
val localListApi = LocalList() val localListApi = LocalList()
@ -29,7 +26,6 @@ abstract class AccountManager {
val addic7ed = Addic7ed() val addic7ed = Addic7ed()
val subDlApi = SubDlApi() val subDlApi = SubDlApi()
val subSourceApi = SubSourceApi() val subSourceApi = SubSourceApi()
val animeSkipApi = AnimeSkipAuth()
var cachedAccounts: MutableMap<String, Array<AuthData>> var cachedAccounts: MutableMap<String, Array<AuthData>>
var cachedAccountIds: MutableMap<String, Int> var cachedAccountIds: MutableMap<String, Int>
@ -63,14 +59,14 @@ abstract class AccountManager {
val allApis = arrayOf( val allApis = arrayOf(
SyncRepo(malApi), SyncRepo(malApi),
SyncRepo(kitsuApi),
SyncRepo(aniListApi), SyncRepo(aniListApi),
SyncRepo(simklApi), SyncRepo(simklApi),
SyncRepo(localListApi), SyncRepo(localListApi),
SubtitleRepo(openSubtitlesApi), SubtitleRepo(openSubtitlesApi),
SubtitleRepo(addic7ed), SubtitleRepo(addic7ed),
SubtitleRepo(subDlApi), SubtitleRepo(subDlApi),
PlainAuthRepo(animeSkipApi) SubtitleRepo(subSourceApi)
) )
fun updateAccountIds() { fun updateAccountIds() {
@ -112,7 +108,6 @@ abstract class AccountManager {
// accessing other classes // accessing other classes
fun initMainAPI() { fun initMainAPI() {
LoadResponse.malIdPrefix = malApi.idPrefix LoadResponse.malIdPrefix = malApi.idPrefix
LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix
LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix
LoadResponse.simklIdPrefix = simklApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix
} }
@ -120,11 +115,11 @@ abstract class AccountManager {
val subtitleProviders = arrayOf( val subtitleProviders = arrayOf(
SubtitleRepo(openSubtitlesApi), SubtitleRepo(openSubtitlesApi),
SubtitleRepo(addic7ed), SubtitleRepo(addic7ed),
SubtitleRepo(subDlApi) SubtitleRepo(subDlApi),
SubtitleRepo(subSourceApi)
) )
val syncApis = arrayOf( val syncApis = arrayOf(
SyncRepo(malApi), SyncRepo(malApi),
SyncRepo(kitsuApi),
SyncRepo(aniListApi), SyncRepo(aniListApi),
SyncRepo(simklApi), SyncRepo(simklApi),
SyncRepo(localListApi) SyncRepo(localListApi)
@ -140,8 +135,6 @@ abstract class AccountManager {
// Instantly resume watching a show // Instantly resume watching a show
const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
const val APP_STRING_SHARE = "csshare"
fun secondsToReadable(seconds: Int, completedValue: String): String { fun secondsToReadable(seconds: Int, completedValue: String): String {
var secondsLong = seconds.toLong() var secondsLong = seconds.toLong()
val days = TimeUnit.SECONDS val days = TimeUnit.SECONDS

View file

@ -1,14 +1,52 @@
package com.lagradost.cloudstream3.syncproviders package com.lagradost.cloudstream3.syncproviders
import android.util.Base64
import androidx.annotation.WorkerThread
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.base64Encode import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.ActorData
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.NextAiring
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.SearchQuality
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
import com.lagradost.cloudstream3.syncproviders.providers.LocalList
import com.lagradost.cloudstream3.syncproviders.providers.MALApi
import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import java.net.URI import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.UiText
import com.lagradost.cloudstream3.utils.txt
import me.xdrop.fuzzywuzzy.FuzzySearch
import java.net.URL
import java.security.SecureRandom import java.security.SecureRandom
import java.util.Date
import java.util.concurrent.TimeUnit
data class AuthLoginPage( data class AuthLoginPage(
/** The website to open to authenticate */ /** The website to open to authenticate */
@ -45,10 +83,10 @@ data class AuthToken(
val payload: String? = null, val payload: String? = null,
) { ) {
fun isAccessTokenExpired(marginSec: Long = 10L) = fun isAccessTokenExpired(marginSec: Long = 10L) =
accessTokenLifetime != null && unixTime + marginSec >= accessTokenLifetime accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime
fun isRefreshTokenExpired(marginSec: Long = 10L) = fun isRefreshTokenExpired(marginSec: Long = 10L) =
refreshTokenLifetime != null && unixTime + marginSec >= refreshTokenLifetime refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime
} }
data class AuthUser( data class AuthUser(
@ -143,33 +181,16 @@ abstract class AuthAPI {
open val inAppLoginRequirement: AuthLoginRequirement? = null open val inAppLoginRequirement: AuthLoginRequirement? = null
companion object { companion object {
@Deprecated(
message = "Use APIHolder.unixTime instead",
replaceWith = ReplaceWith(
expression = "APIHolder.unixTime",
imports = ["com.lagradost.cloudstream3.APIHolder"]
),
level = DeprecationLevel.WARNING,
)
val unixTime: Long val unixTime: Long
get() = APIHolder.unixTime get() = System.currentTimeMillis() / 1000L
@Deprecated(
message = "Use APIHolder.unixTimeMS instead",
replaceWith = ReplaceWith(
expression = "unixTimeMS",
imports = ["com.lagradost.cloudstream3.APIHolder.unixTimeMS"]
),
level = DeprecationLevel.WARNING,
)
val unixTimeMs: Long val unixTimeMs: Long
get() = unixTimeMS get() = System.currentTimeMillis()
fun splitRedirectUrl(redirectUrl: String): Map<String, String> { fun splitRedirectUrl(redirectUrl: String): Map<String, String> {
return splitQuery( return splitQuery(
URI( URL(
redirectUrl.replace(APP_STRING, "https").replace("/#", "?") redirectUrl.replace(APP_STRING, "https").replace("/#", "?")
).toURL() )
) )
} }
@ -179,8 +200,9 @@ abstract class AuthAPI {
val secureRandom = SecureRandom() val secureRandom = SecureRandom()
val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128
secureRandom.nextBytes(codeVerifierBytes) secureRandom.nextBytes(codeVerifierBytes)
return base64Encode(codeVerifierBytes).trimEnd('=') return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=')
.replace("+", "-").replace("/", "_").replace("\n", "") .replace("+", "-")
.replace("/", "_").replace("\n", "")
} }
} }
@ -228,15 +250,14 @@ abstract class AuthAPI {
open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError() open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError()
@Throws @Throws
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
fun toRepo(): AuthRepo = when (this) { fun toRepo(): AuthRepo = when (this) {
is SubtitleAPI -> SubtitleRepo(this) is SubtitleAPI -> SubtitleRepo(this)
is SyncAPI -> SyncRepo(this) is SyncAPI -> SyncRepo(this)
else -> throw NotImplementedError("Unknown inheritance from AuthAPI") else -> throw NotImplementedError("Unknown inheritance from AuthAPI")
} }
@Suppress("DEPRECATION_ERROR") @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR)
fun loginInfo(): LoginInfo? { fun loginInfo(): LoginInfo? {
return this.toRepo().authUser()?.let { user -> return this.toRepo().authUser()?.let { user ->
LoginInfo( LoginInfo(
@ -247,16 +268,19 @@ abstract class AuthAPI {
} }
} }
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
@Suppress("DEPRECATION_ERROR")
return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow() return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow()
} }
@Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) @Deprecated("Please the the new api for AuthAPI", level = DeprecationLevel.WARNING)
class LoginInfo( class LoginInfo(
val profilePicture: String? = null, val profilePicture: String? = null,
val name: String?, val name: String?,
val accountIndex: Int, val accountIndex: Int,
) )
} }

View file

@ -1,6 +1,6 @@
package com.lagradost.cloudstream3.syncproviders package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
@ -9,9 +9,6 @@ import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
/** General-purpose repo */
class PlainAuthRepo(api: AuthAPI) : AuthRepo(api)
/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ /** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */
abstract class AuthRepo(open val api: AuthAPI) { abstract class AuthRepo(open val api: AuthAPI) {
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false

View file

@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
/** Stateless safe abstraction of SubtitleAPI */ /** Stateless safe abstraction of SubtitleAPI */
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
@ -24,30 +24,26 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
) )
// maybe make this a generic struct? right now there is a lot of boilerplate // maybe make this a generic struct? right now there is a lot of boilerplate
private val searchCache = atomicListOf<SavedSearchResponse>() private val searchCache = threadSafeListOf<SavedSearchResponse>()
private var searchCacheIndex: Int = 0 private var searchCacheIndex: Int = 0
private val resourceCache = atomicListOf<SavedResourceResponse>() private val resourceCache = threadSafeListOf<SavedResourceResponse>()
private var resourceCacheIndex: Int = 0 private var resourceCacheIndex: Int = 0
const val CACHE_SIZE = 20 const val CACHE_SIZE = 20
} }
@WorkerThread @WorkerThread
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching { suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
val cached = resourceCache.withLock { synchronized(resourceCache) {
var found: SubtitleResource? = null
for (item in resourceCache) { for (item in resourceCache) {
// 20 min save // 20 min save
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
found = item.response return@runCatching item.response
break
} }
} }
found
} }
if (cached != null) return@runCatching cached
val returnValue = api.resource(freshAuth(), data) val returnValue = api.resource(freshAuth(), data)
resourceCache.withLock { synchronized(resourceCache) {
val add = SavedResourceResponse(unixTime, returnValue, data) val add = SavedResourceResponse(unixTime, returnValue, data)
if (resourceCache.size > CACHE_SIZE) { if (resourceCache.size > CACHE_SIZE) {
resourceCache[resourceCacheIndex] = add // rolling cache resourceCache[resourceCacheIndex] = add // rolling cache
@ -62,25 +58,22 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
@WorkerThread @WorkerThread
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> { suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
return runCatching { return runCatching {
val cached = searchCache.withLock { synchronized(searchCache) {
var found: List<SubtitleEntity>? = null
for (item in searchCache) { for (item in searchCache) {
// 120 min save // 120 min save
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
found = item.response return@runCatching item.response
break
} }
} }
found
} }
if (cached != null) return@runCatching cached val returnValue =
val returnValue = api.search(freshAuth(), query) ?: emptyList() api.search(freshAuth(), query) ?: throw ErrorLoadingException("Null subtitles")
// only cache valid return values // only cache valid return values
if (returnValue.isNotEmpty()) { if (returnValue.isNotEmpty()) {
val add = SavedSearchResponse(unixTime, returnValue, query) val add = SavedSearchResponse(unixTime, returnValue, query)
searchCache.withLock { synchronized(searchCache) {
if (searchCache.size > CACHE_SIZE) { if (searchCache.size > CACHE_SIZE) {
searchCache[searchCacheIndex] = add // rolling cache searchCache[searchCacheIndex] = add // rolling cache
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
@ -93,3 +86,4 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
} }
} }
} }

View file

@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.Levenshtein
import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.UiText
import me.xdrop.fuzzywuzzy.FuzzySearch
import java.util.Date import java.util.Date
/** /**
@ -138,7 +138,7 @@ abstract class SyncAPI : AuthAPI() {
ListSorting.Query -> ListSorting.Query ->
if (query != null) { if (query != null) {
items.sortedBy { items.sortedBy {
-Levenshtein.partialRatio( -FuzzySearch.partialRatio(
query.lowercase(), it.name.lowercase() query.lowercase(), it.name.lowercase()
) )
} }

View file

@ -1,17 +1,16 @@
package com.lagradost.cloudstream3.syncproviders.providers package com.lagradost.cloudstream3.syncproviders.providers
import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName
class Addic7ed : SubtitleAPI() { class Addic7ed : SubtitleAPI() {
override val name = "Addic7ed" override val name = "Addic7ed"
override val idPrefix = "addic7ed" override val idPrefix = "addic7ed"
override val requiresLogin = false override val requiresLogin = false
companion object { companion object {
@ -19,8 +18,7 @@ class Addic7ed : SubtitleAPI() {
const val TAG = "ADDIC7ED" const val TAG = "ADDIC7ED"
} }
private fun String.fixUrl(): String { private fun fixUrl(url: String): String {
val url = this
return if (url.startsWith("/")) HOST + url return if (url.startsWith("/")) HOST + url
else if (!url.startsWith("http")) "$HOST/$url" else if (!url.startsWith("http")) "$HOST/$url"
else url else url
@ -28,178 +26,84 @@ class Addic7ed : SubtitleAPI() {
override suspend fun search( override suspend fun search(
auth: AuthData?, auth: AuthData?,
query: SubtitleSearch query: AbstractSubtitleEntities.SubtitleSearch
): List<SubtitleEntity>? { ): List<AbstractSubtitleEntities.SubtitleEntity>? {
val langTagIETF = query.lang ?: AllLanguagesName val lang = query.lang
val langNumAddic7ed = val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0 val queryText = query.query.trim()
val langName =
langTagIETF2Addic7ed[langTagIETF]?.second ?:
fromTagToEnglishLanguageName(langTagIETF) ?:
"Completed" // this bypasses language filtering
val title = query.query.trim()
val epNum = query.epNumber ?: 0 val epNum = query.epNumber ?: 0
val seasonNum = query.seasonNumber ?: 0 val seasonNum = query.seasonNumber ?: 0
val yearNum = query.year ?: 0 val yearNum = query.year ?: 0
val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title
var downloadPage = ""
fun newSubtitleEntity ( fun cleanResources(
displayName: String?, results: MutableList<AbstractSubtitleEntities.SubtitleEntity>,
link: String?, name: String,
link: String,
headers: Map<String, String>,
isHearingImpaired: Boolean isHearingImpaired: Boolean
): SubtitleEntity? { ) {
if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null results.add(
return SubtitleEntity( AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix, idPrefix = idPrefix,
name = displayName, name = name,
lang = langTagIETF, lang = queryLang.toString(),
data = link, data = link,
source = this.name, source = this.name,
type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
epNumber = epNum, epNumber = epNum,
seasonNumber = seasonNum, seasonNumber = seasonNum,
year = yearNum, year = yearNum,
headers = mapOf("referer" to "$HOST/"), headers = headers,
isHearingImpaired = isHearingImpaired isHearingImpaired = isHearingImpaired
) )
)
} }
val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search") val title = queryText.substringBefore("(").trim()
val hostDocument = response.document val url = "$HOST/search.php?search=${title}&Submit=Search"
val hostDocument = app.get(url).document
// 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name var searchResult = ""
if (response.url.contains("/movie/") || response.url.contains("/serie/")) if (hostDocument.select("span:contains($title)").isNotEmpty()) searchResult = url
downloadPage = response.url else if (hostDocument.select("table.tabel")
.isNotEmpty()
// 2nd case: found tv series ep list. Redirected to $HOST/show/1234 ) searchResult = hostDocument.select("a:contains($title)").attr("href").toString()
else if (response.url.contains("/show/")) { else {
val showId = response.url.substringAfterLast("/") val show =
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
?.substringBefore(",")
val doc = app.get( val doc = app.get(
"$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0", "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
referer = "$HOST/" referer = "$HOST/"
).document ).document
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
// get direct subtitles links from list if (node.selectFirst("td")?.text()
return doc.select("#season tbody tr").mapNotNull { node -> ?.toIntOrNull() == seasonNum && node.select("td:eq(1)")
if (node.select("td:eq(1)").text().toIntOrNull() == epNum) .text()
newSubtitleEntity( .toIntOrNull() == epNum
displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(), ) searchResult = fixUrl(node.select("a").attr("href"))
link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(),
isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty()
)
else null
} }
// 3rd case: found several or no results. Still in $HOST/search.php?search=title
} else {// (response.url.contains("/search.php"))
downloadPage = hostDocument.select("table.tabel a").selectFirst({
// tv series
if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]"
// movie + year
else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)"
// movie
else "a[href~=movie\\/]"
}())?.attr("href")?.fixUrl() ?: return null
} }
val results = mutableListOf<AbstractSubtitleEntities.SubtitleEntity>()
val document = app.get(
url = fixUrl(searchResult),
).document
// filter download page by language. Do not work for movies :/ document.select(".tabel95 .tabel95 tr:contains($queryLang)").mapNotNull { node ->
if (downloadPage.contains("/serie/")) val name = if (seasonNum > 0) "${document.select(".titulo").text().replace("Subtitle","").trim()}${
downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed"
val doc = app.get(url = downloadPage).document
// get subtitles links from download page
return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node ->
val displayName =
doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" +
node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration") node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")
val link = }" else "${document.select(".titulo").text().replace("Subtitle","").trim()}${node.parent()!!.select(".NewsTitle").text().substringAfter("Version").substringBefore(", Duration")}"
node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl() val link = fixUrl(node.select("a.buttonDownload").attr("href"))
val isHearingImpaired = val isHearingImpaired =
node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty() node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty()
cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
newSubtitleEntity(displayName, link, isHearingImpaired)
} }
return results
} }
override suspend fun load( override suspend fun load(
auth: AuthData?, auth: AuthData?,
subtitle: SubtitleEntity subtitle: AbstractSubtitleEntities.SubtitleEntity
): String? { ): String? {
return subtitle.data return subtitle.data
} }
// Missing (?_?)
// Pair("2", ""),
// Pair("3", ""),
// Pair("33", ""),
// Pair("34", ""),
// Do not modify unless Addic7ed changes them!
// as they are the exact values from their website
private val langTagIETF2Addic7ed = mapOf(
"ar" to Pair("38", "Arabic"),
"az" to Pair("48", "Azerbaijani"),
"bg" to Pair("35", "Bulgarian"),
"bn" to Pair("47", "Bengali"),
"bs" to Pair("44", "Bosnian"),
"ca" to Pair("12", "Català"),
"cs" to Pair("14", "Czech"),
"cy" to Pair("65", "Welsh"),
"da" to Pair("30", "Danish"),
"de" to Pair("11", "German"),
"el" to Pair("27", "Greek"),
"en" to Pair("1", "English"),
"es-419" to Pair("6", "Spanish (Latin America)"),
"es-ar" to Pair("69", "Spanish (Argentina)"),
"es-es" to Pair("5", "Spanish (Spain)"),
"es" to Pair("4", "Spanish"),
"et" to Pair("54", "Estonian"),
"eu" to Pair("13", "Euskera"),
"fa" to Pair("43", "Persian"),
"fi" to Pair("28", "Finnish"),
"fr-ca" to Pair("53", "French (Canadian)"),
"fr" to Pair("8", "French"),
"gl" to Pair("15", "Galego"),
"he" to Pair("23", "Hebrew"),
"hi" to Pair("55", "Hindi"),
"hr" to Pair("31", "Croatian"),
"hu" to Pair("20", "Hungarian"),
"hy" to Pair("50", "Armenian"),
"id" to Pair("37", "Indonesian"),
"is" to Pair("56", "Icelandic"),
"it" to Pair("7", "Italian"),
"ja" to Pair("32", "Japanese"),
"kn" to Pair("66", "Kannada"),
"ko" to Pair("42", "Korean"),
"lt" to Pair("58", "Lithuanian"),
"lv" to Pair("57", "Latvian"),
"mk" to Pair("49", "Macedonian"),
"ml" to Pair("67", "Malayalam"),
"mr" to Pair("62", "Marathi"),
"ms" to Pair("40", "Malay"),
"nl" to Pair("17", "Dutch"),
"no" to Pair("29", "Norwegian"),
"pl" to Pair("21", "Polish"),
"pt-br" to Pair("10", "Portuguese (Brazilian)"),
"pt" to Pair("9", "Portuguese"),
"ro" to Pair("26", "Romanian"),
"ru" to Pair("19", "Russian"),
"si" to Pair("60", "Sinhala"),
"sk" to Pair("25", "Slovak"),
"sl" to Pair("22", "Slovenian"),
"sq" to Pair("52", "Albanian"),
"sr-latn" to Pair("36", "Serbian (Latin)"),
"sr" to Pair("39", "Serbian (Cyrillic)"),
"sv" to Pair("18", "Swedish"),
"ta" to Pair("59", "Tamil"),
"te" to Pair("63", "Telugu"),
"th" to Pair("46", "Thai"),
"tl" to Pair("68", "Tagalog"),
"tlh" to Pair("61", "Klingon"),
"tr" to Pair("16", "Turkish"),
"uk" to Pair("51", "Ukrainian"),
"vi" to Pair("45", "Vietnamese"),
"yue" to Pair("64", "Cantonese"),
"zh-hans" to Pair("41", "Chinese (Simplified)"),
"zh-hant" to Pair("24", "Chinese (Traditional)"),
)
} }

View file

@ -2,13 +2,11 @@ package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.Actor import com.lagradost.cloudstream3.Actor
import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorData
import com.lagradost.cloudstream3.ActorRole import com.lagradost.cloudstream3.ActorRole
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.NextAiring import com.lagradost.cloudstream3.NextAiring
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
@ -37,7 +35,7 @@ class AniListApi : SyncAPI() {
override var name = "AniList" override var name = "AniList"
override val idPrefix = "anilist" override val idPrefix = "anilist"
private val key = BuildConfig.ANILIST_KEY val key = "6871"
override val redirectUrlIdentifier = "anilistlogin" override val redirectUrlIdentifier = "anilistlogin"
override var requireLibraryRefresh = true override var requireLibraryRefresh = true
override val hasOAuth2 = true override val hasOAuth2 = true
@ -52,10 +50,9 @@ class AniListApi : SyncAPI() {
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
val sanitizer = splitRedirectUrl(redirectUrl) val sanitizer = splitRedirectUrl(redirectUrl)
val token = AuthToken( val token = AuthToken(
accessToken = sanitizer["access_token"] accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"),
?: throw ErrorLoadingException("No access token"),
//refreshToken = sanitizer["refresh_token"], //refreshToken = sanitizer["refresh_token"],
accessTokenLifetime = APIHolder.unixTime + sanitizer["expires_in"]!!.toLong(), accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(),
) )
return token return token
} }
@ -87,7 +84,7 @@ class AniListApi : SyncAPI() {
} }
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? { override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
val data = searchShows(query) ?: return null val data = searchShows(name) ?: return null
return data.data?.page?.media?.map { return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult( SyncAPI.SyncSearchResult(
it.title.romaji ?: return null, it.title.romaji ?: return null,
@ -109,7 +106,7 @@ class AniListApi : SyncAPI() {
nextAiring = season.nextAiringEpisode?.let { nextAiring = season.nextAiringEpisode?.let {
NextAiring( NextAiring(
it.episode ?: return@let null, it.episode ?: return@let null,
(it.timeUntilAiring ?: return@let null) + APIHolder.unixTime (it.timeUntilAiring ?: return@let null) + unixTime
) )
}, },
title = season.title?.userPreferred, title = season.title?.userPreferred,

View file

@ -1,677 +1,8 @@
package com.lagradost.cloudstream3.syncproviders.providers package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.txt
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.text.SimpleDateFormat
import java.time.LocalDate
import java.time.ZoneId
import java.util.Date
import java.util.Locale
const val KITSU_MAX_SEARCH_LIMIT = 20
class KitsuApi: SyncAPI() {
override var name = "Kitsu"
override val idPrefix = "kitsu"
private val apiUrl = "https://kitsu.io/api/edge"
private val fallbackApiUrl = "https://kitsu.app/api/edge"
private val oauthUrl = "https://kitsu.io/api/oauth"
private val fallbackOauthUrl = "https://kitsu.app/api/oauth"
override val hasInApp = true
override val mainUrl = "https://kitsu.app"
override val icon = R.drawable.kitsu_icon
override val syncIdName = SyncIdName.Kitsu
override val createAccountUrl = mainUrl
override val supportedWatchTypes = setOf(
SyncWatchType.WATCHING,
SyncWatchType.COMPLETED,
SyncWatchType.PLANTOWATCH,
SyncWatchType.DROPPED,
SyncWatchType.ONHOLD,
SyncWatchType.NONE
)
override val inAppLoginRequirement = AuthLoginRequirement(
password = true,
email = true
)
private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
try {
val response = chain.proceed(request);
if (response.isSuccessful) return response
response.close()
} catch (_: Exception) {
}
val fallbackRequest: Request = request.newBuilder()
.url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl))
.build()
return chain.proceed(fallbackRequest)
}
}
private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl)
private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl)
override suspend fun login(form: AuthLoginResponse): AuthToken? {
val username = form.email ?: return null
val password = form.password ?: return null
val grantType = "password"
val token = app.post(
"$oauthUrl/token",
data = mapOf(
"grant_type" to grantType,
"username" to username,
"password" to password
),
interceptor = oauthFallbackInterceptor
).parsed<ResponseToken>()
return AuthToken(
accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(),
refreshToken = token.refreshToken,
accessToken = token.accessToken,
)
}
override suspend fun refreshToken(token: AuthToken): AuthToken {
val res = app.post(
"$oauthUrl/token",
data = mapOf(
"grant_type" to "refresh_token",
"refresh_token" to token.refreshToken!!
),
interceptor = oauthFallbackInterceptor
).parsed<ResponseToken>()
return AuthToken(
accessToken = res.accessToken,
refreshToken = res.refreshToken,
accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong()
)
}
override suspend fun user(token: AuthToken?): AuthUser? {
val user = app.get(
"$apiUrl/users?filter[self]=true",
headers = mapOf(
"Authorization" to "Bearer ${token?.accessToken ?: return null}"
), cacheTime = 0,
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
if (user.data.isEmpty()) {
return null
}
return AuthUser(
id = user.data[0].id.toInt(),
name = user.data[0].attributes.name,
profilePicture = user.data[0].attributes.avatar?.original
)
}
override suspend fun search(auth: AuthData?, query: String): List<SyncSearchResult>? {
val auth = auth?.token?.accessToken ?: return null
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount")
val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth",
), cacheTime = 0,
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
return res.data.map {
val attributes = it.attributes
val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title"
SyncSearchResult(
title,
this.name,
it.id,
"$mainUrl/anime/${it.id}/",
attributes.posterImage?.large ?: attributes.posterImage?.medium
)
}
}
override suspend fun load(auth : AuthData?, id: String): SyncResult? {
val auth = auth?.token?.accessToken ?: return null
if (id.toIntOrNull() == null) {
return null
}
data class KitsuResponse(
@field:JsonProperty(value = "data")
val data: KitsuNode,
)
val url =
"$apiUrl/anime/$id"
val anime = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.attributes
return SyncResult(
id = id,
totalEpisodes = anime.episodeCount,
title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(),
publicScore = Score.from(anime.ratingTwenty, 20),
duration = anime.episodeLength,
synopsis = anime.synopsis,
airStatus = when(anime.status) {
"finished" -> ShowStatus.Completed
"current" -> ShowStatus.Ongoing
else -> null
},
nextAiring = null,
studio = null,
genres = null,
trailers = null,
startDate = LocalDate.parse(anime.startDate).toEpochDay(),
endDate = LocalDate.parse(anime.endDate).toEpochDay(),
recommendations = null,
nextSeason =null,
prevSeason = null,
actors = null,
)
}
override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? {
val accessToken = auth?.token?.accessToken ?: return null
val userId = auth.user.id
val selectedFields = arrayOf("status","ratingTwenty", "progress")
val url =
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}"
val anime = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $accessToken"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.firstOrNull()?.attributes
if (anime == null) {
return SyncStatus(
score = null,
status = SyncWatchType.NONE,
isFavorite = null,
watchedEpisodes = null
)
}
return SyncStatus(
score = Score.from(anime.ratingTwenty, 20),
status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)),
isFavorite = null,
watchedEpisodes = anime.progress,
)
}
suspend fun getAnimeIdByTitle(title: String): String? {
val animeSelectedFields = arrayOf("titles","canonicalTitle")
val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
val res = app.get(url, interceptor = apiFallbackInterceptor).parsed<KitsuResponse>()
return res.data.firstOrNull()?.id
}
override fun urlToId(url: String): String? =
Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first()
override suspend fun updateStatus(
auth : AuthData?,
id: String,
newStatus: AbstractSyncStatus
): Boolean {
return setScoreRequest(
auth ?: return false,
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(newStatus.status),
newStatus.score?.toInt(20),
newStatus.watchedEpisodes
)
}
private suspend fun setScoreRequest(
auth : AuthData,
id: Int,
status: KitsuStatusType? = null,
score: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val libraryEntryId = getAnimeLibraryEntryId(auth, id)
// Exists entry for anime in library
if (libraryEntryId != null) {
// Delete anime from library
if (status == null || status == KitsuStatusType.None) {
val res = app.delete(
"$apiUrl/library-entries/$libraryEntryId",
headers = mapOf(
"Authorization" to "Bearer ${auth.token.accessToken}"
),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
return setScoreRequest(
auth,
libraryEntryId,
kitsuStatusAsString[maxOf(0, status.value)],
score,
numWatchedEpisodes
)
}
val data = mapOf(
"data" to mapOf(
"type" to "libraryEntries",
"attributes" to mapOf(
"ratingTwenty" to score,
"progress" to numWatchedEpisodes,
"status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)],
),
"relationships" to mapOf(
"anime" to mapOf(
"data" to mapOf(
"type" to "anime",
"id" to id.toString()
)
),
"user" to mapOf(
"data" to mapOf(
"type" to "users",
"id" to auth.user.id
)
)
)
)
)
val res = app.post(
"$apiUrl/library-entries",
headers = mapOf(
"content-type" to "application/vnd.api+json",
"Authorization" to "Bearer ${auth.token.accessToken}"
),
requestBody = data.toJson().toRequestBody(),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
@Suppress("UNCHECKED_CAST")
private suspend fun setScoreRequest(
auth : AuthData,
id: Int,
status: String? = null,
score: Int? = null,
numWatchedEpisodes: Int? = null,
): Boolean {
val data = mapOf(
"data" to mapOf(
"type" to "libraryEntries",
"id" to id.toString(),
"attributes" to mapOf(
"ratingTwenty" to score,
"progress" to numWatchedEpisodes,
"status" to status
)
)
)
val res = app.patch(
"$apiUrl/library-entries/$id",
headers = mapOf(
"content-type" to "application/vnd.api+json",
"Authorization" to "Bearer ${auth.token.accessToken}"
),
requestBody = data.toJson().toRequestBody(),
interceptor = apiFallbackInterceptor
)
return res.isSuccessful
}
private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? {
val userId = auth.user.id
val res = app.get(
"$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id",
headers = mapOf(
"Authorization" to "Bearer ${auth.token.accessToken}"
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>().data.firstOrNull() ?: return null
return res.id.toInt()
}
override suspend fun library(auth : AuthData?): LibraryMetadata? {
val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy {
convertToStatus(it.attributes.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.toLibraryItem() }
} ?: emptyMap()
// To fill empty lists when Kitsu does not return them
val baseMap =
KitsuStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList<LibraryItem>()
}
return LibraryMetadata(
(baseMap + list).map { LibraryList(txt(it.key), it.value) },
setOf(
ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.ReleaseDateNew,
ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
)
}
private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array<KitsuNode>? {
return if (requireLibraryRefresh) {
val list = getKitsuAnimeList(auth.token, auth.user.id)
setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list)
list
} else {
getKey<Array<KitsuNode>>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array<KitsuNode>
}
}
private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array<KitsuNode> {
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount")
val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status")
val limit = 500
var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}"
val fullList = mutableListOf<KitsuNode>()
while (true) {
val data: KitsuResponse = getKitsuAnimeListSlice(token, url)
data.data.forEachIndexed { index, value ->
value.anime = data.included?.get(index)
}
fullList.addAll(data.data)
url = data.links?.next ?: break
}
return fullList.toTypedArray()
}
private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse {
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer ${token.accessToken}",
),
interceptor = apiFallbackInterceptor
).parsed<KitsuResponse>()
return res
}
data class ResponseToken(
@JsonProperty("token_type") val tokenType: String,
@JsonProperty("expires_in") val expiresIn: Int,
@JsonProperty("access_token") val accessToken: String,
@JsonProperty("refresh_token") val refreshToken: String,
)
data class KitsuNode(
@JsonProperty("id") val id: String,
@JsonProperty("attributes") val attributes: KitsuNodeAttributes,
/* User list anime node */
@JsonProperty("relationships") val relationships: KitsuRelationships?,
var anime: KitsuAnimeData?
) {
fun toLibraryItem(): LibraryItem {
val animeItem = this.anime
val numEpisodes = animeItem?.attributes?.episodeCount
val startDate = animeItem?.attributes?.startDate
val posterImage = animeItem?.attributes?.posterImage
val canonicalTitle = animeItem?.attributes?.canonicalTitle
val titles = animeItem?.attributes?.titles
val animeId = animeItem?.id
val synopsis: String? = animeItem?.attributes?.synopsis
return LibraryItem(
canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(),
"https://kitsu.app/anime/${animeId}/",
this.id,
this.attributes.progress,
numEpisodes,
Score.from(this.attributes.ratingTwenty, 20),
parseDateLong(this.attributes.updatedAt),
"Kitsu",
TvType.Anime,
posterImage?.large ?: posterImage?.medium,
null,
null,
plot = synopsis,
releaseDate = if (startDate == null) null else try {
Date.from(LocalDate.parse(startDate).atStartOfDay()
.atZone(ZoneId.systemDefault())
.toInstant())
} catch (_: RuntimeException) {
null
}
)
}
}
data class KitsuAnimeAttributes(
@JsonProperty("titles") val titles: KitsuTitles?,
@JsonProperty("canonicalTitle") val canonicalTitle: String?,
@JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
@JsonProperty("synopsis") val synopsis: String?,
@JsonProperty("startDate") val startDate: String?,
@JsonProperty("endDate") val endDate: String?,
@JsonProperty("episodeCount") val episodeCount: Int?,
@JsonProperty("episodeLength") val episodeLength: Int?,
)
data class KitsuAnimeData(
@JsonProperty("id") val id: String,
@JsonProperty("attributes") val attributes: KitsuAnimeAttributes,
)
data class KitsuNodeAttributes(
/* General attributes */
@JsonProperty("titles") val titles: KitsuTitles?,
@JsonProperty("canonicalTitle") val canonicalTitle: String?,
@JsonProperty("posterImage") val posterImage: KitsuPosterImage?,
@JsonProperty("synopsis") val synopsis: String?,
@JsonProperty("startDate") val startDate: String?,
@JsonProperty("endDate") val endDate: String?,
@JsonProperty("episodeCount") val episodeCount: Int?,
@JsonProperty("episodeLength") val episodeLength: Int?,
/* User attributes */
@JsonProperty("name") val name: String?,
@JsonProperty("location") val location: String?,
@JsonProperty("createdAt") val createdAt: String?,
@JsonProperty("avatar") val avatar: KitsuUserAvatar?,
/* User list anime attributes */
@JsonProperty("progress") val progress: Int?,
@JsonProperty("ratingTwenty") val ratingTwenty: Int?,
@JsonProperty("updatedAt") val updatedAt: String?,
@JsonProperty("status") val status: String?,
)
data class KitsuRelationships(
@JsonProperty("anime") val anime: KitsuRelationshipsAnime?
)
data class KitsuRelationshipsAnime(
@JsonProperty("links") val links: KitsuLinks?
)
data class KitsuPosterImage(
@JsonProperty("large") val large: String?,
@JsonProperty("medium") val medium: String?,
)
data class KitsuTitles(
@JsonProperty("en_jp") val enJp: String?,
@JsonProperty("ja_jp") val jaJp: String?
)
data class KitsuUserAvatar(
@JsonProperty("original") val original: String?
)
data class KitsuLinks(
/* Pagination */
@JsonProperty("first") val first: String?,
@JsonProperty("next") val next: String?,
@JsonProperty("last") val last: String?,
/* Relationships */
@JsonProperty("related") val related: String?
)
data class KitsuResponse(
@JsonProperty("links") val links: KitsuLinks?,
@JsonProperty("data") val data: List<KitsuNode>,
/* When requesting related info (User library entry -> anime) */
@JsonProperty("included") val included: List<KitsuAnimeData>?,
)
companion object {
const val KITSU_CACHED_LIST: String = "kitsu_cached_list"
private fun parseDateLong(string: String?): Long? {
return try {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(
string ?: return null
)?.time?.div(1000)
} catch (e: Exception) {
null
}
}
private val kitsuStatusAsString =
arrayOf("current", "completed", "on_hold", "dropped", "planned")
private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType {
return when (inp) {
SyncWatchType.NONE -> KitsuStatusType.None
SyncWatchType.WATCHING -> KitsuStatusType.Watching
SyncWatchType.COMPLETED -> KitsuStatusType.Completed
SyncWatchType.ONHOLD -> KitsuStatusType.OnHold
SyncWatchType.DROPPED -> KitsuStatusType.Dropped
SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch
SyncWatchType.REWATCHING -> KitsuStatusType.Watching
}
}
enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) {
Watching(0, R.string.type_watching),
Completed(1, R.string.type_completed),
OnHold(2, R.string.type_on_hold),
Dropped(3, R.string.type_dropped),
PlanToWatch(4, R.string.type_plan_to_watch),
None(-1, R.string.type_none)
}
private fun convertToStatus(string: String): KitsuStatusType {
return when (string) {
"current" -> KitsuStatusType.Watching
"completed" -> KitsuStatusType.Completed
"on_hold" -> KitsuStatusType.OnHold
"dropped" -> KitsuStatusType.Dropped
"planned" -> KitsuStatusType.PlanToWatch
else -> KitsuStatusType.None
}
}
}
}
// modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt
// GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md

View file

@ -2,10 +2,8 @@ package com.lagradost.cloudstream3.syncproviders.providers
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.ShowStatus
@ -36,7 +34,7 @@ class MALApi : SyncAPI() {
override var name = "MAL" override var name = "MAL"
override val idPrefix = "mal" override val idPrefix = "mal"
private val key = BuildConfig.MAL_KEY val key = "1714d6f2f4f7cc19644384f8c4629910"
private val apiUrl = "https://api.myanimelist.net" private val apiUrl = "https://api.myanimelist.net"
override val hasOAuth2 = true override val hasOAuth2 = true
override val redirectUrlIdentifier: String? = "mallogin" override val redirectUrlIdentifier: String? = "mallogin"
@ -80,7 +78,7 @@ class MALApi : SyncAPI() {
) )
).parsed<ResponseToken>() ).parsed<ResponseToken>()
return AuthToken( return AuthToken(
accessTokenLifetime = APIHolder.unixTime + token.expiresIn.toLong(), accessTokenLifetime = unixTime + token.expiresIn.toLong(),
refreshToken = token.refreshToken, refreshToken = token.refreshToken,
accessToken = token.accessToken accessToken = token.accessToken
) )
@ -102,7 +100,7 @@ class MALApi : SyncAPI() {
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? { override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
val auth = auth?.token?.accessToken ?: return null val auth = auth?.token?.accessToken ?: return null
val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT" val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT"
val res = app.get( val res = app.get(
url, headers = mapOf( url, headers = mapOf(
"Authorization" to "Bearer $auth", "Authorization" to "Bearer $auth",
@ -368,7 +366,7 @@ class MALApi : SyncAPI() {
return AuthToken( return AuthToken(
accessToken = res.accessToken, accessToken = res.accessToken,
refreshToken = res.refreshToken, refreshToken = res.refreshToken,
accessTokenLifetime = APIHolder.unixTime + res.expiresIn.toLong() accessTokenLifetime = unixTime + res.expiresIn.toLong()
) )
} }

View file

@ -2,10 +2,9 @@ package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Log import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthData
@ -14,12 +13,9 @@ import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag
class OpenSubtitlesApi : SubtitleAPI() { class OpenSubtitlesApi : SubtitleAPI() {
override val name = "OpenSubtitles" override val name = "OpenSubtitles"
@ -45,17 +41,17 @@ class OpenSubtitlesApi : SubtitleAPI() {
} }
private fun canDoRequest(): Boolean { private fun canDoRequest(): Boolean {
return unixTimeMS > currentCoolDown return unixTimeMs > currentCoolDown
} }
private fun throwIfCantDoRequest() { private fun throwIfCantDoRequest() {
if (!canDoRequest()) { if (!canDoRequest()) {
throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMS) / 1000L}s") throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMs) / 1000L}s")
} }
} }
private fun throwGotTooManyRequests() { private fun throwGotTooManyRequests() {
currentCoolDown = unixTimeMS + COOLDOWN_DURATION currentCoolDown = unixTimeMs + COOLDOWN_DURATION
throw ErrorLoadingException("Too many requests") throw ErrorLoadingException("Too many requests")
} }
@ -91,11 +87,29 @@ class OpenSubtitlesApi : SubtitleAPI() {
accessToken = response.token accessToken = response.token
?: throw ErrorLoadingException("Invalid password or username"), ?: throw ErrorLoadingException("Invalid password or username"),
/// JWT token is valid 24 hours after successfully authentication of user /// JWT token is valid 24 hours after successfully authentication of user
accessTokenLifetime = APIHolder.unixTime + 60 * 60 * 24, accessTokenLifetime = unixTime + 60 * 60 * 24,
payload = form.toJson() payload = form.toJson()
) )
} }
/**
* Some languages do not use the normal country codes on OpenSubtitles
* */
private val languageExceptions = mapOf<String, String>(
// "pt" to "pt-PT",
// "pt" to "pt-BR"
)
private fun fixLanguage(language: String?): String? {
return languageExceptions[language] ?: language
}
// O(n) but good enough, BiMap did not want to work properly
private fun fixLanguageReverse(language: String?): String? {
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
}
/** /**
* Fetch subtitles using token authenticated on previous method (see authorize). * Fetch subtitles using token authenticated on previous method (see authorize).
* Returns list of Subtitles which user can select to download (see load). * Returns list of Subtitles which user can select to download (see load).
@ -105,7 +119,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
query: AbstractSubtitleEntities.SubtitleSearch query: AbstractSubtitleEntities.SubtitleSearch
): List<AbstractSubtitleEntities.SubtitleEntity>? { ): List<AbstractSubtitleEntities.SubtitleEntity>? {
throwIfCantDoRequest() throwIfCantDoRequest()
val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: "" val fixedLang = fixLanguage(query.lang)
val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
val queryText = query.query val queryText = query.query
@ -118,8 +132,8 @@ class OpenSubtitlesApi : SubtitleAPI() {
val searchQueryUrl = when (imdbId > 0) { val searchQueryUrl = when (imdbId > 0) {
//Use imdb_id to search if its valid //Use imdb_id to search if its valid
true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
} }
val req = app.get( val req = app.get(
@ -128,7 +142,6 @@ class OpenSubtitlesApi : SubtitleAPI() {
Pair("Content-Type", "application/json") Pair("Content-Type", "application/json")
) + headers, ) + headers,
) )
Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}")
Log.i(TAG, "Search Req => ${req.text}") Log.i(TAG, "Search Req => ${req.text}")
if (!req.isSuccessful) { if (!req.isSuccessful) {
if (req.code == 429) if (req.code == 429)
@ -149,7 +162,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
//Use any valid name/title in hierarchy //Use any valid name/title in hierarchy
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
?: featureDetails?.parentTitle ?: attr.release ?: query.query ?: featureDetails?.parentTitle ?: attr.release ?: query.query
val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: "" val lang = fixLanguageReverse(attr.language) ?: ""
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
val year = featureDetails?.year ?: query.year val year = featureDetails?.year ?: query.year
@ -163,7 +176,7 @@ class OpenSubtitlesApi : SubtitleAPI() {
AbstractSubtitleEntities.SubtitleEntity( AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix, idPrefix = this.idPrefix,
name = name, name = name,
lang = langTagIETF, lang = lang,
data = resultData, data = resultData,
type = type, type = type,
source = this.name, source = this.name,

View file

@ -4,19 +4,19 @@ import androidx.annotation.StringRes
import androidx.core.net.toUri import androidx.core.net.toUri
import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.BuildConfig
import com.lagradost.cloudstream3.CloudStreamApp
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.Score
import com.lagradost.cloudstream3.SimklSyncServices import com.lagradost.cloudstream3.SimklSyncServices
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mapper
import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.debugPrint
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
@ -30,7 +30,6 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import java.math.BigInteger import java.math.BigInteger
@ -78,15 +77,15 @@ class SimklApi : SyncAPI() {
private class SimklCacheWrapper<T>( private class SimklCacheWrapper<T>(
@JsonProperty("obj") val obj: T?, @JsonProperty("obj") val obj: T?,
@JsonProperty("validUntil") val validUntil: Long, @JsonProperty("validUntil") val validUntil: Long,
@JsonProperty("cacheTime") val cacheTime: Long = APIHolder.unixTime, @JsonProperty("cacheTime") val cacheTime: Long = unixTime,
) { ) {
/** Returns true if cache is newer than cacheDays */ /** Returns true if cache is newer than cacheDays */
fun isFresh(): Boolean { fun isFresh(): Boolean {
return validUntil > APIHolder.unixTime return validUntil > unixTime
} }
fun remainingTime(): Duration { fun remainingTime(): Duration {
val unixTime = APIHolder.unixTime val unixTime = unixTime
return if (validUntil > unixTime) { return if (validUntil > unixTime) {
(validUntil - unixTime).toDuration(DurationUnit.SECONDS) (validUntil - unixTime).toDuration(DurationUnit.SECONDS)
} else { } else {
@ -97,7 +96,7 @@ class SimklApi : SyncAPI() {
fun cleanOldCache() { fun cleanOldCache() {
getKeys(SIMKL_CACHE_KEY)?.forEach { getKeys(SIMKL_CACHE_KEY)?.forEach {
val isOld = CloudStreamApp.getKey<SimklCacheWrapper<Any>>(it)?.isFresh() == false val isOld = AcraApplication.getKey<SimklCacheWrapper<Any>>(it)?.isFresh() == false
if (isOld) { if (isOld) {
removeKey(it) removeKey(it)
} }
@ -110,7 +109,7 @@ class SimklApi : SyncAPI() {
SIMKL_CACHE_KEY, SIMKL_CACHE_KEY,
path, path,
// Storing as plain sting is required to make generics work. // Storing as plain sting is required to make generics work.
SimklCacheWrapper(value, APIHolder.unixTime + cacheTime.inWholeSeconds).toJson() SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson()
) )
} }
@ -118,8 +117,13 @@ class SimklApi : SyncAPI() {
* Gets cached object, if object is not fresh returns null and removes it from cache * Gets cached object, if object is not fresh returns null and removes it from cache
*/ */
inline fun <reified T : Any> getKey(path: String): T? { inline fun <reified T : Any> getKey(path: String): T? {
// Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject"
val type = mapper.typeFactory.constructParametricType(
SimklCacheWrapper::class.java,
T::class.java
)
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let { val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
tryParseJson<SimklCacheWrapper<T>>(it) mapper.readValue<SimklCacheWrapper<T>>(it, type)
} }
return if (cache?.isFresh() == true) { return if (cache?.isFresh() == true) {
@ -419,7 +423,7 @@ class SimklApi : SyncAPI() {
} }
suspend fun execute(): Boolean { suspend fun execute(): Boolean {
val time = getDateTime(APIHolder.unixTime) val time = getDateTime(unixTime)
val headers = this.headers ?: emptyMap() val headers = this.headers ?: emptyMap()
return if (this.status == SimklListStatusType.None.value) { return if (this.status == SimklListStatusType.None.value) {
app.post( app.post(
@ -569,7 +573,7 @@ class SimklApi : SyncAPI() {
@JsonProperty("year") year: Int?, @JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?, @JsonProperty("ids") ids: Ids?,
@JsonProperty("rating") val rating: Int, @JsonProperty("rating") val rating: Int,
@JsonProperty("rated_at") val ratedAt: String? = getDateTime(APIHolder.unixTime) @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime)
) : MediaObject(title, year, ids) ) : MediaObject(title, year, ids)
@JsonInclude(JsonInclude.Include.NON_EMPTY) @JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -578,7 +582,7 @@ class SimklApi : SyncAPI() {
@JsonProperty("year") year: Int?, @JsonProperty("year") year: Int?,
@JsonProperty("ids") ids: Ids?, @JsonProperty("ids") ids: Ids?,
@JsonProperty("to") val to: String, @JsonProperty("to") val to: String,
@JsonProperty("watched_at") val watchedAt: String? = getDateTime(APIHolder.unixTime) @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime)
) : MediaObject(title, year, ids) ) : MediaObject(title, year, ids)
@JsonInclude(JsonInclude.Include.NON_EMPTY) @JsonInclude(JsonInclude.Include.NON_EMPTY)
@ -863,7 +867,7 @@ class SimklApi : SyncAPI() {
newStatus: AbstractSyncStatus newStatus: AbstractSyncStatus
): Boolean { ): Boolean {
val parsedId = readIdFromString(id) val parsedId = readIdFromString(id)
lastScoreTime = APIHolder.unixTime lastScoreTime = unixTime
val simklStatus = newStatus as? SimklSyncStatus val simklStatus = newStatus as? SimklSyncStatus
val builder = SimklScoreBuilder.Builder() val builder = SimklScoreBuilder.Builder()
@ -912,7 +916,7 @@ class SimklApi : SyncAPI() {
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? { override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
return app.get( return app.get(
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query) "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name)
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() } ).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
} }

View file

@ -29,7 +29,7 @@ class SubSourceApi : SubtitleAPI() {
//Only supports Imdb Id search for now //Only supports Imdb Id search for now
if (query.imdbId == null) return null if (query.imdbId == null) return null
val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang) val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
val searchRes = app.post( val searchRes = app.post(

View file

@ -1,8 +1,9 @@
package com.lagradost.cloudstream3.syncproviders.providers package com.lagradost.cloudstream3.syncproviders.providers
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthData
@ -11,9 +12,6 @@ import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthToken
import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.AuthUser
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
import com.lagradost.cloudstream3.TvType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class SubDlApi : SubtitleAPI() { class SubDlApi : SubtitleAPI() {
override val name = "SubDL" override val name = "SubDL"
@ -26,7 +24,7 @@ class SubDlApi : SubtitleAPI() {
override val createAccountUrl = "https://subdl.com/panel/register" override val createAccountUrl = "https://subdl.com/panel/register"
companion object { companion object {
const val APIURL = "https://api.subdl.com" const val APIURL = "https://apiold.subdl.com"
const val APIENDPOINT = "$APIURL/api/v1/subtitles" const val APIENDPOINT = "$APIURL/api/v1/subtitles"
const val DOWNLOADENDPOINT = "https://dl.subdl.com" const val DOWNLOADENDPOINT = "https://dl.subdl.com"
} }
@ -67,7 +65,6 @@ class SubDlApi : SubtitleAPI() {
val epNum = query.epNumber ?: 0 val epNum = query.epNumber ?: 0
val seasonNum = query.seasonNumber ?: 0 val seasonNum = query.seasonNumber ?: 0
val yearNum = query.year ?: 0 val yearNum = query.year ?: 0
val langSubdlCode = langTagIETF2subdl[query.lang.toString()] ?: query.lang
val idQuery = when { val idQuery = when {
query.imdbId != null -> "&imdb_id=${query.imdbId}" query.imdbId != null -> "&imdb_id=${query.imdbId}"
@ -81,8 +78,8 @@ class SubDlApi : SubtitleAPI() {
val searchQueryUrl = when (idQuery) { val searchQueryUrl = when (idQuery) {
//Use imdb/tmdb id to search if its valid //Use imdb/tmdb id to search if its valid
null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
} }
val req = app.get( val req = app.get(
@ -94,9 +91,7 @@ class SubDlApi : SubtitleAPI() {
return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle -> return req.parsedSafe<ApiResponse>()?.subtitles?.map { subtitle ->
val langTagIETF = val lang = subtitle.lang.replaceFirstChar { it.uppercase() }
langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?:
subtitle.lang
val resEpNum = subtitle.episode ?: query.epNumber val resEpNum = subtitle.episode ?: query.epNumber
val resSeasonNum = subtitle.season ?: query.seasonNumber val resSeasonNum = subtitle.season ?: query.seasonNumber
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
@ -104,7 +99,7 @@ class SubDlApi : SubtitleAPI() {
AbstractSubtitleEntities.SubtitleEntity( AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix, idPrefix = this.idPrefix,
name = subtitle.releaseName, name = subtitle.releaseName,
lang = langTagIETF, lang = lang,
data = "${DOWNLOADENDPOINT}${subtitle.url}", data = "${DOWNLOADENDPOINT}${subtitle.url}",
type = type, type = type,
source = this.name, source = this.name,
@ -124,146 +119,68 @@ class SubDlApi : SubtitleAPI() {
} }
} }
@Serializable
data class SubtitleOAuthEntity( data class SubtitleOAuthEntity(
@JsonProperty("userEmail") @SerialName("userEmail") var userEmail: String, @JsonProperty("userEmail") var userEmail: String,
@JsonProperty("pass") @SerialName("pass") var pass: String, @JsonProperty("pass") var pass: String,
@JsonProperty("name") @SerialName("name") var name: String? = null, @JsonProperty("name") var name: String? = null,
@JsonProperty("accessToken") @SerialName("accessToken") var accessToken: String? = null, @JsonProperty("accessToken") var accessToken: String? = null,
@JsonProperty("apiKey") @SerialName("apiKey") var apiKey: String? = null, @JsonProperty("apiKey") var apiKey: String? = null,
) )
@Serializable
data class OAuthTokenResponse( data class OAuthTokenResponse(
@JsonProperty("token") @SerialName("token") val token: String, @JsonProperty("token") val token: String,
@JsonProperty("userData") @SerialName("userData") val userData: UserData? = null, @JsonProperty("userData") val userData: UserData? = null,
@JsonProperty("status") @SerialName("status") val status: Boolean? = null, @JsonProperty("status") val status: Boolean? = null,
@JsonProperty("message") @SerialName("message") val message: String? = null, @JsonProperty("message") val message: String? = null,
) )
@Serializable
data class UserData( data class UserData(
@JsonProperty("email") @SerialName("email") val email: String, @JsonProperty("email") val email: String,
@JsonProperty("name") @SerialName("name") val name: String, @JsonProperty("name") val name: String,
@JsonProperty("country") @SerialName("country") val country: String, @JsonProperty("country") val country: String,
@JsonProperty("scStepCode") @SerialName("scStepCode") val scStepCode: String, @JsonProperty("scStepCode") val scStepCode: String,
@JsonProperty("scVerified") @SerialName("scVerified") val scVerified: Boolean, @JsonProperty("scVerified") val scVerified: Boolean,
@JsonProperty("username") @SerialName("username") val username: String? = null, @JsonProperty("username") val username: String? = null,
@JsonProperty("scUsername") @SerialName("scUsername") val scUsername: String, @JsonProperty("scUsername") val scUsername: String,
) )
@Serializable
data class ApiKeyResponse( data class ApiKeyResponse(
@JsonProperty("ok") @SerialName("ok") val ok: Boolean? = false, @JsonProperty("ok") val ok: Boolean? = false,
@JsonProperty("api_key") @SerialName("api_key") val apiKey: String, @JsonProperty("api_key") val apiKey: String,
@JsonProperty("usage") @SerialName("usage") val usage: Usage? = null, @JsonProperty("usage") val usage: Usage? = null,
) )
@Serializable
data class Usage( data class Usage(
@JsonProperty("total") @SerialName("total") val total: Long? = 0, @JsonProperty("total") val total: Long? = 0,
@JsonProperty("today") @SerialName("today") val today: Long? = 0, @JsonProperty("today") val today: Long? = 0,
) )
@Serializable
data class ApiResponse( data class ApiResponse(
@JsonProperty("status") @SerialName("status") val status: Boolean? = null, @JsonProperty("status") val status: Boolean? = null,
@JsonProperty("results") @SerialName("results") val results: List<Result>? = null, @JsonProperty("results") val results: List<Result>? = null,
@JsonProperty("subtitles") @SerialName("subtitles") val subtitles: List<Subtitle>? = null, @JsonProperty("subtitles") val subtitles: List<Subtitle>? = null,
) )
@Serializable
data class Result( data class Result(
@JsonProperty("sd_id") @SerialName("sd_id") val sdId: Int? = null, @JsonProperty("sd_id") val sdId: Int? = null,
@JsonProperty("type") @SerialName("type") val type: String? = null, @JsonProperty("type") val type: String? = null,
@JsonProperty("name") @SerialName("name") val name: String? = null, @JsonProperty("name") val name: String? = null,
@JsonProperty("imdb_id") @SerialName("imdb_id") val imdbId: String? = null, @JsonProperty("imdb_id") val imdbId: String? = null,
@JsonProperty("tmdb_id") @SerialName("tmdb_id") val tmdbId: Long? = null, @JsonProperty("tmdb_id") val tmdbId: Long? = null,
@JsonProperty("first_air_date") @SerialName("first_air_date") val firstAirDate: String? = null, @JsonProperty("first_air_date") val firstAirDate: String? = null,
@JsonProperty("year") @SerialName("year") val year: Int? = null, @JsonProperty("year") val year: Int? = null,
) )
@Serializable
data class Subtitle( data class Subtitle(
@JsonProperty("release_name") @SerialName("release_name") val releaseName: String, @JsonProperty("release_name") val releaseName: String,
@JsonProperty("name") @SerialName("name") val name: String, @JsonProperty("name") val name: String,
@JsonProperty("lang") @SerialName("lang") val lang: String, // subdl language code @JsonProperty("lang") val lang: String,
@JsonProperty("author") @SerialName("author") val author: String? = null, @JsonProperty("author") val author: String? = null,
@JsonProperty("url") @SerialName("url") val url: String? = null, @JsonProperty("url") val url: String? = null,
@JsonProperty("subtitlePage") @SerialName("subtitlePage") val subtitlePage: String? = null, @JsonProperty("subtitlePage") val subtitlePage: String? = null,
@JsonProperty("season") @SerialName("season") val season: Int? = null, @JsonProperty("season") val season: Int? = null,
@JsonProperty("episode") @SerialName("episode") val episode: Int? = null, @JsonProperty("episode") val episode: Int? = null,
@JsonProperty("language") @SerialName("language") val language: String? = null, // full language name @JsonProperty("language") val language: String? = null,
@JsonProperty("hi") @SerialName("hi") val hearingImpaired: Boolean? = null, @JsonProperty("hi") val hearingImpaired: Boolean? = null,
)
// https://subdl.com/api-files/language_list.json
// most of it is IETF BPC 47 conformant tag
// but there are some exceptions
private val langTagIETF2subdl = mapOf(
"en-bg" to "BG_EN", // "Bulgarian_English"
"en-de" to "EN_DE", // "English_German"
"en-hu" to "HU_EN", // "Hungarian_English"
"en-nl" to "NL_EN", // "Dutch_English"
"pt-br" to "BR_PT", // "Brazillian Portuguese"
"zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?)
// "ar" to "AR", // "Arabic"
// "az" to "AZ", // "Azerbaijani"
// "be" to "BE", // "Belarusian"
// "bg" to "BG", // "Bulgarian"
// "bn" to "BN", // "Bengali"
// "bs" to "BS", // "Bosnian"
// "ca" to "CA", // "Catalan"
// "cs" to "CS", // "Czech"
// "da" to "DA", // "Danish"
// "de" to "DE", // "German"
// "el" to "EL", // "Greek"
// "en" to "EN", // "English"
// "eo" to "EO", // "Esperanto"
// "es" to "ES", // "Spanish"
// "et" to "ET", // "Estonian"
// "fa" to "FA", // "Farsi_Persian"
// "fi" to "FI", // "Finnish"
// "fr" to "FR", // "French"
// "he" to "HE", // "Hebrew"
// "hi" to "HI", // "Hindi"
// "hr" to "HR", // "Croatian"
// "hu" to "HU", // "Hungarian"
// "id" to "ID", // "Indonesian"
// "is" to "IS", // "Icelandic"
// "it" to "IT", // "Italian"
// "ja" to "JA", // "Japanese"
// "ka" to "KA", // "Georgian"
// "kl" to "KL", // "Greenlandic"
// "ko" to "KO", // "Korean"
// "ku" to "KU", // "Kurdish"
// "lt" to "LT", // "Lithuanian"
// "lv" to "LV", // "Latvian"
// "mk" to "MK", // "Macedonian"
// "ml" to "ML", // "Malayalam"
// "mni" to "MNI", // "Manipuri"
// "ms" to "MS", // "Malay"
// "my" to "MY", // "Burmese"
// "nl" to "NL", // "Dutch"
// "no" to "NO", // "Norwegian"
// "pl" to "PL", // "Polish"
// "pt" to "PT", // "Portuguese"
// "ro" to "RO", // "Romanian"
// "ru" to "RU", // "Russian"
// "si" to "SI", // "Sinhala"
// "sk" to "SK", // "Slovak"
// "sl" to "SL", // "Slovenian"
// "sq" to "SQ", // "Albanian"
// "sr" to "SR", // "Serbian"
// "sv" to "SV", // "Swedish"
// "ta" to "TA", // "Tamil"
// "te" to "TE", // "Telugu"
// "th" to "TH", // "Thai"
// "tl" to "TL", // "Tagalog"
// "tr" to "TR", // "Turkish"
// "uk" to "UK", // "Ukranian"
// "ur" to "UR", // "Urdu"
// "vi" to "VI", // "Vietnamese"
// "zh" to "ZH", // "Chinese BG code"
) )
} }

View file

@ -9,15 +9,14 @@ import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.MainPageRequest
import com.lagradost.cloudstream3.SearchResponseList import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.fixUrl import com.lagradost.cloudstream3.fixUrl
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.newSearchResponseList import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -55,7 +54,7 @@ class APIRepository(val api: MainAPI) {
val hash: Pair<String, String> val hash: Pair<String, String>
) )
private val cache = atomicListOf<SavedLoadResponse>() private val cache = threadSafeListOf<SavedLoadResponse>()
private var cacheIndex: Int = 0 private var cacheIndex: Int = 0
const val CACHE_SIZE = 20 const val CACHE_SIZE = 20
@ -66,9 +65,11 @@ class APIRepository(val api: MainAPI) {
private fun afterPluginsLoaded(forceReload: Boolean) { private fun afterPluginsLoaded(forceReload: Boolean) {
if (forceReload) { if (forceReload) {
synchronized(cache) {
cache.clear() cache.clear()
} }
} }
}
init { init {
afterPluginsLoadedEvent += ::afterPluginsLoaded afterPluginsLoadedEvent += ::afterPluginsLoaded
@ -89,25 +90,21 @@ class APIRepository(val api: MainAPI) {
val fixedUrl = api.fixUrl(url) val fixedUrl = api.fixUrl(url)
val lookingForHash = Pair(api.name, fixedUrl) val lookingForHash = Pair(api.name, fixedUrl)
val cached = cache.withLock { synchronized(cache) {
var found: LoadResponse? = null
for (item in cache) { for (item in cache) {
// 10 min save // 10 min save
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
found = item.response return@withTimeout item.response
break
} }
} }
found
} }
if (cached != null) return@withTimeout cached
api.load(fixedUrl)?.also { response -> api.load(fixedUrl)?.also { response ->
// Remove all blank tags as early as possible // Remove all blank tags as early as possible
response.tags = response.tags?.filter { it.isNotBlank() } response.tags = response.tags?.filter { it.isNotBlank() }
val add = SavedLoadResponse(unixTime, response, lookingForHash) val add = SavedLoadResponse(unixTime, response, lookingForHash)
cache.withLock { synchronized(cache) {
if (cache.size > CACHE_SIZE) { if (cache.size > CACHE_SIZE) {
cache[cacheIndex] = add // rolling cache cache[cacheIndex] = add // rolling cache
cacheIndex = (cacheIndex + 1) % CACHE_SIZE cacheIndex = (cacheIndex + 1) % CACHE_SIZE
@ -120,29 +117,27 @@ class APIRepository(val api: MainAPI) {
} }
} }
suspend fun search(query: String, page: Int): Resource<SearchResponseList> { suspend fun search(query: String): Resource<List<SearchResponse>> {
if (query.isEmpty()) if (query.isEmpty())
return Resource.Success(newSearchResponseList(emptyList())) return Resource.Success(emptyList())
return safeApiCall { return safeApiCall {
withTimeout(getTimeout(api.searchTimeoutMs)) { withTimeout(getTimeout(api.searchTimeoutMs)) {
(api.search(query, page) (api.search(query)
?: throw ErrorLoadingException()) ?: throw ErrorLoadingException())
// .filter { typesActive.contains(it.type) } // .filter { typesActive.contains(it.type) }
.toList()
} }
} }
} }
suspend fun quickSearch(query: String): Resource<SearchResponseList> { suspend fun quickSearch(query: String): Resource<List<SearchResponse>> {
if (query.isEmpty()) if (query.isEmpty())
return Resource.Success(newSearchResponseList(emptyList())) return Resource.Success(emptyList())
return safeApiCall { return safeApiCall {
withTimeout(getTimeout(api.quickSearchTimeoutMs)) { withTimeout(getTimeout(api.quickSearchTimeoutMs)) {
newSearchResponseList( api.quickSearch(query) ?: throw ErrorLoadingException()
api.quickSearch(query) ?: throw ErrorLoadingException(),
false
)
} }
} }
} }

View file

@ -1,55 +1,34 @@
package com.lagradost.cloudstream3.ui package com.lagradost.cloudstream3.ui
import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import androidx.core.view.children import androidx.core.view.children
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import coil3.dispose
import java.util.WeakHashMap
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) { open class ViewHolderState<T>(val view: ViewBinding) : ViewHolder(view.root) {
open fun save(): T? = null open fun save(): T? = null
open fun restore(state: T) = Unit open fun restore(state: T) = Unit
open fun onViewAttachedToWindow() = Unit
open fun onViewDetachedFromWindow() = Unit
open fun onViewRecycled() = Unit
} }
abstract class NoStateAdapter<T : Any>(
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
) : BaseAdapter<T, Any>(0, diffCallback)
/** Creates a new shared pool, using the supplied lambda as a constructor. // Based of the concept https://github.com/brahmkshatriya/echo/blob/main/app%2Fsrc%2Fmain%2Fjava%2Fdev%2Fbrahmkshatriya%2Fecho%2Fui%2Fadapters%2FMediaItemsContainerAdapter.kt#L108-L154
* class StateViewModel : ViewModel() {
* The reason for this complicated structure is that a pool should not be shared between contexts val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>()
* as it makes coil fuck up, and theming.
* */
fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit> =
WeakHashMap<Context, RecyclerView.RecycledViewPool>() to lambda
/** Sets the shared pool of the recyclerview */
fun RecyclerView.setRecycledViewPool(pool: Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>) {
val ctx = context ?: return
synchronized(pool.first) {
this.setRecycledViewPool(pool.first.getOrPut(ctx) {
RecyclerView.RecycledViewPool().apply(pool.second)
})
}
} }
/** Clears the shared pool of views */ abstract class NoStateAdapter<T : Any>(fragment: Fragment) : BaseAdapter<T, Any>(fragment, 0)
fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.RecycledViewPool.() -> Unit>.clear() {
synchronized(this.first) {
for (pool in this.first.values) {
pool?.clear()
}
}
}
/** /**
* BaseAdapter is a persistent state stored adapter that supports headers and footers. * BaseAdapter is a persistent state stored adapter that supports headers and footers.
@ -70,14 +49,13 @@ fun Pair<WeakHashMap<Context, RecyclerView.RecycledViewPool>, RecyclerView.Recyc
abstract class BaseAdapter< abstract class BaseAdapter<
T : Any, T : Any,
S : Any>( S : Any>(
fragment: Fragment,
val id: Int = 0, val id: Int = 0,
diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback() diffCallback: DiffUtil.ItemCallback<T> = BaseDiffCallback()
) : RecyclerView.Adapter<ViewHolderState<S>>() { ) : RecyclerView.Adapter<ViewHolderState<S>>() {
open val footers: Int = 0 open val footers: Int = 0
open val headers: Int = 0 open val headers: Int = 0
val immutableCurrentList: List<T> get() = mDiffer.currentList
fun getItem(position: Int): T { fun getItem(position: Int): T {
return mDiffer.currentList[position] return mDiffer.currentList[position]
} }
@ -107,33 +85,9 @@ abstract class BaseAdapter<
AsyncDifferConfig.Builder(diffCallback).build() AsyncDifferConfig.Builder(diffCallback).build()
) )
/** open fun submitList(list: List<T>?) {
* Instantly submits a **new and fresh** list. This means that no changes like moves are done as
* we assume the new list is not the same thing as the old list, nothing is shared.
*
* The views are rendered instantly as a result, so no fade/pop-ins or similar.
*
* Use `submitList` for general use, as that can reuse old views.
* */
open fun submitIncomparableList(list: List<T>?, commitCallback : Runnable? = null) {
// This leverages a quirk in the submitList function that has a fast case for null arrays
// What this implies is that as long as we do a double submit we can ensure no pop-ins,
// as the changes are the entire list instead of calculating deltas
submitList(null)
submitList(list, commitCallback)
}
/**
* @param commitCallback Optional runnable that is executed when the List is committed, if it is committed.
* This is needed for some tasks as submitList will use a background thread for diff
* */
open fun submitList(list: Collection<T>?, commitCallback : Runnable? = null) {
// deep copy at least the top list, because otherwise adapter can go crazy // deep copy at least the top list, because otherwise adapter can go crazy
if (list.isNullOrEmpty()) { mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList()
} else {
mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback)
}
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
@ -147,25 +101,16 @@ abstract class BaseAdapter<
open fun onBindFooter(holder: ViewHolderState<S>) = Unit open fun onBindFooter(holder: ViewHolderState<S>) = Unit
open fun onBindHeader(holder: ViewHolderState<S>) = Unit open fun onBindHeader(holder: ViewHolderState<S>) = Unit
open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError() open fun onCreateContent(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateCustomContent(
parent: ViewGroup,
viewType: Int
) = onCreateContent(parent)
open fun onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError() open fun onCreateFooter(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateCustomFooter(
parent: ViewGroup,
viewType: Int
) = onCreateFooter(parent)
open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError() open fun onCreateHeader(parent: ViewGroup): ViewHolderState<S> = throw NotImplementedError()
open fun onCreateCustomHeader(
parent: ViewGroup,
viewType: Int
) = onCreateHeader(parent)
override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {} override fun onViewAttachedToWindow(holder: ViewHolderState<S>) {
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {} holder.onViewAttachedToWindow()
}
override fun onViewDetachedFromWindow(holder: ViewHolderState<S>) {
holder.onViewDetachedFromWindow()
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun save(recyclerView: RecyclerView) { fun save(recyclerView: RecyclerView) {
@ -176,20 +121,21 @@ abstract class BaseAdapter<
} }
} }
fun clearState() { fun clear() {
layoutManagerStates[id]?.clear() stateViewModel.layoutManagerStates[id]?.clear()
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun getState(holder: ViewHolderState<S>): S? = private fun getState(holder: ViewHolderState<S>): S? =
layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
private fun setState(holder: ViewHolderState<S>) { private fun setState(holder: ViewHolderState<S>) {
if(id == 0) return if(id == 0) return
if (!layoutManagerStates.contains(id)) {
layoutManagerStates[id] = HashMap() if (!stateViewModel.layoutManagerStates.contains(id)) {
stateViewModel.layoutManagerStates[id] = HashMap()
} }
layoutManagerStates[id]?.let { map -> stateViewModel.layoutManagerStates[id]?.let { map ->
map[holder.absoluteAdapterPosition] = holder.save() map[holder.absoluteAdapterPosition] = holder.save()
} }
} }
@ -212,40 +158,30 @@ abstract class BaseAdapter<
super.onDetachedFromRecyclerView(recyclerView) super.onDetachedFromRecyclerView(recyclerView)
} }
open fun customContentViewType(item: T): Int = 0
open fun customFooterViewType(): Int = 0
open fun customHeaderViewType(): Int = 0
final override fun getItemViewType(position: Int): Int { final override fun getItemViewType(position: Int): Int {
if (position < headers) { if (position < headers) {
return HEADER or customHeaderViewType() return HEADER
} }
val realPosition = position - headers if (position - headers >= mDiffer.currentList.size) {
if (realPosition >= mDiffer.currentList.size) { return FOOTER
return FOOTER or customFooterViewType()
} }
return CONTENT or customContentViewType(getItem(realPosition))
return CONTENT
} }
private val stateViewModel: StateViewModel by fragment.viewModels()
final override fun onViewRecycled(holder: ViewHolderState<S>) { final override fun onViewRecycled(holder: ViewHolderState<S>) {
setState(holder) setState(holder)
onClearView(holder) holder.onViewRecycled()
super.onViewRecycled(holder) super.onViewRecycled(holder)
} }
/** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data.
*
* If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources.
*
* Use this with `clearImage`
* */
open fun onClearView(holder: ViewHolderState<S>) {}
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> { final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState<S> {
return when (viewType and TYPE_MASK) { return when (viewType) {
CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK) CONTENT -> onCreateContent(parent)
HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK) HEADER -> onCreateHeader(parent)
FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK) FOOTER -> onCreateFooter(parent)
else -> throw NotImplementedError() else -> throw NotImplementedError()
} }
} }
@ -260,7 +196,7 @@ abstract class BaseAdapter<
super.onBindViewHolder(holder, position, payloads) super.onBindViewHolder(holder, position, payloads)
return return
} }
when (getItemViewType(position) and TYPE_MASK) { when (getItemViewType(position)) {
CONTENT -> { CONTENT -> {
val realPosition = position - headers val realPosition = position - headers
val item = getItem(realPosition) val item = getItem(realPosition)
@ -278,7 +214,7 @@ abstract class BaseAdapter<
} }
final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) { final override fun onBindViewHolder(holder: ViewHolderState<S>, position: Int) {
when (getItemViewType(position) and TYPE_MASK) { when (getItemViewType(position)) {
CONTENT -> { CONTENT -> {
val realPosition = position - headers val realPosition = position - headers
val item = getItem(realPosition) val item = getItem(realPosition)
@ -300,20 +236,9 @@ abstract class BaseAdapter<
} }
companion object { companion object {
val layoutManagerStates = hashMapOf<Int, HashMap<Int, Any?>>() private const val HEADER: Int = 1
fun clearImage(image: ImageView?) { private const val FOOTER: Int = 2
image?.dispose() private const val CONTENT: Int = 0
}
// Use the lowermost MASK_SIZE bits for the custom content,
// use the uppermost 32 - MASK_SIZE to the type
private const val MASK_SIZE = 28
private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1
private const val TYPE_MASK = CUSTOM_MASK.inv()
const val HEADER: Int = 3 shl MASK_SIZE
const val FOOTER: Int = 2 shl MASK_SIZE
/** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */
const val CONTENT: Int = 1 shl MASK_SIZE
} }
} }
@ -323,5 +248,5 @@ class BaseDiffCallback<T : Any>(
) : DiffUtil.ItemCallback<T>() { ) : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem) override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem) override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem)
override fun getChangePayload(oldItem: T, newItem: T): Any? = Any() override fun getChangePayload(oldItem: T, newItem: T): Any = Any()
} }

View file

@ -1,278 +0,0 @@
package com.lagradost.cloudstream3.ui
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceFragmentCompat
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding
import com.lagradost.cloudstream3.utils.txt
/**
* A base Fragment class that simplifies ViewBinding usage and handles view inflation safely.
*
* This class allows two modes of creating ViewBinding:
* 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes.
* 2. Bind: Using `bind()` on an existing root view.
*
* It also provides hooks for:
* - Safe initialization of the binding (`onBindingCreated`)
* - Automatic padding adjustment for system bars (`fixPadding`)
* - Optional layout resource selection via `pickLayout()`
*
* @param T The type of ViewBinding for this Fragment.
* @param bindingCreator The strategy used to create the binding instance.
*/
private interface BaseFragmentHelper<T : ViewBinding> {
val bindingCreator: BaseFragment.BindingCreator<T>
var _binding: T?
val binding: T? get() = _binding
fun createBinding(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val layoutId = pickLayout()
val root: View? = layoutId?.let { inflater.inflate(it, container, false) }
_binding = try {
when (val creator = bindingCreator) {
is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false)
is BaseFragment.BindingCreator.Bind -> {
if (root != null) creator.fn(root)
else throw IllegalStateException("Root view is null for bind()")
}
}
} catch (t: Throwable) {
showToast(
txt(R.string.unable_to_inflate, t.message ?: ""),
Toast.LENGTH_LONG
)
logError(t)
null
}
return _binding?.root ?: root
}
/**
* Called after the fragment's view has been created.
*
* This method is `final` to ensure that the binding is properly initialized and
* system bar padding adjustments are applied before any subclass logic runs.
* Subclasses should use [onBindingCreated] instead of overriding this method directly.
*/
fun onViewReady(view: View, savedInstanceState: Bundle?) {
fixLayout(view)
binding?.let { onBindingCreated(it, savedInstanceState) }
}
/**
* Called when the binding is safely created and view is ready.
* Can be overridden to provide fragment-specific initialization.
*
* @param binding The safely created ViewBinding.
* @param savedInstanceState Saved state bundle or null.
*/
fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {
onBindingCreated(binding)
}
/**
* Called when the binding is safely created and view is ready.
* Overload without savedInstanceState for convenience.
*
* @param binding The safely created ViewBinding.
*/
fun onBindingCreated(binding: T) {}
/**
* Pick a layout resource ID for the fragment.
*
* Return `null` by default. Override to provide a layout resource when using
* `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`.
*
* @return Layout resource ID or null.
*/
@LayoutRes
fun pickLayout(): Int? = null
/**
* Ensures the layout of the root view is correctly adjusted for the current configuration.
*
* This may include applying padding for system bars, adjusting insets, or performing other
* layout updates. `fixLayout` should remain idempotent, as it can be called multiple
* times on the same view, such as during configuration changes (e.g. device rotation) or when
* the view is recreated.
*
* @param view The root view to adjust.
*/
fun fixLayout(view: View)
}
abstract class BaseFragment<T : ViewBinding>(
override val bindingCreator: BindingCreator<T>
) : Fragment(), BaseFragmentHelper<T> {
override var _binding: T? = null
/** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */
fun dispatchBackPressed() {
try {
activity?.onBackPressedDispatcher?.onBackPressed()
} catch (_: IllegalStateException) {
// FragmentManager is already executing transactions, so try again
delayedDispatchBackPressed(5)
} catch (t: Throwable) {
logError(t)
}
}
/** Recursive back press when available */
private fun delayedDispatchBackPressed(remaining: Int) {
if (remaining <= 0) return
binding?.root?.postDelayed({
try {
activity?.onBackPressedDispatcher?.onBackPressed()
} catch (_: IllegalStateException) {
// FragmentManager is already executing transactions, so try again
delayedDispatchBackPressed(remaining - 1)
} catch (t: Throwable) {
logError(t)
}
}, 200)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = createBinding(inflater, container, savedInstanceState)
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onViewReady(view, savedInstanceState)
}
/**
* Called when the device configuration changes (e.g., orientation).
* Re-applies system bar padding fixes to the root view to ensure it
* readjusts for orientation changes.
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
view?.let { fixLayout(it) }
}
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/**
* Sealed class representing the two strategies for creating a ViewBinding instance.
*/
sealed class BindingCreator<T : ViewBinding> {
/**
* Use the standard inflate() method for creating the binding.
*
* @param fn Lambda that inflates the binding.
*/
class Inflate<T : ViewBinding>(
val fn: (LayoutInflater, ViewGroup?, Boolean) -> T
) : BindingCreator<T>()
/**
* Use bind() on an existing root view to create the binding. This should
* be used if you are differing per device layouts, such as different
* layouts for TV and Phone.
*
* @param fn Lambda that binds the root view.
*/
class Bind<T : ViewBinding>(
val fn: (View) -> T
) : BindingCreator<T>()
}
}
abstract class BaseDialogFragment<T : ViewBinding>(
override val bindingCreator: BaseFragment.BindingCreator<T>
) : DialogFragment(), BaseFragmentHelper<T> {
override var _binding: T? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = createBinding(inflater, container, savedInstanceState)
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onViewReady(view, savedInstanceState)
}
/** @see [BaseFragment.onConfigurationChanged] for documentation. */
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
view?.let { fixLayout(it) }
}
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
abstract class BaseBottomSheetDialogFragment<T : ViewBinding>(
override val bindingCreator: BaseFragment.BindingCreator<T>
) : BottomSheetDialogFragment(), BaseFragmentHelper<T> {
override var _binding: T? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = createBinding(inflater, container, savedInstanceState)
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
onViewReady(view, savedInstanceState)
}
/** @see [BaseFragment.onConfigurationChanged] for documentation. */
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
view?.let { fixLayout(it) }
}
/** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setSystemBarsPadding()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
setSystemBarsPadding()
}
}

View file

@ -12,6 +12,9 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ListView import android.widget.ListView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaLoadOptions
import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaSeekOptions
@ -102,6 +105,9 @@ data class MetadataHolder(
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
UIController() { UIController() {
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
init { init {
view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
view.setOnClickListener { view.setOnClickListener {
@ -239,12 +245,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
.setPlayPosition(startAt) .setPlayPosition(startAt)
.setAutoplay(true) .setAutoplay(true)
.build() .build()
awaitLinks( awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) {
remoteMediaClient?.load(
mediaItem,
mediaLoadOptions
)
) {
loadMirror(index + 1) loadMirror(index + 1)
} }
} }
@ -298,13 +299,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val currentDuration = remoteMediaClient?.streamDuration val currentDuration = remoteMediaClient?.streamDuration
val currentPosition = remoteMediaClient?.approximateStreamPosition val currentPosition = remoteMediaClient?.approximateStreamPosition
if (currentDuration != null && currentPosition != null) if (currentDuration != null && currentPosition != null)
DataStoreHelper.setViewPosAndResume( DataStoreHelper.setViewPos(epData.id, currentPosition, currentDuration)
epData.id,
currentPosition,
currentDuration,
epData,
meta.episodes.getOrNull(index + 1)
)
} catch (t: Throwable) { } catch (t: Throwable) {
logError(t) logError(t)
} }
@ -320,7 +315,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val isSuccessful = safeApiCall { val isSuccessful = safeApiCall {
generator.generateLinks( generator.generateLinks(
clearCache = false, clearCache = false,
sourceTypes = LOADTYPE_CHROMECAST, allowedTypes = LOADTYPE_CHROMECAST,
callback = { callback = {
it.first?.let { link -> it.first?.let { link ->
currentLinks.add(link) currentLinks.add(link)
@ -328,9 +323,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
}, subtitleCallback = { }, subtitleCallback = {
currentSubs.add(it) currentSubs.add(it)
}, },
offset = 0, isCasting = true)
isCasting = true
)
} }
val sortedLinks = sortUrls(currentLinks) val sortedLinks = sortUrls(currentLinks)

View file

@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.ui
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.core.content.withStyledAttributes
import androidx.core.view.children import androidx.core.view.children
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -155,9 +154,10 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
init { init {
if (attrs != null) { if (attrs != null) {
context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { val attrsArray = intArrayOf(android.R.attr.columnWidth)
columnWidth = getDimensionPixelSize(0, -1) val array = context.obtainStyledAttributes(attrs, attrsArray)
} columnWidth = array.getDimensionPixelSize(0, -1)
array.recycle()
} }
layoutManager = manager layoutManager = manager

View file

@ -4,13 +4,17 @@ import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator import android.view.animation.AccelerateInterpolator
import android.view.animation.LinearInterpolator import android.view.animation.LinearInterpolator
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding
@ -22,9 +26,10 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.random.Random import kotlin.random.Random
class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>( class EasterEggMonkeFragment : Fragment() {
BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate)
) { private var _binding: FragmentEasterEggMonkeBinding? = null
private val binding get() = _binding!!
// planet of monks // planet of monks
private val monkeys: List<Int> = listOf( private val monkeys: List<Int> = listOf(
@ -46,20 +51,27 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
private val activeMonkeys = mutableListOf<ImageView>() private val activeMonkeys = mutableListOf<ImageView>()
private var spawningJob: Job? = null private var spawningJob: Job? = null
override fun fixLayout(view: View) = Unit override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentEasterEggMonkeBinding.inflate(layoutInflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) {
activity?.hideSystemUI() activity?.hideSystemUI()
spawningJob = lifecycleScope.launch { spawningJob = lifecycleScope.launch {
delay(1000) delay(1000)
while (isActive) { while (isActive) {
spawnMonkey(binding) spawnMonkey()
delay(500) delay(500)
} }
} }
} }
private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) { private fun spawnMonkey() {
val newMonkey = ImageView(context ?: return).apply { val newMonkey = ImageView(context ?: return).apply {
setImageResource(monkeys.random()) setImageResource(monkeys.random())
isVisible = true isVisible = true
@ -90,12 +102,12 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
} }
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) } newMonkey.setOnTouchListener { view, event -> handleTouch(view, event) }
startFloatingAnimation(newMonkey, binding) startFloatingAnimation(newMonkey)
} }
private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { private fun startFloatingAnimation(monkey: ImageView) {
val floatUpAnimator = ObjectAnimator.ofFloat( val floatUpAnimator = ObjectAnimator.ofFloat(
monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat() monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat()
).apply { ).apply {
@ -105,20 +117,19 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
floatUpAnimator.addListener(object : AnimatorListenerAdapter() { floatUpAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) { override fun onAnimationEnd(animation: Animator) {
// necessary check because binding becomes null but monkes are still moving until onDestroy()
if (_binding != null) {
binding.frame.removeView(monkey) binding.frame.removeView(monkey)
activeMonkeys.remove(monkey) activeMonkeys.remove(monkey)
} }
}
}) })
floatUpAnimator.start() floatUpAnimator.start()
monkey.tag = floatUpAnimator monkey.tag = floatUpAnimator
} }
private fun handleTouch( private fun handleTouch(view: View, event: MotionEvent): Boolean {
view: View,
event: MotionEvent,
binding: FragmentEasterEggMonkeBinding
): Boolean {
val monkey = view as ImageView val monkey = view as ImageView
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
@ -132,17 +143,17 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
monkey.y = event.rawY - monkey.height / 2 monkey.y = event.rawY - monkey.height / 2
// Check if monkey touches the screen edge // Check if monkey touches the screen edge
if (isTouchingEdge(monkey, binding)) { if (isTouchingEdge(monkey)) {
removeMonkey(monkey, binding) removeMonkey(monkey)
} }
return true return true
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (isTouchingEdge(monkey, binding)) { if (isTouchingEdge(monkey)) {
removeMonkey(monkey, binding) removeMonkey(monkey)
} else { } else {
startFloatingAnimation(monkey, binding) startFloatingAnimation(monkey)
} }
return true return true
} }
@ -150,12 +161,12 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
return false return false
} }
private fun isTouchingEdge(monkey: ImageView, binding: FragmentEasterEggMonkeBinding): Boolean { private fun isTouchingEdge(monkey: ImageView): Boolean {
return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width || return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width ||
monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height
} }
private fun removeMonkey(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { private fun removeMonkey(monkey: ImageView) {
// Fade out and remove the monkey // Fade out and remove the monkey
ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply { ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply {
duration = 300 duration = 300
@ -173,5 +184,6 @@ class EasterEggMonkeFragment : BaseFragment<FragmentEasterEggMonkeBinding>(
super.onDestroyView() super.onDestroyView()
activity?.showSystemUI() activity?.showSystemUI()
spawningJob?.cancel() spawningJob?.cancel()
_binding = null
} }
} }

View file

@ -0,0 +1,42 @@
package com.lagradost.cloudstream3.ui
import android.graphics.Canvas
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class HeaderViewDecoration(private val customView: View) : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
customView.layout(parent.left, 0, parent.right, customView.measuredHeight)
for (i in 0 until parent.childCount) {
val view = parent.getChildAt(i)
if (parent.getChildAdapterPosition(view) == 0) {
c.save()
val height = customView.measuredHeight
val top = view.top - height
c.translate(0f, top.toFloat())
customView.draw(c)
c.restore()
break
}
}
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
if (parent.getChildAdapterPosition(view) == 0) {
customView.measure(
View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.AT_MOST),
View.MeasureSpec.makeMeasureSpec(parent.measuredHeight, View.MeasureSpec.AT_MOST)
)
outRect.set(0, customView.measuredHeight, 0, 0)
} else {
outRect.setEmpty()
}
}
}

View file

@ -7,12 +7,12 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.RelativeLayout import android.widget.RelativeLayout
import androidx.core.content.withStyledAttributes
import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import java.lang.ref.WeakReference
class MyMiniControllerFragment : MiniControllerFragment() { class MyMiniControllerFragment : MiniControllerFragment() {
@ -25,15 +25,26 @@ class MyMiniControllerFragment : MiniControllerFragment() {
// I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS
override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) {
if (currentColor == 0) {
context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) {
if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) {
currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0)
}
}
}
super.onInflate(context, attributeSet, bundle) super.onInflate(context, attributeSet, bundle)
// somehow this leaks and I really dont know why, it seams like if you go back to a fragment with this, it leaks????
if (currentColor == 0) {
WeakReference(
context.obtainStyledAttributes(
attributeSet,
R.styleable.CustomCast
)
).apply {
if (get()
?.hasValue(R.styleable.CustomCast_customCastBackgroundColor) == true
) {
currentColor =
get()
?.getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) ?: 0
}
get()?.recycle()
}.clear()
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View file

@ -1,12 +1,17 @@
package com.lagradost.cloudstream3.ui package com.lagradost.cloudstream3.ui
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.media3.common.util.UnstableApi
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
@ -14,18 +19,19 @@ import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.network.WebViewResolver
import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
class WebviewFragment : BaseFragment<FragmentWebviewBinding>(
BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate)
) {
override fun fixLayout(view: View) = Unit class WebviewFragment : Fragment() {
override fun onBindingCreated(binding: FragmentWebviewBinding) { var binding: FragmentWebviewBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val url = arguments?.getString(WEBVIEW_URL) ?: "".also { val url = arguments?.getString(WEBVIEW_URL) ?: "".also {
findNavController().popBackStack() findNavController().popBackStack()
} }
binding.webView.webViewClient = object : WebViewClient() { binding?.webView?.webViewClient = object : WebViewClient() {
@OptIn(UnstableApi::class)
override fun shouldOverrideUrlLoading( override fun shouldOverrideUrlLoading(
view: WebView?, view: WebView?,
request: WebResourceRequest? request: WebResourceRequest?
@ -40,17 +46,28 @@ class WebviewFragment : BaseFragment<FragmentWebviewBinding>(
return super.shouldOverrideUrlLoading(view, request) return super.shouldOverrideUrlLoading(view, request)
} }
} }
binding?.webView?.apply {
binding.webView.apply {
WebViewResolver.webViewUserAgent = settings.userAgentString WebViewResolver.webViewUserAgent = settings.userAgentString
addJavascriptInterface(RepoApi(activity), "RepoApi") addJavascriptInterface(RepoApi(activity), "RepoApi")
settings.javaScriptEnabled = true settings.javaScriptEnabled = true
settings.userAgentString = USER_AGENT settings.userAgentString = USER_AGENT
settings.domStorageEnabled = true settings.domStorageEnabled = true
// WebView.setWebContentsDebuggingEnabled(true)
loadUrl(url) loadUrl(url)
} }
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val localBinding = FragmentWebviewBinding.inflate(inflater, container, false)
binding = localBinding
// Inflate the layout for this fragment
return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false)
} }
companion object { companion object {

View file

@ -1,17 +1,16 @@
package com.lagradost.cloudstream3.ui.account package com.lagradost.cloudstream3.ui.account
import android.os.Build
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import coil3.transform.RoundedCornersTransformation import coil3.transform.RoundedCornersTransformation
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding
import com.lagradost.cloudstream3.databinding.AccountListItemBinding import com.lagradost.cloudstream3.databinding.AccountListItemBinding
import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
@ -20,39 +19,34 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
class AccountAdapter( class AccountAdapter(
private val accounts: List<DataStoreHelper.Account>,
private val accountSelectCallback: (DataStoreHelper.Account) -> Unit, private val accountSelectCallback: (DataStoreHelper.Account) -> Unit,
private val accountCreateCallback: (DataStoreHelper.Account) -> Unit, private val accountCreateCallback: (DataStoreHelper.Account) -> Unit,
private val accountEditCallback: (DataStoreHelper.Account) -> Unit, private val accountEditCallback: (DataStoreHelper.Account) -> Unit,
private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit
) : NoStateAdapter<DataStoreHelper.Account>() { ) : RecyclerView.Adapter<AccountAdapter.AccountViewHolder>() {
companion object { companion object {
const val VIEW_TYPE_SELECT_ACCOUNT = 0 const val VIEW_TYPE_SELECT_ACCOUNT = 0
const val VIEW_TYPE_ADD_ACCOUNT = 1
const val VIEW_TYPE_EDIT_ACCOUNT = 2 const val VIEW_TYPE_EDIT_ACCOUNT = 2
} }
inner class AccountViewHolder(private val binding: ViewBinding) :
RecyclerView.ViewHolder(binding.root) {
override val footers: Int = 1 fun bind(account: DataStoreHelper.Account?) {
var viewType = VIEW_TYPE_SELECT_ACCOUNT when (binding) {
override fun customContentViewType(item: DataStoreHelper.Account): Int {
return viewType
}
override fun onBindContent(
holder: ViewHolderState<Any>,
item: DataStoreHelper.Account,
position: Int
) {
when (val binding = holder.view) {
is AccountListItemBinding -> binding.apply { is AccountListItemBinding -> binding.apply {
if (account == null) return@apply
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
accountName.text = item.name accountName.text = account.name
accountImage.loadImage(item.image) accountImage.loadImage(account.image)
lockIcon.isVisible = item.lockPin != null lockIcon.isVisible = account.lockPin != null
outline.isVisible = !isTv && isLastUsedAccount outline.isVisible = !isTv && isLastUsedAccount
if (isTv) { if (isTv) {
@ -62,28 +56,18 @@ class AccountAdapter(
root.requestFocus() root.requestFocus()
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
root.foreground = ContextCompat.getDrawable( root.foreground = ContextCompat.getDrawable(
root.context, root.context,
R.drawable.outline_drawable R.drawable.outline_drawable
) )
}
} else { } else {
root.setOnLongClickListener { root.setOnLongClickListener {
showAccountEditDialog( showAccountEditDialog(
context = root.context, context = root.context,
account = item, account = account,
isNewAccount = false, isNewAccount = false,
accountEditCallback = { account -> accountEditCallback = { account -> accountEditCallback.invoke(account) },
accountEditCallback.invoke( accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
account
)
},
accountDeleteCallback = { account ->
accountDeleteCallback.invoke(
account
)
}
) )
true true
@ -91,20 +75,22 @@ class AccountAdapter(
} }
root.setOnClickListener { root.setOnClickListener {
accountSelectCallback.invoke(item) accountSelectCallback.invoke(account)
} }
} }
is AccountListItemEditBinding -> binding.apply { is AccountListItemEditBinding -> binding.apply {
if (account == null) return@apply
val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
accountName.text = item.name accountName.text = account.name
accountImage.loadImage(item.image) { accountImage.loadImage(account.image) {
RoundedCornersTransformation(10f) RoundedCornersTransformation(10f)
} }
lockIcon.isVisible = item.lockPin != null lockIcon.isVisible = account.lockPin != null
outline.isVisible = !isTv && isLastUsedAccount outline.isVisible = !isTv && isLastUsedAccount
if (isTv) { if (isTv) {
@ -114,47 +100,31 @@ class AccountAdapter(
root.requestFocus() root.requestFocus()
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
root.foreground = ContextCompat.getDrawable( root.foreground = ContextCompat.getDrawable(
root.context, root.context,
R.drawable.outline_drawable R.drawable.outline_drawable
) )
} }
}
root.setOnClickListener { root.setOnClickListener {
showAccountEditDialog( showAccountEditDialog(
context = root.context, context = root.context,
account = item, account = account,
isNewAccount = false, isNewAccount = false,
accountEditCallback = { account -> accountEditCallback.invoke(account) }, accountEditCallback = { account -> accountEditCallback.invoke(account) },
accountDeleteCallback = { account -> accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
accountDeleteCallback.invoke(
account
) )
} }
)
}
}
}
} }
override fun onBindFooter(holder: ViewHolderState<Any>) { is AccountListItemAddBinding -> binding.apply {
val binding = holder.view as? AccountListItemAddBinding ?: return
binding.apply {
root.setOnClickListener { root.setOnClickListener {
val accounts = this@AccountAdapter.immutableCurrentList
val remainingImages = val remainingImages =
DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
.mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) } .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet()
.toSet()
val image = val image =
DataStoreHelper.profileImages.indexOf( DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random())
remainingImages.randomOrNull()
?: DataStoreHelper.profileImages.random()
)
val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
val accountName = root.context.getString(R.string.account) val accountName = root.context.getString(R.string.account)
@ -174,20 +144,12 @@ class AccountAdapter(
} }
} }
} }
}
override fun onCreateFooter(parent: ViewGroup): ViewHolderState<Any> {
return ViewHolderState(
AccountListItemAddBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
} }
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder =
return ViewHolderState( AccountViewHolder(
when (viewType) { binding = when (viewType) {
VIEW_TYPE_SELECT_ACCOUNT -> { VIEW_TYPE_SELECT_ACCOUNT -> {
AccountListItemBinding.inflate( AccountListItemBinding.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
@ -195,7 +157,13 @@ class AccountAdapter(
false false
) )
} }
VIEW_TYPE_ADD_ACCOUNT -> {
AccountListItemAddBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
}
VIEW_TYPE_EDIT_ACCOUNT -> { VIEW_TYPE_EDIT_ACCOUNT -> {
AccountListItemEditBinding.inflate( AccountListItemEditBinding.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
@ -203,9 +171,28 @@ class AccountAdapter(
false false
) )
} }
else -> throw IllegalArgumentException("Invalid view type") else -> throw IllegalArgumentException("Invalid view type")
} }
) )
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
holder.bind(accounts.getOrNull(position))
}
var viewType = 0
override fun getItemViewType(position: Int): Int {
if (viewType != 0 && position != accounts.count()) {
return viewType
}
return when (position) {
accounts.count() -> VIEW_TYPE_ADD_ACCOUNT
else -> VIEW_TYPE_SELECT_ACCOUNT
}
}
override fun getItemCount(): Int {
return accounts.count() + 1
} }
} }

View file

@ -21,7 +21,7 @@ import coil3.ImageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.allowHardware import coil3.request.allowHardware
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
@ -392,6 +392,7 @@ object AccountHelper {
activity.observe(viewModel.accounts) { liveAccounts -> activity.observe(viewModel.accounts) { liveAccounts ->
recyclerView.adapter = AccountAdapter( recyclerView.adapter = AccountAdapter(
liveAccounts,
accountSelectCallback = { account -> accountSelectCallback = { account ->
viewModel.handleAccountSelect(account, activity) viewModel.handleAccountSelect(account, activity)
builder.dismissSafe() builder.dismissSafe()
@ -399,9 +400,7 @@ object AccountHelper {
accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) }, accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) },
accountEditCallback = { viewModel.handleAccountUpdate(it, activity) }, accountEditCallback = { viewModel.handleAccountUpdate(it, activity) },
accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) } accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) }
).apply { )
submitList(liveAccounts)
}
activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
// Scroll to current account (which is focused by default) // Scroll to current account (which is focused by default)

View file

@ -31,22 +31,20 @@ import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAut
import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.openActivity import com.lagradost.cloudstream3.utils.UIHelper.openActivity
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
class AccountSelectActivity : FragmentActivity(), BiometricCallback { class AccountSelectActivity : FragmentActivity(), BiometricCallback {
companion object {
var hasLoggedIn: Boolean = false
}
val accountViewModel: AccountViewModel by viewModels() val accountViewModel: AccountViewModel by viewModels()
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
loadThemes(this)
@Suppress("DEPRECATION")
window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground)
// Are we editing and coming from MainActivity? // Are we editing and coming from MainActivity?
val isEditingFromMainActivity = intent.getBooleanExtra( val isEditingFromMainActivity = intent.getBooleanExtra(
@ -54,22 +52,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
false false
) )
// Sometimes we start this activity when we have already logged in
// For example when using cloudstreamsearch://
// In those cases we want to just go to the main activity instantly
if (hasLoggedIn && !isEditingFromMainActivity) {
navigateToMainActivity()
return
}
loadThemes(this)
enableEdgeToEdgeCompat()
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val skipStartup = settingsManager.getBoolean( val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false
getString(R.string.skip_startup_account_select_key), false
) || accounts.count() <= 1 ) || accounts.count() <= 1
fun askBiometricAuth() { fun askBiometricAuth() {
@ -105,12 +89,10 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
accountViewModel.handleAccountSelect(currentAccount, this, true) accountViewModel.handleAccountSelect(currentAccount, this, true)
} else { } else {
if (accounts.count() > 1) { if (accounts.count() > 1) {
showToast( showToast(this, getString(
this, getString(
R.string.logged_account, R.string.logged_account,
currentAccount?.name currentAccount?.name
) ))
)
} }
navigateToMainActivity() navigateToMainActivity()
@ -123,12 +105,12 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
val binding = ActivityAccountSelectBinding.inflate(layoutInflater) val binding = ActivityAccountSelectBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
fixSystemBarsPadding(binding.root, padTop = false)
val recyclerView: AutofitRecyclerView = binding.accountRecyclerView val recyclerView: AutofitRecyclerView = binding.accountRecyclerView
observe(accountViewModel.accounts) { liveAccounts -> observe(accountViewModel.accounts) { liveAccounts ->
val adapter = AccountAdapter( val adapter = AccountAdapter(
liveAccounts,
// Handle the selected account // Handle the selected account
accountSelectCallback = { accountSelectCallback = {
accountViewModel.handleAccountSelect(it, this) accountViewModel.handleAccountSelect(it, this)
@ -136,6 +118,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) }, accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) },
accountEditCallback = { accountEditCallback = {
accountViewModel.handleAccountUpdate(it, this) accountViewModel.handleAccountUpdate(it, this)
// We came from MainActivity, return there // We came from MainActivity, return there
// and switch to the edited account // and switch to the edited account
if (isEditingFromMainActivity) { if (isEditingFromMainActivity) {
@ -144,9 +127,7 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
} }
}, },
accountDeleteCallback = { accountViewModel.handleAccountDelete(it,this) } accountDeleteCallback = { accountViewModel.handleAccountDelete(it,this) }
).apply { )
submitList(liveAccounts)
}
recyclerView.adapter = adapter recyclerView.adapter = adapter
@ -201,11 +182,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
askBiometricAuth() askBiometricAuth()
} }
@SuppressLint("UnsafeIntentLaunch")
private fun navigateToMainActivity() { private fun navigateToMainActivity() {
hasLoggedIn = true openActivity(MainActivity::class.java)
// We want to propagate any intent we get here to MainActivity since this is just an intermediary
openActivity(MainActivity::class.java, baseIntent = intent)
finish() // Finish the account selection activity finish() // Finish the account selection activity
} }

View file

@ -4,8 +4,8 @@ import android.content.Context
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper

View file

@ -1,6 +1,5 @@
package com.lagradost.cloudstream3.ui.download package com.lagradost.cloudstream3.ui.download
import android.annotation.SuppressLint
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
@ -8,18 +7,19 @@ import android.widget.CheckBox
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.VideoDownloadHelper
const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1 const val DOWNLOAD_ACTION_DELETE_FILE = 1
@ -27,7 +27,6 @@ const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
const val DOWNLOAD_ACTION_DOWNLOAD = 4 const val DOWNLOAD_ACTION_DOWNLOAD = 4
const val DOWNLOAD_ACTION_LONG_CLICK = 5 const val DOWNLOAD_ACTION_LONG_CLICK = 5
const val DOWNLOAD_ACTION_CANCEL_PENDING = 6
const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
const val DOWNLOAD_ACTION_LOAD_RESULT = 1 const val DOWNLOAD_ACTION_LOAD_RESULT = 1
@ -35,22 +34,22 @@ const val DOWNLOAD_ACTION_LOAD_RESULT = 1
sealed class VisualDownloadCached { sealed class VisualDownloadCached {
abstract val currentBytes: Long abstract val currentBytes: Long
abstract val totalBytes: Long abstract val totalBytes: Long
abstract val data: DownloadObjects.DownloadCached abstract val data: VideoDownloadHelper.DownloadCached
abstract var isSelected: Boolean abstract var isSelected: Boolean
data class Child( data class Child(
override val currentBytes: Long, override val currentBytes: Long,
override val totalBytes: Long, override val totalBytes: Long,
override val data: DownloadObjects.DownloadEpisodeCached, override val data: VideoDownloadHelper.DownloadEpisodeCached,
override var isSelected: Boolean, override var isSelected: Boolean,
) : VisualDownloadCached() ) : VisualDownloadCached()
data class Header( data class Header(
override val currentBytes: Long, override val currentBytes: Long,
override val totalBytes: Long, override val totalBytes: Long,
override val data: DownloadObjects.DownloadHeaderCached, override val data: VideoDownloadHelper.DownloadHeaderCached,
override var isSelected: Boolean, override var isSelected: Boolean,
val child: DownloadObjects.DownloadEpisodeCached?, val child: VideoDownloadHelper.DownloadEpisodeCached?,
val currentOngoingDownloads: Int, val currentOngoingDownloads: Int,
val totalDownloads: Int, val totalDownloads: Int,
) : VisualDownloadCached() ) : VisualDownloadCached()
@ -58,19 +57,19 @@ sealed class VisualDownloadCached {
data class DownloadClickEvent( data class DownloadClickEvent(
val action: Int, val action: Int,
val data: DownloadObjects.DownloadEpisodeCached val data: VideoDownloadHelper.DownloadEpisodeCached
) )
data class DownloadHeaderClickEvent( data class DownloadHeaderClickEvent(
val action: Int, val action: Int,
val data: DownloadObjects.DownloadHeaderCached val data: VideoDownloadHelper.DownloadHeaderCached
) )
class DownloadAdapter( class DownloadAdapter(
private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
private val onItemClickEvent: (DownloadClickEvent) -> Unit, private val onItemClickEvent: (DownloadClickEvent) -> Unit,
private val onItemSelectionChanged: (Int, Boolean) -> Unit, private val onItemSelectionChanged: (Int, Boolean) -> Unit,
) : NoStateAdapter<VisualDownloadCached>(DiffCallback()) { ) : ListAdapter<VisualDownloadCached, DownloadAdapter.DownloadViewHolder>(DiffCallback()) {
private var isMultiDeleteState: Boolean = false private var isMultiDeleteState: Boolean = false
@ -79,8 +78,18 @@ class DownloadAdapter(
private const val VIEW_TYPE_CHILD = 1 private const val VIEW_TYPE_CHILD = 1
} }
inner class DownloadViewHolder(
private val binding: ViewBinding
) : RecyclerView.ViewHolder(binding.root) {
private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) { fun bind(card: VisualDownloadCached?) {
when (binding) {
is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header)
is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child)
}
}
private fun bindHeader(card: VisualDownloadCached.Header?) {
if (binding !is DownloadHeaderEpisodeBinding || card == null) return if (binding !is DownloadHeaderEpisodeBinding || card == null) return
val data = card.data val data = card.data
@ -90,16 +99,12 @@ class DownloadAdapter(
setOnClickListener { setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id) toggleIsChecked(deleteCheckbox, data.id)
} }
}
setOnLongClickListener { setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id) toggleIsChecked(deleteCheckbox, data.id)
true true
} }
} else {
setOnLongClickListener {
onItemSelectionChanged.invoke(data.id, true)
true
}
}
} }
downloadHeaderPoster.apply { downloadHeaderPoster.apply {
@ -125,7 +130,7 @@ class DownloadAdapter(
} }
} }
downloadHeaderTitle.text = data.name downloadHeaderTitle.text = data.name
val formattedSize = formatShortFileSize(binding.root.context, card.totalBytes) val formattedSize = formatShortFileSize(itemView.context, card.totalBytes)
if (card.child != null) { if (card.child != null) {
handleChildDownload(card, formattedSize) handleChildDownload(card, formattedSize)
@ -152,26 +157,15 @@ class DownloadAdapter(
downloadHeaderGotoChild.isVisible = false downloadHeaderGotoChild.isVisible = false
val posDur = getViewPos(card.data.id) val posDur = getViewPos(card.data.id)
watchProgressContainer.isVisible = true
downloadHeaderEpisodeProgress.apply { downloadHeaderEpisodeProgress.apply {
isVisible = posDur != null isVisible = posDur != null
posDur?.let { posDur?.let {
val max = (it.duration / 1000).toInt() val visualPos = it.fixVisual()
val progress = (it.position / 1000).toInt() max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
if (max > 0 && progress >= (0.95 * max).toInt()) {
playIcon.setImageResource(R.drawable.ic_baseline_check_24)
isVisible = false
} else {
playIcon.setImageResource(R.drawable.netflix_play)
this.max = max
this.progress = progress
isVisible = true
}
} }
} }
downloadButton.resetView()
val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) { if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading // We do this here instead if we are finished downloading
@ -189,6 +183,7 @@ class DownloadAdapter(
} else { } else {
// We need to make sure we restore the correct progress // We need to make sure we restore the correct progress
// when we refresh data in the adapter. // when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let { val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it) ContextCompat.getDrawable(downloadButton.context, it)
} }
@ -200,7 +195,6 @@ class DownloadAdapter(
) )
} }
downloadHeaderInfo.isVisible = true
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
downloadButton.isVisible = !isMultiDeleteState downloadButton.isVisible = !isMultiDeleteState
@ -220,14 +214,11 @@ class DownloadAdapter(
card: VisualDownloadCached.Header, card: VisualDownloadCached.Header,
formattedSize: String formattedSize: String
) { ) {
downloadButton.resetViewData()
watchProgressContainer.isVisible = false
downloadButton.isVisible = false downloadButton.isVisible = false
downloadHeaderEpisodeProgress.isVisible = false downloadHeaderEpisodeProgress.isVisible = false
downloadHeaderGotoChild.isVisible = !isMultiDeleteState downloadHeaderGotoChild.isVisible = !isMultiDeleteState
try { try {
downloadHeaderInfo.isVisible = true
downloadHeaderInfo.text = downloadHeaderInfo.text =
downloadHeaderInfo.context.getString(R.string.extra_info_format).format( downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
card.totalDownloads, card.totalDownloads,
@ -254,7 +245,7 @@ class DownloadAdapter(
} }
} }
private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) { private fun bindChild(card: VisualDownloadCached.Child?) {
if (binding !is DownloadChildEpisodeBinding || card == null) return if (binding !is DownloadChildEpisodeBinding || card == null) return
val data = card.data val data = card.data
@ -263,22 +254,12 @@ class DownloadAdapter(
downloadChildEpisodeProgress.apply { downloadChildEpisodeProgress.apply {
isVisible = posDur != null isVisible = posDur != null
posDur?.let { posDur?.let {
val max = (it.duration / 1000).toInt() val visualPos = it.fixVisual()
val progress = (it.position / 1000).toInt() max = (visualPos.duration / 1000).toInt()
progress = (visualPos.position / 1000).toInt()
if (max > 0 && progress >= (0.95 * max).toInt()) {
downloadChildEpisodePlay.setImageResource(R.drawable.ic_baseline_check_24)
isVisible = false
} else {
downloadChildEpisodePlay.setImageResource(R.drawable.play_button_transparent)
this.max = max
this.progress = progress
isVisible = true
}
} }
} }
downloadButton.resetView()
val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
if (status == DownloadStatusTell.IsDone) { if (status == DownloadStatusTell.IsDone) {
// We do this here instead if we are finished downloading // We do this here instead if we are finished downloading
@ -297,6 +278,7 @@ class DownloadAdapter(
} else { } else {
// We need to make sure we restore the correct progress // We need to make sure we restore the correct progress
// when we refresh data in the adapter. // when we refresh data in the adapter.
downloadButton.resetView()
val drawable = downloadButton.getDrawableFromStatus(status)?.let { val drawable = downloadButton.getDrawableFromStatus(status)?.let {
ContextCompat.getDrawable(downloadButton.context, it) ContextCompat.getDrawable(downloadButton.context, it)
} }
@ -330,10 +312,6 @@ class DownloadAdapter(
setOnClickListener { setOnClickListener {
toggleIsChecked(deleteCheckbox, data.id) toggleIsChecked(deleteCheckbox, data.id)
} }
setOnLongClickListener {
toggleIsChecked(deleteCheckbox, data.id)
true
}
} }
else -> { else -> {
@ -345,14 +323,14 @@ class DownloadAdapter(
) )
) )
} }
}
}
setOnLongClickListener { setOnLongClickListener {
onItemSelectionChanged.invoke(data.id, true) toggleIsChecked(deleteCheckbox, data.id)
true true
} }
} }
}
}
if (isMultiDeleteState) { if (isMultiDeleteState) {
deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
@ -366,47 +344,50 @@ class DownloadAdapter(
} }
} }
} }
}
override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState<Any> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
val binding = when (viewType) { val binding = when (viewType) {
VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false) VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false)
VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
else -> throw IllegalArgumentException("Invalid view type") else -> throw IllegalArgumentException("Invalid view type")
} }
return ViewHolderState(binding) return DownloadViewHolder(binding)
} }
override fun onBindContent( override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
holder: ViewHolderState<Any>, holder.bind(getItem(position))
item: VisualDownloadCached,
position: Int
) {
when (val binding = holder.view) {
is DownloadHeaderEpisodeBinding -> bindHeader(
binding,
item as? VisualDownloadCached.Header
)
is DownloadChildEpisodeBinding -> bindChild(
binding,
item as? VisualDownloadCached.Child
)
}
} }
override fun customContentViewType(item: VisualDownloadCached): Int { override fun getItemViewType(position: Int): Int {
return when (item) { return when (getItem(position)) {
is VisualDownloadCached.Child -> VIEW_TYPE_CHILD is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
is VisualDownloadCached.Header -> VIEW_TYPE_HEADER is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
else -> throw IllegalArgumentException("Invalid data type at position $position")
} }
} }
@SuppressLint("NotifyDataSetChanged")
fun setIsMultiDeleteState(value: Boolean) { fun setIsMultiDeleteState(value: Boolean) {
if (isMultiDeleteState == value) return if (isMultiDeleteState == value) return
isMultiDeleteState = value isMultiDeleteState = value
notifyDataSetChanged() // This is shit, but what can you do? notifyItemRangeChanged(0, itemCount)
}
fun notifyAllSelected() {
currentList.indices.forEach { index ->
if (!currentList[index].isSelected) {
notifyItemChanged(index)
}
}
}
fun notifySelectionStates() {
currentList.indices.forEach { index ->
if (currentList[index].isSelected) {
notifyItemChanged(index)
}
}
} }
private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) {

View file

@ -4,8 +4,8 @@ import android.content.DialogInterface
import android.net.Uri import android.net.Uri
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
@ -18,9 +18,8 @@ import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
object DownloadButtonSetup { object DownloadButtonSetup {
@ -83,7 +82,7 @@ object DownloadButtonSetup {
} else { } else {
val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id)
if (pkg != null) { if (pkg != null) {
DownloadQueueManager.addToQueue(pkg.toWrapper()) VideoDownloadManager.downloadFromResumeUsingWorker(ctx, pkg)
} else { } else {
VideoDownloadManager.downloadEvent.invoke( VideoDownloadManager.downloadEvent.invoke(
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume)
@ -96,7 +95,7 @@ object DownloadButtonSetup {
DOWNLOAD_ACTION_LONG_CLICK -> { DOWNLOAD_ACTION_LONG_CLICK -> {
activity?.let { act -> activity?.let { act ->
val length = val length =
VideoDownloadManager.getDownloadFileInfo( VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
act, act,
click.data.id click.data.id
)?.fileLength )?.fileLength
@ -111,31 +110,24 @@ object DownloadButtonSetup {
} }
} }
DOWNLOAD_ACTION_CANCEL_PENDING -> {
DownloadQueueManager.cancelDownload(id)
}
DOWNLOAD_ACTION_PLAY_FILE -> { DOWNLOAD_ACTION_PLAY_FILE -> {
activity?.let { act -> activity?.let { act ->
val parent = getKey<DownloadObjects.DownloadHeaderCached>( val parent = getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
click.data.parentId.toString() click.data.parentId.toString()
) ?: return ) ?: return
val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
?.mapNotNull { ?.mapNotNull {
getKey<DownloadObjects.DownloadEpisodeCached>(it) getKey<VideoDownloadHelper.DownloadEpisodeCached>(it)
} }
?.filter { it.parentId == click.data.parentId } ?.filter { it.parentId == click.data.parentId }
val items = mutableListOf<ExtractorUri>() val items = mutableListOf<ExtractorUri>()
val allRelevantEpisodes = val allRelevantEpisodes = episodes?.sortedWith(compareBy<VideoDownloadHelper.DownloadEpisodeCached> { it.season ?: 0 }.thenBy { it.episode })
episodes?.sortedWith(compareBy<DownloadObjects.DownloadEpisodeCached> {
it.season ?: 0
}.thenBy { it.episode })
allRelevantEpisodes?.forEach { allRelevantEpisodes?.forEach {
val keyInfo = getKey<DownloadObjects.DownloadedFileInfo>( val keyInfo = getKey<VideoDownloadManager.DownloadedFileInfo>(
VideoDownloadManager.KEY_DOWNLOAD_INFO, VideoDownloadManager.KEY_DOWNLOAD_INFO,
it.id.toString() it.id.toString()
) ?: return@forEach ) ?: return@forEach
@ -149,7 +141,7 @@ object DownloadButtonSetup {
uri = Uri.EMPTY, uri = Uri.EMPTY,
id = it.id, id = it.id,
parentId = it.parentId, parentId = it.parentId,
name = it.name ?: act.getString(R.string.downloaded_file), name = act.getString(R.string.downloaded_file),
season = it.season, season = it.season,
episode = it.episode, episode = it.episode,
headerName = parent.name, headerName = parent.name,
@ -162,8 +154,7 @@ object DownloadButtonSetup {
} }
act.navigate( act.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance( R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator(items), DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) }
items.indexOfFirst { it.id == click.data.id }
) )
) )
} }

View file

@ -1,35 +1,32 @@
package com.lagradost.cloudstream3.ui.download package com.lagradost.cloudstream3.ui.download
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.core.view.isGone import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE 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.isLayout
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>( class DownloadChildFragment : Fragment() {
BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate) private lateinit var downloadsViewModel: DownloadViewModel
) { private var binding: FragmentChildDownloadsBinding? = null
private val downloadViewModel: DownloadViewModel by activityViewModels()
companion object { companion object {
fun newInstance(headerName: String, folder: String): Bundle { fun newInstance(headerName: String, folder: String): Bundle {
@ -42,104 +39,99 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
override fun onDestroyView() { override fun onDestroyView() {
activity?.detachBackPressedCallback("Downloads") activity?.detachBackPressedCallback("Downloads")
downloadViewModel.clearChildren() binding = null
super.onDestroyView() super.onDestroyView()
} }
override fun fixLayout(view: View) { override fun onCreateView(
fixSystemBarsPadding( inflater: LayoutInflater,
view, container: ViewGroup?,
padBottom = isLandscape(), savedInstanceState: Bundle?
padLeft = isLayout(TV or EMULATOR) ): View {
) downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root
} }
override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
/**
* We never want to retain multi-delete state
* when navigating to downloads. Setting this state
* immediately can sometimes result in the observer
* not being notified in time to update the UI.
*
* By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadsViewModel.setIsMultiDeleteState(false)
}
/**
* We have to make sure selected items are
* cleared here as well so we don't run in an
* inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
val folder = arguments?.getString("folder") val folder = arguments?.getString("folder")
val name = arguments?.getString("name") val name = arguments?.getString("name")
if (folder == null) { if (folder == null) {
dispatchBackPressed() activity?.onBackPressedDispatcher?.onBackPressed()
return return
} }
context?.let { downloadViewModel.updateChildList(it, folder) } binding?.downloadChildToolbar?.apply {
binding.downloadChildToolbar.apply {
title = name title = name
if (isLayout(PHONE or EMULATOR)) { if (isLayout(PHONE or EMULATOR)) {
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
setNavigationOnClickListener { setNavigationOnClickListener {
dispatchBackPressed() activity?.onBackPressedDispatcher?.onBackPressed()
} }
} }
setAppBarNoScrollFlagsOnTV() setAppBarNoScrollFlagsOnTV()
} }
binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
observe(downloadViewModel.childCards) { cards -> observe(downloadsViewModel.childCards) {
when (cards) { if (it.isEmpty()) {
is Resource.Success -> { activity?.onBackPressedDispatcher?.onBackPressed()
if (cards.value.isEmpty()) { return@observe
dispatchBackPressed()
}
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value)
} }
else -> { (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it)
(binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null)
} }
} observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
} val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
}
binding.apply {
btnDelete.setOnClickListener { view ->
downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener)
}
btnCancel.setOnClickListener {
downloadViewModel.cancelSelection()
}
btnToggleAll.setOnClickListener {
val allSelected = downloadViewModel.isAllChildrenSelected()
if (allSelected) {
downloadViewModel.clearSelectedItems()
} else {
downloadViewModel.selectAllChildren()
}
}
}
observeNullable(downloadViewModel.selectedItemIds) { selection ->
val isMultiDeleteState = selection != null
val adapter = binding.downloadChildList.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState) adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
binding.downloadChildToolbar.isGone = isMultiDeleteState if (!isMultiDeleteState) {
if (selection == null) {
activity?.detachBackPressedCallback("Downloads") activity?.detachBackPressedCallback("Downloads")
return@observeNullable downloadsViewModel.clearSelectedItems()
binding?.downloadChildToolbar?.isVisible = true
} }
activity?.attachBackPressedCallback("Downloads") {
downloadViewModel.cancelSelection()
} }
observe(downloadsViewModel.selectedBytes) {
updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
}
observe(downloadsViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) binding?.btnDelete?.isVisible = it.isNotEmpty()
binding?.selectItemsText?.isVisible = it.isEmpty()
binding.btnDelete.isVisible = selection.isNotEmpty() val allSelected = downloadsViewModel.isAllSelected()
binding.selectItemsText.isVisible = selection.isEmpty()
val allSelected = downloadViewModel.isAllChildrenSelected()
if (allSelected) { if (allSelected) {
binding.btnToggleAll.setText(R.string.deselect_all) binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding.btnToggleAll.setText(R.string.select_all) } else binding?.btnToggleAll?.setText(R.string.select_all)
} }
val adapter = DownloadAdapter( val adapter = DownloadAdapter(
@ -147,18 +139,18 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
{ click -> { click ->
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx -> context?.let { ctx ->
downloadViewModel.handleSingleDelete(ctx, click.data.id) downloadsViewModel.handleSingleDelete(ctx, click.data.id)
} }
} else handleDownloadClick(click) } else handleDownloadClick(click)
}, },
{ itemId, isChecked -> { itemId, isChecked ->
if (isChecked) { if (isChecked) {
downloadViewModel.addSelected(itemId) downloadsViewModel.addSelected(itemId)
} else downloadViewModel.removeSelected(itemId) } else downloadsViewModel.removeSelected(itemId)
} }
) )
binding.downloadChildList.apply { binding?.downloadChildList?.apply {
setHasFixedSize(true) setHasFixedSize(true)
setItemViewCacheSize(20) setItemViewCacheSize(20)
this.adapter = adapter this.adapter = adapter
@ -168,6 +160,43 @@ class DownloadChildFragment : BaseFragment<FragmentChildDownloadsBinding>(
nextDown = FOCUS_SELF, nextDown = FOCUS_SELF,
) )
} }
context?.let { downloadsViewModel.updateChildList(it, folder) }
fixPaddingStatusbar(binding?.downloadChildRoot)
}
private fun handleSelectedChange(selected: MutableSet<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadChildToolbar?.isVisible = false
activity?.attachBackPressedCallback("Downloads") {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnDelete?.setOnClickListener {
context?.let { ctx ->
downloadsViewModel.handleMultiDelete(ctx)
}
}
binding?.btnCancel?.setOnClickListener {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnToggleAll?.setOnClickListener {
val allSelected = downloadsViewModel.isAllSelected()
val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
if (allSelected) {
adapter?.notifySelectionStates()
downloadsViewModel.clearSelectedItems()
} else {
adapter?.notifyAllSelected()
downloadsViewModel.selectAllItems()
}
}
downloadsViewModel.setIsMultiDeleteState(true)
}
} }
private fun updateDeleteButton(count: Int, selectedBytes: Long) { private fun updateDeleteButton(count: Int, selectedBytes: Long) {

View file

@ -7,8 +7,13 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
@ -17,28 +22,23 @@ import androidx.annotation.StringRes
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.activityViewModels import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
import com.lagradost.cloudstream3.databinding.StreamInputBinding import com.lagradost.cloudstream3.databinding.StreamInputBinding
import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel
import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator import com.lagradost.cloudstream3.ui.player.LinkGenerator
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
@ -46,7 +46,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPres
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
@ -54,12 +54,9 @@ import java.net.URI
const val DOWNLOAD_NAVIGATE_TO = "downloadpage" const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
class DownloadFragment : BaseFragment<FragmentDownloadsBinding>( class DownloadFragment : Fragment() {
BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate) private lateinit var downloadsViewModel: DownloadViewModel
) { private var binding: FragmentDownloadsBinding? = null
private val downloadViewModel: DownloadViewModel by activityViewModels()
private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels()
private fun View.setLayoutWidth(weight: Long) { private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams( val param = LinearLayout.LayoutParams(
@ -72,135 +69,120 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
override fun onDestroyView() { override fun onDestroyView() {
activity?.detachBackPressedCallback("Downloads") activity?.detachBackPressedCallback("Downloads")
binding = null
super.onDestroyView() super.onDestroyView()
} }
override fun fixLayout(view: View) { override fun onCreateView(
fixSystemBarsPadding( inflater: LayoutInflater,
view, container: ViewGroup?,
padBottom = isLandscape(), savedInstanceState: Bundle?
padLeft = isLayout(TV or EMULATOR) ): View {
) downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root
} }
override fun onBindingCreated(binding: FragmentDownloadsBinding) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
hideKeyboard() hideKeyboard()
binding.downloadAppbar.setAppBarNoScrollFlagsOnTV() binding?.downloadAppbar?.setAppBarNoScrollFlagsOnTV()
binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
observe(downloadViewModel.headerCards) { cards -> /**
when (cards) { * We never want to retain multi-delete state
is Resource.Success -> { * when navigating to downloads. Setting this state
(binding.downloadList.adapter as? DownloadAdapter)?.submitList(cards.value) * immediately can sometimes result in the observer
binding.textNoDownloads.isVisible = cards.value.isEmpty() * not being notified in time to update the UI.
binding.downloadLoading.isVisible = false *
binding.downloadList.isVisible = true * By posting to the main looper, we ensure that this
* operation is executed after the view has been fully created
* and all initializations are completed, allowing the
* observer to properly receive and handle the state change.
*/
Handler(Looper.getMainLooper()).post {
downloadsViewModel.setIsMultiDeleteState(false)
} }
is Resource.Loading -> { /**
binding.downloadList.isVisible = false * We have to make sure selected items are
binding.downloadLoading.isVisible = true * cleared here as well so we don't run in an
} * inconsistent state where selected items do
* not match the multi delete state we are in.
*/
downloadsViewModel.clearSelectedItems()
is Resource.Failure -> { observe(downloadsViewModel.headerCards) {
binding.downloadList.isVisible = true (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
binding.downloadLoading.isVisible = false binding?.downloadLoading?.isVisible = false
binding?.textNoDownloads?.isVisible = it.isEmpty()
} }
} observe(downloadsViewModel.availableBytes) {
}
observe(downloadViewModel.availableBytes) {
updateStorageInfo( updateStorageInfo(
binding.root.context, view.context,
it, it,
R.string.free_storage, R.string.free_storage,
binding.downloadFreeTxt, binding?.downloadFreeTxt,
binding.downloadFree binding?.downloadFree
) )
} }
observe(downloadViewModel.usedBytes) { observe(downloadsViewModel.usedBytes) {
updateStorageInfo( updateStorageInfo(
binding.root.context, view.context,
it, it,
R.string.used_storage, R.string.used_storage,
binding.downloadUsedTxt, binding?.downloadUsedTxt,
binding.downloadUsed binding?.downloadUsed
) )
val hasBytes = it > 0 val hasBytes = it > 0
if(hasBytes) { if(hasBytes) {
binding.downloadLoadingBytes.stopShimmer() binding?.downloadLoadingBytes?.stopShimmer()
} else binding.downloadLoadingBytes.startShimmer() } else {
binding?.downloadLoadingBytes?.startShimmer()
binding.downloadBytesBar.isVisible = hasBytes
binding.downloadLoadingBytes.isGone = hasBytes
} }
observe(downloadViewModel.downloadBytes) {
binding?.downloadBytesBar?.isVisible = hasBytes
binding?.downloadLoadingBytes?.isGone = hasBytes
}
observe(downloadsViewModel.downloadBytes) {
updateStorageInfo( updateStorageInfo(
binding.root.context, view.context,
it, it,
R.string.app_storage, R.string.app_storage,
binding.downloadAppTxt, binding?.downloadAppTxt,
binding.downloadApp binding?.downloadApp
) )
} }
observe(downloadQueueViewModel.childCards) { cards -> observe(downloadsViewModel.selectedBytes) {
val size = cards.currentDownloads.size + cards.queue.size updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
val context = binding.root.context
val baseText = context.getString(R.string.download_queue)
binding.downloadQueueText.text = if (size > 0) {
"$baseText (${cards.currentDownloads.size}/$size)"
} else {
baseText
} }
} observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
observe(downloadViewModel.selectedBytes) {
updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it)
}
binding.apply {
btnDelete.setOnClickListener { view ->
downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener)
}
btnCancel.setOnClickListener {
downloadViewModel.cancelSelection()
}
btnToggleAll.setOnClickListener {
val allSelected = downloadViewModel.isAllHeadersSelected()
if (allSelected) {
downloadViewModel.clearSelectedItems()
} else {
downloadViewModel.selectAllHeaders()
}
}
}
observeNullable(downloadViewModel.selectedItemIds) { selection ->
val isMultiDeleteState = selection != null
val adapter = binding.downloadList.adapter as? DownloadAdapter
adapter?.setIsMultiDeleteState(isMultiDeleteState) adapter?.setIsMultiDeleteState(isMultiDeleteState)
binding.downloadDeleteAppbar.isVisible = isMultiDeleteState binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
binding.downloadAppbar.isGone = isMultiDeleteState if (!isMultiDeleteState) {
if (selection == null) {
activity?.detachBackPressedCallback("Downloads") activity?.detachBackPressedCallback("Downloads")
return@observeNullable downloadsViewModel.clearSelectedItems()
// Prevent race condition and make sure
// we don't display it early
if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
binding?.downloadAppbar?.isVisible = true
} }
activity?.attachBackPressedCallback("Downloads") {
downloadViewModel.cancelSelection()
} }
updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) }
observe(downloadsViewModel.selectedItemIds) {
handleSelectedChange(it)
updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
binding.btnDelete.isVisible = selection.isNotEmpty() binding?.btnDelete?.isVisible = it.isNotEmpty()
binding.selectItemsText.isVisible = selection.isEmpty() binding?.selectItemsText?.isVisible = it.isEmpty()
val allSelected = downloadViewModel.isAllHeadersSelected() val allSelected = downloadsViewModel.isAllSelected()
if (allSelected) { if (allSelected) {
binding.btnToggleAll.setText(R.string.deselect_all) binding?.btnToggleAll?.setText(R.string.deselect_all)
} else binding.btnToggleAll.setText(R.string.select_all) } else binding?.btnToggleAll?.setText(R.string.select_all)
} }
val adapter = DownloadAdapter( val adapter = DownloadAdapter(
@ -208,29 +190,29 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
{ click -> { click ->
if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx -> context?.let { ctx ->
downloadViewModel.handleSingleDelete(ctx, click.data.id) downloadsViewModel.handleSingleDelete(ctx, click.data.id)
} }
} else handleDownloadClick(click) } else handleDownloadClick(click)
}, },
{ itemId, isChecked -> { itemId, isChecked ->
if (isChecked) { if (isChecked) {
downloadViewModel.addSelected(itemId) downloadsViewModel.addSelected(itemId)
} else downloadViewModel.removeSelected(itemId) } else downloadsViewModel.removeSelected(itemId)
} }
) )
binding.downloadList.apply { binding?.downloadList?.apply {
setHasFixedSize(true) setHasFixedSize(true)
setItemViewCacheSize(20) setItemViewCacheSize(20)
this.adapter = adapter this.adapter = adapter
setLinearListLayout( setLinearListLayout(
isHorizontal = false, isHorizontal = false,
nextRight = FOCUS_SELF, nextRight = FOCUS_SELF,
nextDown = R.id.download_queue_button, nextDown = FOCUS_SELF,
) )
} }
binding.apply { binding?.apply {
openLocalVideoButton.apply { openLocalVideoButton.apply {
isGone = isLayout(TV) isGone = isLayout(TV)
setOnClickListener { openLocalVideo() } setOnClickListener { openLocalVideo() }
@ -240,10 +222,6 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
setOnClickListener { showStreamInputDialog(it.context) } setOnClickListener { showStreamInputDialog(it.context) }
} }
downloadQueueButton.setOnClickListener {
activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue)
}
downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV) downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV)
downloadAppbar.isFocusableInTouchMode = isLayout(TV) downloadAppbar.isFocusableInTouchMode = isLayout(TV)
@ -252,12 +230,13 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.downloadList.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
handleScroll(scrollY - oldScrollY) handleScroll(scrollY - oldScrollY)
} }
} }
context?.let { downloadViewModel.updateHeaderList(it) } context?.let { downloadsViewModel.updateHeaderList(it) }
fixPaddingStatusbar(binding?.downloadRoot)
} }
private fun handleItemClick(click: DownloadHeaderClickEvent) { private fun handleItemClick(click: DownloadHeaderClickEvent) {
@ -279,6 +258,40 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
} }
} }
private fun handleSelectedChange(selected: MutableSet<Int>) {
if (selected.isNotEmpty()) {
binding?.downloadDeleteAppbar?.isVisible = true
binding?.downloadAppbar?.isVisible = false
activity?.attachBackPressedCallback("Downloads") {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnDelete?.setOnClickListener {
context?.let { ctx ->
downloadsViewModel.handleMultiDelete(ctx)
}
}
binding?.btnCancel?.setOnClickListener {
downloadsViewModel.setIsMultiDeleteState(false)
}
binding?.btnToggleAll?.setOnClickListener {
val allSelected = downloadsViewModel.isAllSelected()
val adapter = binding?.downloadList?.adapter as? DownloadAdapter
if (allSelected) {
adapter?.notifySelectionStates()
downloadsViewModel.clearSelectedItems()
} else {
adapter?.notifyAllSelected()
downloadsViewModel.selectAllItems()
}
}
downloadsViewModel.setIsMultiDeleteState(true)
}
}
private fun updateDeleteButton(count: Int, selectedBytes: Long) { private fun updateDeleteButton(count: Int, selectedBytes: Long) {
val formattedSize = formatShortFileSize(context, selectedBytes) val formattedSize = formatShortFileSize(context, selectedBytes)
binding?.btnDelete?.text = binding?.btnDelete?.text =
@ -349,8 +362,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
listOf(BasicLink(url)), listOf(BasicLink(url)),
extract = true, extract = true,
refererUrl = referer, refererUrl = referer,
id = url.hashCode() )
), 0
) )
) )
dialog.dismissSafe(activity) dialog.dismissSafe(activity)
@ -381,7 +393,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val selectedVideoUri = result.data?.data ?: return@registerForActivityResult val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult
playUri(activity ?: return@registerForActivityResult, selectedVideoUri) playUri(activity ?: return@registerForActivityResult, selectedVideoUri)
} }
} }

View file

@ -5,119 +5,91 @@ import android.content.DialogInterface
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.lagradost.api.Log
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.isEpisodeBased
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.services.DownloadQueueService
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.ConsistentLiveData
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
import com.lagradost.cloudstream3.utils.ResourceLiveData
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class DownloadViewModel : ViewModel() { class DownloadViewModel : ViewModel() {
companion object {
const val TAG = "DownloadViewModel"
}
private val _headerCards = private val _headerCards = MutableLiveData<List<VisualDownloadCached.Header>>()
ResourceLiveData<List<VisualDownloadCached.Header>>(Resource.Loading()) val headerCards: LiveData<List<VisualDownloadCached.Header>> = _headerCards
val headerCards: LiveData<Resource<List<VisualDownloadCached.Header>>> = _headerCards
private val _childCards = ResourceLiveData<List<VisualDownloadCached.Child>>(Resource.Loading()) private val _childCards = MutableLiveData<List<VisualDownloadCached.Child>>()
val childCards: LiveData<Resource<List<VisualDownloadCached.Child>>> = _childCards val childCards: LiveData<List<VisualDownloadCached.Child>> = _childCards
private val _usedBytes = ConsistentLiveData<Long>() private val _usedBytes = MutableLiveData<Long>()
val usedBytes: LiveData<Long> = _usedBytes val usedBytes: LiveData<Long> = _usedBytes
private val _availableBytes = ConsistentLiveData<Long>() private val _availableBytes = MutableLiveData<Long>()
val availableBytes: LiveData<Long> = _availableBytes val availableBytes: LiveData<Long> = _availableBytes
private val _downloadBytes = ConsistentLiveData<Long>() private val _downloadBytes = MutableLiveData<Long>()
val downloadBytes: LiveData<Long> = _downloadBytes val downloadBytes: LiveData<Long> = _downloadBytes
private val _selectedBytes = ConsistentLiveData<Long>(0) private val _selectedBytes = MutableLiveData<Long>(0)
val selectedBytes: LiveData<Long> = _selectedBytes val selectedBytes: LiveData<Long> = _selectedBytes
private val _selectedItemIds = ConsistentLiveData<Set<Int>?>(null) private val _isMultiDeleteState = MutableLiveData(false)
val selectedItemIds: LiveData<Set<Int>?> = _selectedItemIds val isMultiDeleteState: LiveData<Boolean> = _isMultiDeleteState
private val _selectedItemIds = MutableLiveData<MutableSet<Int>>(mutableSetOf())
val selectedItemIds: LiveData<MutableSet<Int>> = _selectedItemIds
fun cancelSelection() { private var previousVisual: List<VisualDownloadCached>? = null
updateSelectedItems { null }
fun setIsMultiDeleteState(value: Boolean) {
_isMultiDeleteState.postValue(value)
} }
fun addSelected(itemId: Int) { fun addSelected(itemId: Int) {
updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) } updateSelectedItems { it.add(itemId) }
} }
fun removeSelected(itemId: Int) { fun removeSelected(itemId: Int) {
updateSelectedItems { it?.minus(itemId) ?: emptySet() } updateSelectedItems { it.remove(itemId) }
} }
fun selectAllHeaders() { fun selectAllItems() {
updateSelectedItems { val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
_headerCards.success.orEmpty() updateSelectedItems { it.addAll(items.map { item -> item.data.id }) }
.map { item -> item.data.id }.toSet()
}
}
fun selectAllChildren() {
updateSelectedItems {
_childCards.success.orEmpty()
.map { item -> item.data.id }.toSet()
}
} }
fun clearSelectedItems() { fun clearSelectedItems() {
// We need this to be done immediately // We need this to be done immediately
// so we can't use postValue // so we can't use postValue
updateSelectedItems { emptySet() } _selectedItemIds.value = mutableSetOf()
updateSelectedItems { it.clear() }
} }
fun isAllChildrenSelected(): Boolean { fun isAllSelected(): Boolean {
val currentSelected = selectedItemIds.value ?: return false val currentSelected = selectedItemIds.value ?: return false
val children = _childCards.success.orEmpty() val items = headerCards.value.orEmpty() + childCards.value.orEmpty()
return currentSelected.size == children.size && children.all { it.data.id in currentSelected } return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected }
} }
fun isAllHeadersSelected(): Boolean { private fun updateSelectedItems(action: (MutableSet<Int>) -> Unit) {
val currentSelected = selectedItemIds.value ?: return false val currentSelected = selectedItemIds.value ?: mutableSetOf()
val headers = _headerCards.success.orEmpty() action(currentSelected)
return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected }
}
private fun updateSelectedItems(action: (Set<Int>?) -> Set<Int>?) {
val currentSelected = action(selectedItemIds.value)
_selectedItemIds.postValue(currentSelected) _selectedItemIds.postValue(currentSelected)
postHeaders()
postChildren()
updateSelectedBytes() updateSelectedBytes()
updateSelectedCards()
} }
private fun updateSelectedBytes() = viewModelScope.launchSafe { private fun updateSelectedBytes() = viewModelScope.launchSafe {
@ -126,173 +98,61 @@ class DownloadViewModel : ViewModel() {
_selectedBytes.postValue(totalSelectedBytes) _selectedBytes.postValue(totalSelectedBytes)
} }
private fun updateSelectedCards() = viewModelScope.launchSafe {
val currentSelected = selectedItemIds.value ?: return@launchSafe
fun removeRedundantEpisodeKeys(context: Context, keys: List<Pair<Int, Int>>) { headerCards.value?.let { headers ->
val settingsManager = context.getSharedPrefs() headers.forEach { header ->
ioSafe { header.isSelected = header.data.id in currentSelected
settingsManager.edit {
keys.forEach { (parentId, childId) ->
Log.i(TAG, "Removing download episode key: ${parentId}/${childId}")
val oldPath = getFolderName(
getFolderName(
DOWNLOAD_EPISODE_CACHE,
parentId.toString()
),
childId.toString()
)
val newPath = getFolderName(
getFolderName(
DOWNLOAD_EPISODE_CACHE_BACKUP,
parentId.toString()
),
childId.toString()
)
val oldPref = settingsManager.getString(oldPath, null)
// Cowardly future backup solution in case the key removal fails in some edge case.
// This and all backup keys may be removed in a future update if the key removal is proven to be robust.
this.putString(newPath, oldPref)
this.remove(oldPath)
}
}
} }
_headerCards.postValue(headers)
} }
fun removeRedundantHeaderKeys( childCards.value?.let { children ->
context: Context, children.forEach { child ->
cached: List<DownloadObjects.DownloadHeaderCached>, child.isSelected = child.data.id in currentSelected
totalBytesUsedByChild: Map<Int, Long>,
totalDownloads: Map<Int, Int>
) {
val settingsManager = context.getSharedPrefs()
ioSafe {
// Do not remove headers used by resume watching
val resumeWatchingIds =
getAllResumeStateIds()?.mapNotNull { id ->
getLastWatched(id)?.parentId
}?.toSet() ?: emptySet()
settingsManager.edit {
cached.forEach { header ->
val downloads = totalDownloads[header.id] ?: 0
val bytes = totalBytesUsedByChild[header.id] ?: 0
if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) {
Log.i(TAG, "Removing download header key: ${header.id}")
val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString())
val newPath =
getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString())
val oldPref = settingsManager.getString(oldPAth, null)
// Cowardly future backup solution in case the key removal fails in some edge case.
// This and all backup keys may be removed in a future update if the key removal is proven to be robust.
this.putString(newPath, oldPref)
this.remove(oldPAth)
}
}
} }
_childCards.postValue(children)
} }
} }
fun updateHeaderList(context: Context) = viewModelScope.launchSafe { fun updateHeaderList(context: Context) = viewModelScope.launchSafe {
// Do not push loading as it interrupts the UI val visual = withContext(Dispatchers.IO) {
//_headerCards.postValue(Resource.Loading())
val visual = ioWork {
val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) val children = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull { context.getKey<DownloadObjects.DownloadEpisodeCached>(it) } .mapNotNull { context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(it) }
.distinctBy { it.id } // Remove duplicates .distinctBy { it.id } // Remove duplicates
val isCurrentlyDownloading = val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) =
DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty()
val downloadStats =
calculateDownloadStats(context, children) calculateDownloadStats(context, children)
val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) val cached = context.getKeys(DOWNLOAD_HEADER_CACHE)
.mapNotNull { context.getKey<DownloadObjects.DownloadHeaderCached>(it) } .mapNotNull { context.getKey<VideoDownloadHelper.DownloadHeaderCached>(it) }
// Download stats and header keys may change when downloading.
// To prevent the downloader and key removal from colliding, simply do not prune keys when downloading.
if (!isCurrentlyDownloading) {
removeRedundantHeaderKeys(
context,
cached,
downloadStats.totalBytesUsedByChild,
downloadStats.totalDownloads
)
}
// calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required
removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads)
createVisualDownloadList( createVisualDownloadList(
context, context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads
cached,
downloadStats.totalBytesUsedByChild,
downloadStats.currentBytesUsedByChild,
downloadStats.totalDownloads
) )
} }
if (visual != previousVisual) {
previousVisual = visual
updateStorageStats(visual) updateStorageStats(visual)
postHeaders(visual) _headerCards.postValue(visual)
} }
fun postHeaders(newValue: List<VisualDownloadCached.Header>? = null) {
val newValue = newValue ?: _headerCards.success ?: return
val selection = selectedItemIds.value ?: emptySet()
_headerCards.postValue(Resource.Success(newValue.map {
it.copy(
isSelected = selection.contains(
it.data.id
)
)
}))
} }
fun postChildren(newValue: List<VisualDownloadCached.Child>? = null) {
val newValue = newValue ?: _childCards.success ?: return
val selection = selectedItemIds.value ?: emptySet()
_childCards.postValue(Resource.Success(newValue.map {
it.copy(
isSelected = selection.contains(
it.data.id
)
)
}))
}
private data class DownloadStats(
val totalBytesUsedByChild: Map<Int, Long>,
val currentBytesUsedByChild: Map<Int, Long>,
val totalDownloads: Map<Int, Int>,
/** Parent ID to child ID. Keys to be removed. */
val redundantDownloads: List<Pair<Int, Int>>
)
private fun calculateDownloadStats( private fun calculateDownloadStats(
context: Context, context: Context,
children: List<DownloadObjects.DownloadEpisodeCached> children: List<VideoDownloadHelper.DownloadEpisodeCached>
): DownloadStats { ): Triple<Map<Int, Long>, Map<Int, Long>, Map<Int, Int>> {
// parentId : bytes // parentId : bytes
val totalBytesUsedByChild = mutableMapOf<Int, Long>() val totalBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : bytes // parentId : bytes
val currentBytesUsedByChild = mutableMapOf<Int, Long>() val currentBytesUsedByChild = mutableMapOf<Int, Long>()
// parentId : downloadsCount // parentId : downloadsCount
val totalDownloads = mutableMapOf<Int, Int>() val totalDownloads = mutableMapOf<Int, Int>()
val redundantDownloads = mutableListOf<Pair<Int, Int>>()
children.forEach { child -> children.forEach { child ->
val childFile = getDownloadFileInfo(context, child.id) val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach
if (childFile == null) {
// It may not be a redundant child if something is currently downloading.
// DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader
// leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE
if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) {
redundantDownloads.add(child.parentId to child.id)
}
return@forEach
}
if (childFile.fileLength <= 1) return@forEach if (childFile.fileLength <= 1) return@forEach
val len = childFile.totalBytes val len = childFile.totalBytes
@ -302,17 +162,12 @@ class DownloadViewModel : ViewModel() {
currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) currentBytesUsedByChild.merge(child.parentId, flen, Long::plus)
totalDownloads.merge(child.parentId, 1, Int::plus) totalDownloads.merge(child.parentId, 1, Int::plus)
} }
return DownloadStats( return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads)
totalBytesUsedByChild,
currentBytesUsedByChild,
totalDownloads,
redundantDownloads
)
} }
private fun createVisualDownloadList( private fun createVisualDownloadList(
context: Context, context: Context,
cached: List<DownloadObjects.DownloadHeaderCached>, cached: List<VideoDownloadHelper.DownloadHeaderCached>,
totalBytesUsedByChild: Map<Int, Long>, totalBytesUsedByChild: Map<Int, Long>,
currentBytesUsedByChild: Map<Int, Long>, currentBytesUsedByChild: Map<Int, Long>,
totalDownloads: Map<Int, Int> totalDownloads: Map<Int, Int>
@ -321,14 +176,10 @@ class DownloadViewModel : ViewModel() {
val downloads = totalDownloads[it.id] ?: 0 val downloads = totalDownloads[it.id] ?: 0
val bytes = totalBytesUsedByChild[it.id] ?: 0 val bytes = totalBytesUsedByChild[it.id] ?: 0
val currentBytes = currentBytesUsedByChild[it.id] ?: 0 val currentBytes = currentBytesUsedByChild[it.id] ?: 0
if (bytes <= 0 || downloads <= 0) return@mapNotNull null
if (bytes <= 0 || downloads <= 0) {
return@mapNotNull null
}
val isSelected = selectedItemIds.value?.contains(it.id) ?: false val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val movieEpisode = val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
if (it.type.isEpisodeBased()) null else context.getKey<DownloadObjects.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE, DOWNLOAD_EPISODE_CACHE,
getFolderName(it.id.toString(), it.id.toString()) getFolderName(it.id.toString(), it.id.toString())
) )
@ -357,14 +208,12 @@ class DownloadViewModel : ViewModel() {
} }
fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe {
_childCards.postValue(Resource.Loading()) // always push loading
val visual = withContext(Dispatchers.IO) { val visual = withContext(Dispatchers.IO) {
context.getKeys(folder).mapNotNull { key -> context.getKeys(folder).mapNotNull { key ->
context.getKey<DownloadObjects.DownloadEpisodeCached>(key) context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(key)
}.mapNotNull { }.mapNotNull {
val isSelected = selectedItemIds.value?.contains(it.id) ?: false val isSelected = selectedItemIds.value?.contains(it.id) ?: false
val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null
VisualDownloadCached.Child( VisualDownloadCached.Child(
currentBytes = info.fileLength, currentBytes = info.fileLength,
totalBytes = info.totalBytes, totalBytes = info.totalBytes,
@ -372,21 +221,24 @@ class DownloadViewModel : ViewModel() {
data = it, data = it,
) )
} }
}.sortedWith( }.sortedWith(compareBy(
compareBy(
// Sort by season first, and then by episode number, // Sort by season first, and then by episode number,
// to ensure sorting is consistent. // to ensure sorting is consistent.
{ it.data.season ?: 0 }, { it.data.season ?: 0 },
{ it.data.episode } { it.data.episode }
)) ))
postChildren(visual) if (previousVisual != visual) {
previousVisual = visual
_childCards.postValue(visual)
}
} }
private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe { private fun removeItems(idsToRemove: Set<Int>) = viewModelScope.launchSafe {
_selectedItemIds.postValue(null) val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove }
postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove }) val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove }
postChildren(_childCards.success?.filter { it.data.id !in idsToRemove }) _headerCards.postValue(updatedHeaders)
_childCards.postValue(updatedChildren)
} }
private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) { private fun updateStorageStats(visual: List<VisualDownloadCached.Header>) {
@ -440,7 +292,7 @@ class DownloadViewModel : ViewModel() {
if (item.data.type.isEpisodeBased()) { if (item.data.type.isEpisodeBased()) {
val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE)
.mapNotNull { .mapNotNull {
context.getKey<DownloadObjects.DownloadEpisodeCached>( context.getKey<VideoDownloadHelper.DownloadEpisodeCached>(
it it
) )
} }
@ -464,7 +316,7 @@ class DownloadViewModel : ViewModel() {
is VisualDownloadCached.Child -> { is VisualDownloadCached.Child -> {
ids.add(item.data.id) ids.add(item.data.id)
val parent = context.getKey<DownloadObjects.DownloadHeaderCached>( val parent = context.getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
item.data.parentId.toString() item.data.parentId.toString()
) )
@ -493,16 +345,16 @@ class DownloadViewModel : ViewModel() {
.joinToString(separator = "\n") { "$it" } .joinToString(separator = "\n") { "$it" }
return when { return when {
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
}
data.ids.count() == 1 -> { data.ids.count() == 1 -> {
context.getString(R.string.delete_message).format( context.getString(R.string.delete_message).format(
data.names.firstOrNull() data.names.firstOrNull()
) )
} }
data.seriesNames.isNotEmpty() && data.names.isEmpty() -> {
context.getString(R.string.delete_message_series_only).format(formattedSeriesNames)
}
data.parentName != null && data.names.isNotEmpty() -> { data.parentName != null && data.names.isNotEmpty() -> {
context.getString(R.string.delete_message_series_episodes) context.getString(R.string.delete_message_series_episodes)
.format(data.parentName, formattedNames) .format(data.parentName, formattedNames)
@ -531,6 +383,7 @@ class DownloadViewModel : ViewModel() {
when (which) { when (which) {
DialogInterface.BUTTON_POSITIVE -> { DialogInterface.BUTTON_POSITIVE -> {
viewModelScope.launchSafe { viewModelScope.launchSafe {
setIsMultiDeleteState(false)
deleteFilesAndUpdateSettings(context, ids, this) { successfulIds -> deleteFilesAndUpdateSettings(context, ids, this) { successfulIds ->
// We always remove parent because if we are deleting from here // We always remove parent because if we are deleting from here
// and we have it as non-empty, it was triggered on // and we have it as non-empty, it was triggered on
@ -561,8 +414,8 @@ class DownloadViewModel : ViewModel() {
} }
private fun getSelectedItemsData(): List<VisualDownloadCached>? { private fun getSelectedItemsData(): List<VisualDownloadCached>? {
val headers = _headerCards.success.orEmpty() val headers = headerCards.value.orEmpty()
val children = _childCards.success.orEmpty() val children = childCards.value.orEmpty()
return selectedItemIds.value?.mapNotNull { id -> return selectedItemIds.value?.mapNotNull { id ->
headers.find { it.data.id == id } ?: children.find { it.data.id == id } headers.find { it.data.id == id } ?: children.find { it.data.id == id }
@ -570,11 +423,10 @@ class DownloadViewModel : ViewModel() {
} }
private fun getItemDataFromId(itemId: Int): List<VisualDownloadCached> { private fun getItemDataFromId(itemId: Int): List<VisualDownloadCached> {
return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId } val headers = headerCards.value.orEmpty()
} val children = childCards.value.orEmpty()
fun clearChildren() { return (headers + children).filter { it.data.id == itemId }
_childCards.postValue(Resource.Loading())
} }
private data class DeleteData( private data class DeleteData(

View file

@ -11,7 +11,7 @@ import androidx.core.widget.ContentLoadingProgressBar
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.Coroutines.mainWork
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
typealias DownloadStatusTell = VideoDownloadManager.DownloadType typealias DownloadStatusTell = VideoDownloadManager.DownloadType
@ -62,7 +62,6 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
open fun resetViewData() { open fun resetViewData() {
// lastRequest = null // lastRequest = null
progressText = null
isZeroBytes = true isZeroBytes = true
doSetProgress = true doSetProgress = true
persistentId = null persistentId = null
@ -76,10 +75,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
currentMetaData.id = id currentMetaData.id = id
if (!doSetProgress) return if (!doSetProgress) return
val appContext = context.applicationContext
ioSafe { ioSafe {
val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id) val savedData = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)
mainWork { mainWork {
if (savedData != null) { if (savedData != null) {
val downloadedBytes = savedData.fileLength val downloadedBytes = savedData.fileLength
@ -87,7 +86,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
setProgress(downloadedBytes, totalBytes) setProgress(downloadedBytes, totalBytes)
applyMetaData(id, downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes)
} } else run { resetView() }
} }
} }
} }

View file

@ -8,7 +8,7 @@ import androidx.core.view.isVisible
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.VideoDownloadHelper
class DownloadButton(context: Context, attributeSet: AttributeSet) : class DownloadButton(context: Context, attributeSet: AttributeSet) :
PieFetchButton(context, attributeSet) { PieFetchButton(context, attributeSet) {
@ -18,7 +18,6 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
super.onAttachedToWindow() super.onAttachedToWindow()
progressText = findViewById(R.id.result_movie_download_text_precentage) progressText = findViewById(R.id.result_movie_download_text_precentage)
mainText = findViewById(R.id.result_movie_download_text) mainText = findViewById(R.id.result_movie_download_text)
setStatus(null)
} }
override fun setStatus(status: DownloadStatusTell?) { override fun setStatus(status: DownloadStatusTell?) {
@ -36,7 +35,7 @@ class DownloadButton(context: Context, attributeSet: AttributeSet) :
} }
override fun setDefaultClickListener( override fun setDefaultClickListener(
card: DownloadObjects.DownloadEpisodeCached, card: VideoDownloadHelper.DownloadEpisodeCached,
textView: TextView?, textView: TextView?,
callback: (DownloadClickEvent) -> Unit callback: (DownloadClickEvent) -> Unit
) { ) {

View file

@ -10,14 +10,11 @@ import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
@ -26,10 +23,9 @@ import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager.KEY_RESUME_PACKAGES
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES
open class PieFetchButton(context: Context, attributeSet: AttributeSet) : open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
BaseFetchButton(context, attributeSet) { BaseFetchButton(context, attributeSet) {
@ -67,7 +63,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
open fun onInflate() {} open fun onInflate() {}
init { init {
context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) { context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply {
try { try {
inflate( inflate(
overrideLayout ?: getResourceId( overrideLayout ?: getResourceId(
@ -76,7 +72,6 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
) )
) )
} catch (e: Exception) { } catch (e: Exception) {
recycle() // Manually call recycle first to avoid memory leaks
Log.e( Log.e(
"PieFetchButton", "Error inflating PieFetchButton, " + "PieFetchButton", "Error inflating PieFetchButton, " +
"check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color" "check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color"
@ -84,6 +79,11 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
throw e throw e
} }
progressBar = findViewById(R.id.progress_downloaded)
progressBarBackground = findViewById(R.id.progress_downloaded_background)
statusView = findViewById(R.id.image_download_status)
animateWaiting = getBoolean( animateWaiting = getBoolean(
R.styleable.PieFetchButton_download_animate_waiting, R.styleable.PieFetchButton_download_animate_waiting,
true true
@ -92,13 +92,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
R.styleable.PieFetchButton_download_hide_when_icon, R.styleable.PieFetchButton_download_hide_when_icon,
true true
) )
waitingAnimation = getResourceId( waitingAnimation = getResourceId(
R.styleable.PieFetchButton_download_waiting_animation, R.styleable.PieFetchButton_download_waiting_animation,
R.anim.rotate_around_center_point R.anim.rotate_around_center_point
) )
activeOutline = getResourceId( activeOutline = getResourceId(
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
) )
nonActiveOutline = getResourceId( nonActiveOutline = getResourceId(
R.styleable.PieFetchButton_download_outline_non_active, R.styleable.PieFetchButton_download_outline_non_active,
R.drawable.circle_shape_dotted R.drawable.circle_shape_dotted
@ -126,27 +129,17 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
) )
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0) val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
progressDrawable = getResourceId( progressDrawable = getResourceId(
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
) )
}
progressBar = findViewById(R.id.progress_downloaded)
progressBarBackground = findViewById(R.id.progress_downloaded_background)
statusView = findViewById(R.id.image_download_status)
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
// resetView() recycle()
onInflate()
} }
resetView()
onInflate()
override fun onAttachedToWindow() {
super.onAttachedToWindow()
// Re-run all animations when the view gets visible.
// Otherwise views may run without animations after recycled
setStatusInternal(currentStatus)
} }
private var currentStatus: DownloadStatusTell? = null private var currentStatus: DownloadStatusTell? = null
@ -169,31 +162,16 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
}*/ }*/
protected fun setDefaultClickListener( protected fun setDefaultClickListener(
view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached, view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached,
callback: (DownloadClickEvent) -> Unit callback: (DownloadClickEvent) -> Unit
) { ) {
this.progressText = textView this.progressText = textView
this.setPersistentId(card.id) this.setPersistentId(card.id)
view.setOnClickListener { view.setOnClickListener {
if (isZeroBytes) { if (isZeroBytes) {
val localQueue = queue.value
val localInstances = downloadInstances.value
val id = card.id
// If the download is already in queue or active downloads, provide an option to cancel it
if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) {
it.popupMenuNoIcons(
arrayListOf(
Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel),
)
) {
callback(DownloadClickEvent(itemId, card))
}
} else {
// Otherwise just start a download instantly
removeKey(KEY_RESUME_PACKAGES, card.id.toString()) removeKey(KEY_RESUME_PACKAGES, card.id.toString())
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
} // callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
} else { } else {
val list = arrayListOf( val list = arrayListOf(
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
@ -234,7 +212,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
} }
open fun setDefaultClickListener( open fun setDefaultClickListener(
card: DownloadObjects.DownloadEpisodeCached, card: VideoDownloadHelper.DownloadEpisodeCached,
textView: TextView?, textView: TextView?,
callback: (DownloadClickEvent) -> Unit callback: (DownloadClickEvent) -> Unit
) { ) {
@ -304,8 +282,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
override fun setStatus(status: DownloadStatusTell?) { override fun setStatus(status: DownloadStatusTell?) {
currentStatus = status currentStatus = status
// Runs on the main thread, but also instant if it already is. // Runs on the main thread, but also instant if it already is
if (Looper.getMainLooper().isCurrentThread) { if (Looper.myLooper() == Looper.getMainLooper()) {
try { try {
setStatusInternal(status) setStatusInternal(status)
} catch (t: Throwable) { } catch (t: Throwable) {

View file

@ -1,274 +0,0 @@
package com.lagradost.cloudstream3.ui.download.queue
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding
import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO
/** An item in the adapter can either be a separator or a real item.
* isCurrentlyDownloading is used to fully update items as opposed to just moving them. */
class DownloadAdapterItem(val item: DownloadQueueWrapper?) {
val isSeparator = item == null
}
class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter<DownloadAdapterItem, Unit>(
diffCallback = BaseDiffCallback(
itemSame = { a, b -> a.item?.id == b.item?.id },
contentSame = { a, b ->
a.item == b.item
})
) {
var currentDownloads = 0
companion object {
val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG"
}
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Unit> {
val inflater = LayoutInflater.from(parent.context)
val binding = DownloadQueueItemBinding.inflate(inflater, parent, false)
return ViewHolderState(binding)
}
override fun onBindContent(
holder: ViewHolderState<Unit>,
item: DownloadAdapterItem,
position: Int
) {
when (val binding = holder.view) {
is DownloadQueueItemBinding -> {
if (item.item == null) {
holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG
bindSeparator(binding)
} else {
holder.itemView.tag = null
bind(binding, item.item)
}
}
}
}
fun submitQueue(newQueue: DownloadAdapterQueue) {
val index = newQueue.currentDownloads.size
val current = newQueue.currentDownloads
val queue = newQueue.queue
currentDownloads = current.size
val newList =
(current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList()
.apply {
// Only add the separator if it actually separates something
if (index < this.size) {
add(index, DownloadAdapterItem(null))
}
}
submitList(newList)
}
fun bindSeparator(binding: DownloadQueueItemBinding) {
binding.apply {
separatorHolder.isGone = false
downloadChildEpisodeHolder.isGone = true
}
}
fun bind(
binding: DownloadQueueItemBinding,
queueWrapper: DownloadQueueWrapper,
) {
val context = binding.root.context
binding.apply {
separatorHolder.isGone = true
downloadChildEpisodeHolder.isGone = false
// Only set the child-text if child and parent are not the same
// This prevents setting movie titles twice
if (queueWrapper.id != queueWrapper.parentId) {
val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName
downloadChildEpisodeTextExtra.text = mainName
} else {
downloadChildEpisodeTextExtra.text = null
}
downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank()
val status = VideoDownloadManager.downloadStatus[queueWrapper.id]
downloadButton.setOnClickListener { view ->
val episodeCached =
getKey<DownloadObjects.DownloadEpisodeCached>(
DOWNLOAD_EPISODE_CACHE,
getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString())
)
val downloadInfo = context.getKey<DownloadObjects.DownloadedFileInfo>(
KEY_DOWNLOAD_INFO,
queueWrapper.id.toString()
)
val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading()
val actionList = arrayListOf<Pair<Int,Int>>()
if (isCurrentlyDownloading && episodeCached != null) {
// KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything
if (downloadInfo != null) {
actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file))
} else {
actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel))
}
val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id]
when (currentStatus) {
VideoDownloadManager.DownloadType.IsDownloading -> {
actionList.add(
Pair(
DOWNLOAD_ACTION_PAUSE_DOWNLOAD,
R.string.popup_pause_download
)
)
}
VideoDownloadManager.DownloadType.IsPaused -> {
actionList.add(
Pair(
DOWNLOAD_ACTION_RESUME_DOWNLOAD,
R.string.popup_resume_download
)
)
}
else -> {}
}
view.popupMenuNoIcons(
actionList
) {
handleDownloadClick(DownloadClickEvent(itemId, episodeCached))
}
} else {
actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel))
view.popupMenuNoIcons(
actionList
) {
when (itemId) {
DOWNLOAD_ACTION_CANCEL_PENDING -> {
DownloadQueueManager.cancelDownload(queueWrapper.id)
}
}
}
}
}
downloadButton.resetView()
downloadButton.setStatus(status)
downloadButton.setPersistentId(queueWrapper.id)
downloadChildEpisodeText.apply {
val name = queueWrapper.downloadItem?.episode?.name
?: queueWrapper.resumePackage?.item?.ep?.name
val episode =
queueWrapper.downloadItem?.episode?.episode
?: queueWrapper.resumePackage?.item?.ep?.episode
val season =
queueWrapper.downloadItem?.episode?.season
?: queueWrapper.resumePackage?.item?.ep?.season
text = context.getNameFull(name, episode, season)
isSelected = true // Needed for text repeating
}
}
}
}
class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) :
ItemTouchHelper(
DragAndDropTouchHelperCallback(adapter)
)
private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) :
ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val item = adapter.getItem(viewHolder.absoluteAdapterPosition)
val isDownloading = item.item?.isCurrentlyDownloading() == true
val dragFlags = if (item.isSeparator || isDownloading) {
0
} else {
ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down
}
val swipeFlags = 0 // Disable swipe functionality
return makeMovementFlags(dragFlags, swipeFlags)
}
override fun onMove(
recyclerView: RecyclerView,
source: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val fromPosition = source.absoluteAdapterPosition
val toPosition = target.absoluteAdapterPosition
val separatorPosition = adapter.currentDownloads
val toPositionNoSeparator =
if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition
if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) {
return false
} else {
adapter.getItem(fromPosition).item?.let { downloadQueueInfo ->
DownloadQueueManager.reorderItem(
downloadQueueInfo,
toPositionNoSeparator - 1
)
}
}
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
}
override fun isLongPressDragEnabled(): Boolean {
return true // Enable drag with long press
}
override fun isItemViewSwipeEnabled(): Boolean {
return false // Disable swipe by default
}
}

View file

@ -1,79 +0,0 @@
package com.lagradost.cloudstream3.ui.download.queue
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.fragment.app.activityViewModels
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.txt
class DownloadQueueFragment :
BaseFragment<FragmentDownloadQueueBinding>(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) {
private val queueViewModel: DownloadQueueViewModel by activityViewModels()
override fun onBindingCreated(binding: FragmentDownloadQueueBinding) {
val adapter = DownloadQueueAdapter(this@DownloadQueueFragment)
val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all)
observe(queueViewModel.childCards) { cards ->
val size = cards.queue.size + cards.currentDownloads.size
val isEmptyQueue = size == 0
binding.downloadQueueList.isGone = isEmptyQueue
binding.textNoQueue.isGone = !isEmptyQueue
clearQueueItem?.isVisible = !isEmptyQueue
adapter.submitQueue(cards)
}
binding.apply {
downloadQueueToolbar.apply {
title = txt(R.string.download_queue).asString(context)
if (isLayout(PHONE or EMULATOR)) {
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
setNavigationOnClickListener {
dispatchBackPressed()
}
}
setAppBarNoScrollFlagsOnTV()
clearQueueItem?.setOnMenuItemClickListener {
AlertDialog.Builder(context, R.style.AlertDialogCustom)
.setTitle(R.string.cancel_all)
.setMessage(R.string.cancel_queue_message)
.setPositiveButton(R.string.yes) { _, _ ->
DownloadQueueManager.removeAllFromQueue()
}
.setNegativeButton(R.string.no) { _, _ ->
}.show()
true
}
}
downloadQueueList.adapter = adapter
// Drag and drop
val helper = DragAndDropTouchHelper(adapter)
helper.attachToRecyclerView(downloadQueueList)
}
}
override fun fixLayout(view: View) {
fixSystemBarsPadding(
view,
padBottom = isLandscape(),
padLeft = isLayout(TV or EMULATOR)
)
}
}

View file

@ -1,43 +0,0 @@
package com.lagradost.cloudstream3.ui.download.queue
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
data class DownloadAdapterQueue(
val currentDownloads: List<DownloadObjects.DownloadQueueWrapper>,
val queue: List<DownloadObjects.DownloadQueueWrapper>,
)
class DownloadQueueViewModel : ViewModel() {
private val _childCards = MutableLiveData<DownloadAdapterQueue>()
val childCards: LiveData<DownloadAdapterQueue> = _childCards
private val totalDownloadFlow =
downloadInstances.combine(DownloadQueueManager.queue) { instances, queue ->
val current = instances.map { it.downloadQueueWrapper }
DownloadAdapterQueue(current, queue.toList())
}.combine(VideoDownloadManager.currentDownloads) { total, _ ->
// We want to update the flow when currentDownloads updates, but we do not care about its value
total
}
init {
viewModelScope.launch {
totalDownloadFlow.collect { queue ->
updateChildList(queue)
}
}
}
fun updateChildList(downloads: DownloadAdapterQueue) {
_childCards.postValue(downloads)
}
}

View file

@ -1,10 +1,9 @@
package com.lagradost.cloudstream3.ui.home package com.lagradost.cloudstream3.ui.home
import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
@ -14,9 +13,7 @@ import com.lagradost.cloudstream3.databinding.HomeRemoveGridExpandedBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
@ -44,11 +41,13 @@ class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState<Boolean>(vi
} }
class ResumeItemAdapter( class ResumeItemAdapter(
fragment: Fragment,
nextFocusUp: Int? = null, nextFocusUp: Int? = null,
nextFocusDown: Int? = null, nextFocusDown: Int? = null,
clickCallback: (SearchClickCallback) -> Unit, clickCallback: (SearchClickCallback) -> Unit,
private val removeCallback: (View) -> Unit, private val removeCallback: (View) -> Unit,
) : HomeChildItemAdapter( ) : HomeChildItemAdapter(
fragment = fragment,
id = "resumeAdapter".hashCode(), id = "resumeAdapter".hashCode(),
nextFocusUp = nextFocusUp, nextFocusUp = nextFocusUp,
nextFocusDown = nextFocusDown, nextFocusDown = nextFocusDown,
@ -68,32 +67,20 @@ class ResumeItemAdapter(
return HomeScrollViewHolderState(binding) return HomeScrollViewHolderState(binding)
} }
override fun onClearView(holder: ViewHolderState<Boolean>) {
// Clear the image, idk if this saves ram or not, but I guess?
clearImage(holder.view.root.findViewById(R.id.imageView))
}
override fun onBindFooter(holder: ViewHolderState<Boolean>) { override fun onBindFooter(holder: ViewHolderState<Boolean>) {
this.applyBinding(holder, false) this.applyBinding(holder, false)
when (val binding = holder.view) {
is HomeRemoveGridBinding -> {
updateLayoutParms(binding.backgroundCard, setWidth, setHeight)
}
is HomeRemoveGridExpandedBinding -> {
updateLayoutParms(binding.backgroundCard, setWidth, setHeight)
}
}
holder.itemView.apply { holder.itemView.apply {
if (isLayout(TV)) { if (isLayout(TV)) {
isFocusableInTouchMode = true isFocusableInTouchMode = true
isFocusable = true isFocusable = true
} }
nextFocusUp?.let {
nextFocusUpId = it if (nextFocusUp != null) {
nextFocusUpId = nextFocusUp
} }
nextFocusDown?.let {
nextFocusDownId = it if (nextFocusDown != null) {
nextFocusDownId = nextFocusDown
} }
setOnClickListener { v -> setOnClickListener { v ->
@ -103,49 +90,16 @@ class ResumeItemAdapter(
} }
} }
/** Remember to set `updatePosterSize` to cache the poster size,
* otherwise the width and height is unset */
open class HomeChildItemAdapter( open class HomeChildItemAdapter(
fragment: Fragment,
id: Int, id: Int,
var nextFocusUp: Int? = null, protected val nextFocusUp: Int? = null,
var nextFocusDown: Int? = null, protected val nextFocusDown: Int? = null,
var clickCallback: (SearchClickCallback) -> Unit, private val clickCallback: (SearchClickCallback) -> Unit,
) : ) :
BaseAdapter<SearchResponse, Boolean>( BaseAdapter<SearchResponse, Boolean>(fragment, id) {
id, diffCallback = BaseDiffCallback(
itemSame = { a, b ->
a.url == b.url && a.name == b.name
},
contentSame = { a, b ->
a == b
})
) {
var hasNext: Boolean = false
var isHorizontal: Boolean = false var isHorizontal: Boolean = false
set(value) { var hasNext: Boolean = false
field = value
updateCachedPosterSize()
}
private fun updateCachedPosterSize() {
setWidth = if (!isHorizontal) {
minPosterSize
} else {
maxPosterSize
}
setHeight = if (!isHorizontal) {
maxPosterSize
} else {
minPosterSize
}
}
init {
updateCachedPosterSize()
}
protected var setWidth = 0
protected var setHeight = 0
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Boolean> { override fun onCreateContent(parent: ViewGroup): ViewHolderState<Boolean> {
val expanded = parent.context.isBottomLayout() val expanded = parent.context.isBottomLayout()
@ -158,43 +112,52 @@ open class HomeChildItemAdapter(
return HomeScrollViewHolderState(binding) return HomeScrollViewHolderState(binding)
} }
companion object { protected fun applyBinding(holder: ViewHolderState<Boolean>, isFirstItem: Boolean) {
// The vast majority of the lag comes from creating the view val context = holder.view.root.context
// This simply shares the views between all HomeChildItemAdapter val scale = PreferenceManager.getDefaultSharedPreferences(context)
val sharedPool =
newSharedPool { setMaxRecycledViews(CONTENT, 20) }
var minPosterSize: Int = 0
var maxPosterSize: Int = 0
fun updatePosterSize(context: Context, value: Int? = null) {
val scale = value ?: PreferenceManager.getDefaultSharedPreferences(context)
?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0 ?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0
// Scale by +10% per step // Scale by +10% per step
val mul = 1.0f + scale * 0.1f val mul = 1.0f + scale * 0.1f
minPosterSize = (114.toPx.toFloat() * mul).toInt() val min = (114.toPx.toFloat() * mul).toInt()
maxPosterSize = (180.toPx.toFloat() * mul).toInt() val max = (180.toPx.toFloat() * mul).toInt()
}
fun updateLayoutParms(layout: FrameLayout, width: Int, height: Int) {
val params = layout.layoutParams
if (params.height == height && params.width == width) return
params.width = width
params.height = height
layout.layoutParams = params
}
}
protected fun applyBinding(holder: ViewHolderState<Boolean>, isFirstItem: Boolean) {
when (val binding = holder.view) { when (val binding = holder.view) {
is HomeResultGridBinding -> { is HomeResultGridBinding -> {
updateLayoutParms(binding.backgroundCard, setWidth, setHeight) binding.backgroundCard.apply {
layoutParams =
layoutParams.apply {
width = if (!isHorizontal) {
min
} else {
max
}
height = if (!isHorizontal) {
max
} else {
min
}
}
}
} }
is HomeResultGridExpandedBinding -> { is HomeResultGridExpandedBinding -> {
updateLayoutParms(binding.backgroundCard, setWidth, setHeight) binding.backgroundCard.apply {
layoutParams =
layoutParams.apply {
width = if (!isHorizontal) {
min
} else {
max
}
height = if (!isHorizontal) {
max
} else {
min
}
}
}
if (isFirstItem) { // to fix tv if (isFirstItem) { // to fix tv
binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view

View file

@ -5,36 +5,26 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AbsListView import android.widget.*
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.net.toUri
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.lagradost.api.Log import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.AllLanguagesName
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding
import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding
@ -45,18 +35,13 @@ import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.account.AccountViewModel import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.*
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE
import com.lagradost.cloudstream3.ui.search.SearchAdapter
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV 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.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
@ -66,30 +51,22 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide
import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.EmptyEvent import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso
import com.lagradost.cloudstream3.utils.TvChannelUtils
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
import com.lagradost.cloudstream3.utils.UIHelper.toPx import java.util.*
private const val TAG = "HomeFragment"
class HomeFragment : BaseFragment<FragmentHomeBinding>( class HomeFragment : Fragment() {
BindingCreator.Bind(FragmentHomeBinding::bind)
) {
companion object { companion object {
// Used for configuration changed events to fix any popups that are not attached to a fragment val configEvent = Event<Int>()
val configEvent = EmptyEvent()
var currentSpan = 1 var currentSpan = 1
val listHomepageItems = mutableListOf<SearchResponse>()
private val errorProfilePics = listOf( private val errorProfilePics = listOf(
R.drawable.monke_benene, R.drawable.monke_benene,
@ -118,7 +95,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
//} //}
// returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView // returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView
fun Activity.loadHomepageList( fun Activity.loadHomepageList(
expand: HomeViewModel.ExpandableHomepageList, expand: HomeViewModel.ExpandableHomepageList,
deleteCallback: (() -> Unit)? = null, deleteCallback: (() -> Unit)? = null,
@ -200,17 +176,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
// Span settings // Span settings
binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) binding.homeExpandedRecycler.spanCount = currentSpan
binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool)
binding.homeExpandedRecycler.adapter = binding.homeExpandedRecycler.adapter =
SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { callback -> SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback ->
handleSearchClickCallback(callback) handleSearchClickCallback(callback)
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) { if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later
//bottomSheetDialogBuilder.dismissSafe(this) //bottomSheetDialogBuilder.dismissSafe(this)
} }
}.apply { }.apply {
submitList(item.list)
hasNext = expand.hasNext hasNext = expand.hasNext
} }
@ -234,7 +209,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
expandCallback?.invoke(name)?.let { newExpand -> expandCallback?.invoke(name)?.let { newExpand ->
(recyclerView.adapter as? SearchAdapter?)?.apply { (recyclerView.adapter as? SearchAdapter?)?.apply {
hasNext = newExpand.hasNext hasNext = newExpand.hasNext
submitList(newExpand.list.list) updateList(newExpand.list.list)
} }
} }
} }
@ -242,12 +217,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
} }
}) })
val spanListener = Runnable { val spanListener = { span: Int ->
binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) binding.homeExpandedRecycler.spanCount = span
// We want to rebind everything to update the UI, however we also want to avoid //(recycle.adapter as SearchAdapter).notifyDataSetChanged()
// any animations ect, this is the easiest way to do this, and the most correct
@SuppressLint("NotifyDataSetChanged")
binding.homeExpandedRecycler.adapter?.notifyDataSetChanged()
} }
configEvent += spanListener configEvent += spanListener
@ -317,7 +289,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
val pairList = getPairList(header) val pairList = getPairList(header)
for ((button, types) in pairList) { for ((button, types) in pairList) {
button?.isChecked = button?.isChecked =
button.isVisible && selectedTypes.any { types.contains(it) } button?.isVisible == true && selectedTypes.any { types.contains(it) }
} }
} }
@ -411,23 +383,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
val listView = dialog.findViewById<ListView>(R.id.listview1) val listView = dialog.findViewById<ListView>(R.id.listview1)
val arrayAdapter = object : ArrayAdapter<String>( val arrayAdapter = object : ArrayAdapter<String>(this, R.layout.sort_bottom_single_provider_choice,
this, R.layout.sort_bottom_single_provider_choice,
mutableListOf() mutableListOf()
) { ) {
override fun getView( override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
position: Int, val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.sort_bottom_single_provider_choice, parent, false)
convertView: View?,
parent: ViewGroup
): View {
val view = convertView ?: LayoutInflater.from(context)
.inflate(R.layout.sort_bottom_single_provider_choice, parent, false)
val titleText = view.findViewById<TextView>(R.id.text1) val titleText = view.findViewById<TextView>(R.id.text1)
val pinIcon = view.findViewById<ImageView>(R.id.pinicon) val pinIcon = view.findViewById<ImageView>(R.id.pinicon)
val name = getItem(position) val name = getItem(position)
titleText?.text = name titleText?.text = name
val isPinned = val isPinned = pinnedphashset.contains(currentValidApis[position].name ?: "")
pinnedphashset.contains(currentValidApis[position].name)
pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE
return view return view
} }
@ -439,7 +404,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
if (currentValidApis.isNotEmpty()) { if (currentValidApis.isNotEmpty()) {
currentApiName = currentValidApis[i].name currentApiName = currentValidApis[i].name
//to switch to apply simply remove this //to switch to apply simply remove this
currentApiName.let(callback) currentApiName?.let(callback)
dialog.dismissSafe() dialog.dismissSafe()
} }
} }
@ -450,11 +415,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
pinnedphashset = pinnedp.toHashSet() pinnedphashset = pinnedp.toHashSet()
arrayAdapter.clear() arrayAdapter.clear()
val sortedApis = validAPIs val sortedApis = validAPIs
.filter { .filter {it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any(preSelectedTypes::contains)) }
it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any(
preSelectedTypes::contains
))
}
.sortedBy { it.name.lowercase() } .sortedBy { it.name.lowercase() }
val sortedApiMap = LinkedHashMap<String, MainAPI>().apply { val sortedApiMap = LinkedHashMap<String, MainAPI>().apply {
@ -511,71 +472,47 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
} }
private val homeViewModel: HomeViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels()
private val accountViewModel: AccountViewModel by activityViewModels()
fun addMovies(cards: List<SearchResponse>) { var binding: FragmentHomeBinding? = null
val ctx = context ?: run {
Log.e(TAG, "Context is null, aborting addMovies")
return
}
try {
val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name))
if (existingId != null) {
Log.d(TAG, "Channel ID: $existingId")
val programCards = cards
TvChannelUtils.addPrograms(
context = ctx,
channelId = existingId,
items = programCards
)
} else {
Log.d(TAG, "Channel does not exist")
}
} catch (e: Exception) {
Log.e(TAG, "Error adding movies: $e")
}
}
private fun deleteAll() {
val ctx = context ?: run {
Log.e(TAG, "Context is null, aborting deleteAll")
return
}
try {
val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name))
if (existingId != null) {
Log.d(TAG, "Channel ID: $existingId")
TvChannelUtils.deleteStoredPrograms(ctx)
} else {
Log.d(TAG, "Channel does not exist")
}
} catch (e: Exception) {
Log.e(TAG, "Error deleting programs: ${e.message}")
}
}
override fun pickLayout(): Int? =
if (isLayout(PHONE)) R.layout.fragment_home else R.layout.fragment_home_tv
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
//homeViewModel =
// ViewModelProvider(this).get(HomeViewModel::class.java)
bottomSheetDialog?.ownShow() bottomSheetDialog?.ownShow()
return super.onCreateView(inflater, container, savedInstanceState) val layout =
if (isLayout(TV or EMULATOR)) R.layout.fragment_home_tv else R.layout.fragment_home
val root = inflater.inflate(layout, container, false)
binding = try {
FragmentHomeBinding.bind(root)
} catch (t: Throwable) {
showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG)
logError(t)
null
}
return root
} }
override fun onDestroyView() { override fun onDestroyView() {
(activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress")
bottomSheetDialog?.ownHide() bottomSheetDialog?.ownHide()
binding = null
super.onDestroyView() super.onDestroyView()
} }
private fun fixGrid() {
activity?.getSpanCount()?.let {
currentSpan = it
}
configEvent.invoke(currentSpan)
}
private val apiChangeClickListener = View.OnClickListener { view -> private val apiChangeClickListener = View.OnClickListener { view ->
view.context.selectHomepage(currentApiName) { api -> view.context.selectHomepage(currentApiName) { api ->
homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true)
@ -589,94 +526,48 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
}*/ }*/
} }
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
//(home_preview_viewpager?.adapter as? HomeScrollAdapter)?.notifyDataSetChanged()
fixGrid()
}
private var currentApiName: String? = null private var currentApiName: String? = null
private var toggleRandomButton = false private var toggleRandomButton = false
private var bottomSheetDialog: BottomSheetDialog? = null private var bottomSheetDialog: BottomSheetDialog? = null
private var homeMasterAdapter: HomeParentItemAdapterPreview? = null private var homeMasterAdapter: HomeParentItemAdapterPreview? = null
var lastSavedHomepage: String? = null
fun saveHomepageToTV(page: Map<String, HomeViewModel.ExpandableHomepageList>) {
// No need to update for phone
if (isLayout(PHONE)) {
return
}
val (name, data) = page.entries.firstOrNull() ?: return
// Modifying homepage is an expensive operation, and therefore we avoid it at all cost
if (name == lastSavedHomepage) {
return
}
Log.i(TAG, "Adding programs $name to TV")
lastSavedHomepage = name
ioSafe {
// empty the channel
deleteAll()
// insert the program from first array
addMovies(data.list.list)
}
}
override fun fixLayout(view: View) {
fixSystemBarsPadding(
view,
padTop = false,
padBottom = isLandscape(),
padLeft = isLayout(TV or EMULATOR)
)
// Fix grid
configEvent.invoke()
}
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onBindingCreated(binding: FragmentHomeBinding) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
context?.let { HomeChildItemAdapter.updatePosterSize(it) } super.onViewCreated(view, savedInstanceState)
(activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { fixGrid()
handleTvBackPress(this)
} binding?.apply {
binding.apply {
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
homeApiFab.setOnClickListener(apiChangeClickListener) homeApiFab.setOnClickListener(apiChangeClickListener)
homeApiFab.setOnLongClickListener {
if (currentApiName == noneApi.name) return@setOnLongClickListener false
homeViewModel.loadAndCancel(currentApiName, forceReload = true, fromUI = true)
showToast(R.string.action_reload, Toast.LENGTH_SHORT)
true
}
homeChangeApi.setOnClickListener(apiChangeClickListener) homeChangeApi.setOnClickListener(apiChangeClickListener)
homeSwitchAccount.setOnClickListener { homeSwitchAccount.setOnClickListener {
activity?.showAccountSelectLinear() activity?.showAccountSelectLinear()
} }
homeRandom.setOnClickListener {
if (listHomepageItems.isNotEmpty()) {
activity.loadSearchResult(listHomepageItems.random())
}
}
homeMasterAdapter = HomeParentItemAdapterPreview( homeMasterAdapter = HomeParentItemAdapterPreview(
homeViewModel, accountViewModel fragment = this@HomeFragment,
homeViewModel,
) )
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
homeMasterRecycler.adapter = homeMasterAdapter homeMasterRecycler.adapter = homeMasterAdapter
//fixPaddingStatusbar(homeLoadingStatusbar)
homeApiFab.isVisible = isLayout(PHONE) homeApiFab.isVisible = isLayout(PHONE)
homePreviewReloadProvider.setOnClickListener {
homeViewModel.loadAndCancel(
homeViewModel.apiName.value ?: noneApi.name,
forceReload = true,
fromUI = true
)
showToast(R.string.action_reload, Toast.LENGTH_SHORT)
true
}
homePreviewSearchButton.setOnClickListener { _ ->
// Open blank screen.
homeViewModel.queryTextSubmit("")
}
homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (isLayout(PHONE)) {
// Fab is only relevant to Phone
if (dy > 0) { //check for scroll down if (dy > 0) { //check for scroll down
homeApiFab.shrink() // hide homeApiFab.shrink() // hide
homeRandom.shrink() homeRandom.shrink()
@ -686,40 +577,13 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
homeRandom.extend() homeRandom.extend()
} }
} }
} else {
// Header scrolling is only relevant to TV/Emulator
val view = recyclerView.findViewHolderForAdapterPosition(0)?.itemView
val scrollParent = binding.homeApiHolder
if (view == null) {
// The first view is not visible, so we can assume we have scrolled past it
scrollParent.isVisible = false
} else {
// A bit weird, but this is a major limitation we are working around here
// 1. We cant have a real parent to the recyclerview as android cant layout that without lagging
// 2. We cant put the view in the recyclerview, as it should always be shown
// 3. We cant mirror the view in the recyclerview as then it causes focus issues when swaping out the mirror view
//
// This means that if we want to have a parent view to the recyclerview we are out of luck
// Instead this uses getLocationInWindow to calculate how much the view should be scrolled
// as recyclerView has no scrollY (always 0)
//
// Then it manually "scrolls" it to the correct position
//
// Hopefully getLocationInWindow acts correctly on all devices
val rect = IntArray(2)
view.getLocationInWindow(rect)
scrollParent.isVisible = true
scrollParent.translationY = rect[1].toFloat() - 60.toPx
}
}
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
} }
}) })
} }
//Load value for toggling Random button. Hide at startup //Load value for toggling Random button. Hide at startup
context?.let { context?.let {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
@ -727,56 +591,46 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
settingsManager.getBoolean( settingsManager.getBoolean(
getString(R.string.random_button_key), getString(R.string.random_button_key),
false false
) ) && isLayout(PHONE)
binding.homeRandom.visibility = View.GONE binding?.homeRandom?.visibility = View.GONE
binding.homeRandomButtonTv.visibility = View.GONE
} }
observe(homeViewModel.apiName) { apiName -> observe(homeViewModel.apiName) { apiName ->
currentApiName = apiName currentApiName = apiName
binding.apply { binding?.homeApiFab?.text = apiName
homeApiFab.text = apiName binding?.homeChangeApi?.text = apiName
homeChangeApi.text = apiName
homePreviewReloadProvider.isGone = (apiName == noneApi.name)
homePreviewSearchButton.isGone = (apiName == noneApi.name)
}
} }
observe(homeViewModel.page) { data -> observe(homeViewModel.page) { data ->
binding.apply { binding?.apply {
when (data) { when (data) {
is Resource.Success -> { is Resource.Success -> {
homeLoadingShimmer.stopShimmer()
val d = data.value val d = data.value
val mutableListOfResponse = mutableListOf<SearchResponse>()
listHomepageItems.clear()
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map { (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map {
it.copy( it.copy(
list = it.list.copy(list = it.list.list.toMutableList()) list = it.list.copy(list = it.list.list.toMutableList())
) )
}) }.toMutableList())
saveHomepageToTV(d)
homeLoading.isVisible = false homeLoading.isVisible = false
homeLoadingError.isVisible = false homeLoadingError.isVisible = false
homeMasterRecycler.isVisible = true homeMasterRecycler.isVisible = true
homeLoadingShimmer.stopShimmer()
//home_loaded?.isVisible = true //home_loaded?.isVisible = true
if (toggleRandomButton) { if (toggleRandomButton) {
val distinct = d.values //Flatten list
.flatMap { it.list.list } d.values.forEach { dlist ->
.distinctBy { it.url } mutableListOfResponse.addAll(dlist.list.list)
val hasItems = distinct.isNotEmpty()
val isPhone = isLayout(PHONE)
val randomClickListener = View.OnClickListener {
distinct.randomOrNull()?.let { activity.loadSearchResult(it) }
} }
listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url })
homeRandom.isVisible = isPhone && hasItems homeRandom.isVisible = listHomepageItems.isNotEmpty()
homeRandom.setOnClickListener(randomClickListener)
homeRandomButtonTv.isVisible = !isPhone && hasItems
homeRandomButtonTv.setOnClickListener(randomClickListener)
} else { } else {
homeRandom.isGone = true homeRandom.isGone = true
homeRandomButtonTv.isGone = true
} }
} }
@ -794,7 +648,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
}) { }) {
try { try {
val i = Intent(Intent.ACTION_VIEW) val i = Intent(Intent.ACTION_VIEW)
i.data = validAPIs[itemId].mainUrl.toUri() i.data = Uri.parse(validAPIs[itemId].mainUrl)
startActivity(i) startActivity(i)
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
@ -804,7 +658,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
homeLoading.isVisible = false homeLoading.isVisible = false
homeLoadingError.isVisible = true homeLoadingError.isVisible = true
homeMasterRecycler.isInvisible = true homeMasterRecycler.isVisible = false
// Based on https://github.com/recloudstream/cloudstream/pull/1438 // Based on https://github.com/recloudstream/cloudstream/pull/1438
val hasNoNetworkConnection = context?.isNetworkAvailable() == false val hasNoNetworkConnection = context?.isNetworkAvailable() == false
@ -826,28 +680,24 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
homeReloadConnectionGoToDownloads.setOnClickListener { homeReloadConnectionGoToDownloads.setOnClickListener {
activity.navigate(R.id.navigation_downloads) activity.navigate(R.id.navigation_downloads)
} }
(homeMasterRecycler.adapter as? ParentItemAdapter)?.apply {
submitList(null)
clearState()
}
} }
is Resource.Loading -> { is Resource.Loading -> {
(homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(listOf())
homeLoadingShimmer.startShimmer() homeLoadingShimmer.startShimmer()
homeLoading.isVisible = true homeLoading.isVisible = true
homeLoadingError.isVisible = false homeLoadingError.isVisible = false
homeMasterRecycler.isInvisible = true homeMasterRecycler.isVisible = false
(homeMasterRecycler.adapter as? ParentItemAdapter)?.apply {
submitList(null)
clearState()
}
//home_loaded?.isVisible = false //home_loaded?.isVisible = false
} }
} }
} }
} }
//context?.fixPaddingStatusbarView(home_statusbar)
//context?.fixPaddingStatusbar(home_padding)
observeNullable(homeViewModel.popup) { item -> observeNullable(homeViewModel.popup) { item ->
if (item == null) { if (item == null) {
bottomSheetDialog?.dismissSafe() bottomSheetDialog?.dismissSafe()
@ -892,44 +742,4 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
} }
}*/ }*/
} }
private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) {
// Only apply custom behavior on TV interface
if (!isLayout(TV)) {
helper.runDefault()
return
}
val currentFocus = activity?.currentFocus ?: run {
helper.runDefault()
return
}
// isInsideRecycle is true when focus is inside home_master_recycler
var parent = currentFocus.parent
var isInsideRecycler = false
while (parent != null) {
if (parent is View && parent.id == R.id.home_master_recycler) {
isInsideRecycler = true
break
}
parent = parent.parent
}
when {
// Case 1: Focus is within plugin content -> Move to plugin selector
isInsideRecycler -> {
binding?.homeMasterRecycler?.scrollToPosition(0)
// Defer focus request until after scroll ends
binding?.homeChangeApi?.post {
binding?.homeChangeApi?.requestFocus()
}
}
// Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation
currentFocus.id == R.id.home_change_api ||
currentFocus.id == R.id.home_preview_reload_provider ||
currentFocus.id == R.id.home_preview_search_button -> {
activity?.findViewById<View>(R.id.navigation_home)?.requestFocus()
}
// Case 3: Any other location -> Use default back behavior
else -> helper.runDefault()
}
}
} }

View file

@ -6,8 +6,10 @@ import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.HomepageParentBinding import com.lagradost.cloudstream3.databinding.HomepageParentBinding
@ -15,11 +17,9 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseAdapter
import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.newSharedPool
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.setRecycledViewPool
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
@ -34,11 +34,13 @@ class LoadClickCallback(
) )
open class ParentItemAdapter( open class ParentItemAdapter(
open val fragment: Fragment,
id: Int, id: Int,
private val clickCallback: (SearchClickCallback) -> Unit, private val clickCallback: (SearchClickCallback) -> Unit,
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
private val expandCallback: ((String) -> Unit)? = null, private val expandCallback: ((String) -> Unit)? = null,
) : BaseAdapter<HomeViewModel.ExpandableHomepageList, Bundle>( ) : BaseAdapter<HomeViewModel.ExpandableHomepageList, Bundle>(
fragment,
id, id,
diffCallback = BaseDiffCallback( diffCallback = BaseDiffCallback(
itemSame = { a, b -> a.list.name == b.list.name }, itemSame = { a, b -> a.list.name == b.list.name },
@ -46,11 +48,6 @@ open class ParentItemAdapter(
a.list.list == b.list.list a.list.list == b.list.list
}) })
) { ) {
companion object {
val sharedPool =
newSharedPool { setMaxRecycledViews(CONTENT, 4) }
}
data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) { data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState<Bundle>(binding) {
override fun save(): Bundle = Bundle().apply { override fun save(): Bundle = Bundle().apply {
val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview
@ -68,11 +65,8 @@ open class ParentItemAdapter(
} }
} }
override fun submitList( override fun submitList(list: List<HomeViewModel.ExpandableHomepageList>?) {
list: Collection<HomeViewModel.ExpandableHomepageList>?, super.submitList(list?.sortedBy { it.list.list.isEmpty() })
commitCallback: Runnable?
) {
super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback)
} }
override fun onUpdateContent( override fun onUpdateContent(
@ -96,10 +90,8 @@ open class ParentItemAdapter(
if (binding !is HomepageParentBinding) return if (binding !is HomepageParentBinding) return
val info = item.list val info = item.list
binding.apply { binding.apply {
val currentAdapter = homeChildRecyclerview.adapter as? HomeChildItemAdapter
if (currentAdapter == null) {
homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool)
homeChildRecyclerview.adapter = HomeChildItemAdapter( homeChildRecyclerview.adapter = HomeChildItemAdapter(
fragment = fragment,
id = id + position + 100, id = id + position + 100,
clickCallback = clickCallback, clickCallback = clickCallback,
nextFocusUp = homeChildRecyclerview.nextFocusUpId, nextFocusUp = homeChildRecyclerview.nextFocusUpId,
@ -109,17 +101,6 @@ open class ParentItemAdapter(
hasNext = item.hasNext hasNext = item.hasNext
submitList(item.list.list) submitList(item.list.list)
} }
} else {
currentAdapter.apply {
isHorizontal = info.isHorizontalImages
hasNext = item.hasNext
this.clickCallback = this@ParentItemAdapter.clickCallback
nextFocusUp = homeChildRecyclerview.nextFocusUpId
nextFocusDown = homeChildRecyclerview.nextFocusDownId
submitIncomparableList(item.list.list)
}
}
homeChildRecyclerview.setLinearListLayout( homeChildRecyclerview.setLinearListLayout(
isHorizontal = true, isHorizontal = true,
nextLeft = startFocus, nextLeft = startFocus,
@ -185,6 +166,11 @@ open class ParentItemAdapter(
return ParentItemHolder(binding) return ParentItemHolder(binding)
} }
fun updateList(newList: List<HomePageList>) {
submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) }
.toMutableList())
}
} }
@Suppress("DEPRECATION") @Suppress("DEPRECATION")

View file

@ -1,18 +1,16 @@
package com.lagradost.cloudstream3.ui.home package com.lagradost.cloudstream3.ui.home
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.fragment.app.Fragment
import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
@ -20,8 +18,9 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigation.NavigationBarItemView import com.google.android.material.navigation.NavigationBarItemView
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
@ -35,11 +34,9 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog
import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear
import com.lagradost.cloudstream3.ui.account.AccountViewModel import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.selectHomepage
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.ResultViewModel2
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.ui.result.getId
@ -50,23 +47,19 @@ import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.html
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.populateChips
import androidx.core.graphics.toColorInt
import com.lagradost.cloudstream3.ui.setRecycledViewPool
class HomeParentItemAdapterPreview( class HomeParentItemAdapterPreview(
override val fragment: Fragment,
private val viewModel: HomeViewModel, private val viewModel: HomeViewModel,
private val accountViewModel: AccountViewModel
) : ParentItemAdapter( ) : ParentItemAdapter(
id = "HomeParentItemAdapterPreview".hashCode(), fragment, id = "HomeParentItemAdapterPreview".hashCode(),
clickCallback = { clickCallback = {
viewModel.click(it) viewModel.click(it)
}, moreInfoClickCallback = { }, moreInfoClickCallback = {
@ -104,33 +97,15 @@ class HomeParentItemAdapterPreview(
) )
} }
return HeaderViewHolder(binding, viewModel, accountViewModel) return HeaderViewHolder(binding, viewModel, fragment = fragment)
} }
override fun onBindHeader(holder: ViewHolderState<Bundle>) { override fun onBindHeader(holder: ViewHolderState<Bundle>) {
(holder as? HeaderViewHolder)?.bind() (holder as? HeaderViewHolder)?.bind()
} }
override fun onViewDetachedFromWindow(holder: ViewHolderState<Bundle>) {
when (holder) {
is HeaderViewHolder -> {
holder.onViewDetachedFromWindow()
}
}
}
override fun onViewAttachedToWindow(holder: ViewHolderState<Bundle>) {
when (holder) {
is HeaderViewHolder -> {
holder.onViewAttachedToWindow()
}
}
}
private class HeaderViewHolder( private class HeaderViewHolder(
val binding: ViewBinding, val binding: ViewBinding, val viewModel: HomeViewModel, fragment: Fragment,
val viewModel: HomeViewModel,
accountViewModel: AccountViewModel,
) : ) :
ViewHolderState<Bundle>(binding) { ViewHolderState<Bundle>(binding) {
@ -156,13 +131,9 @@ class HomeParentItemAdapterPreview(
} }
} }
val previewAdapter = HomeScrollAdapter { view, position, item -> val previewAdapter = HomeScrollAdapter(fragment = fragment)
viewModel.click(
LoadClickCallback(0, view, position, item)
)
}
private val resumeAdapter = ResumeItemAdapter( private val resumeAdapter = ResumeItemAdapter(
fragment,
nextFocusUp = itemView.nextFocusUpId, nextFocusUp = itemView.nextFocusUpId,
nextFocusDown = itemView.nextFocusDownId, nextFocusDown = itemView.nextFocusDownId,
removeCallback = { v -> removeCallback = { v ->
@ -245,6 +216,7 @@ class HomeParentItemAdapterPreview(
} }
}) })
private val bookmarkAdapter = HomeChildItemAdapter( private val bookmarkAdapter = HomeChildItemAdapter(
fragment,
id = "bookmarkAdapter".hashCode(), id = "bookmarkAdapter".hashCode(),
nextFocusUp = itemView.nextFocusUpId, nextFocusUp = itemView.nextFocusUpId,
nextFocusDown = itemView.nextFocusDownId nextFocusDown = itemView.nextFocusDownId
@ -321,14 +293,9 @@ class HomeParentItemAdapterPreview(
private val bookmarkRecyclerView: RecyclerView = private val bookmarkRecyclerView: RecyclerView =
itemView.findViewById(R.id.home_bookmarked_child_recyclerview) itemView.findViewById(R.id.home_bookmarked_child_recyclerview)
private val headProfilePic: ImageView? = itemView.findViewById(R.id.home_head_profile_pic) private val homeAccount: View? = itemView.findViewById(R.id.home_preview_switch_account)
private val headProfilePicCard: View? = private val alternativeHomeAccount: View? =
itemView.findViewById(R.id.home_head_profile_padding) itemView.findViewById(R.id.alternative_switch_account)
private val alternateHeadProfilePic: ImageView? =
itemView.findViewById(R.id.alternate_home_head_profile_pic)
private val alternateHeadProfilePicCard: View? =
itemView.findViewById(R.id.alternate_home_head_profile_padding)
private val topPadding: View? = itemView.findViewById(R.id.home_padding) private val topPadding: View? = itemView.findViewById(R.id.home_padding)
@ -339,73 +306,38 @@ class HomeParentItemAdapterPreview(
fun onSelect(item: LoadResponse, position: Int) { fun onSelect(item: LoadResponse, position: Int) {
(binding as? FragmentHomeHeadTvBinding)?.apply { (binding as? FragmentHomeHeadTvBinding)?.apply {
homePreviewDescription.isGone = item.plot.isNullOrBlank() homePreviewDescription.isGone =
homePreviewDescription.text = item.plot?.html() ?: "" item.plot.isNullOrBlank()
homePreviewDescription.text =
item.plot ?: ""
val scoreText = item.score?.toStringNull(0.1, 10, 1, false) homePreviewText.text = item.name
scoreText?.let { score ->
homePreviewScore.text =
homePreviewScore.context.getString(R.string.extension_rating, score)
// while it should never fail, we do this just in case
val rating = score.toDoubleOrNull() ?: item.score?.toDouble() ?: 0.0
val color = when {
rating < 5.0 -> "#eb2f2f".toColorInt() // Red
rating < 8.0 -> "#eda009".toColorInt() // Yellow
else -> "#3bb33b".toColorInt() // Green
}
homePreviewScore.backgroundTintList =
android.content.res.ColorStateList.valueOf(color)
}
homePreviewScore.isGone = scoreText == null
item.year?.let { year ->
homePreviewYear.text = year.toString()
}
homePreviewYear.isGone = item.year == null
val duration = item.duration
duration?.let { min ->
homePreviewDuration.text =
homePreviewDuration.context.getString(R.string.duration_format, min)
}
homePreviewDuration.isGone = duration == null || duration <= 0
val castText = item.actors?.take(3)?.joinToString(", ") { it.actor.name }
if (!castText.isNullOrBlank()) {
homePreviewCast.text =
homePreviewCast.context.getString(R.string.cast_format, castText)
homePreviewCast.isVisible = true
} else {
homePreviewCast.isVisible = false
}
homePreviewText.text = item.name.html()
populateChips( populateChips(
homePreviewTags, homePreviewTags,
item.tags?.take(6) ?: emptyList(), item.tags?.take(6) ?: emptyList(),
R.style.ChipFilledSemiTransparent, R.style.ChipFilledSemiTransparent
null
)
bindLogo(
url = item.logoUrl,
headers = item.posterHeaders,
titleView = homePreviewText,
logoView = homeBackgroundPosterWatermarkBadgeHolder
) )
homePreviewTags.isGone = homePreviewTags.isGone =
item.tags.isNullOrEmpty() item.tags.isNullOrEmpty()
homePreviewPlayBtt.setOnClickListener { view ->
viewModel.click(
LoadClickCallback(
START_ACTION_RESUME_LATEST,
view,
position,
item
)
)
}
homePreviewInfoBtt.setOnClickListener { view -> homePreviewInfoBtt.setOnClickListener { view ->
viewModel.click( viewModel.click(
LoadClickCallback(0, view, position, item) LoadClickCallback(0, view, position, item)
) )
} }
} }
(binding as? FragmentHomeHeadBinding)?.apply { (binding as? FragmentHomeHeadBinding)?.apply {
//homePreviewImage.setImage(item.posterUrl, item.posterHeaders) //homePreviewImage.setImage(item.posterUrl, item.posterHeaders)
@ -490,7 +422,7 @@ class HomeParentItemAdapterPreview(
} }
} }
fun onViewDetachedFromWindow() { override fun onViewDetachedFromWindow() {
previewViewpager.unregisterOnPageChangeCallback(previewCallback) previewViewpager.unregisterOnPageChangeCallback(previewCallback)
} }
@ -511,14 +443,12 @@ class HomeParentItemAdapterPreview(
previewViewpager.adapter = previewAdapter previewViewpager.adapter = previewAdapter
resumeRecyclerView.adapter = resumeAdapter resumeRecyclerView.adapter = resumeAdapter
bookmarkRecyclerView.setRecycledViewPool(HomeChildItemAdapter.sharedPool)
bookmarkRecyclerView.adapter = bookmarkAdapter bookmarkRecyclerView.adapter = bookmarkAdapter
resumeRecyclerView.setLinearListLayout( resumeRecyclerView.setLinearListLayout(
nextLeft = R.id.nav_rail_view, nextLeft = R.id.nav_rail_view,
nextRight = FOCUS_SELF nextRight = FOCUS_SELF
) )
bookmarkRecyclerView.setLinearListLayout( bookmarkRecyclerView.setLinearListLayout(
nextLeft = R.id.nav_rail_view, nextLeft = R.id.nav_rail_view,
nextRight = FOCUS_SELF nextRight = FOCUS_SELF
@ -539,80 +469,36 @@ class HomeParentItemAdapterPreview(
} }
} }
headProfilePicCard?.isGone = isLayout(TV or EMULATOR) homeAccount?.isGone = isLayout(TV or EMULATOR)
alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR)
(headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount -> homeAccount?.setOnClickListener {
headProfilePic?.loadImage(currentAccount?.image)
alternateHeadProfilePic?.loadImage(currentAccount?.image)
}
headProfilePicCard?.setOnClickListener {
activity?.showAccountSelectLinear() activity?.showAccountSelectLinear()
} }
fun showAccountEditBox(context: Context): Boolean { alternativeHomeAccount?.setOnClickListener {
val currentAccount = DataStoreHelper.getCurrentAccount()
return if (currentAccount != null) {
showAccountEditDialog(
context = context,
account = currentAccount,
isNewAccount = false,
accountEditCallback = { accountViewModel.handleAccountUpdate(it, context) },
accountDeleteCallback = {
accountViewModel.handleAccountDelete(
it,
context
)
}
)
true
} else false
}
alternateHeadProfilePicCard?.setOnLongClickListener {
showAccountEditBox(it.context)
}
headProfilePicCard?.setOnLongClickListener {
showAccountEditBox(it.context)
}
alternateHeadProfilePicCard?.setOnClickListener {
activity?.showAccountSelectLinear() activity?.showAccountSelectLinear()
} }
(binding as? FragmentHomeHeadTvBinding)?.apply { (binding as? FragmentHomeHeadTvBinding)?.apply {
/*homePreviewChangeApi.setOnClickListener { view -> homePreviewChangeApi.setOnClickListener { view ->
view.context.selectHomepage(viewModel.repo?.name) { api -> view.context.selectHomepage(viewModel.repo?.name) { api ->
viewModel.loadAndCancel(api, forceReload = true, fromUI = true) viewModel.loadAndCancel(api, forceReload = true, fromUI = true)
} }
} }
homePreviewReloadProvider.setOnClickListener {
viewModel.loadAndCancel(
viewModel.apiName.value ?: noneApi.name,
forceReload = true,
fromUI = true
)
showToast(R.string.action_reload, Toast.LENGTH_SHORT)
true
}
homePreviewSearchButton.setOnClickListener { _ -> homePreviewSearchButton.setOnClickListener { _ ->
// Open blank screen. // Open blank screen.
viewModel.queryTextSubmit("") viewModel.queryTextSubmit("")
}*/
// A workaround to the focus problem of always centering the view on focus
// as that causes higher android versions to stretch the ui when switching between shows
var lastFocusTimeoutMs = 0L
homePreviewInfoBtt.setOnFocusChangeListener { view, hasFocus ->
val lastFocusMs = lastFocusTimeoutMs
// Always reset timer, as we only want to update
// it if we have not interacted in half a second
lastFocusTimeoutMs = System.currentTimeMillis()
if (!hasFocus) return@setOnFocusChangeListener
if (lastFocusMs + 500L < System.currentTimeMillis()) {
MainActivity.centerView(view)
} }
// This makes the hidden next buttons only available when on the info button
// Otherwise you might be able to go to the next item without being at the info button
homePreviewInfoBtt.setOnFocusChangeListener { _, hasFocus ->
homePreviewHiddenNextFocus.isFocusable = hasFocus
}
homePreviewPlayBtt.setOnFocusChangeListener { _, hasFocus ->
homePreviewHiddenPrevFocus.isFocusable = hasFocus
} }
homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus ->
@ -630,8 +516,7 @@ class HomeParentItemAdapterPreview(
)?.requestFocus() )?.requestFocus()
} else { } else {
previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true) previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true)
binding.homePreviewInfoBtt.requestFocus() binding.homePreviewPlayBtt.requestFocus()
//binding.homePreviewPlayBtt.requestFocus()
} }
} }
} }
@ -658,7 +543,9 @@ class HomeParentItemAdapterPreview(
params.height = 0 params.height = 0
layoutParams = params layoutParams = params
} }
} else fixPaddingStatusbarView(homeNonePadding) } else {
fixPaddingStatusbarView(homeNonePadding)
}
when (preview) { when (preview) {
is Resource.Success -> { is Resource.Success -> {
@ -682,15 +569,6 @@ class HomeParentItemAdapterPreview(
previewViewpager.isVisible = true previewViewpager.isVisible = true
previewViewpagerText.isVisible = true previewViewpagerText.isVisible = true
alternativeAccountPadding?.isVisible = false alternativeAccountPadding?.isVisible = false
(binding as? FragmentHomeHeadTvBinding)?.apply {
homePreviewInfoBtt.isVisible = true
}
// Explicitly bind the current item to ensure instant loading
val currentPos = previewViewpager.currentItem
val item = preview.value.second.getOrNull(currentPos)
if (item != null) {
onSelect(item, currentPos)
}
} }
else -> { else -> {
@ -699,9 +577,6 @@ class HomeParentItemAdapterPreview(
previewViewpager.isVisible = false previewViewpager.isVisible = false
previewViewpagerText.isVisible = false previewViewpagerText.isVisible = false
alternativeAccountPadding?.isVisible = true alternativeAccountPadding?.isVisible = true
(binding as? FragmentHomeHeadTvBinding)?.apply {
homePreviewInfoBtt.isVisible = false
}
//previewHeader.isVisible = false //previewHeader.isVisible = false
} }
} }
@ -770,19 +645,18 @@ class HomeParentItemAdapterPreview(
} }
} }
fun onViewAttachedToWindow() { override fun onViewAttachedToWindow() {
previewViewpager.registerOnPageChangeCallback(previewCallback) previewViewpager.registerOnPageChangeCallback(previewCallback)
previewViewpager.apply { binding.root.findViewTreeLifecycleOwner()?.apply {
observe(viewModel.preview) { observe(viewModel.preview) {
updatePreview(it) updatePreview(it)
} }
/*if (binding is FragmentHomeHeadTvBinding) { if (binding is FragmentHomeHeadTvBinding) {
observe(viewModel.apiName) { name -> observe(viewModel.apiName) { name ->
binding.homePreviewChangeApi.text = name binding.homePreviewChangeApi.text = name
binding.homePreviewReloadProvider.isGone = (name == noneApi.name)
} }
}*/ }
observe(viewModel.resumeWatching) { observe(viewModel.resumeWatching) {
updateResume(it) updateResume(it)
} }
@ -798,7 +672,7 @@ class HomeParentItemAdapterPreview(
} }
toggleListHolder?.isGone = visible.isEmpty() toggleListHolder?.isGone = visible.isEmpty()
} }
} } ?: debugException { "Expected findViewTreeLifecycleOwner" }
} }
} }
} }

View file

@ -1,27 +1,23 @@
package com.lagradost.cloudstream3.ui.home package com.lagradost.cloudstream3.ui.home
import android.content.res.Configuration
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.fragment.app.Fragment
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding
import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.html
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
class HomeScrollAdapter( class HomeScrollAdapter(
val callback: ((View, Int, LoadResponse) -> Unit) fragment: Fragment
) : NoStateAdapter<LoadResponse>(diffCallback = BaseDiffCallback(itemSame = { a, b -> ) : NoStateAdapter<LoadResponse>(fragment) {
a.uniqueUrl == b.uniqueUrl && a.name == b.name
})) {
var hasMoreItems: Boolean = false var hasMoreItems: Boolean = false
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> { override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> {
@ -35,26 +31,19 @@ class HomeScrollAdapter(
return ViewHolderState(binding) return ViewHolderState(binding)
} }
override fun onClearView(holder: ViewHolderState<Any>) {
when (val binding = holder.view) {
is HomeScrollViewBinding -> {
clearImage(binding.homeScrollPreview)
}
is HomeScrollViewTvBinding -> {
clearImage(binding.homeScrollPreview)
}
}
}
override fun onBindContent( override fun onBindContent(
holder: ViewHolderState<Any>, holder: ViewHolderState<Any>,
item: LoadResponse, item: LoadResponse,
position: Int, position: Int,
) { ) {
val binding = holder.view val binding = holder.view
val itemView = holder.itemView
val isHorizontal =
binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val posterUrl = item.backgroundPosterUrl ?: item.posterUrl val posterUrl =
if (isHorizontal) item.backgroundPosterUrl ?: item.posterUrl else item.posterUrl
?: item.backgroundPosterUrl
when (binding) { when (binding) {
is HomeScrollViewBinding -> { is HomeScrollViewBinding -> {
@ -64,21 +53,10 @@ class HomeScrollAdapter(
isGone = item.tags.isNullOrEmpty() isGone = item.tags.isNullOrEmpty()
maxLines = 2 maxLines = 2
} }
binding.homeScrollPreviewTitle.text = item.name.html() binding.homeScrollPreviewTitle.text = item.name
bindLogo(
url = item.logoUrl,
headers = item.posterHeaders,
titleView = binding.homeScrollPreviewTitle,
logoView = binding.homePreviewLogo
)
} }
is HomeScrollViewTvBinding -> { is HomeScrollViewTvBinding -> {
binding.homeScrollPreview.isFocusable = false
binding.homeScrollPreview.setOnClickListener { view ->
callback.invoke(view ?: return@setOnClickListener, position, item)
}
binding.homeScrollPreview.loadImage(posterUrl) binding.homeScrollPreview.loadImage(posterUrl)
} }
} }

View file

@ -7,14 +7,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
@ -40,7 +40,6 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilm
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
@ -50,12 +49,13 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.EnumSet import java.util.EnumSet
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import kotlin.collections.set
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
companion object { companion object {
@ -67,27 +67,12 @@ class HomeViewModel : ViewModel() {
} }
val resumeWatchingResult = withContext(Dispatchers.IO) { val resumeWatchingResult = withContext(Dispatchers.IO) {
resumeWatching?.mapNotNull { resume -> resumeWatching?.mapNotNull { resume ->
val headerCache = getKey<DownloadObjects.DownloadHeaderCached>(
val data = getKey<VideoDownloadHelper.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE, DOWNLOAD_HEADER_CACHE,
resume.parentId.toString() resume.parentId.toString()
)
val data = if (headerCache == null) {
// We store resume watching data in download header cache
// Because downloads automatically pruned outdated download headers we
// removed resume watching data. We should restore the data for affected users.
val oldData = getKey<DownloadObjects.DownloadHeaderCached>(
DOWNLOAD_HEADER_CACHE_BACKUP,
resume.parentId.toString()
) ?: return@mapNotNull null ) ?: return@mapNotNull null
// Restore data
setKey(DOWNLOAD_HEADER_CACHE, resume.parentId.toString(), oldData)
oldData
} else {
headerCache
}
val watchPos = getViewPos(resume.episodeId) val watchPos = getViewPos(resume.episodeId)
DataStoreHelper.ResumeWatchingResult( DataStoreHelper.ResumeWatchingResult(
@ -133,7 +118,7 @@ class HomeViewModel : ViewModel() {
private var currentShuffledList: List<SearchResponse> = listOf() private var currentShuffledList: List<SearchResponse> = listOf()
private fun autoloadRepo(): APIRepository { private fun autoloadRepo(): APIRepository {
return APIRepository(apis.withLock { apis.first { it.hasMainPage } }) return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } })
} }
private val _availableWatchStatusTypes = private val _availableWatchStatusTypes =
@ -535,12 +520,12 @@ class HomeViewModel : ViewModel() {
} else if (api == null) { } else if (api == null) {
// API is not found aka not loaded or removed, post the loading // API is not found aka not loaded or removed, post the loading
// progress if waiting for plugins, otherwise nothing // progress if waiting for plugins, otherwise nothing
if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) { if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) {
loadAndCancel(noneApi) loadAndCancel(noneApi)
} else { } else {
_page.postValue(Resource.Loading()) _page.postValue(Resource.Loading())
if (preferredApiName != null) if (preferredApiName != null)
_apiName.postValue(preferredApiName) _apiName.postValue(preferredApiName!!)
} }
} else { } else {
// if the api is found, then set it to it and save key // if the api is found, then set it to it and save key

View file

@ -7,16 +7,22 @@ import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS
import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS
import android.view.animation.AlphaAnimation import android.view.animation.AlphaAnimation
import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.allViews import androidx.core.view.allViews
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -24,33 +30,35 @@ import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder
import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.txt
import com.lagradost.cloudstream3.ui.BaseFragment
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.TV
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.abs import kotlin.math.abs
@ -76,10 +84,10 @@ data class ProviderLibraryData(
val apiName: String val apiName: String
) )
class LibraryFragment : BaseFragment<FragmentLibraryBinding>( class LibraryFragment : Fragment() {
BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind)
) {
companion object { companion object {
val listLibraryItems = mutableListOf<SyncAPI.LibraryItem>()
fun newInstance() = LibraryFragment() fun newInstance() = LibraryFragment()
/** /**
@ -90,10 +98,35 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
private val libraryViewModel: LibraryViewModel by activityViewModels() private val libraryViewModel: LibraryViewModel by activityViewModels()
var binding: FragmentLibraryBinding? = null
private var toggleRandomButton = false private var toggleRandomButton = false
override fun pickLayout(): Int? = override fun onCreateView(
if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
val layout =
if (isLayout(TV or EMULATOR)) R.layout.fragment_library_tv else R.layout.fragment_library
val root = inflater.inflate(layout, container, false)
binding = try {
FragmentLibraryBinding.bind(root)
} catch (t: Throwable) {
CommonActivity.showToast(
txt(R.string.unable_to_inflate, t.message ?: ""),
Toast.LENGTH_LONG
)
logError(t)
null
}
return root
//return inflater.inflate(R.layout.fragment_library, container, false)
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
binding?.viewpager?.currentItem?.let { currentItem -> binding?.viewpager?.currentItem?.let { currentItem ->
@ -102,52 +135,48 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
private fun updateRandomVisibility(binding: FragmentLibraryBinding) { private fun updateRandom() {
if (!toggleRandomButton) {
binding.libraryRandom.isGone = true
binding.libraryRandomButtonTv.isGone = true
return
}
val position = libraryViewModel.currentPage.value ?: 0 val position = libraryViewModel.currentPage.value ?: 0
val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return
val hasItems = pages[position].items.isNotEmpty() if (toggleRandomButton) {
val isPhone = isLayout(PHONE) listLibraryItems.clear()
listLibraryItems.addAll(pages[position].items)
binding.libraryRandom.isVisible = isPhone && hasItems binding?.libraryRandom?.isVisible = listLibraryItems.isNotEmpty()
binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems } else {
binding?.libraryRandom?.isGone = true
} }
override fun fixLayout(view: View) {
fixSystemBarsPadding(
view,
padBottom = isLandscape(),
padLeft = !isLayout(PHONE)
)
} }
@SuppressLint("ResourceType", "CutPasteId") @SuppressLint("ResourceType", "CutPasteId")
override fun onBindingCreated( override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding: FragmentLibraryBinding, super.onViewCreated(view, savedInstanceState)
savedInstanceState: Bundle? fixPaddingStatusbar(binding?.searchStatusBarPadding)
) {
binding.sortFab.setOnClickListener(sortChangeClickListener)
binding.librarySort.setOnClickListener(sortChangeClickListener)
binding.libraryRoot.findViewById<TextView>(androidx.appcompat.R.id.search_src_text) binding?.sortFab?.setOnClickListener(sortChangeClickListener)
?.apply { binding?.librarySort?.setOnClickListener(sortChangeClickListener)
binding?.libraryRoot?.findViewById<TextView>(androidx.appcompat.R.id.search_src_text)?.apply {
tag = "tv_no_focus_tag" tag = "tv_no_focus_tag"
//Expand the Appbar when search bar is focused, fixing scroll up issue //Expand the Appbar when search bar is focused, fixing scroll up issue
setOnFocusChangeListener { _, _ -> setOnFocusChangeListener { _, _ ->
binding.searchBar.setExpanded(true) binding?.searchBar?.setExpanded(true)
} }
} }
// Set the color for the search exit icon to the correct theme text color
val searchExitIcon =
binding?.mainSearch?.findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)
val searchExitIconColor = TypedValue()
activity?.theme?.resolveAttribute(android.R.attr.textColor, searchExitIconColor, true)
searchExitIcon?.setColorFilter(searchExitIconColor.data)
val searchCallback = Runnable { val searchCallback = Runnable {
val newText = binding.mainSearch.query.toString() val newText = binding?.mainSearch?.query?.toString() ?: return@Runnable
libraryViewModel.sort(ListSorting.Query, newText) libraryViewModel.sort(ListSorting.Query, newText)
} }
binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
libraryViewModel.sort(ListSorting.Query, query) libraryViewModel.sort(ListSorting.Query, query)
return true return true
@ -163,11 +192,11 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
return true return true
} }
binding.mainSearch.removeCallbacks(searchCallback) binding?.mainSearch?.removeCallbacks(searchCallback)
// Delay the execution of the search operation by 1 second (adjust as needed) // Delay the execution of the search operation by 1 second (adjust as needed)
// this prevents running search when the user is typing // this prevents running search when the user is typing
binding.mainSearch.postDelayed(searchCallback, 1000) binding?.mainSearch?.postDelayed(searchCallback, 1000)
return true return true
} }
@ -175,12 +204,11 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
libraryViewModel.reloadPages(false) libraryViewModel.reloadPages(false)
binding.listSelector.setOnClickListener { binding?.listSelector?.setOnClickListener {
val items = libraryViewModel.availableApiNames val items = libraryViewModel.availableApiNames
val currentItem = libraryViewModel.currentApiName.value val currentItem = libraryViewModel.currentApiName.value
activity?.showBottomDialog( activity?.showBottomDialog(items,
items,
items.indexOf(currentItem), items.indexOf(currentItem),
txt(R.string.select_library).asString(it.context), txt(R.string.select_library).asString(it.context),
false, false,
@ -197,9 +225,17 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
settingsManager.getBoolean( settingsManager.getBoolean(
getString(R.string.random_button_key), getString(R.string.random_button_key),
false false
) ) && isLayout(PHONE)
binding.libraryRandom.visibility = View.GONE binding?.libraryRandom?.visibility = View.GONE
binding.libraryRandomButtonTv.visibility = View.GONE }
binding?.libraryRandom?.setOnClickListener {
if (listLibraryItems.isNotEmpty()) {
val listLibraryItem = listLibraryItems.random()
libraryViewModel.currentSyncApi?.syncIdName?.let {
loadLibraryItem(it, listLibraryItem.syncId, listLibraryItem)
}
}
} }
/** /**
@ -210,13 +246,14 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
syncId: SyncIdName, syncId: SyncIdName,
apiName: String? = null, apiName: String? = null,
) { ) {
val availableProviders = allProviders.filter { val availableProviders = synchronized(allProviders) {
allProviders.filter {
it.supportedSyncNames.contains(syncId) it.supportedSyncNames.contains(syncId)
}.map { it.name } + }.map { it.name } +
// Add the api if it exists // Add the api if it exists
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
?: emptyList()) ?: emptyList())
}
val baseOptions = listOf( val baseOptions = listOf(
LibraryOpenerType.Default, LibraryOpenerType.Default,
LibraryOpenerType.None, LibraryOpenerType.None,
@ -268,21 +305,22 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
} }
} }
binding.providerSelector.setOnClickListener { binding?.providerSelector?.setOnClickListener {
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
activity?.showPluginSelectionDialog(syncName.name, syncName) activity?.showPluginSelectionDialog(syncName.name, syncName)
} }
binding.viewpager.setPageTransformer(LibraryScrollTransformer()) binding?.viewpager?.setPageTransformer(LibraryScrollTransformer())
binding.viewpager.adapter = ViewpagerAdapter( binding?.viewpager?.adapter = ViewpagerAdapter(
fragment = this,
{ isScrollingDown: Boolean -> { isScrollingDown: Boolean ->
if (isScrollingDown) { if (isScrollingDown) {
binding.sortFab.shrink() binding?.sortFab?.shrink()
binding.libraryRandom.shrink() binding?.libraryRandom?.shrink()
} else { } else {
binding.sortFab.extend() binding?.sortFab?.extend()
binding.libraryRandom.extend() binding?.libraryRandom?.extend()
} }
}) callback@{ searchClickCallback -> }) callback@{ searchClickCallback ->
// To prevent future accidents // To prevent future accidents
@ -315,15 +353,15 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
} }
} }
binding.apply { binding?.apply {
viewpager.offscreenPageLimit = 2 viewpager.offscreenPageLimit = 2
viewpager.reduceDragSensitivity() viewpager.reduceDragSensitivity()
searchBar.setExpanded(true) searchBar.setExpanded(true)
} }
val startLoading = Runnable { val startLoading = Runnable {
binding.apply { binding?.apply {
gridview.numColumns = root.context.getSpanCount() gridview.numColumns = context?.getSpanCount() ?: 3
gridview.adapter = gridview.adapter =
context?.let { LoadingPosterAdapter(it, 6 * 3) } context?.let { LoadingPosterAdapter(it, 6 * 3) }
libraryLoadingOverlay.isVisible = true libraryLoadingOverlay.isVisible = true
@ -333,7 +371,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
} }
val stopLoading = Runnable { val stopLoading = Runnable {
binding.apply { binding?.apply {
gridview.adapter = null gridview.adapter = null
libraryLoadingOverlay.isVisible = false libraryLoadingOverlay.isVisible = false
libraryLoadingShimmer.stopShimmer() libraryLoadingShimmer.stopShimmer()
@ -349,7 +387,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
val pages = resource.value val pages = resource.value
val showNotice = pages.all { it.items.isEmpty() } val showNotice = pages.all { it.items.isEmpty() }
binding.apply { binding?.apply {
emptyListTextview.isVisible = showNotice emptyListTextview.isVisible = showNotice
if (showNotice) { if (showNotice) {
if (libraryViewModel.availableApiNames.size > 1) { if (libraryViewModel.availableApiNames.size > 1) {
@ -377,23 +415,10 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
)*/ )*/
libraryViewModel.currentPage.value?.let { page -> libraryViewModel.currentPage.value?.let { page ->
binding.viewpager.setCurrentItem(page, false) binding?.viewpager?.setCurrentItem(page, false)
binding.searchBar.setExpanded(true)
} }
// Set up random button click listener updateRandom()
if (toggleRandomButton) {
val randomClickListener = View.OnClickListener {
val position = libraryViewModel.currentPage.value ?: 0
val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener
pages[position].items.randomOrNull()?.let { item ->
loadLibraryItem(syncIdName, item.syncId, item)
}
}
libraryRandom.setOnClickListener(randomClickListener)
libraryRandomButtonTv.setOnClickListener(randomClickListener)
}
updateRandomVisibility(binding)
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
// Without this there would be a flashing effect: // Without this there would be a flashing effect:
@ -434,20 +459,21 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
tab.view.nextFocusDownId = R.id.search_result_root tab.view.nextFocusDownId = R.id.search_result_root
tab.view.setOnClickListener { tab.view.setOnClickListener {
val currentItem = binding.viewpager.currentItem val currentItem =
binding?.viewpager?.currentItem ?: return@setOnClickListener
val distance = abs(position - currentItem) val distance = abs(position - currentItem)
hideViewpager(distance) hideViewpager(distance)
} }
//Expand the appBar on tab focus //Expand the appBar on tab focus
tab.view.setOnFocusChangeListener { _, _ -> tab.view.setOnFocusChangeListener { _, _ ->
binding.searchBar.setExpanded(true) binding?.searchBar?.setExpanded(true)
} }
}.attach() }.attach()
binding.libraryTabLayout.addOnTabSelectedListener(object : binding?.libraryTabLayout?.addOnTabSelectedListener(object :
TabLayout.OnTabSelectedListener { TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) { override fun onTabSelected(tab: TabLayout.Tab?) {
binding.libraryTabLayout.selectedTabPosition.let { page -> binding?.libraryTabLayout?.selectedTabPosition?.let { page ->
libraryViewModel.switchPage(page) libraryViewModel.switchPage(page)
} }
} }
@ -472,11 +498,11 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
} }
observe(libraryViewModel.currentPage) { position -> observe(libraryViewModel.currentPage) { position ->
updateRandomVisibility(binding) updateRandom()
val all = binding.viewpager.allViews.toList() val all = binding?.viewpager?.allViews?.toList()
.filterIsInstance<AutofitRecyclerView>() ?.filterIsInstance<AutofitRecyclerView>()
all.forEach { view -> all?.forEach { view ->
view.isVisible = view.tag == position view.isVisible = view.tag == position
view.isFocusable = view.tag == position view.isFocusable = view.tag == position
@ -486,6 +512,14 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS
} }
} }
/*binding?.viewpager?.registerOnPageChangeCallback(object :
ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
}
})*/
} }
private fun loadLibraryItem( private fun loadLibraryItem(
@ -544,10 +578,10 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
} }
@SuppressLint("NotifyDataSetChanged")
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
binding?.viewpager?.adapter?.notifyDataSetChanged()
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
val adapter = binding?.viewpager?.adapter ?: return
adapter.notifyItemRangeChanged(0, adapter.itemCount)
} }
private val sortChangeClickListener = View.OnClickListener { view -> private val sortChangeClickListener = View.OnClickListener { view ->
@ -555,8 +589,7 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
txt(it.stringRes).asString(view.context) txt(it.stringRes).asString(view.context)
} }
activity?.showBottomDialog( activity?.showBottomDialog(methods,
methods,
libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod),
txt(R.string.sort_by).asString(view.context), txt(R.string.sort_by).asString(view.context),
false, false,

View file

@ -4,8 +4,8 @@ import androidx.annotation.StringRes
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource

View file

@ -1,34 +1,31 @@
package com.lagradost.cloudstream3.ui.library package com.lagradost.cloudstream3.ui.library
import android.graphics.Color
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.graphics.ColorUtils
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.BaseDiffCallback
import com.lagradost.cloudstream3.ui.NoStateAdapter
import com.lagradost.cloudstream3.ui.ViewHolderState
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import kotlin.math.roundToInt import kotlin.math.roundToInt
class PageAdapter( class PageAdapter(
override val items: MutableList<SyncAPI.LibraryItem>,
private val resView: AutofitRecyclerView, private val resView: AutofitRecyclerView,
val clickCallback: (SearchClickCallback) -> Unit val clickCallback: (SearchClickCallback) -> Unit
) : ) :
NoStateAdapter<SyncAPI.LibraryItem>(diffCallback = BaseDiffCallback(itemSame = { a, b -> AppContextUtils.DiffAdapter<SyncAPI.LibraryItem>(items) {
if (a.id != null || b.id != null) {
a.id == b.id
} else {
a.name == b.name && a.url == b.url
}
})) {
private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt()
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Any> { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return ViewHolderState( return LibraryItemViewHolder(
SearchResultGridExpandedBinding.inflate( SearchResultGridExpandedBinding.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
parent, parent,
@ -37,45 +34,86 @@ class PageAdapter(
) )
} }
override fun onClearView(holder: ViewHolderState<Any>) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val binding = holder.view) { when (holder) {
is SearchResultGridExpandedBinding -> { is LibraryItemViewHolder -> {
clearImage(binding.imageView) holder.bind(items[position], position)
} }
} }
} }
override fun onBindContent( private fun isDark(color: Int): Boolean {
holder: ViewHolderState<Any>, return ColorUtils.calculateLuminance(color) < 0.5
item: SyncAPI.LibraryItem, }
position: Int
) {
val binding = holder.view as? SearchResultGridExpandedBinding ?: return
fun getDifferentColor(color: Int, ratio: Float = 0.7f): Int {
return if (isDark(color)) {
ColorUtils.blendARGB(color, Color.WHITE, ratio)
} else {
ColorUtils.blendARGB(color, Color.BLACK, ratio)
}
}
inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) :
RecyclerView.ViewHolder(binding.root) {
private val compactView = false//itemView.context.getGridIsCompact()
private val coverHeight: Int =
if (compactView) 80.toPx else (resView.itemWidth / 0.68).roundToInt()
fun bind(item: SyncAPI.LibraryItem, position: Int) {
/** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */
SearchResultBuilder.bind( SearchResultBuilder.bind(
this@PageAdapter.clickCallback, this@PageAdapter.clickCallback,
item, item,
position, position,
holder.itemView, itemView,
/*colorCallback = { palette ->
AcraApplication.context?.let { ctx ->
val defColor = ContextCompat.getColor(ctx, R.color.ratingColorBg)
var bg = palette.getDarkVibrantColor(defColor)
if (bg == defColor) {
bg = palette.getDarkMutedColor(defColor)
}
if (bg == defColor) {
bg = palette.getVibrantColor(defColor)
}
val fg =
getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor))
binding.textRating.apply {
setTextColor(ColorStateList.valueOf(fg))
}
binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg)
binding.textRating.backgroundTintList = ColorStateList.valueOf(bg)
binding.watchProgress.apply {
progressTintList = ColorStateList.valueOf(fg)
progressBackgroundTintList = ColorStateList.valueOf(bg)
}
}
}
*/
) )
// See searchAdaptor for this, it basically fixes the height // See searchAdaptor for this, it basically fixes the height
val params = FrameLayout.LayoutParams( if (!compactView) {
binding.imageView.apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
coverHeight coverHeight
) )
if (params.height != binding.imageView.layoutParams.height || params.width != binding.imageView.layoutParams.width) { }
binding.imageView.layoutParams = params
} }
val showProgress = item.episodesCompleted?.let{ it>0 } ?: false && item.episodesTotal != null val showProgress = item.episodesCompleted != null && item.episodesTotal != null
binding.watchProgress.isVisible = showProgress binding.watchProgress.isVisible = showProgress
if (showProgress) { if (showProgress) {
binding.watchProgress.max = item.episodesTotal binding.watchProgress.max = item.episodesTotal!!
binding.watchProgress.progress = item.episodesCompleted binding.watchProgress.progress = item.episodesCompleted!!
} }
binding.imageText.text = item.name binding.imageText.text = item.name
} }
} }
}

View file

@ -40,9 +40,10 @@ class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding)
} }
class ViewpagerAdapter( class ViewpagerAdapter(
fragment: Fragment,
val scrollCallback: (isScrollingDown: Boolean) -> Unit, val scrollCallback: (isScrollingDown: Boolean) -> Unit,
val clickCallback: (SearchClickCallback) -> Unit val clickCallback: (SearchClickCallback) -> Unit
) : BaseAdapter<SyncAPI.Page, Bundle>( ) : BaseAdapter<SyncAPI.Page, Bundle>(fragment,
id = "ViewpagerAdapter".hashCode(), id = "ViewpagerAdapter".hashCode(),
diffCallback = BaseDiffCallback( diffCallback = BaseDiffCallback(
itemSame = { a, b -> itemSame = { a, b ->
@ -52,7 +53,6 @@ class ViewpagerAdapter(
a.items == b.items && a.title == b.title a.items == b.items && a.title == b.title
} }
)) { )) {
override fun onCreateContent(parent: ViewGroup): ViewHolderState<Bundle> { override fun onCreateContent(parent: ViewGroup): ViewHolderState<Bundle> {
return ViewpagerAdapterViewHolderState( return ViewpagerAdapterViewHolderState(
LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false) LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
@ -66,8 +66,7 @@ class ViewpagerAdapter(
) { ) {
val binding = holder.view val binding = holder.view
if (binding !is LibraryViewpagerPageBinding) return if (binding !is LibraryViewpagerPageBinding) return
(binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items) (binding.pageRecyclerview.adapter as? PageAdapter)?.updateList(item.items)
binding.pageRecyclerview.scrollToPosition(0)
} }
override fun onBindContent(holder: ViewHolderState<Bundle>, item: SyncAPI.Page, position: Int) { override fun onBindContent(holder: ViewHolderState<Bundle>, item: SyncAPI.Page, position: Int) {
@ -76,21 +75,21 @@ class ViewpagerAdapter(
binding.pageRecyclerview.tag = position binding.pageRecyclerview.tag = position
binding.pageRecyclerview.apply { binding.pageRecyclerview.apply {
spanCount = binding.root.context.getSpanCount() spanCount =
binding.root.context.getSpanCount() ?: 3
if (adapter == null) { // || rebind if (adapter == null) { // || rebind
// Only add the items after it has been attached since the items rely on ItemWidth // Only add the items after it has been attached since the items rely on ItemWidth
// Which is only determined after the recyclerview is attached. // Which is only determined after the recyclerview is attached.
// If this fails then item height becomes 0 when there is only one item // If this fails then item height becomes 0 when there is only one item
doOnAttach { doOnAttach {
adapter = PageAdapter( adapter = PageAdapter(
item.items.toMutableList(),
this, this,
clickCallback clickCallback
).apply { )
submitList(item.items)
}
} }
} else { } else {
(adapter as? PageAdapter)?.submitList(item.items) (adapter as? PageAdapter)?.updateList(item.items)
// scrollToPosition(0) // scrollToPosition(0)
} }
@ -101,7 +100,7 @@ class ViewpagerAdapter(
//Expand the top Appbar based on scroll direction up/down, simulate phone behavior //Expand the top Appbar based on scroll direction up/down, simulate phone behavior
if (isLayout(TV or EMULATOR)) { if (isLayout(TV or EMULATOR)) {
binding.root.rootView.findViewById<AppBarLayout>(R.id.search_bar) binding.root.rootView.findViewById<AppBarLayout>(R.id.search_bar)
?.apply { .apply {
if (diff <= 0) if (diff <= 0)
setExpanded(true) setExpanded(true)
else else

View file

@ -1,16 +1,61 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.AnimatedImageDrawable
import android.graphics.drawable.AnimatedVectorDrawable
import android.media.metrics.PlaybackErrorEvent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import androidx.annotation.OptIn import android.widget.ProgressBar
import android.widget.Toast
import androidx.annotation.LayoutRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.media3.common.util.UnstableApi import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.media3.common.PlaybackException
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import androidx.viewbinding.ViewBinding import androidx.media3.ui.TimeBar
import androidx.preference.PreferenceManager
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.github.rubensousa.previewseekbar.PreviewBar
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
import com.lagradost.cloudstream3.CommonActivity.canEnterPipMode
import com.lagradost.cloudstream3.CommonActivity.isInPIPMode
import com.lagradost.cloudstream3.CommonActivity.keyEventListener
import com.lagradost.cloudstream3.CommonActivity.playerEventListener
import com.lagradost.cloudstream3.CommonActivity.screenWidth
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.AppContextUtils
import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import java.net.SocketTimeoutException
enum class PlayerResize(@StringRes val nameRes: Int) { enum class PlayerResize(@StringRes val nameRes: Int) {
Fit(R.string.resize_fit), Fit(R.string.resize_fit),
@ -30,132 +75,669 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90
// when the player should sync the progress of "watched", TODO MAKE SETTING // when the player should sync the progress of "watched", TODO MAKE SETTING
const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80 const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80
@OptIn(UnstableApi::class) abstract class AbstractPlayerFragment(
abstract class AbstractPlayerFragment<T : ViewBinding>( var player: IPlayer = CS3IPlayer()
bindingCreator: BindingCreator<T> ) : Fragment() {
) : BaseFragment<T>(bindingCreator), PlayerView.Callbacks { var resizeMode: Int = 0
var subView: SubtitleView? = null
var isBuffering = true
protected open var hasPipModeSupport = true
// Stored pre-initialization so subclasses can set them before onBindingCreated. var playerPausePlayHolderHolder: FrameLayout? = null
private var _player: IPlayer = CS3IPlayer() var playerPausePlay: ImageView? = null
var playerBuffering: ProgressBar? = null
var playerView: PlayerView? = null
var piphide: FrameLayout? = null
var subtitleHolder: FrameLayout? = null
/** The shared [PlayerView] host that owns all player state and view references. */ @LayoutRes
protected var playerHostView: PlayerView? = null protected open var layout: Int = R.layout.fragment_player
var player: IPlayer open fun nextEpisode() {
get() = playerHostView?.player ?: _player
set(value) {
_player = value
playerHostView?.player = value
}
val subView: SubtitleView? get() = playerHostView?.subView
val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay
/** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */
val playerView: androidx.media3.ui.PlayerView?
get() = playerHostView?.exoPlayerView
var currentPlayerStatus: CSPlayerLoading
get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering
set(value) { playerHostView?.currentPlayerStatus = value }
protected var mMediaSession: MediaSession?
get() = playerHostView?.mMediaSession
set(value) { playerHostView?.mMediaSession = value }
// No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as
// open so subclasses can override only what they need. The ones below throw
// to make it obvious when an implementation is missing.
override fun nextEpisode() {
throw NotImplementedError() throw NotImplementedError()
} }
override fun prevEpisode() { open fun prevEpisode() {
throw NotImplementedError() throw NotImplementedError()
} }
override fun playerPositionChanged(position: Long, duration: Long) { open fun playerPositionChanged(position: Long, duration: Long) {
throw NotImplementedError() throw NotImplementedError()
} }
override fun playerDimensionsLoaded(width: Int, height: Int) { open fun playerStatusChanged() {}
open fun playerDimensionsLoaded(width: Int, height: Int) {
throw NotImplementedError() throw NotImplementedError()
} }
override fun subtitlesChanged() { open fun subtitlesChanged() {
throw NotImplementedError() throw NotImplementedError()
} }
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) { open fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
throw NotImplementedError() throw NotImplementedError()
} }
override fun onTracksInfoChanged() { open fun onTracksInfoChanged() {
throw NotImplementedError() throw NotImplementedError()
} }
override fun exitedPipMode() { open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
}
open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) {
}
open fun exitedPipMode() {
throw NotImplementedError() throw NotImplementedError()
} }
override fun hasNextMirror(): Boolean { private fun keepScreenOn(on: Boolean) {
throw NotImplementedError() if (on) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
} }
override fun nextMirror() { private fun updateIsPlaying(
throw NotImplementedError() wasPlaying: CSPlayerLoading,
isPlaying: CSPlayerLoading
) {
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
keepScreenOn(!isPausedRightNow)
isBuffering = CSPlayerLoading.IsBuffering == isPlaying
if (isBuffering) {
playerPausePlayHolderHolder?.isVisible = false
playerBuffering?.isVisible = true
} else {
playerPausePlayHolderHolder?.isVisible = true
playerBuffering?.isVisible = false
if (wasPlaying != isPlaying) {
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play)
val drawable = playerPausePlay?.drawable
var startedAnimation = false
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
if (drawable is AnimatedImageDrawable) {
drawable.start()
startedAnimation = true
}
} }
/** Delegates to [PlayerView.playerError] by default; override to customize. */ if (drawable is AnimatedVectorDrawable) {
override fun playerError(exception: Throwable) { drawable.start()
playerHostView?.playerError(exception) startedAnimation = true
} }
/** Player fragments don't need system-bar padding adjustment by default. */ if (drawable is AnimatedVectorDrawableCompat) {
override fun fixLayout(view: View) = Unit drawable.start()
startedAnimation = true
override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {
val ctx = context ?: return
playerHostView = PlayerView(ctx)
playerHostView?.player = _player
playerHostView?.callbacks = this
playerHostView?.bindViews(binding.root)
playerHostView?.initialize()
} }
// somehow the phone is wacked
if (!startedAnimation) {
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
}
} else {
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
}
}
canEnterPipMode = isPlayingRightNow && hasPipModeSupport
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.let { act ->
PlayerPipHelper.updatePIPModeActions(
act,
isPlayingRightNow,
player.getAspectRatio()
)
}
}
}
private var pipReceiver: BroadcastReceiver? = null
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode) super.onPictureInPictureModeChanged(isInPictureInPictureMode)
playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity) try {
isInPIPMode = isInPictureInPictureMode
if (isInPictureInPictureMode) {
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
piphide?.isVisible = false
pipReceiver = object : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
if (ACTION_MEDIA_CONTROL != intent.action) {
return
}
player.handleEvent(
CSPlayerEvent.entries[intent.getIntExtra(
EXTRA_CONTROL_TYPE,
0
)], source = PlayerEventSource.UI
)
}
}
val filter = IntentFilter()
filter.addAction(ACTION_MEDIA_CONTROL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED)
} else activity?.registerReceiver(pipReceiver, filter)
val isPlaying = player.getIsPlaying()
val isPlayingValue =
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
updateIsPlaying(isPlayingValue, isPlayingValue)
} else {
// Restore the full-screen UI.
piphide?.isVisible = true
exitedPipMode()
pipReceiver?.let {
// Prevents java.lang.IllegalArgumentException: Receiver not registered
safe {
activity?.unregisterReceiver(it)
}
}
activity?.hideSystemUI()
this.view?.let { UIHelper.hideKeyboard(it) }
}
} catch (e: Exception) {
logError(e)
}
}
open fun hasNextMirror(): Boolean {
throw NotImplementedError()
}
open fun nextMirror() {
throw NotImplementedError()
}
private fun requestAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest())
}
}
open fun playerError(exception: Throwable) {
fun showToast(message: String, gotoNext: Boolean = false) {
if (gotoNext && hasNextMirror()) {
showToast(
message,
Toast.LENGTH_SHORT
)
nextMirror()
} else {
showToast(
context?.getString(R.string.no_links_found_toast) + "\n" + message,
Toast.LENGTH_LONG
)
activity?.popCurrentPage()
}
}
val ctx = context ?: return
when (exception) {
is PlaybackException -> {
val msg = exception.message ?: ""
val errorName = exception.errorCodeName
when (val code = exception.errorCode) {
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> {
showToast(
"${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
PlaybackException.ERROR_CODE_REMOTE_ERROR,
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
PlaybackException.ERROR_CODE_TIMEOUT,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> {
showToast(
"${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED,
PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER,
PlaybackException.ERROR_CODE_DECODING_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> {
showToast(
"${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> {
showToast(
"${ctx.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> {
showToast(
"${ctx.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg",
gotoNext = true
)
}
else -> {
showToast(
"${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg",
gotoNext = false
)
}
}
}
is InvalidFileException -> {
showToast(
"${ctx.getString(R.string.source_error)}\n${exception.message}",
gotoNext = true
)
}
is SocketTimeoutException -> {
/**
* Ensures this is run on the UI thread to prevent issues
* caused by SocketTimeoutException in torrents. Running
* on another thread can break player interactions or
* prevent switching to the next source.
*/
activity?.runOnUiThread {
showToast(
"${ctx.getString(R.string.remote_error)}\n${exception.message}",
gotoNext = true
)
}
}
is ErrorLoadingException -> {
exception.message?.let {
showToast(
it,
gotoNext = true
)
} ?: showToast(
exception.toString(),
gotoNext = true
)
}
else -> {
exception.message?.let {
showToast(
it,
gotoNext = false
)
} ?: showToast(
exception.toString(),
gotoNext = false
)
}
}
}
private fun onSubStyleChanged(style: SaveCaptionStyle) {
player.updateSubtitleStyle(style)
// Forcefully update the subtitle encoding in case the edge size is changed
player.seekTime(-1)
}
@SuppressLint("UnsafeOptInUsageError")
open fun playerUpdated(player: Any?) {
if (player is ExoPlayer) {
context?.let { ctx ->
mMediaSession?.release()
mMediaSession = MediaSession.Builder(ctx, player)
// Ensure unique ID for concurrent players
.setId(System.currentTimeMillis().toString())
.build()
}
// Necessary for multiple combined videos
@Suppress("DEPRECATION")
playerView?.setShowMultiWindowTimeBar(true)
playerView?.player = player
playerView?.performClick()
}
}
protected var mMediaSession: MediaSession? = null
// this can be used in the future for players other than exoplayer
//private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() {
// override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
// val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as KeyEvent?
// if (keyEvent != null) {
// if (keyEvent.action == KeyEvent.ACTION_DOWN) { // NO DOUBLE SKIP
// val consumed = when (keyEvent.keyCode) {
// KeyEvent.KEYCODE_MEDIA_PAUSE -> callOnPause()
// KeyEvent.KEYCODE_MEDIA_PLAY -> callOnPlay()
// KeyEvent.KEYCODE_MEDIA_STOP -> callOnStop()
// KeyEvent.KEYCODE_MEDIA_NEXT -> callOnNext()
// else -> false
// }
// if (consumed) return true
// }
// }
//
// return super.onMediaButtonEvent(mediaButtonEvent)
// }
//}
open fun onDownload(event: DownloadEvent) = Unit
/** This receives the events from the player, if you want to append functionality you do it here,
* do note that this only receives events for UI changes,
* and returning early WONT stop it from changing in eg the player time or pause status */
open fun mainCallback(event: PlayerEvent) {
// we don't want to spam DownloadEvent
if (event !is DownloadEvent) {
Log.i(TAG, "Handle event: $event")
}
when (event) {
is DownloadEvent -> {
onDownload(event)
}
is ResizedEvent -> {
playerDimensionsLoaded(event.width, event.height)
}
is PlayerAttachedEvent -> {
playerUpdated(event.player)
}
is SubtitlesUpdatedEvent -> {
subtitlesChanged()
}
is TimestampSkippedEvent -> {
onTimestampSkipped(event.timestamp)
}
is TimestampInvokedEvent -> {
onTimestamp(event.timestamp)
}
is TracksChangedEvent -> {
onTracksInfoChanged()
}
is EmbeddedSubtitlesFetchedEvent -> {
embeddedSubtitlesFetched(event.tracks)
}
is ErrorEvent -> {
playerError(event.error)
}
is RequestAudioFocusEvent -> {
requestAudioFocus()
}
is EpisodeSeekEvent -> {
when (event.offset) {
-1 -> prevEpisode()
1 -> nextEpisode()
else -> {}
}
}
is StatusEvent -> {
updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying)
playerStatusChanged()
}
is PositionEvent -> {
playerPositionChanged(position = event.toMs, duration = event.durationMs)
}
is VideoEndedEvent -> {
context?.let { ctx ->
// Resets subtitle delay on ended video
player.setSubtitleOffset(0)
// Only play next episode if autoplay is on (default)
if (PreferenceManager.getDefaultSharedPreferences(ctx)
?.getBoolean(
ctx.getString(R.string.autoplay_next_key),
true
) == true
) {
player.handleEvent(
CSPlayerEvent.NextEpisode,
source = PlayerEventSource.Player
)
}
}
}
is PauseEvent -> Unit
is PlayEvent -> Unit
}
}
@SuppressLint("SetTextI18n", "UnsafeOptInUsageError")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
resizeMode = DataStoreHelper.resizeMode
resize(resizeMode, false)
player.releaseCallbacks()
player.initCallbacks(
eventHandler = ::mainCallback,
requestedListeningPercentages = listOf(
SKIP_OP_VIDEO_PERCENTAGE,
PRELOAD_NEXT_EPISODE_PERCENTAGE,
NEXT_WATCH_EPISODE_PERCENTAGE,
UPDATE_SYNC_PROGRESS_PERCENTAGE,
),
)
val player = player
if (player is CS3IPlayer) {
// preview bar
val progressBar: PreviewTimeBar? = playerView?.findViewById(R.id.exo_progress)
val previewImageView: ImageView? = playerView?.findViewById(R.id.previewImageView)
val previewFrameLayout: FrameLayout? = playerView?.findViewById(R.id.previewFrameLayout)
if (progressBar != null && previewImageView != null && previewFrameLayout != null) {
var resume = false
progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener {
override fun onScrubStart(previewBar: PreviewBar?) {
val hasPreview = player.hasPreview()
progressBar.isPreviewEnabled = hasPreview
resume = player.getIsPlaying()
if (resume) player.handleEvent(
CSPlayerEvent.Pause,
PlayerEventSource.Player
)
// No clashing UI
if (hasPreview) {
subView?.isVisible = false
}
}
override fun onScrubMove(
previewBar: PreviewBar?,
progress: Int,
fromUser: Boolean
) {
}
override fun onScrubStop(previewBar: PreviewBar?) {
if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player)
// Delay to prevent the small flicker of subtitle before seeking
subView?.postDelayed({
// If we are not scrubbing then show subtitles again
if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) {
subView?.isVisible = true
}
}, 200)
}
})
progressBar.attachPreviewView(previewFrameLayout)
progressBar.setPreviewLoader { currentPosition, max ->
val bitmap = player.getPreview(currentPosition.toFloat().div(max.toFloat()))
previewImageView.isGone = bitmap == null
previewImageView.setImageBitmap(bitmap)
}
}
subView = playerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles)
player.initSubtitles(subView, subtitleHolder, CustomDecoder.style)
(player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth)
/*previewImageView?.doOnLayout {
(player.imageGenerator as? PreviewGenerator)?.params = ImageParams(
it.measuredWidth,
it.measuredHeight
)
}*/
/** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player
* and once by the UI even if it should only be registered once by the UI */
playerView?.findViewById<DefaultTimeBar>(R.id.exo_progress)
?.addListener(object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
if (canceled) return
val playerDuration = player.getDuration() ?: return
val playerPosition = player.getPosition() ?: return
mainCallback(
PositionEvent(
source = PlayerEventSource.UI,
durationMs = playerDuration,
fromMs = playerPosition,
toMs = position
)
)
}
})
SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged
try {
context?.let { ctx ->
val settingsManager = PreferenceManager.getDefaultSharedPreferences(
ctx
)
val currentPrefCacheSize =
settingsManager.getInt(getString(R.string.video_buffer_size_key), 0)
val currentPrefDiskSize =
settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0)
val currentPrefBufferSec =
settingsManager.getInt(getString(R.string.video_buffer_length_key), 0)
player.cacheSize = currentPrefCacheSize * 1024L * 1024L
player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L
player.videoBufferMs = currentPrefBufferSec * 1000L
}
} catch (e: Exception) {
logError(e)
}
}
/*context?.let { ctx ->
player.loadPlayer(
ctx,
false,
ExtractorLink(
"idk",
"bunny",
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
"",
Qualities.P720.value,
false
),
)
}*/
} }
override fun onDestroy() { override fun onDestroy() {
playerHostView?.release() playerEventListener = null
keyEventListener = null
canEnterPipMode = false
mMediaSession?.release()
mMediaSession = null
playerView?.player = null
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
keepScreenOn(false)
super.onDestroy() super.onDestroy()
} }
override fun onPause() { fun nextResize() {
playerHostView?.releaseKeyEventListener() resizeMode = (resizeMode + 1) % PlayerResize.entries.size
super.onPause() resize(resizeMode, true)
}
fun resize(resize: Int, showToast: Boolean) {
resize(PlayerResize.entries[resize], showToast)
}
@SuppressLint("UnsafeOptInUsageError")
fun resize(resize: PlayerResize, showToast: Boolean) {
DataStoreHelper.resizeMode = resize.ordinal
val type = when (resize) {
PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
}
playerView?.resizeMode = type
if (showToast)
showToast(resize.nameRes, Toast.LENGTH_SHORT)
} }
override fun onStop() { override fun onStop() {
playerHostView?.onStop() player.onStop()
super.onStop() super.onStop()
} }
override fun onResume() { override fun onResume() {
context?.let { ctx -> context?.let { ctx ->
playerHostView?.onResume(ctx) player.onResume(ctx)
} }
super.onResume() super.onResume()
} }
fun nextResize() { override fun onCreateView(
playerHostView?.nextResize() inflater: LayoutInflater,
} container: ViewGroup?,
savedInstanceState: Bundle?
open fun resize(resize: PlayerResize, showToast: Boolean) { ): View? {
playerHostView?.resize(resize, showToast) val root = inflater.inflate(layout, container, false)
playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder)
playerPausePlay = root.findViewById(R.id.player_pause_play)
playerBuffering = root.findViewById(R.id.player_buffering)
playerView = root.findViewById(R.id.player_view)
piphide = root.findViewById(R.id.piphide)
subtitleHolder = root.findViewById(R.id.subtitle_holder)
return root
} }
} }

View file

@ -1,296 +0,0 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes.
*/
package com.lagradost.cloudstream3.ui.player
import android.text.Html
import android.text.Spanned
import android.text.TextUtils
import androidx.annotation.VisibleForTesting
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.Format.CueReplacementBehavior
import androidx.media3.common.text.Cue
import androidx.media3.common.text.Cue.AnchorType
import androidx.media3.common.util.Consumer
import androidx.media3.common.util.Log
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.text.CuesWithTiming
import androidx.media3.extractor.text.SubtitleParser
import androidx.media3.extractor.text.SubtitleParser.OutputOptions
import com.google.common.base.Preconditions.checkNotNull
import com.google.common.collect.ImmutableList
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.util.regex.Matcher
import java.util.regex.Pattern
/** A [SubtitleParser] for SubRip. */
@UnstableApi
class CustomSubripParser : SubtitleParser {
private val textBuilder: StringBuilder = StringBuilder()
private val tags: ArrayList<String> = ArrayList()
private val parsableByteArray: ParsableByteArray = ParsableByteArray()
override fun getCueReplacementBehavior(): @CueReplacementBehavior Int {
return CUE_REPLACEMENT_BEHAVIOR
}
override fun parse(
data: ByteArray,
offset: Int,
length: Int,
outputOptions: OutputOptions,
output: Consumer<CuesWithTiming>
) {
parsableByteArray.reset(data, /* limit= */offset + length)
parsableByteArray.setPosition(offset)
val charset = detectUtfCharset(parsableByteArray)
val cuesWithTimingBeforeRequestedStartTimeUs: MutableList<CuesWithTiming>? =
if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues)
ArrayList<CuesWithTiming>()
else
null
var currentLine: String?
while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) {
if (currentLine!!.isEmpty()) {
// Skip blank lines.
continue
}
// Parse and check the index line.
try {
currentLine.toInt()
} catch (_: NumberFormatException) {
Log.w(TAG, "Skipping invalid index: $currentLine")
continue
}
// Read and parse the timing line.
currentLine = parsableByteArray.readLine(charset)
if (currentLine == null) {
Log.w(TAG, "Unexpected end")
break
}
val startTimeUs: Long
val endTimeUs: Long
val matcher = SUBRIP_TIMING_LINE.matcher(currentLine)
if (matcher.matches()) {
startTimeUs = parseTimecode(matcher, /* groupOffset= */1)
endTimeUs = parseTimecode(matcher, /* groupOffset= */6)
} else {
Log.w(TAG, "Skipping invalid timing: $currentLine")
continue
}
// Read and parse the text and tags.
textBuilder.setLength(0)
tags.clear()
currentLine = parsableByteArray.readLine(charset)
while (!TextUtils.isEmpty(currentLine)) {
if (textBuilder.isNotEmpty()) {
textBuilder.append("<br>")
}
textBuilder.append(processLine(currentLine!!, tags))
currentLine = parsableByteArray.readLine(charset)
}
@Suppress("DEPRECATION")
val text = Html.fromHtml(textBuilder.toString())
var alignmentTag: String? = null
for (i in tags.indices) {
val tag = tags[i]
if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) {
alignmentTag = tag
// Subsequent alignment tags should be ignored.
break
}
}
if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) {
output.accept(
CuesWithTiming(
ImmutableList.of<Cue>(buildCue(text, alignmentTag)),
startTimeUs, /* durationUs= */
endTimeUs - startTimeUs
)
)
} else cuesWithTimingBeforeRequestedStartTimeUs?.add(
CuesWithTiming(
ImmutableList.of<Cue>(buildCue(text, alignmentTag)),
startTimeUs, /* durationUs= */
endTimeUs - startTimeUs
)
)
}
if (cuesWithTimingBeforeRequestedStartTimeUs != null) {
for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) {
output.accept(cuesWithTiming)
}
}
}
/**
* Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if
* no BOM is found.
*/
private fun detectUtfCharset(data: ParsableByteArray): Charset {
val charset = data.readUtfCharsetFromBom()
return charset ?: StandardCharsets.UTF_8
}
/**
* Trims and removes tags from the given line. The removed tags are added to `tags`.
*
* @param line The line to process.
* @param tags A list to which removed tags will be added.
* @return The processed line.
*/
private fun processLine(line: String, tags: ArrayList<String>): String {
var line = line
line = line.trim { it <= ' ' }
var removedCharacterCount = 0
val processedLine = StringBuilder(line)
val matcher = SUBRIP_TAG_PATTERN.matcher(line)
while (matcher.find()) {
val tag = matcher.group()
tags.add(tag)
val start = matcher.start() - removedCharacterCount
val tagLength = tag.length
processedLine.replace(start, /* end= */start + tagLength, /* str= */"")
removedCharacterCount += tagLength
}
return processedLine.toString()
}
/**
* Build a [Cue] based on the given text and alignment tag.
*
* @param text The text.
* @param alignmentTag The alignment tag, or `null` if no alignment tag is available.
* @return Built cue
*/
private fun buildCue(text: Spanned, alignmentTag: String?): Cue {
val cue = Cue.Builder().setText(text)
if (alignmentTag == null) {
return cue.build()
}
// Horizontal alignment.
when (alignmentTag) {
ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START)
ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END)
ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE)
else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE)
}
// Vertical alignment.
when (alignmentTag) {
ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END)
ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START)
ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE)
else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE)
}
return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor()))
.setLine(
getFractionalPositionForAnchorType(cue.getLineAnchor()),
Cue.LINE_TYPE_FRACTION
)
.build()
}
companion object {
/**
* The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this
* implementation.
*/
const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int =
Format.CUE_REPLACEMENT_BEHAVIOR_MERGE
// Fractional positions for use when alignment tags are present.
private const val START_FRACTION = 0.08f
private const val END_FRACTION = 1 - START_FRACTION
private const val MID_FRACTION = 0.5f
private const val TAG = "SubripParser"
// The google devs are useless, this entire class is just to override this
private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?"
private val SUBRIP_TIMING_LINE: Pattern =
Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*")
// NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183].
private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}")
private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}"
// Alignment tags for SSA V4+.
private const val ALIGN_BOTTOM_LEFT = "{\\an1}"
private const val ALIGN_BOTTOM_MID = "{\\an2}"
private const val ALIGN_BOTTOM_RIGHT = "{\\an3}"
private const val ALIGN_MID_LEFT = "{\\an4}"
private const val ALIGN_MID_MID = "{\\an5}"
private const val ALIGN_MID_RIGHT = "{\\an6}"
private const val ALIGN_TOP_LEFT = "{\\an7}"
private const val ALIGN_TOP_MID = "{\\an8}"
private const val ALIGN_TOP_RIGHT = "{\\an9}"
private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long {
val hours = matcher.group(groupOffset + 1)
var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0
timestampMs += checkNotNull(matcher.group(groupOffset + 2))
.toLong() * 60 * 1000
timestampMs += checkNotNull(matcher.group(groupOffset + 3))
.toLong() * 1000
val millis = matcher.group(groupOffset + 4)
timestampMs += when (millis?.length) {
null -> 0L
1 -> millis.toLong() * 100L
2 -> millis.toLong() * 10L
3 -> millis.toLong() * 1L
else -> millis.substring(0, 3).toLong()
}
return timestampMs * 1000
}
// TODO(b/289983417): Make package-private again, once it is no longer needed in
// DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed)
@VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE)
fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float {
return when (anchorType) {
Cue.ANCHOR_TYPE_START -> START_FRACTION
Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION
Cue.ANCHOR_TYPE_END -> END_FRACTION
Cue.TYPE_UNSET -> // Should never happen.
throw IllegalArgumentException()
else ->
throw IllegalArgumentException()
}
}
}
}

View file

@ -18,6 +18,7 @@ import androidx.media3.extractor.text.SubtitleParser
import androidx.media3.extractor.text.dvb.DvbParser import androidx.media3.extractor.text.dvb.DvbParser
import androidx.media3.extractor.text.pgs.PgsParser import androidx.media3.extractor.text.pgs.PgsParser
import androidx.media3.extractor.text.ssa.SsaParser import androidx.media3.extractor.text.ssa.SsaParser
import androidx.media3.extractor.text.subrip.SubripParser
import androidx.media3.extractor.text.ttml.TtmlParser import androidx.media3.extractor.text.ttml.TtmlParser
import androidx.media3.extractor.text.tx3g.Tx3gParser import androidx.media3.extractor.text.tx3g.Tx3gParser
import androidx.media3.extractor.text.webvtt.Mp4WebvttParser import androidx.media3.extractor.text.webvtt.Mp4WebvttParser
@ -34,8 +35,8 @@ import java.nio.charset.Charset
/** /**
* @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not
* enough to identify the subtitle format. * enough to identify the subtitle format.
*/ **/
@OptIn(UnstableApi::class) @UnstableApi
class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
companion object { companion object {
fun updateForcedEncoding(context: Context) { fun updateForcedEncoding(context: Context) {
@ -52,15 +53,15 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
} }
private const val DEFAULT_MARGIN: Float = 0.05f private const val DEFAULT_MARGIN: Float = 0.05f
const val SSA_ALIGNMENT_BOTTOM_LEFT = 1 private const val SSA_ALIGNMENT_BOTTOM_LEFT = 1
const val SSA_ALIGNMENT_BOTTOM_CENTER = 2 private const val SSA_ALIGNMENT_BOTTOM_CENTER = 2
const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3 private const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3
const val SSA_ALIGNMENT_MIDDLE_LEFT = 4 private const val SSA_ALIGNMENT_MIDDLE_LEFT = 4
const val SSA_ALIGNMENT_MIDDLE_CENTER = 5 private const val SSA_ALIGNMENT_MIDDLE_CENTER = 5
const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6 private const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6
const val SSA_ALIGNMENT_TOP_LEFT = 7 private const val SSA_ALIGNMENT_TOP_LEFT = 7
const val SSA_ALIGNMENT_TOP_CENTER = 8 private const val SSA_ALIGNMENT_TOP_CENTER = 8
const val SSA_ALIGNMENT_TOP_RIGHT = 9 private const val SSA_ALIGNMENT_TOP_RIGHT = 9
/** Subtitle offset in milliseconds */ /** Subtitle offset in milliseconds */
var subtitleOffset: Long = 0 var subtitleOffset: Long = 0
@ -147,17 +148,6 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
// exoplayer can already parse this, however for eg webvtt it fails // exoplayer can already parse this, however for eg webvtt it fails
locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment -> locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment ->
// toLineAnchor // toLineAnchor
this.setSubtitleAlignment(alignment)
}
// remove all matches, so we do not display \anx
trimmed = trimmed.replace(locationRegex, "")
setText(trimmed)
return this
}
fun Cue.Builder.setSubtitleAlignment(alignment: Int?): Cue.Builder {
if (alignment == null) return this
when (alignment) { when (alignment) {
SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END
SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE
@ -189,6 +179,11 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
}?.let { anchor -> }?.let { anchor ->
setTextAlignment(anchor) setTextAlignment(anchor)
} }
}
// remove all matches, so we do not display \anx
trimmed = trimmed.replace(locationRegex, "")
setText(trimmed)
return this return this
} }
} }
@ -250,14 +245,14 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser {
ignoreCase = true ignoreCase = true
)) -> SsaParser(fallbackFormat?.initializationData) )) -> SsaParser(fallbackFormat?.initializationData)
trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() trimmedText.startsWith("1", ignoreCase = true) -> SubripParser()
fallbackFormat != null -> { fallbackFormat != null -> {
when (fallbackFormat.sampleMimeType) { when (val mimeType = fallbackFormat.sampleMimeType) {
MimeTypes.TEXT_VTT -> WebvttParser() MimeTypes.TEXT_VTT -> WebvttParser()
MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData)
MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser()
MimeTypes.APPLICATION_TTML -> TtmlParser() MimeTypes.APPLICATION_TTML -> TtmlParser()
MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser() MimeTypes.APPLICATION_SUBRIP -> SubripParser()
MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData)
// These decoders are not converted to parsers yet // These decoders are not converted to parsers yet
// TODO // TODO
@ -391,7 +386,7 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
/** /**
* Decoders created here persists across reset() * Decoders created here persists across reset()
* Do not save state in the decoder which you want to reset (e.g subtitle offset) * Do not save state in the decoder which you want to reset (e.g subtitle offset)
*/ **/
override fun createDecoder(format: Format): SubtitleDecoder { override fun createDecoder(format: Format): SubtitleDecoder {
val parser = CustomDecoder(format) val parser = CustomDecoder(format)
// Allow garbage collection if player releases the decoder // Allow garbage collection if player releases the decoder
@ -403,8 +398,8 @@ class CustomSubtitleDecoderFactory : SubtitleDecoderFactory {
} }
} }
/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
/** We need to convert the newer SubtitleParser to an older SubtitleDecoder */
class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) : class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) :
SimpleSubtitleDecoder(name) { SimpleSubtitleDecoder(name) {

View file

@ -1,25 +1,60 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.net.Uri import android.net.Uri
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName
import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder
import kotlin.math.max
import kotlin.math.min
class DownloadFileGenerator( class DownloadFileGenerator(
episodes: List<ExtractorUri> private val episodes: List<ExtractorUri>,
) : VideoGenerator<ExtractorUri>(episodes) { private var currentIndex: Int = 0
) : IGenerator {
override val hasCache = false override val hasCache = false
override val canSkipLoading = false override val canSkipLoading = false
override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id override fun hasNext(): Boolean {
return currentIndex < episodes.size - 1
}
override fun hasPrev(): Boolean {
return currentIndex > 0
}
override fun next() {
if (hasNext())
currentIndex++
}
override fun prev() {
if (hasPrev())
currentIndex--
}
override fun goto(index: Int) {
// clamps value
currentIndex = min(episodes.size - 1, max(0, index))
}
override fun getCurrentId(): Int? {
return episodes[currentIndex].id
}
override fun getCurrent(offset: Int): Any? {
return episodes.getOrNull(currentIndex + offset)
}
override fun getAll(): List<Any>? {
return null
}
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
@ -29,14 +64,14 @@ class DownloadFileGenerator(
offset: Int, offset: Int,
isCasting: Boolean isCasting: Boolean
): Boolean { ): Boolean {
val meta = videos.getOrNull(offset) ?: return false val meta = episodes[currentIndex + offset]
if (meta.uri == Uri.EMPTY) { if (meta.uri == Uri.EMPTY) {
// We do this here so that we only load it when // We do this here so that we only load it when
// we actually need it as it can be more expensive. // we actually need it as it can be more expensive.
val info = meta.id?.let { id -> val info = meta.id?.let { id ->
activity?.let { act -> activity?.let { act ->
getDownloadFileInfo(act, id) getDownloadFileInfoAndUpdateSettings(act, id)
} }
} }
@ -55,19 +90,17 @@ class DownloadFileGenerator(
getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) -> getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) ->
if (isMatchingSubtitle(name, display, cleanDisplay)) { if (isMatchingSubtitle(name, display, cleanDisplay)) {
val cleanName = cleanDisplayName(name) val cleanName = cleanDisplayName(name)
val lastNum = Regex(" ([0-9]+)$") val realName = cleanName.removePrefix(cleanDisplay)
val nameSuffix = lastNum.find(cleanName)?.groupValues?.get(1) ?: ""
val originalName = cleanName.removePrefix(cleanDisplay).replace(lastNum, "").trim()
subtitleCallback( subtitleCallback(
SubtitleData( SubtitleData(
originalName.ifBlank { ctx.getString(R.string.default_subtitles) }, realName.ifBlank { ctx.getString(R.string.default_subtitles) },
nameSuffix, "",
uri.toString(), uri.toString(),
SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.DOWNLOADED_FILE,
name.toSubtitleMimeType(), name.toSubtitleMimeType(),
emptyMap(), emptyMap(),
fromLanguageToTagIETF(originalName, true) null
) )
) )
} }

View file

@ -11,12 +11,9 @@ import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink
import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
class DownloadedPlayerActivity : AppCompatActivity() { class DownloadedPlayerActivity : AppCompatActivity() {
companion object { private val dTAG = "DownloadedPlayerAct"
const val TAG = "DownloadedPlayerActivity"
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean = override fun dispatchKeyEvent(event: KeyEvent): Boolean =
CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
@ -29,79 +26,48 @@ class DownloadedPlayerActivity : AppCompatActivity() {
CommonActivity.onUserLeaveHint(this) CommonActivity.onUserLeaveHint(this)
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Ignore same intent so the player doesnt totally
// reload if you are playing the same thing.
if (isSameIntent(intent)) return
setIntent(intent)
Log.i(TAG, "onNewIntent")
handleIntent(intent)
}
private fun isSameIntent(newIntent: Intent): Boolean {
val old = intent ?: return false
// Compare URIs first
val oldUri = old.data ?: old.clipData?.getItemAt(0)?.uri
val newUri = newIntent.data ?: newIntent.clipData?.getItemAt(0)?.uri
if (oldUri != null && oldUri == newUri) return true
// Fall back to comparing EXTRA_TEXT links
val oldText = safe { old.getStringExtra(Intent.EXTRA_TEXT) }
val newText = safe { newIntent.getStringExtra(Intent.EXTRA_TEXT) }
return oldText != null && oldText == newText
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
CommonActivity.loadThemes(this) CommonActivity.loadThemes(this)
CommonActivity.init(this) CommonActivity.init(this)
enableEdgeToEdgeCompat()
setContentView(R.layout.empty_layout) setContentView(R.layout.empty_layout)
Log.i(TAG, "onCreate") Log.i(dTAG, "onCreate")
handleIntent(intent)
/**
* Use moveTaskToBack instead of finish() so there is always exactly one task
* entry in recents, always reflecting the current file.
*
* finish() destroys the Activity but may leave the task in recents. Each new file
* open can create a new task entry, so recents accumulates stale entries for old
* files. The user then taps a stale entry and gets the wrong file.
*
* moveTaskToBack keeps the Activity alive in the background. There is only ever
* one task entry in recents. New files opened from the file manager arrive via
* onNewIntent on the live instance, updating the player immediately. The single
* recents entry always reflects the current state, ensuring we load the
* correct file.
*/
attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) }
}
private fun handleIntent(intent: Intent) {
val data = intent.data val data = intent.data
if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) { if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) {
return return
} }
if ( if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) {
intent.action == Intent.ACTION_SEND || val extraText = safe { // I dont trust android
intent.action == Intent.ACTION_OPEN_DOCUMENT || intent.getStringExtra(Intent.EXTRA_TEXT)
intent.action == Intent.ACTION_VIEW }
) {
val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) }
val cd = intent.clipData val cd = intent.clipData
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
val url = item?.text?.toString() val url = item?.text?.toString()
when {
item?.uri != null -> playUri(this, item.uri) // idk what I am doing, just hope any of these work
url != null -> playLink(this, url) if (item?.uri != null)
data != null -> playUri(this, data) playUri(this, item.uri)
extraText != null -> playLink(this, extraText) else if (url != null)
else -> finishAndRemoveTask() playLink(this, url)
else if (data != null)
playUri(this, data)
else if (extraText != null)
playLink(this, extraText)
else {
finish()
return
} }
} else if (data?.scheme == "content") { } else if (data?.scheme == "content") {
playUri(this, data) playUri(this, data)
} else finishAndRemoveTask() } else {
finish()
return
}
attachBackPressedCallback("DownloadedPlayerActivity") { finish() }
} }
override fun onResume() { override fun onResume() {

View file

@ -6,7 +6,36 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
class ExtractorLinkGenerator( class ExtractorLinkGenerator(
private val links: List<ExtractorLink>, private val links: List<ExtractorLink>,
private val subtitles: List<SubtitleData>, private val subtitles: List<SubtitleData>,
) : NoVideoGenerator(null) { ) : IGenerator {
override val hasCache = false
override val canSkipLoading = true
override fun getCurrentId(): Int? {
return null
}
override fun hasNext(): Boolean {
return false
}
override fun getAll(): List<Any>? {
return null
}
override fun hasPrev(): Boolean {
return false
}
override fun getCurrent(offset: Int): Any? {
return null
}
override fun goto(index: Int) {}
override fun next() {}
override fun prev() {}
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>, sourceTypes: Set<ExtractorLinkType>,

View file

@ -1,28 +0,0 @@
package com.lagradost.cloudstream3.ui.player
import android.content.Context
import android.os.Looper
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.text.TextRenderer
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
@UnstableApi
class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) {
/** Somehow the nextlib authors decided that we need a text renderer that causes
* "ERROR_CODE_FAILED_RUNTIME_CHECK".
*
* Core issue: https://github.com/anilbeesetti/nextlib/pull/158
* Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718
* */
override fun buildTextRenderers(
context: Context,
output: TextOutput,
outputLooper: Looper,
extensionRendererMode: Int,
out: ArrayList<Renderer>
) {
out.add(TextRenderer(output, outputLooper))
}
}

View file

@ -25,27 +25,27 @@ val LOADTYPE_CHROMECAST = setOf(
val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() val LOADTYPE_ALL = ExtractorLinkType.entries.toSet()
abstract class NoVideoGenerator(val id : Int?) : VideoGenerator<Nothing>(emptyList()) { interface IGenerator {
override val hasCache = false val hasCache: Boolean
override val canSkipLoading = false val canSkipLoading: Boolean
override fun getId(index: Int): Int? = id
}
abstract class VideoGenerator<T : Any>(val videos: List<T>) { fun hasNext(): Boolean
abstract val hasCache: Boolean fun hasPrev(): Boolean
abstract val canSkipLoading: Boolean fun next()
abstract fun getId(index : Int) : Int? fun prev()
fun goto(index: Int)
fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex fun getCurrentId(): Int? // this is used to save data or read data about this id
fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0 fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null
fun getAll(): List<Any>? // this us used to get the metadata about all entries, not needed
@Throws /* not safe, must use try catch */
abstract suspend fun generateLinks( suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
sourceTypes: Set<ExtractorLinkType>, sourceTypes: Set<ExtractorLinkType>,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit, callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit, subtitleCallback: (SubtitleData) -> Unit,
offset: Int, offset: Int = 0,
isCasting: Boolean isCasting: Boolean = false
): Boolean ): Boolean
} }

Some files were not shown because too many files have changed in this diff Show more