diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml
index 931db3bd..f3590067 100644
--- a/.github/ISSUE_TEMPLATE/application-bug.yml
+++ b/.github/ISSUE_TEMPLATE/application-bug.yml
@@ -80,13 +80,13 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
+ - label: I am sure my issue is related to the app and **NOT some extension**.
+ required: true
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**.
required: true
- - label: If related to a provider, I have checked the site and it works, but not the app.
- required: true
- label: I will fill out all of the requested information in this form.
required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 250734cd..b56cdf8e 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -2,7 +2,7 @@ blank_issues_enabled: false
contact_links:
- name: Request a new provider or report bug with an existing provider
url: https://github.com/recloudstream
- about: Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
+ about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord.
- name: Discord
url: https://discord.gg/5Hus6fM
about: Join our discord for faster support on smaller issues.
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml
index 9c35ba56..e18daebb 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yml
@@ -27,9 +27,7 @@ body:
label: Acknowledgements
description: Your issue will be closed if you haven't done these steps.
options:
+ - label: My suggestion is **NOT** about adding a new provider
+ required: true
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
- required: true
- - label: I have written a short but informative title.
- required: true
- - label: I will fill out all of the requested information in this form.
- required: true
+ required: true
\ No newline at end of file
diff --git a/.github/locales.py b/.github/locales.py
index 7d6d6b90..a74d7258 100644
--- a/.github/locales.py
+++ b/.github/locales.py
@@ -1,6 +1,7 @@
import re
import glob
import requests
+import os
import lxml.etree as ET # builtin library doesn't preserve comments
@@ -53,11 +54,16 @@ for file in glob.glob(f"{XML_NAME}*/strings.xml"):
try:
tree = ET.parse(file)
for child in tree.getroot():
+ if not child.text:
+ continue
if child.text.startswith("\\@string/"):
print(f"[{file}] fixing {child.attrib['name']}")
child.text = child.text.replace("\\@string/", "@string/")
with open(file, 'wb') as fp:
fp.write(b'\n')
tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False)
+ # Remove trailing new line to be consistent with weblate
+ fp.seek(-1, os.SEEK_END)
+ fp.truncate()
except ET.ParseError as ex:
print(f"[{file}] {ex}")
diff --git a/.github/workflows/build_to_archive.yml b/.github/workflows/build_to_archive.yml
index 9cd2c523..e84bb08b 100644
--- a/.github/workflows/build_to_archive.yml
+++ b/.github/workflows/build_to_archive.yml
@@ -19,21 +19,21 @@ jobs:
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- name: Generate access token (archive)
id: generate_archive_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream-archive"
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- name: Set up JDK 17
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt'
@@ -56,7 +56,9 @@ jobs:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
- - uses: actions/checkout@v3
+ SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
+ SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
+ - uses: actions/checkout@v4
with:
repository: "recloudstream/cloudstream-archive"
token: ${{ steps.generate_archive_token.outputs.token }}
diff --git a/.github/workflows/generate_dokka.yml b/.github/workflows/generate_dokka.yml
index abeee0b2..96e61644 100644
--- a/.github/workflows/generate_dokka.yml
+++ b/.github/workflows/generate_dokka.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
@@ -43,12 +43,13 @@ jobs:
rm -rf "./-cloudstream"
- name: Setup JDK 17
- uses: actions/setup-java@v1
+ uses: actions/setup-java@v4
with:
java-version: 17
+ distribution: 'adopt'
- name: Setup Android SDK
- uses: android-actions/setup-android@v2
+ uses: android-actions/setup-android@v3
- name: Generate Dokka
run: |
diff --git a/.github/workflows/issue_action.yml b/.github/workflows/issue_action.yml
index 108cec82..88ab3656 100644
--- a/.github/workflows/issue_action.yml
+++ b/.github/workflows/issue_action.yml
@@ -10,7 +10,7 @@ jobs:
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
@@ -27,7 +27,7 @@ jobs:
comment-body: '${index}. ${similarity} #${number}'
- name: Label if possible duplicate
if: steps.similarity.outputs.similar-issues-found =='true'
- uses: actions/github-script@v6
+ uses: actions/github-script@v7
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |
@@ -37,7 +37,7 @@ jobs:
repo: context.repo.repo,
labels: ["possible duplicate"]
})
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- name: Automatically close issues that dont follow the issue template
uses: lucasbento/auto-close-issues@v1.0.2
with:
@@ -68,7 +68,7 @@ jobs:
Found provider name: `${{ steps.provider_check.outputs.name }}`
- name: Label if mentions provider
if: steps.provider_check.outputs.name != 'none'
- uses: actions/github-script@v6
+ uses: actions/github-script@v7
with:
github-token: ${{ steps.generate_token.outputs.token }}
script: |
diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml
index 856d267c..f35cd58c 100644
--- a/.github/workflows/prerelease.yml
+++ b/.github/workflows/prerelease.yml
@@ -18,14 +18,14 @@ jobs:
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets"
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- name: Set up JDK 17
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt'
@@ -43,11 +43,14 @@ jobs:
echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT
- name: Run Gradle
run: |
- ./gradlew assemblePrerelease makeJar androidSourcesJar
+ ./gradlew assemblePrerelease build androidSourcesJar
+ ./gradlew makeJar # for classes.jar, has to be done after assemblePrerelease
env:
SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
+ SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
+ SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- name: Create pre-release
uses: "marvinpinto/action-automatic-releases@latest"
with:
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index b6177710..7f6dd412 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -6,9 +6,9 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- name: Set up JDK 17
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'adopt'
@@ -17,7 +17,7 @@ jobs:
- name: Run Gradle
run: ./gradlew assemblePrereleaseDebug
- name: Upload Artifact
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v4
with:
name: pull-request-build
path: "app/build/outputs/apk/prerelease/debug/*.apk"
diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml
index 628e9bc9..ce140e55 100644
--- a/.github/workflows/update_locales.yml
+++ b/.github/workflows/update_locales.yml
@@ -18,12 +18,12 @@ jobs:
steps:
- name: Generate access token
id: generate_token
- uses: tibdex/github-app-token@v1
+ uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream"
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
- name: Install dependencies
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index a8a2961a..d7c08c9c 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -4,16 +4,16 @@
diff --git a/README.md b/README.md
index e3d033ba..8949304e 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,9 @@
+ **AdFree**, No ads whatsoever
+ No tracking/analytics
+ Bookmarks
-+ Download and stream movies, tv-shows and anime
++ Phone and TV support
+ Chromecast
++ Extension system for personal customization
### Supported languages:
diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt
new file mode 100644
index 00000000..7f7fd14c
--- /dev/null
+++ b/app/CMakeLists.txt
@@ -0,0 +1,6 @@
+# Set this to the minimum version your project supports.
+cmake_minimum_required(VERSION 3.18)
+project(CrashHandler)
+find_library(log-lib log)
+add_library(native-lib SHARED src/main/cpp/native-lib.cpp)
+target_link_libraries(native-lib ${log-lib})
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 27bd1e48..d0c86bab 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,12 +1,14 @@
-import com.android.build.gradle.api.BaseVariantOutput
+import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.DokkaTask
+import org.jetbrains.kotlin.gradle.plugin.mpp.pm20.util.archivesName
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import java.io.ByteArrayOutputStream
import java.net.URL
plugins {
id("com.android.application")
+ id("com.google.devtools.ksp")
id("kotlin-android")
- id("kotlin-kapt")
id("org.jetbrains.dokka")
}
@@ -18,7 +20,7 @@ fun String.execute() = ByteArrayOutputStream().use { baot ->
workingDir = projectDir
commandLine = this@execute.split(Regex("\\s"))
standardOutput = baot
- }.exitValue == 0)
+ }.exitValue == 0)
String(baot.toByteArray()).trim()
else null
}
@@ -32,9 +34,16 @@ android {
enable = true
}
+ /* disable this for now
+ externalNativeBuild {
+ cmake {
+ path("CMakeLists.txt")
+ }
+ }*/
+
signingConfigs {
- create("prerelease") {
- if (prereleaseStoreFile != null) {
+ if (prereleaseStoreFile != null) {
+ create("prerelease") {
storeFile = file(prereleaseStoreFile)
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
@@ -43,33 +52,44 @@ android {
}
}
- compileSdk = 33
- buildToolsVersion = "30.0.3"
+ compileSdk = 34
+ buildToolsVersion = "34.0.0"
defaultConfig {
applicationId = "com.lagradost.cloudstream3"
minSdk = 21
- targetSdk = 33
-
- versionCode = 59
- versionName = "4.1.1"
+ targetSdk = 33 /* Android 14 is Fu*ked
+ ^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/
+ versionCode = 64
+ versionName = "4.4.0"
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
-
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
-
resValue("bool", "is_prerelease", "false")
+ // Reads local.properties
+ val localProperties = gradleLocalProperties(rootDir)
+
+ buildConfigField(
+ "long",
+ "BUILD_DATE",
+ "${System.currentTimeMillis()}"
+ )
buildConfigField(
"String",
- "BUILDDATE",
- "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));"
+ "SIMKL_CLIENT_ID",
+ "\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\""
+ )
+ buildConfigField(
+ "String",
+ "SIMKL_CLIENT_SECRET",
+ "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
)
-
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- kapt {
- includeCompileClasspath = true
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+ arg("exportSchema", "true")
}
}
@@ -78,14 +98,21 @@ android {
isDebuggable = false
isMinifyEnabled = false
isShrinkResources = false
- proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
}
debug {
isDebuggable = true
applicationIdSuffix = ".debug"
- proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
}
}
+
flavorDimensions.add("state")
productFlavors {
create("stable") {
@@ -97,25 +124,31 @@ android {
resValue("bool", "is_prerelease", "true")
buildConfigField("boolean", "BETA", "true")
applicationIdSuffix = ".prerelease"
- signingConfig = signingConfigs.getByName("prerelease")
+ if (signingConfigs.names.contains("prerelease")) {
+ signingConfig = signingConfigs.getByName("prerelease")
+ } else {
+ logger.warn("No prerelease signing config!")
+ }
versionNameSuffix = "-PRE"
versionCode = (System.currentTimeMillis() / 60000).toInt()
}
}
+
compileOptions {
isCoreLibraryDesugaringEnabled = true
-
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
- kotlinOptions {
- jvmTarget = "1.8"
- freeCompilerArgs = listOf("-Xjvm-default=compatibility")
- }
+
lint {
abortOnError = false
checkReleaseBuilds = false
}
+
+ buildFeatures {
+ buildConfig = true
+ }
+
namespace = "com.lagradost.cloudstream3"
}
@@ -124,125 +157,132 @@ repositories {
}
dependencies {
- implementation("com.google.android.mediahome:video:1.0.0")
- implementation("androidx.test.ext:junit-ktx:1.1.3")
- testImplementation("org.json:json:20180813")
-
- implementation("androidx.core:core-ktx:1.8.0")
- implementation("androidx.appcompat:appcompat:1.4.2") // need target 32 for 1.5.0
-
- // dont change this to 1.6.0 it looks ugly af
- implementation("com.google.android.material:material:1.5.0")
- implementation("androidx.constraintlayout:constraintlayout:2.1.4")
- implementation("androidx.navigation:navigation-fragment-ktx:2.5.1")
- implementation("androidx.navigation:navigation-ui-ktx:2.5.1")
- implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1")
- implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1")
+ // Testing
testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.3")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
+ testImplementation("org.json:json:20240303")
androidTestImplementation("androidx.test:core")
+ implementation("androidx.test.ext:junit-ktx:1.2.1")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
- //implementation("io.karn:khttp-android:0.1.2") //okhttp instead
-// implementation("org.jsoup:jsoup:1.13.1")
- implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1")
-
- implementation("androidx.preference:preference-ktx:1.2.0")
-
- implementation("com.github.bumptech.glide:glide:4.13.1")
- kapt("com.github.bumptech.glide:compiler:4.13.1")
- implementation("com.github.bumptech.glide:okhttp3-integration:4.13.0")
+ // Android Core & Lifecycle
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
+ implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
+ implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
+ // Design & UI
implementation("jp.wasabeef:glide-transformations:4.3.0")
-
+ implementation("androidx.preference:preference-ktx:1.2.1")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
- // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09")
+ // Glide Module
+ ksp("com.github.bumptech.glide:ksp:4.16.0")
+ implementation("com.github.bumptech.glide:glide:4.16.0")
+ implementation("com.github.bumptech.glide:okhttp3-integration:4.16.0")
- // Exoplayer
- implementation("com.google.android.exoplayer:exoplayer:2.18.2")
- implementation("com.google.android.exoplayer:extension-cast:2.18.2")
- implementation("com.google.android.exoplayer:extension-mediasession:2.18.2")
- implementation("com.google.android.exoplayer:extension-okhttp:2.18.2")
- // Use the Jellyfin ffmpeg extension for easy ffmpeg audio decoding in exoplayer. Thank you Jellyfin <3
-// implementation("org.jellyfin.exoplayer:exoplayer-ffmpeg-extension:2.18.2+1")
+ // For KSP -> Official Annotation Processors are Not Yet Supported for KSP
+ ksp("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
+ implementation("com.google.guava:guava:33.2.1-android")
+ implementation("dev.zacsweers.autoservice:auto-service-ksp:1.2.0")
- //implementation("com.google.android.exoplayer:extension-leanback:2.14.0")
+ // Media 3 (ExoPlayer)
+ implementation("androidx.media3:media3-ui:1.1.1")
+ implementation("androidx.media3:media3-cast:1.1.1")
+ implementation("androidx.media3:media3-common:1.1.1")
+ implementation("androidx.media3:media3-session:1.1.1")
+ implementation("androidx.media3:media3-exoplayer:1.1.1")
+ implementation("com.google.android.mediahome:video:1.0.0")
+ implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
+ implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
+ implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
- // Bug reports
- implementation("ch.acra:acra-core:5.8.4")
- implementation("ch.acra:acra-toast:5.8.4")
+ // PlayBack
+ implementation("com.jaredrummler:colorpicker:1.1.0") // Subtitle Color Picker
+ implementation("com.github.recloudstream:media-ffmpeg:1.1.0") // Custom FF-MPEG Lib for Audio Codecs
+ implementation("com.github.teamnewpipe:NewPipeExtractor:176da72") /* For Trailers
+ ^ Update to Latest Commits if Trailers Misbehave, github.com/TeamNewPipe/NewPipeExtractor/commits/dev */
+ implementation("com.github.albfernandez:juniversalchardet:2.5.0") // Subtitle Decoding
- compileOnly("com.google.auto.service:auto-service-annotations:1.0")
- //either for java sources:
- annotationProcessor("com.google.auto.service:auto-service:1.0")
- //or for kotlin sources (requires kapt gradle plugin):
- kapt("com.google.auto.service:auto-service:1.0")
-
- // subtitle color picker
- implementation("com.jaredrummler:colorpicker:1.1.0")
-
- //run JS
- // do not upgrade to 1.7.14, since in 1.7.14 Rhino uses the `SourceVersion` class, which is not
- // available on Android (even when using desugaring), and `NoClassDefFoundError` is thrown
- implementation("org.mozilla:rhino:1.7.13")
-
- // TorrentStream
- //implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
-
- // Downloading
- implementation("androidx.work:work-runtime:2.8.0")
- implementation("androidx.work:work-runtime-ktx:2.8.0")
-
- // Networking
-// implementation("com.squareup.okhttp3:okhttp:4.9.2")
-// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
- implementation("com.github.Blatzar:NiceHttp:0.4.2")
- // To fix SSL fuckery on android 9
- implementation("org.conscrypt:conscrypt-android:2.2.1")
- // Util to skip the URI file fuckery 🙏
- implementation("com.github.tachiyomiorg:unifile:17bec43")
-
- // API because cba maintaining it myself
- implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0")
-
- implementation("com.github.discord:OverlappingPanels:0.1.3")
- // debugImplementation because LeakCanary should only run in debug builds.
- //debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
-
- // for shimmer when loading
- implementation("com.facebook.shimmer:shimmer:0.5.0")
+ // Crash Reports (AcraApplication.kt)
+ implementation("ch.acra:acra-core:5.11.3")
+ implementation("ch.acra:acra-toast:5.11.3")
+ // UI Stuff
+ implementation("com.facebook.shimmer:shimmer:0.5.0") // Shimmering Effect (Loading Skeleton)
+ implementation("androidx.palette:palette-ktx:1.0.0") // Palette For Images -> Colors
implementation("androidx.tvprovider:tvprovider:1.0.0")
+ implementation("com.github.discord:OverlappingPanels:0.1.5") // Gestures
+ implementation("androidx.biometric:biometric:1.2.0-alpha05") // Fingerprint Authentication
+ implementation("com.github.rubensousa:previewseekbar-media3:1.1.1.0") // SeekBar Preview
+ implementation("io.github.g0dkar:qrcode-kotlin:4.2.0") // QR code for PIN Auth on TV
- // used for subtitle decoding https://github.com/albfernandez/juniversalchardet
- implementation("com.github.albfernandez:juniversalchardet:2.4.0")
+ // Extensions & Other Libs
+ implementation("org.mozilla:rhino:1.7.15") // run JavaScript
+ implementation("me.xdrop:fuzzywuzzy:1.4.0") // Library/Ext Searching with Levenshtein Distance
+ implementation("com.github.LagradOst:SafeFile:0.0.6") // To Prevent the URI File Fu*kery
+ implementation("org.conscrypt:conscrypt-android:2.5.2") // To Fix SSL Fu*kery on Android 9
+ implementation("com.uwetrottmann.tmdb2:tmdb-java:2.11.0") // TMDB API v3 Wrapper Made with RetroFit
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs_nio:2.0.4") //nio flavor needed for NewPipeExtractor
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") /* JSON Parser
+ ^ Don't Bump Jackson above 2.13.1 , Crashes on Android TV's and FireSticks that have Min API
+ Level 25 or Less. */
- // slow af yt
- //implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT")
+ // Downloading & Networking
+ implementation("androidx.work:work-runtime:2.9.0")
+ implementation("androidx.work:work-runtime-ktx:2.9.0")
+ implementation("com.github.Blatzar:NiceHttp:0.4.11") // HTTP Lib
- // newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L204
- implementation("com.github.TeamNewPipe:NewPipeExtractor:8495ad619e")
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6")
+ 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)
+ }
+ }
- // Library/extensions searching with Levenshtein distance
- implementation("me.xdrop:fuzzywuzzy:1.4.0")
-
- // color palette for images -> colors
- implementation("androidx.palette:palette-ktx:1.0.0")
+ this.extra.set("isDebug", isDebug)
+ })
}
-tasks.register("androidSourcesJar", Jar::class) {
+tasks.register("androidSourcesJar") {
archiveClassifier.set("sources")
- from(android.sourceSets.getByName("main").java.srcDirs) //full sources
+ from(android.sourceSets.getByName("main").java.srcDirs) // Full Sources
}
-// this is used by the gradlew plugin
-tasks.register("makeJar", Copy::class) {
- from("build/intermediates/compile_app_classes_jar/prereleaseDebug")
- into("build")
- include("classes.jar")
- dependsOn("build")
+tasks.register("copyJar") {
+ from(
+ "build/intermediates/compile_app_classes_jar/prereleaseDebug",
+ "../library/build/libs"
+ )
+ into("build/app-classes")
+ include("classes.jar", "library-jvm*.jar")
+ // Remove the version
+ rename("library-jvm.*.jar", "library-jvm.jar")
+}
+
+// Merge the app classes and the library classes into classes.jar
+tasks.register("makeJar") {
+ // Duplicates cause hard to catch errors, better to fail at compile time.
+ duplicatesStrategy = DuplicatesStrategy.FAIL
+ dependsOn(tasks.getByName("copyJar"))
+ from(
+ zipTree("build/app-classes/classes.jar"),
+ zipTree("build/app-classes/library-jvm.jar")
+ )
+ destinationDirectory.set(layout.buildDirectory)
+ archivesName = "classes"
+}
+
+tasks.withType {
+ kotlinOptions {
+ jvmTarget = "1.8"
+ freeCompilerArgs = listOf("-Xjvm-default=all-compatibility")
+ }
}
tasks.withType().configureEach {
@@ -255,6 +295,7 @@ tasks.withType().configureEach {
// URL showing where the source code can be accessed through the web browser
remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java"))
+
// Suffix which is used to append the line number to the URL. Use #L for GitHub
remoteLineSuffix.set("#L")
}
diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
index df41ef91..c7f02baf 100644
--- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
+++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt
@@ -9,6 +9,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
+import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
+import com.lagradost.cloudstream3.databinding.FragmentLibraryTvBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding
import com.lagradost.cloudstream3.databinding.FragmentResultBinding
@@ -17,6 +19,7 @@ import com.lagradost.cloudstream3.databinding.FragmentSearchBinding
import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
+import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding
import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding
@@ -117,9 +120,12 @@ class ExampleInstrumentedTest {
// testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
// testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
- testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent)
- testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent)
+ testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
+ testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library)
}
}
}
@@ -148,7 +154,7 @@ class ExampleInstrumentedTest {
fun providerCorrectHomepage() {
runBlocking {
getAllProviders().toList().amap { api ->
- TestingUtils.testHomepage(api, ::println)
+ TestingUtils.testHomepage(api, TestingUtils.Logger())
}
}
println("Done providerCorrectHomepage")
@@ -160,7 +166,6 @@ class ExampleInstrumentedTest {
TestingUtils.getDeferredProviderTests(
this,
getAllProviders(),
- ::println
) { _, _ -> }
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 563c82f8..888be999 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,7 +6,7 @@
-
+
@@ -14,8 +14,14 @@
+
+
+
+
+
-
+ tools:targetApi="tiramisu">
+ android:supportsPictureInPicture="true"
+ android:taskAffinity="com.lagradost.cloudstream3.downloadedplayer"
+ android:launchMode="singleTask">
@@ -87,17 +97,11 @@
-->
-
-
-
-
-
-
@@ -161,6 +165,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -168,13 +187,14 @@
-
+ android:exported="false">
+
@@ -184,6 +204,7 @@
android:exported="false" />
diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp
new file mode 100644
index 00000000..f4cb531f
--- /dev/null
+++ b/app/src/main/cpp/native-lib.cpp
@@ -0,0 +1,28 @@
+#include
+#include
+#include
+
+#define TAG "CloudStream Crash Handler"
+volatile sig_atomic_t gSignalStatus = 0;
+void handleNativeCrash(int signal) {
+ gSignalStatus = signal;
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_lagradost_cloudstream3_NativeCrashHandler_initNativeCrashHandler(JNIEnv *env, jobject) {
+ #define REGISTER_SIGNAL(X) signal(X, handleNativeCrash);
+ REGISTER_SIGNAL(SIGSEGV)
+ #undef REGISTER_SIGNAL
+}
+
+//extern "C" JNIEXPORT void JNICALL
+//Java_com_lagradost_cloudstream3_NativeCrashHandler_triggerNativeCrash(JNIEnv *env, jobject thiz) {
+// int *p = nullptr;
+// *p = 0;
+//}
+
+extern "C" JNIEXPORT int JNICALL
+Java_com_lagradost_cloudstream3_NativeCrashHandler_getSignalStatus(JNIEnv *env, jobject) {
+ //__android_log_print(ANDROID_LOG_INFO, TAG, "Got signal status %d", gSignalStatus);
+ return gSignalStatus;
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
index 76b2321f..d6f978fe 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
@@ -8,12 +8,14 @@ import android.content.Intent
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
-import com.google.auto.service.AutoService
+import com.lagradost.api.setContext
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.plugins.PluginManager
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
-import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
+import com.lagradost.cloudstream3.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
@@ -32,20 +34,19 @@ import org.acra.sender.ReportSenderFactory
import java.io.File
import java.io.FileNotFoundException
import java.io.PrintStream
-import java.lang.Exception
import java.lang.ref.WeakReference
+import 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/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse"
+ "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
val data = mapOf(
- "entry.753293084" to errorContent.toJSON()
+ "entry.1993829403" to errorContent.toJSON()
)
thread { // to not run it on main thread
@@ -65,7 +66,6 @@ class CustomReportSender : ReportSender {
}
}
-@AutoService(ReportSenderFactory::class)
class CustomSenderFactory : ReportSenderFactory {
override fun create(context: Context, config: CoreConfiguration): ReportSender {
return CustomReportSender()
@@ -82,14 +82,8 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
ACRA.errorReporter.handleException(error)
try {
PrintStream(errorFile).use { ps ->
- ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}"))
- ps.println(
- String.format(
- "Fatal exception on thread %s (%d)",
- thread.name,
- thread.id
- )
- )
+ ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")
+ ps.println("Fatal exception on thread ${thread.name} (${thread.id})")
error.printStackTrace(ps)
}
} catch (ignored: FileNotFoundException) {
@@ -104,12 +98,16 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
}
class AcraApplication : Application() {
+
override fun onCreate() {
super.onCreate()
- Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) {
+ 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?) {
@@ -121,10 +119,10 @@ class AcraApplication : Application() {
buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON
- reportContent = arrayOf(
+ reportContent = listOf(
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
- ReportField.STACK_TRACE
+ ReportField.STACK_TRACE,
)
// removed this due to bug when starting the app, moved it to when it actually crashes
@@ -137,6 +135,8 @@ class AcraApplication : Application() {
}
companion object {
+ var exceptionHandler: ExceptionHandler? = null
+
/** Use to get activity from Context */
tailrec fun Context.getActivity(): Activity? = this as? Activity
?: (this as? ContextWrapper)?.baseContext?.getActivity()
@@ -146,8 +146,17 @@ class AcraApplication : Application() {
get() = _context?.get()
private set(value) {
_context = WeakReference(value)
+ setContext(WeakReference(value))
}
+ fun getKeyClass(path: String, valueType: Class): T? {
+ return context?.getKey(path, valueType)
+ }
+
+ fun setKeyClass(path: String, value: T) {
+ context?.setKey(path, value)
+ }
+
fun removeKeys(folder: String): Int? {
return context?.removeKeys(folder)
}
@@ -199,10 +208,9 @@ class AcraApplication : Application() {
fun openBrowser(url: String, activity: FragmentActivity?) {
openBrowser(
url,
- isTvSettings(),
+ isLayout(TV or EMULATOR),
activity?.supportFragmentManager?.fragments?.lastOrNull()
)
}
-
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
index 9c7c319e..ee3a5d12 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt
@@ -5,12 +5,16 @@ import android.app.Activity
import android.app.PictureInPictureParams
import android.content.Context
import android.content.pm.PackageManager
+import android.content.res.Configuration
import android.content.res.Resources
import android.os.Build
+import android.util.DisplayMetrics
import android.util.Log
-import android.view.*
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.View
import android.view.View.NO_ID
-import android.widget.TextView
+import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
@@ -19,16 +23,21 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
+import androidx.core.view.children
import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession
+import com.google.android.material.chip.ChipGroup
+import com.google.android.material.navigationrail.NavigationRailView
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
+import com.lagradost.cloudstream3.MainActivity.Companion.resumeApps
+import com.lagradost.cloudstream3.databinding.ToastBinding
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.ui.result.UiText
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
-import com.lagradost.cloudstream3.utils.AppUtils.isRtl
+import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
+import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper
@@ -37,7 +46,16 @@ import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import org.schabi.newpipe.extractor.NewPipe
import java.lang.ref.WeakReference
-import java.util.*
+import java.util.Locale
+import kotlin.math.max
+import kotlin.math.min
+
+enum class FocusDirection {
+ Start,
+ End,
+ Up,
+ Down,
+}
object CommonActivity {
@@ -48,11 +66,29 @@ object CommonActivity {
_activity = WeakReference(value)
}
+ @MainThread
+ fun setActivityInstance(newActivity: Activity?) {
+ activity = newActivity
+ }
+
@MainThread
fun Activity?.getCastSession(): CastSession? {
return (this as MainActivity?)?.mSessionManager?.currentCastSession
}
+ val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
+
+ // screenWidth and screenHeight does always
+ // refer to the screen while in landscape mode
+ val screenWidth: Int
+ get() {
+ return max(displayMetrics.widthPixels, displayMetrics.heightPixels)
+ }
+ val screenHeight: Int
+ get() {
+ return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
+ }
+
var canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false
@@ -64,8 +100,7 @@ object CommonActivity {
var playerEventListener: ((PlayerEventType) -> Unit)? = null
var keyEventListener: ((Pair) -> Boolean)? = null
-
- var currentToast: Toast? = null
+ private var currentToast: Toast? = null
fun showToast(@StringRes message: Int, duration: Int? = null) {
val act = activity ?: return
@@ -121,25 +156,19 @@ object CommonActivity {
} catch (e: Exception) {
logError(e)
}
+
try {
- val inflater =
- act.getSystemService(AppCompatActivity.LAYOUT_INFLATER_SERVICE) as LayoutInflater
-
- val layout: View = inflater.inflate(
- R.layout.toast,
- act.findViewById(R.id.toast_layout_root) as ViewGroup?
- )
-
- val text = layout.findViewById(R.id.text) as TextView
- text.text = message.trim()
+ val binding = ToastBinding.inflate(act.layoutInflater)
+ binding.text.text = message.trim()
+ // custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11)
val toast = Toast(act)
- toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
toast.duration = duration ?: Toast.LENGTH_SHORT
- toast.view = layout
- //https://github.com/PureWriter/ToastCompat
- toast.show()
+ toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx)
+ toast.view = binding.root //fixme Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version.
currentToast = toast
+ toast.show()
+
} catch (e: Exception) {
logError(e)
}
@@ -173,23 +202,25 @@ object CommonActivity {
setLocale(this, localeCode)
}
- fun init(act: ComponentActivity?) {
- if (act == null) return
- activity = act
+ fun init(act: Activity) {
+ setActivityInstance(act)
+
+ 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
- act.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // HAS FEATURE, MIGHT BE BLOCKED DUE TO POWER DRAIN
- act.hasPIPPermission() // CHECK IF FEATURE IS ENABLED IN SETTINGS
+ 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
- act.updateLocale()
- act.updateTv()
+ componentActivity.updateLocale()
+ componentActivity.updateTv()
NewPipe.init(DownloaderTestImpl.getInstance())
for (resumeApp in resumeApps) {
resumeApp.launcher =
- act.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val resultCode = result.resultCode
val data = result.data
if (resultCode == AppCompatActivity.RESULT_OK && data != null && resumeApp.position != null && resumeApp.duration != null) {
@@ -206,11 +237,11 @@ object CommonActivity {
// Ask for notification permissions on Android 13
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(
- act,
+ componentActivity,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
- val requestPermissionLauncher = act.registerForActivityResult(
+ val requestPermissionLauncher = componentActivity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
Log.d(TAG, "Notification permission: $isGranted")
@@ -246,12 +277,35 @@ object CommonActivity {
}
}
+ fun updateTheme(act: Activity) {
+ val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
+ if (settingsManager
+ .getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System"
+ && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ loadThemes(act)
+ }
+ }
+
+ private fun mapSystemTheme(act: Activity): Int {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val currentNightMode =
+ act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
+ return when (currentNightMode) {
+ Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme
+ else -> R.style.AppTheme // Night mode is active, we're using dark theme
+ }
+ } else {
+ return R.style.AppTheme
+ }
+ }
+
fun loadThemes(act: Activity?) {
if (act == null) return
val settingsManager = PreferenceManager.getDefaultSharedPreferences(act)
val currentTheme =
when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) {
+ "System" -> mapSystemTheme(act)
"Black" -> R.style.AppTheme
"Light" -> R.style.LightMode
"Amoled" -> R.style.AmoledMode
@@ -265,12 +319,15 @@ object CommonActivity {
val currentOverlayTheme =
when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) {
"Normal" -> R.style.OverlayPrimaryColorNormal
+ "DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow
"CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink
+ "Orange" -> R.style.OverlayPrimaryColorOrange
"DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen
"Maroon" -> R.style.OverlayPrimaryColorMaroon
"NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue
"Grey" -> R.style.OverlayPrimaryColorGrey
"White" -> R.style.OverlayPrimaryColorWhite
+ "CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue
"Brown" -> R.style.OverlayPrimaryColorBrown
"Purple" -> R.style.OverlayPrimaryColorPurple
"Green" -> R.style.OverlayPrimaryColorGreen
@@ -279,6 +336,7 @@ object CommonActivity {
"Banana" -> R.style.OverlayPrimaryColorBanana
"Party" -> R.style.OverlayPrimaryColorParty
"Pink" -> R.style.OverlayPrimaryColorPink
+ "Lavender" -> R.style.OverlayPrimaryColorLavender
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
@@ -301,7 +359,8 @@ object CommonActivity {
private fun localLook(from: View, id: Int): View? {
if (id == NO_ID) return null
var currentLook: View = from
- while (true) {
+ // limit to 15 look depth
+ for (i in 0..15) {
currentLook.findViewById(id)?.let { return it }
currentLook = (currentLook.parent as? View) ?: break
}
@@ -317,17 +376,79 @@ object CommonActivity {
currentLook = currentLook.parent as? View ?: break
}*/
+ private fun View.hasContent(): Boolean {
+ return isShown && when (this) {
+ //is RecyclerView -> this.childCount > 0
+ is ViewGroup -> this.childCount > 0
+ else -> true
+ }
+ }
+
+ /** skips the initial stage of searching for an id using the view, see getNextFocus for specification */
+ fun continueGetNextFocus(
+ root: Any?,
+ view: View,
+ direction: FocusDirection,
+ nextId: Int,
+ depth: Int = 0
+ ): View? {
+ if (nextId == NO_ID) return null
+
+ // do an initial search for the view, in case the localLook is too deep we can use this as
+ // an early break and backup view
+ var next =
+ when (root) {
+ is Activity -> root.findViewById(nextId)
+ is View -> root.rootView.findViewById(nextId)
+ else -> null
+ } ?: return null
+
+ next = localLook(view, nextId) ?: next
+ val shown = next.hasContent()
+
+ // if cant focus but visible then break and let android decide
+ // the exception if is the view is a parent and has children that wants focus
+ val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
+ parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
+ } ?: false
+ if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
+
+ // if not shown then continue because we will "skip" over views to get to a replacement
+ if (!shown) {
+ // we don't want a while true loop, so we let android decide if we find a recursive view
+ if (next == view) return null
+ return getNextFocus(root, next, direction, depth + 1)
+ }
+
+ (when (next) {
+ is ChipGroup -> {
+ next.children.firstOrNull { it.isFocusable && it.isShown }
+ }
+
+ is NavigationRailView -> {
+ next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home)
+ }
+
+ else -> null
+ })?.let {
+ return it
+ }
+
+ // nothing wrong with the view found, return it
+ return next
+ }
+
/** recursively looks for a next focus up to a depth of 10,
* this is used to override the normal shit focus system
* because this application has a lot of invisible views that messes with some tv devices*/
- private fun getNextFocus(
- act: Activity?,
+ fun getNextFocus(
+ root: Any?,
view: View?,
direction: FocusDirection,
depth: Int = 0
): View? {
// if input is invalid let android decide + depth test to not crash if loop is found
- if (view == null || depth >= 10 || act == null) {
+ if (view == null || depth >= 10 || root == null) {
return null
}
@@ -359,50 +480,14 @@ object CommonActivity {
// if not specified then use forward id
nextId = view.nextFocusForwardId
// if view is still not found to next focus then return and let android decide
- if (nextId == NO_ID) return null
+ if (nextId == NO_ID)
+ return null
}
-
- var next = act.findViewById(nextId) ?: return null
-
- next = localLook(view, nextId) ?: next
-
- var currentLook: View = view
- while (currentLook.findViewById(nextId)?.also { next = it } == null) {
- currentLook = (currentLook.parent as? View) ?: break
- }
-
- // if cant focus but visible then break and let android decide
- if (!next.isFocusable && next.isShown) return null
-
- // if not shown then continue because we will "skip" over views to get to a replacement
- if (!next.isShown) return getNextFocus(act, next, direction, depth + 1)
-
- // nothing wrong with the view found, return it
- return next
+ return continueGetNextFocus(root, view, direction, nextId, depth)
}
- private enum class FocusDirection {
- Start,
- End,
- Up,
- Down,
- }
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
- //println("Keycode: $keyCode")
- //showToast(
- // this,
- // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
- // Toast.LENGTH_LONG
- //)
-
- // Tested keycodes on remote:
- // KeyEvent.KEYCODE_MEDIA_FAST_FORWARD
- // KeyEvent.KEYCODE_MEDIA_REWIND
- // KeyEvent.KEYCODE_MENU
- // KeyEvent.KEYCODE_MEDIA_NEXT
- // KeyEvent.KEYCODE_MEDIA_PREVIOUS
- // KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE
// 149 keycode_numpad 5
when (keyCode) {
@@ -516,7 +601,7 @@ object CommonActivity {
else -> null
}
-
+ // println("NEXT FOCUS : $nextView")
if (nextView != null) {
nextView.requestFocus()
keyEventListener?.invoke(Pair(event, true))
diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
index 379a91e4..8da7ca38 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt
@@ -2,6 +2,7 @@ package com.lagradost.cloudstream3
import okhttp3.OkHttpClient
import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
@@ -10,7 +11,7 @@ import java.util.concurrent.TimeUnit
class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() {
- private val client: OkHttpClient
+ private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build()
override fun execute(request: Request): Response {
val httpMethod: String = request.httpMethod()
val url: String = request.url()
@@ -18,7 +19,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
val dataToSend: ByteArray? = request.dataToSend()
var requestBody: RequestBody? = null
if (dataToSend != null) {
- requestBody = RequestBody.create(null, dataToSend)
+ requestBody = dataToSend.toRequestBody(null, 0, dataToSend.size)
}
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
.method(httpMethod, requestBody).url(url)
@@ -50,7 +51,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
companion object {
private const val USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
private var instance: DownloaderTestImpl? = null
/**
@@ -73,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
return instance
}
}
-
- init {
- client = builder.readTimeout(30, TimeUnit.SECONDS).build()
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
index b7add6ff..5408d2a8 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
@@ -6,6 +6,7 @@ import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Configuration
+import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Bundle
@@ -18,6 +19,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IdRes
import androidx.annotation.MainThread
@@ -26,7 +28,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.core.view.isGone
+import androidx.core.view.isInvisible
import androidx.core.view.isVisible
+import androidx.core.view.marginStart
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
@@ -37,9 +41,9 @@ import androidx.navigation.NavOptions
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import androidx.preference.PreferenceManager
-import com.fasterxml.jackson.databind.DeserializationFeature
-import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.LinearSnapHelper
+import androidx.recyclerview.widget.RecyclerView
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.Session
import com.google.android.gms.cast.framework.SessionManager
@@ -48,83 +52,108 @@ import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.navigationrail.NavigationRailView
import com.google.android.material.snackbar.Snackbar
+import com.google.common.collect.Comparators.min
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.APIHolder.apis
-import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.initAll
-import com.lagradost.cloudstream3.APIHolder.updateHasTrailers
+import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.loadThemes
import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent
import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent
import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint
+import com.lagradost.cloudstream3.CommonActivity.screenHeight
+import com.lagradost.cloudstream3.CommonActivity.setActivityInstance
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.CommonActivity.updateLocale
+import com.lagradost.cloudstream3.CommonActivity.updateTheme
import com.lagradost.cloudstream3.databinding.ActivityMainBinding
import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding
import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding
import com.lagradost.cloudstream3.mvvm.Resource
-import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
+import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.network.initClient
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins
import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
+import com.lagradost.cloudstream3.services.SubscriptionWorkManager
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringPlayer
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths
+import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi
+import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.APIRepository
+import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
import com.lagradost.cloudstream3.ui.home.HomeViewModel
+import com.lagradost.cloudstream3.ui.library.LibraryViewModel
import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator
+import com.lagradost.cloudstream3.ui.result.LinearListLayout
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
+import com.lagradost.cloudstream3.ui.result.SyncViewModel
import com.lagradost.cloudstream3.ui.result.setImage
import com.lagradost.cloudstream3.ui.result.setText
+import com.lagradost.cloudstream3.ui.result.setTextHtml
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.search.SearchFragment
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isEmulatorSettings
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.ui.settings.Globals.updateTv
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral
import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY
import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions
import com.lagradost.cloudstream3.utils.ApkInstaller
-import com.lagradost.cloudstream3.utils.AppUtils.html
-import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable
-import com.lagradost.cloudstream3.utils.AppUtils.isLtr
-import com.lagradost.cloudstream3.utils.AppUtils.isNetworkAvailable
-import com.lagradost.cloudstream3.utils.AppUtils.loadCache
-import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
-import com.lagradost.cloudstream3.utils.AppUtils.loadResult
-import com.lagradost.cloudstream3.utils.AppUtils.loadSearchResult
-import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
+import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
+import com.lagradost.cloudstream3.utils.AppContextUtils.html
+import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
+import com.lagradost.cloudstream3.utils.AppContextUtils.isLtr
+import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable
+import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult
+import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
+import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.BackupUtils.backup
import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.setKey
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching
import com.lagradost.cloudstream3.utils.Event
-import com.lagradost.cloudstream3.utils.IOnBackPressed
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
+import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
@@ -136,8 +165,8 @@ import com.lagradost.cloudstream3.utils.UIHelper.requestRW
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
-import com.lagradost.nicehttp.Requests
-import com.lagradost.nicehttp.ResponseParser
+import com.lagradost.cloudstream3.utils.fcast.FcastManager
+import com.lagradost.safefile.SafeFile
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
@@ -145,11 +174,10 @@ import java.lang.ref.WeakReference
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.Charset
+import kotlin.math.abs
import kotlin.math.absoluteValue
-import kotlin.reflect.KClass
import kotlin.system.exitProcess
-
//https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898
//https://wiki.videolan.org/Android_Player_Intents/
@@ -160,117 +188,113 @@ import kotlin.system.exitProcess
//https://github.com/jellyfin/jellyfin-android/blob/6cbf0edf84a3da82347c8d59b5d5590749da81a9/app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt#L225
-const val VLC_PACKAGE = "org.videolan.vlc"
-const val MPV_PACKAGE = "is.xyz.mpv"
-const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo"
-
-val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity")
-val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity")
-
-//TODO REFACTOR AF
-open class ResultResume(
- val packageString: String,
- val action: String = Intent.ACTION_VIEW,
- val position: String? = null,
- val duration: String? = null,
- var launcher: ActivityResultLauncher? = null,
-) {
- val defaultTime = -1L
-
- val lastId get() = "${packageString}_last_open_id"
- suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) {
- val intent = Intent(action)
-
- if (id != null)
- setKey(lastId, id)
- else
- removeKey(lastId)
-
- intent.setPackage(packageString)
- callback.invoke(intent)
- launcher?.launch(intent)
- }
-
- open fun getPosition(intent: Intent?): Long {
- return defaultTime
- }
-
- open fun getDuration(intent: Intent?): Long {
- return defaultTime
- }
-}
-
-val VLC = object : ResultResume(
- VLC_PACKAGE,
- // Android 13 intent restrictions fucks up specifically launching the VLC player
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
- "org.videolan.vlc.player.result"
- } else {
- Intent.ACTION_VIEW
- },
- "extra_position",
- "extra_duration",
-) {
- override fun getPosition(intent: Intent?): Long {
- return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime
- }
-
- override fun getDuration(intent: Intent?): Long {
- return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime
- }
-}
-
-val MPV = object : ResultResume(
- MPV_PACKAGE,
- //"is.xyz.mpv.MPVActivity.result", // resume not working :pensive:
- position = "position",
- duration = "duration",
-) {
- override fun getPosition(intent: Intent?): Long {
- return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong() ?: defaultTime
- }
-
- override fun getDuration(intent: Intent?): Long {
- return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong() ?: defaultTime
- }
-}
-
-val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE)
-
-val resumeApps = arrayOf(
- VLC, MPV, WEB_VIDEO
-)
-
-// Short name for requests client to make it nicer to use
-
-var app = Requests(responseParser = object : ResponseParser {
- val mapper: ObjectMapper = jacksonObjectMapper().configure(
- DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
- false
- )
-
- override fun parse(text: String, kClass: KClass): T {
- return mapper.readValue(text, kClass.java)
- }
-
- override fun parseSafe(text: String, kClass: KClass): T? {
- return try {
- mapper.readValue(text, kClass.java)
- } catch (e: Exception) {
- null
- }
- }
-
- override fun writeValueAsString(obj: Any): String {
- return mapper.writeValueAsString(obj)
- }
-}).apply {
- defaultHeaders = mapOf("user-agent" to USER_AGENT)
-}
-
-class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
+class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
companion object {
+ const val VLC_PACKAGE = "org.videolan.vlc"
+ const val MPV_PACKAGE = "is.xyz.mpv"
+ const val WEB_VIDEO_CAST_PACKAGE = "com.instantbits.cast.webvideo"
+
+ val VLC_COMPONENT = ComponentName(VLC_PACKAGE, "$VLC_PACKAGE.gui.video.VideoPlayerActivity")
+ val MPV_COMPONENT = ComponentName(MPV_PACKAGE, "$MPV_PACKAGE.MPVActivity")
+
+ //TODO REFACTOR AF
+ open class ResultResume(
+ val packageString: String,
+ val action: String = Intent.ACTION_VIEW,
+ val position: String? = null,
+ val duration: String? = null,
+ var launcher: ActivityResultLauncher? = null,
+ ) {
+ val defaultTime = -1L
+
+ val lastId get() = "${packageString}_last_open_id"
+ suspend fun launch(id: Int?, callback: suspend Intent.() -> Unit) {
+ val intent = Intent(action)
+
+ if (id != null)
+ setKey(lastId, id)
+ else
+ removeKey(lastId)
+
+ intent.setPackage(packageString)
+ callback.invoke(intent)
+ launcher?.launch(intent)
+ }
+
+ open fun getPosition(intent: Intent?): Long {
+ return defaultTime
+ }
+
+ open fun getDuration(intent: Intent?): Long {
+ return defaultTime
+ }
+ }
+
+ val VLC = object : ResultResume(
+ VLC_PACKAGE,
+ // Android 13 intent restrictions fucks up specifically launching the VLC player
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ "org.videolan.vlc.player.result"
+ } else {
+ Intent.ACTION_VIEW
+ },
+ "extra_position",
+ "extra_duration",
+ ) {
+ override fun getPosition(intent: Intent?): Long {
+ return intent?.getLongExtra(this.position, defaultTime) ?: defaultTime
+ }
+
+ override fun getDuration(intent: Intent?): Long {
+ return intent?.getLongExtra(this.duration, defaultTime) ?: defaultTime
+ }
+ }
+
+ val MPV = object : ResultResume(
+ MPV_PACKAGE,
+ //"is.xyz.mpv.MPVActivity.result", // resume not working :pensive:
+ position = "position",
+ duration = "duration",
+ ) {
+ override fun getPosition(intent: Intent?): Long {
+ return intent?.getIntExtra(this.position, defaultTime.toInt())?.toLong()
+ ?: defaultTime
+ }
+
+ override fun getDuration(intent: Intent?): Long {
+ return intent?.getIntExtra(this.duration, defaultTime.toInt())?.toLong()
+ ?: defaultTime
+ }
+ }
+
+ val WEB_VIDEO = ResultResume(WEB_VIDEO_CAST_PACKAGE)
+
+ val resumeApps = arrayOf(
+ VLC, MPV, WEB_VIDEO
+ )
+
+
const val TAG = "MAINACT"
+ const val ANIMATED_OUTLINE: Boolean = false
+ var lastError: String? = null
+
+ private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY"
+
+ /**
+ * Transient files to delete on application exit.
+ * Deletes files on onDestroy().
+ */
+ private var filesToDelete: Set
+ // This needs to be persistent because the application may exit without calling onDestroy.
+ get() = getKey>(FILE_DELETE_KEY) ?: setOf()
+ private set(value) = setKey(FILE_DELETE_KEY, value)
+
+ /**
+ * Add file to delete on Exit.
+ */
+ fun deleteFileOnExit(file: File) {
+ filesToDelete = filesToDelete + file.path
+ }
/**
* Setting this will automatically enter the query in the search
@@ -279,7 +303,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
*
* This is a very bad solution but I was unable to find a better one.
**/
- private var nextSearchQuery: String? = null
+ var nextSearchQuery: String? = null
/**
* Fires every time a new batch of plugins have been loaded, no guarantee about how often this is run and on which thread
@@ -295,6 +319,16 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
// kinda shitty solution, but cant com main->home otherwise for popups
val bookmarksUpdatedEvent = Event()
+ /**
+ * Used by DataStoreHelper to fully reload home when switching accounts
+ */
+ val reloadHomeEvent = Event()
+
+ /**
+ * Used by DataStoreHelper to fully reload library when switching accounts
+ */
+ val reloadLibraryEvent = Event()
+
/**
* @return true if the str has launched an app task (be it successful or not)
@@ -317,7 +351,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
println("Repository url: $realUrl")
loadRepository(realUrl)
return true
- } else if (str.contains(appString)) {
+ } else if (str.contains(APP_STRING)) {
for (api in OAuth2Apis) {
if (str.contains("/${api.redirectUrl}")) {
ioSafe {
@@ -347,24 +381,29 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
// This specific intent is used for the gradle deployWithAdb
// https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46
- if (str == "$appString:") {
+ if (str == "$APP_STRING:") {
PluginManager.hotReloadAllLocalPlugins(activity)
}
- } else if (safeURI(str)?.scheme == appStringRepo) {
- val url = str.replaceFirst(appStringRepo, "https")
+ } else if (safeURI(str)?.scheme == APP_STRING_REPO) {
+ val url = str.replaceFirst(APP_STRING_REPO, "https")
loadRepository(url)
return true
- } else if (safeURI(str)?.scheme == appStringSearch) {
+ } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) {
+ val query = str.substringAfter("$APP_STRING_SEARCH://")
nextSearchQuery =
- URLDecoder.decode(str.substringAfter("$appStringSearch://"), "UTF-8")
-
+ try {
+ URLDecoder.decode(query, "UTF-8")
+ } catch (t: Throwable) {
+ logError(t)
+ query
+ }
// Use both navigation views to support both layouts.
// It might be better to use the QuickSearch.
activity?.findViewById(R.id.nav_view)?.selectedItemId =
R.id.navigation_search
activity?.findViewById(R.id.nav_rail_view)?.selectedItemId =
R.id.navigation_search
- } else if (safeURI(str)?.scheme == appStringPlayer) {
+ } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) {
val uri = Uri.parse(str)
val name = uri.getQueryParameter("name")
val url = URLDecoder.decode(uri.authority, "UTF-8")
@@ -378,9 +417,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
)
)
)
- } else if (safeURI(str)?.scheme == appStringResumeWatching) {
+ } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
val id =
- str.substringAfter("$appStringResumeWatching://").toIntOrNull()
+ str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull()
?: return false
ioSafe {
val resumeWatchingCard =
@@ -412,13 +451,30 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
var lastPopup: SearchResponse? = null
- fun loadPopup(result: SearchResponse) {
+ fun loadPopup(result: SearchResponse, load: Boolean = true) {
lastPopup = result
- viewModel.load(
- this, result.url, result.apiName, false, if (getApiDubstatusSettings()
- .contains(DubStatus.Dubbed)
- ) DubStatus.Dubbed else DubStatus.Subbed, null
- )
+ val syncName = syncViewModel.syncName(result.apiName)
+
+ // based on apiName we decide on if it is a local list or not, this is because
+ // we want to show a bit of extra UI to sync apis
+ if (result is SyncAPI.LibraryItem && syncName != null) {
+ isLocalList = false
+ syncViewModel.setSync(syncName, result.syncId)
+ syncViewModel.updateMetaAndUser()
+ } else {
+ isLocalList = true
+ syncViewModel.clear()
+ }
+
+ if (load) {
+ viewModel.load(
+ this, result.url, result.apiName, false, if (getApiDubstatusSettings()
+ .contains(DubStatus.Dubbed)
+ ) DubStatus.Dubbed else DubStatus.Subbed, null
+ )
+ } else {
+ viewModel.loadSmall(result)
+ }
}
override fun onColorSelected(dialogId: Int, color: Int) {
@@ -432,6 +488,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateLocale() // android fucks me by chaining lang when rotating the phone
+ updateTheme(this) // Update if system theme
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
@@ -476,12 +533,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
R.id.navigation_results_phone,
R.id.navigation_results_tv,
R.id.navigation_player,
+ R.id.navigation_quick_search,
).contains(destination.id)
binding?.navHostFragment?.apply {
val params = layoutParams as ConstraintLayout.LayoutParams
val push =
- if (!dontPush && isTvSettings()) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0
+ if (!dontPush && isLayout(TV or EMULATOR)) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0
if (!this.isLtr()) {
params.setMargins(
@@ -508,26 +566,51 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
Configuration.ORIENTATION_PORTRAIT -> {
- isTvSettings()
+ isLayout(TV or EMULATOR)
}
else -> {
false
}
}
- binding?.apply {
- navView.isVisible = isNavVisible && !landscape
- navRailView.isVisible = isNavVisible && landscape
- // Hide library on TV since it is not supported yet :(
- val isTrueTv = isTrueTvSettings()
- navView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv
- navRailView.menu.findItem(R.id.navigation_library)?.isVisible = !isTrueTv
+ binding?.apply {
+ navRailView.isVisible = isNavVisible && landscape
+ navView.isVisible = isNavVisible && !landscape
+
+ /**
+ * We need to make sure if we return to a sub-fragment,
+ * the correct navigation item is selected so that it does not
+ * highlight the wrong one in UI.
+ */
+ when (destination.id) {
+ in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> {
+ navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
+ navView.menu.findItem(R.id.navigation_downloads).isChecked = true
+ }
+ in listOf(
+ R.id.navigation_settings,
+ R.id.navigation_subtitles,
+ R.id.navigation_chrome_subtitles,
+ R.id.navigation_settings_player,
+ R.id.navigation_settings_updates,
+ R.id.navigation_settings_ui,
+ R.id.navigation_settings_account,
+ R.id.navigation_settings_providers,
+ R.id.navigation_settings_general,
+ R.id.navigation_settings_extensions,
+ R.id.navigation_settings_plugins,
+ R.id.navigation_test_providers
+ ) -> {
+ navRailView.menu.findItem(R.id.navigation_settings).isChecked = true
+ navView.menu.findItem(R.id.navigation_settings).isChecked = true
+ }
+ }
}
}
//private var mCastSession: CastSession? = null
- lateinit var mSessionManager: SessionManager
+ var mSessionManager: SessionManager? = null
private val mSessionManagerListener: SessionManagerListener by lazy { SessionManagerListenerImpl() }
private inner class SessionManagerListenerImpl : SessionManagerListener {
@@ -564,10 +647,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
override fun onResume() {
super.onResume()
afterPluginsLoadedEvent += ::onAllPluginsLoaded
+ setActivityInstance(this)
try {
if (isCastApiAvailable()) {
- //mCastSession = mSessionManager.currentCastSession
- mSessionManager.addSessionManagerListener(mSessionManagerListener)
+ mSessionManager?.addSessionManagerListener(mSessionManagerListener)
}
} catch (e: Exception) {
logError(e)
@@ -583,7 +666,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
try {
if (isCastApiAvailable()) {
- mSessionManager.removeSessionManagerListener(mSessionManagerListener)
+ mSessionManager?.removeSessionManagerListener(mSessionManagerListener)
//mCastSession = null
}
} catch (e: Exception) {
@@ -591,23 +674,10 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
}
- override fun dispatchKeyEvent(event: KeyEvent?): Boolean {
- val start = System.currentTimeMillis()
- try {
- val response = CommonActivity.dispatchKeyEvent(this, event)
-
- if (response != null)
- return response
- } finally {
- debugAssert({
- val end = System.currentTimeMillis()
- val delta = end - start
- delta > 100
- }) {
- "Took over 100ms to navigate, smth is VERY wrong"
- }
- }
-
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ val response = CommonActivity.dispatchKeyEvent(this, event)
+ if (response != null)
+ return response
return super.dispatchKeyEvent(event)
}
@@ -634,35 +704,16 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
builder.show().setDefaultFocus()
}
- private fun backPressed() {
- this.window?.navigationBarColor =
- this.colorFromAttribute(R.attr.primaryGrayBackground)
- this.updateLocale()
- this.updateLocale()
-
- val navHostFragment =
- supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment
- val navController = navHostFragment?.navController
- val isAtHome =
- navController?.currentDestination?.matchDestination(R.id.navigation_home) == true
-
- if (isAtHome && isTrueTvSettings()) {
- showConfirmExitDialog()
- } else {
- super.onBackPressed()
- }
- }
-
- override fun onBackPressed() {
- ((supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment?)?.childFragmentManager?.primaryNavigationFragment as? IOnBackPressed)?.onBackPressed()
- ?.let { runNormal ->
- if (runNormal) backPressed()
- } ?: run {
- backPressed()
- }
- }
-
override fun onDestroy() {
+ filesToDelete.forEach { path ->
+ val result = File(path).deleteRecursively()
+ if (result) {
+ Log.d(TAG, "Deleted temporary file: $path")
+ } else {
+ Log.d(TAG, "Failed to delete temporary file: $path")
+ }
+ }
+ filesToDelete = setOf()
val broadcastIntent = Intent()
broadcastIntent.action = "restart_service"
broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java)
@@ -719,7 +770,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
list.forEach { custom ->
allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass }
?.let {
- allProviders.add(it.javaClass.newInstance().apply {
+ allProviders.add(it.javaClass.getDeclaredConstructor().newInstance().apply {
name = custom.name
lang = custom.lang
mainUrl = custom.url.trimEnd('/')
@@ -741,10 +792,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
lateinit var viewModel: ResultViewModel2
+ lateinit var syncViewModel: SyncViewModel
+ private var libraryViewModel: LibraryViewModel? = null
+ /** kinda dirty, however it signals that we should use the watch status as sync or not*/
+ var isLocalList: Boolean = false
override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? {
- viewModel =
- ViewModelProvider(this)[ResultViewModel2::class.java]
+
+ viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
+ syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java]
return super.onCreateView(name, context, attrs)
}
@@ -779,7 +835,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
return ret
}
- private var binding: ActivityMainBinding? = null
+ var binding: ActivityMainBinding? = null
object TvFocus {
data class FocusTarget(
@@ -807,10 +863,18 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
var focusOutline: WeakReference = WeakReference(null)
var lastFocus: WeakReference = WeakReference(null)
private val layoutListener: View.OnLayoutChangeListener =
- View.OnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
- updateFocusView(
- v, same = true
- )
+ View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ // shitty fix for layouts
+ lastFocus.get()?.apply {
+ updateFocusView(
+ this, same = true
+ )
+ postDelayed({
+ updateFocusView(
+ lastFocus.get(), same = false
+ )
+ }, 300)
+ }
}
private val attachListener: View.OnAttachStateChangeListener =
object : View.OnAttachStateChangeListener {
@@ -823,6 +887,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
focusOutline.get()?.isVisible = false
}
}
+ /*private val scrollListener = object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ current = current.copy(x = current.x + dx, y = current.y + dy)
+ setTargetPosition(current)
+ }
+ }*/
private fun setTargetPosition(target: FocusTarget) {
focusOutline.get()?.apply {
@@ -839,12 +910,36 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
private var animator: ValueAnimator? = null
+ /** if this is enabled it will keep the focus unmoving
+ * during listview move */
+ private const val NO_MOVE_LIST: Boolean = false
+
+ /** If this is enabled then it will try to move the
+ * listview focus to the left instead of center */
+ private const val LEFTMOST_MOVE_LIST: Boolean = true
+
+ private val reflectedScroll by lazy {
+ try {
+ RecyclerView::class.java.declaredMethods.firstOrNull {
+ it.name == "scrollStep"
+ }?.also { it.isAccessible = true }
+ } catch (t: Throwable) {
+ null
+ }
+ }
+
@MainThread
fun updateFocusView(newFocus: View?, same: Boolean = false) {
val focusOutline = focusOutline.get() ?: return
- lastFocus.get()?.apply {
- removeOnLayoutChangeListener(layoutListener)
- removeOnAttachStateChangeListener(attachListener)
+ val lastView = lastFocus.get()
+ val exactlyTheSame = lastView == newFocus && newFocus != null
+ if (!exactlyTheSame) {
+ lastView?.removeOnLayoutChangeListener(layoutListener)
+ lastView?.removeOnAttachStateChangeListener(attachListener)
+ (lastView?.parent as? RecyclerView)?.apply {
+ removeOnLayoutChangeListener(layoutListener)
+ //removeOnScrollListener(scrollListener)
+ }
}
val wasGone = focusOutline.isGone
@@ -855,25 +950,80 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (newFocus != null) {
lastFocus = WeakReference(newFocus)
+ val parent = newFocus.parent
+ var targetDx = 0
+ if (parent is RecyclerView) {
+ val layoutManager = parent.layoutManager
+ if (layoutManager is LinearListLayout && layoutManager.orientation == LinearLayoutManager.HORIZONTAL) {
+ val dx =
+ LinearSnapHelper().calculateDistanceToFinalSnap(layoutManager, newFocus)
+ ?.get(0)
+
+ if (dx != null) {
+ val rdx = if (LEFTMOST_MOVE_LIST) {
+ // this makes the item the leftmost in ltr, instead of center
+ val diff =
+ ((layoutManager.width - layoutManager.paddingStart - newFocus.measuredWidth) / 2) - newFocus.marginStart
+ dx + if (parent.isRtl()) {
+ -diff
+ } else {
+ diff
+ }
+ } else {
+ if (dx > 0) dx else 0
+ }
+
+ if (!NO_MOVE_LIST) {
+ parent.smoothScrollBy(rdx, 0)
+ } else {
+ val smoothScroll = reflectedScroll
+ if (smoothScroll == null) {
+ parent.smoothScrollBy(rdx, 0)
+ } else {
+ try {
+ // this is very fucked but because it is a protected method to
+ // be able to compute the scroll I use reflection, scroll, then
+ // scroll back, then smooth scroll and set the no move
+ val out = IntArray(2)
+ smoothScroll.invoke(parent, rdx, 0, out)
+ val scrolledX = out[0]
+ if (abs(scrolledX) <= 0) { // newFocus.measuredWidth*2
+ smoothScroll.invoke(parent, -rdx, 0, out)
+ parent.smoothScrollBy(scrolledX, 0)
+ if (NO_MOVE_LIST) targetDx = scrolledX
+ }
+ } catch (t: Throwable) {
+ parent.smoothScrollBy(rdx, 0)
+ }
+ }
+ }
+ }
+ }
+ }
val out = IntArray(2)
newFocus.getLocationInWindow(out)
val (screenX, screenY) = out
var (x, y) = screenX.toFloat() to screenY.toFloat()
val (currentX, currentY) = focusOutline.translationX to focusOutline.translationY
- // println(">><<< $x $y $currentX $currentY")
+
if (!newFocus.isLtr()) {
x = x - focusOutline.rootView.width + newFocus.measuredWidth
}
+ x -= targetDx
// out of bounds = 0,0
if (screenX == 0 && screenY == 0) {
focusOutline.isVisible = false
}
-
- newFocus.addOnLayoutChangeListener(layoutListener)
- newFocus.addOnAttachStateChangeListener(attachListener)
-
+ if (!exactlyTheSame) {
+ (newFocus.parent as? RecyclerView)?.apply {
+ addOnLayoutChangeListener(layoutListener)
+ //addOnScrollListener(scrollListener)
+ }
+ newFocus.addOnLayoutChangeListener(layoutListener)
+ newFocus.addOnAttachStateChangeListener(attachListener)
+ }
val start = FocusTarget(
x = currentX,
y = currentY,
@@ -888,8 +1038,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
)
// if they are the same within then snap, aka scrolling
- val deltaMin = 50.toPx
- if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMin && (start.y - end.y).absoluteValue < deltaMin) {
+ val deltaMinX = min(end.width / 2, 60.toPx)
+ val deltaMinY = min(end.height / 2, 60.toPx)
+ if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMinX && (start.y - end.y).absoluteValue < deltaMinY) {
animator?.cancel()
last = start
current = end
@@ -918,7 +1069,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
// animate between a and b
animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply {
startDelay = 0
- duration = 100
+ duration = 200
addUpdateListener { animation ->
val animatedValue = animation.animatedValue as Float
val target = FocusTarget.lerp(last, current, minOf(animatedValue, 1.0f))
@@ -960,16 +1111,33 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
}
+ private 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) {
+ }
+ }
override fun onCreate(savedInstanceState: Bundle?) {
app.initClient(this)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val errorFile = filesDir.resolve("last_error")
- var lastError: String? = null
if (errorFile.exists() && errorFile.isFile) {
lastError = errorFile.readText(Charset.defaultCharset())
errorFile.delete()
+ } else {
+ lastError = null
}
val settingsForProvider = SettingsJson()
@@ -983,7 +1151,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
super.onCreate(savedInstanceState)
try {
if (isCastApiAvailable()) {
- mSessionManager = CastContext.getSharedInstance(this).sessionManager
+ CastContext.getSharedInstance(this) {it.run()}.addOnSuccessListener { mSessionManager = it.sessionManager }
}
} catch (t: Throwable) {
logError(t)
@@ -993,29 +1161,61 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
updateTv()
// backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting?
- try {
+ normalSafeApiCall {
val appVer = BuildConfig.VERSION_NAME
val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: ""
if (appVer != lastAppAutoBackup) {
setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
- backup()
+ normalSafeApiCall {
+ backup(this)
+ }
+ normalSafeApiCall {
+ // Recompile oat on new version
+ PluginManager.deleteAllOatFiles(this)
+ }
}
- } catch (t: Throwable) {
- logError(t)
}
// just in case, MAIN SHOULD *NEVER* BOOT LOOP CRASH
binding = try {
- if (isTvSettings()) {
+ if (isLayout(TV or EMULATOR)) {
val newLocalBinding = ActivityMainTvBinding.inflate(layoutInflater, null, false)
setContentView(newLocalBinding.root)
- TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline)
- newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
- // println("refocus $oldFocus -> $newFocus")
- TvFocus.updateFocusView(newFocus)
+
+ if (isLayout(TV) && ANIMATED_OUTLINE) {
+ TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline)
+ newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener {
+ TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true)
+ }
+ newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
+ TvFocus.updateFocusView(newFocus)
+ }
+ } else {
+ newLocalBinding.focusOutline.isVisible = false
}
- newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener {
- TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true)
+
+ if (isLayout(TV)) {
+ // Put here any button you don't want focusing it to center the view
+ val exceptionButtons = listOf(
+ R.id.home_preview_play_btt,
+ R.id.home_preview_info_btt,
+ R.id.home_preview_hidden_next_focus,
+ R.id.home_preview_hidden_prev_focus,
+ R.id.result_play_movie_button,
+ R.id.result_play_series_button,
+ R.id.result_resume_series_button,
+ R.id.result_play_trailer_button,
+ R.id.result_bookmark_Button,
+ R.id.result_favorite_Button,
+ R.id.result_subscribe_Button,
+ R.id.result_search_Button,
+ R.id.result_episodes_show_button,
+ )
+
+ newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus ->
+ if (exceptionButtons.contains(newFocus?.id)) return@addOnGlobalFocusChangeListener
+ centerView(newFocus)
+ }
}
ActivityMainBinding.bind(newLocalBinding.root) // this may crash
@@ -1029,7 +1229,26 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
null
}
- changeStatusBarState(isEmulatorSettings())
+ changeStatusBarState(isLayout(EMULATOR))
+
+ /** Biometric stuff for users without accounts **/
+ val noAccounts = settingsManager.getBoolean(
+ getString(R.string.skip_startup_account_select_key),
+ false
+ ) || accounts.count() <= 1
+
+ if (isLayout(PHONE) && isAuthEnabled(this) && noAccounts) {
+ if (deviceHasPasswordPinLock(this)) {
+ startBiometricAuthentication(this, R.string.biometric_authentication_title, false)
+
+ promptInfo?.let { prompt ->
+ biometricPrompt?.authenticate(prompt)
+ }
+
+ // hide background while authenticating, Sorry moms & dads 🙏
+ binding?.navHostFragment?.isInvisible = true
+ }
+ }
// Automatically enable jsdelivr if cant connect to raw.githubusercontent.com
if (this.getKey(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) {
@@ -1038,22 +1257,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
this.setKey(getString(R.string.jsdelivr_proxy_key), false)
} else {
this.setKey(getString(R.string.jsdelivr_proxy_key), true)
- val parentView: View = findViewById(android.R.id.content)
- Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG)
- .let { snackbar ->
- snackbar.setAction(R.string.revert) {
- setKey(getString(R.string.jsdelivr_proxy_key), false)
- }
- snackbar.setBackgroundTint(colorFromAttribute(R.attr.primaryGrayBackground))
- snackbar.setTextColor(colorFromAttribute(R.attr.textColor))
- snackbar.setActionTextColor(colorFromAttribute(R.attr.colorPrimary))
- snackbar.show()
- }
+ showSnackbar(
+ this@MainActivity,
+ R.string.jsdelivr_enabled,
+ Snackbar.LENGTH_LONG,
+ R.string.revert
+ ) { setKey(getString(R.string.jsdelivr_proxy_key), false) }
}
-
}
}
+ ioSafe { SafeFile.check(this@MainActivity) }
if (PluginManager.checkSafeModeFile()) {
normalSafeApiCall {
@@ -1061,7 +1275,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
} else if (lastError == null) {
ioSafe {
- getKey(USER_SELECTED_HOMEPAGE_API)?.let { homeApi ->
+ DataStoreHelper.currentHomePage?.let { homeApi ->
mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi))
} ?: run {
mainPluginsLoadedEvent.invoke(false)
@@ -1078,13 +1292,18 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
loadAllOnlinePlugins(this@MainActivity)
}
- //Automatically download not existing plugins
- if (settingsManager.getBoolean(
+ //Automatically download not existing plugins, using mode specified.
+ val autoDownloadPlugin = AutoDownloadMode.getEnum(
+ settingsManager.getInt(
getString(R.string.auto_download_plugins_key),
- false
+ 0
+ )
+ ) ?: AutoDownloadMode.Disable
+ if (autoDownloadPlugin != AutoDownloadMode.Disable) {
+ PluginManager.downloadNotExistingPluginsAndLoad(
+ this@MainActivity,
+ autoDownloadPlugin
)
- ) {
- PluginManager.downloadNotExistingPluginsAndLoad(this@MainActivity)
}
}
@@ -1109,6 +1328,77 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
builder.show().setDefaultFocus()
}
+
+ fun setUserData(status: Resource?) {
+ if (isLocalList) return
+ bottomPreviewBinding?.apply {
+ when (status) {
+ is Resource.Success -> {
+ resultviewPreviewBookmark.isEnabled = true
+ resultviewPreviewBookmark.setText(status.value.status.stringRes)
+ resultviewPreviewBookmark.setIconResource(status.value.status.iconRes)
+ }
+
+ is Resource.Failure -> {
+ resultviewPreviewBookmark.isEnabled = false
+ resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
+ resultviewPreviewBookmark.text = status.errorString
+ }
+
+ else -> {
+ resultviewPreviewBookmark.isEnabled = false
+ resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
+ resultviewPreviewBookmark.setText(R.string.loading)
+ }
+ }
+ }
+ }
+
+ fun setWatchStatus(state: WatchType?) {
+ if (!isLocalList || state == null) return
+
+ bottomPreviewBinding?.resultviewPreviewBookmark?.apply {
+ setIconResource(state.iconRes)
+ setText(state.stringRes)
+ }
+ }
+
+ fun setSubscribeStatus(state: Boolean?) {
+ bottomPreviewBinding?.resultviewPreviewSubscribe?.apply {
+ if (state != null) {
+ val drawable = if (state) {
+ R.drawable.ic_baseline_notifications_active_24
+ } else {
+ R.drawable.baseline_notifications_none_24
+ }
+ setImageResource(drawable)
+ }
+ isVisible = state != null
+
+ setOnClickListener {
+ viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? ->
+ if (newStatus == null) return@toggleSubscriptionStatus
+
+ val message = if (newStatus) {
+ // Kinda icky to have this here, but it works.
+ SubscriptionWorkManager.enqueuePeriodicWork(context)
+ R.string.subscription_new
+ } else {
+ R.string.subscription_deleted
+ }
+
+ val name = (viewModel.page.value as? Resource.Success)?.value?.title
+ ?: txt(R.string.no_data).asStringNull(context) ?: ""
+ showToast(txt(message, name), Toast.LENGTH_SHORT)
+ }
+ }
+ }
+ }
+
+ observe(viewModel.watchStatus, ::setWatchStatus)
+ observe(syncViewModel.userData, ::setUserData)
+ observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus)
+
observeNullable(viewModel.page) { resource ->
if (resource == null) {
hidePreviewPopupDialog()
@@ -1143,26 +1433,78 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
resultviewPreviewMetaDuration.setText(d.durationText)
resultviewPreviewMetaRating.setText(d.ratingText)
- resultviewPreviewDescription.setText(d.plotText)
+ resultviewPreviewDescription.setTextHtml(d.plotText)
resultviewPreviewPoster.setImage(
d.posterImage ?: d.posterBackgroundImage
)
- resultviewPreviewPoster.setOnClickListener {
- //viewModel.updateWatchStatus(WatchType.PLANTOWATCH)
- val value = viewModel.watchStatus.value ?: WatchType.NONE
+ setUserData(syncViewModel.userData.value)
+ setWatchStatus(viewModel.watchStatus.value)
+ setSubscribeStatus(viewModel.subscribeStatus.value)
- this@MainActivity.showBottomDialog(
- WatchType.values().map { getString(it.stringRes) }.toList(),
- value.ordinal,
- this@MainActivity.getString(R.string.action_add_to_bookmarks),
- showApply = false,
- {}) {
- viewModel.updateWatchStatus(WatchType.values()[it])
+ resultviewPreviewBookmark.setOnClickListener {
+ //viewModel.updateWatchStatus(WatchType.PLANTOWATCH)
+ if (isLocalList) {
+ val value = viewModel.watchStatus.value ?: WatchType.NONE
+
+ this@MainActivity.showBottomDialog(
+ WatchType.entries.map { getString(it.stringRes) }.toList(),
+ value.ordinal,
+ this@MainActivity.getString(R.string.action_add_to_bookmarks),
+ showApply = false,
+ {}) {
+ viewModel.updateWatchStatus(
+ WatchType.entries[it],
+ this@MainActivity
+ )
+ }
+ } else {
+ val value =
+ (syncViewModel.userData.value as? Resource.Success)?.value?.status
+ ?: SyncWatchType.NONE
+
+ this@MainActivity.showBottomDialog(
+ SyncWatchType.entries.map { getString(it.stringRes) }.toList(),
+ value.ordinal,
+ this@MainActivity.getString(R.string.action_add_to_bookmarks),
+ showApply = false,
+ {}) {
+ syncViewModel.setStatus(SyncWatchType.entries[it].internalId)
+ syncViewModel.publishUserData()
+ }
}
}
- if (!isTvSettings()) // dont want this clickable on tv layout
+ observeNullable(viewModel.favoriteStatus) observeFavoriteStatus@{ isFavorite ->
+ resultviewPreviewFavorite.isVisible = isFavorite != null
+ if (isFavorite == null) return@observeFavoriteStatus
+
+ val drawable = if (isFavorite) {
+ R.drawable.ic_baseline_favorite_24
+ } else {
+ R.drawable.ic_baseline_favorite_border_24
+ }
+
+ resultviewPreviewFavorite.setImageResource(drawable)
+ }
+
+ resultviewPreviewFavorite.setOnClickListener {
+ viewModel.toggleFavoriteStatus(this@MainActivity) { newStatus: Boolean? ->
+ if (newStatus == null) return@toggleFavoriteStatus
+
+ val message = if (newStatus) {
+ R.string.favorite_added
+ } else {
+ R.string.favorite_removed
+ }
+
+ val name = (viewModel.page.value as? Resource.Success)?.value?.title
+ ?: txt(R.string.no_data).asStringNull(this@MainActivity) ?: ""
+ showToast(txt(message, name), Toast.LENGTH_SHORT)
+ }
+ }
+
+ if (isLayout(PHONE)) // dont want this clickable on tv layout
resultviewPreviewDescription.setOnClickListener { view ->
view.context?.let { ctx ->
val builder: AlertDialog.Builder =
@@ -1208,6 +1550,26 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
logError(e)
}
}
+
+ // we need to run this after we init all apis, otherwise currentSyncApi will fuck itself
+ this@MainActivity.runOnUiThread {
+ // Change library icon with logo of current api in sync
+ libraryViewModel = ViewModelProvider(this@MainActivity)[LibraryViewModel::class.java]
+ libraryViewModel?.currentApiName?.observe(this@MainActivity) {
+ val syncAPI = libraryViewModel?.currentSyncApi
+ Log.i("SYNC_API", "${syncAPI?.name}, ${syncAPI?.idPrefix}")
+ val icon = if (syncAPI?.idPrefix == localListApi.idPrefix) {
+ R.drawable.library_icon
+ } else {
+ syncAPI?.icon ?: R.drawable.library_icon
+ }
+
+ binding?.apply {
+ navRailView.menu.findItem(R.id.navigation_library)?.setIcon(icon)
+ navView.menu.findItem(R.id.navigation_library)?.setIcon(icon)
+ }
+ }
+ }
}
SearchResultBuilder.updateCache(this)
@@ -1234,9 +1596,19 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) {
bundle?.apply {
this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery)
- nextSearchQuery = null
}
}
+
+ if (isLayout(TV or EMULATOR)) {
+ if (navDestination.matchDestination(R.id.navigation_home)) {
+ attachBackPressedCallback {
+ showConfirmExitDialog()
+ window?.navigationBarColor =
+ colorFromAttribute(R.attr.primaryGrayBackground)
+ updateLocale()
+ }
+ } else detachBackPressedCallback()
+ }
}
//val navController = findNavController(R.id.nav_host_fragment)
@@ -1268,7 +1640,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
itemRippleColor = rippleColor
itemActiveIndicatorColor = rippleColor
setupWithNavController(navController)
- if (isTvSettings()) {
+ if (isLayout(TV or EMULATOR)) {
background?.alpha = 200
} else {
background?.alpha = 255
@@ -1402,13 +1774,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
runAutoUpdate()
}
+ FcastManager().init(this, false)
+
APIRepository.dubStatusActive = getApiDubstatusSettings()
try {
// this ensures that no unnecessary space is taken
loadCache()
File(filesDir, "exoplayer").deleteRecursively() // old cache
- File(cacheDir, "exoplayer").deleteOnExit() // current cache
+ deleteFileOnExit(File(cacheDir, "exoplayer")) // current cache
} catch (e: Exception) {
logError(e)
}
@@ -1418,6 +1792,11 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
migrateResumeWatching()
}
+ getKey(USER_SELECTED_HOMEPAGE_API)?.let { homepage ->
+ DataStoreHelper.currentHomePage = homepage
+ removeKey(USER_SELECTED_HOMEPAGE_API)
+ }
+
try {
if (getKey(HAS_DONE_SETUP_KEY, false) != true) {
navController.navigate(R.id.navigation_setup_language)
@@ -1433,8 +1812,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
}
} catch (e: Exception) {
logError(e)
- } finally {
- setKey(HAS_DONE_SETUP_KEY, true)
}
// Used to check current focus for TV
@@ -1446,6 +1823,32 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
// }
// }
+ onBackPressedDispatcher.addCallback(
+ this,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ window?.navigationBarColor = colorFromAttribute(R.attr.primaryGrayBackground)
+ updateLocale()
+
+ // If we don't disable we end up in a loop with default behavior calling
+ // 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 **/
+ override fun onAuthenticationSuccess() {
+ // make background (nav host fragment) visible again
+ binding?.navHostFragment?.isInvisible = false
+ }
+
+ override fun onAuthenticationError() {
+ finish()
}
suspend fun checkGithubConnectivity(): Boolean {
@@ -1458,4 +1861,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
false
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt
deleted file mode 100644
index c782b29d..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Acefile.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.base64Decode
-import com.lagradost.cloudstream3.utils.*
-
-open class Acefile : ExtractorApi() {
- override val name = "Acefile"
- override val mainUrl = "https://acefile.co"
- override val requiresReferer = false
-
- override suspend fun getUrl(url: String, referer: String?): List {
- val sources = mutableListOf()
- app.get(url).document.select("script").map { script ->
- if (script.data().contains("eval(function(p,a,c,k,e,d)")) {
- val data = getAndUnpack(script.data())
- val id = data.substringAfter("{\"id\":\"").substringBefore("\",")
- val key = data.substringAfter("var nfck=\"").substringBefore("\";")
- app.get("https://acefile.co/local/$id?key=$key").text.let {
- base64Decode(
- it.substringAfter("JSON.parse(atob(\"").substringBefore("\"))")
- ).let { res ->
- sources.add(
- ExtractorLink(
- name,
- name,
- res.substringAfter("\"file\":\"").substringBefore("\","),
- "$mainUrl/",
- Qualities.Unknown.value,
- )
- )
- }
- }
- }
- }
- return sources
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Chillx.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Chillx.kt
deleted file mode 100644
index b4f3d897..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Chillx.kt
+++ /dev/null
@@ -1,140 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.*
-import com.lagradost.cloudstream3.utils.AppUtils
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.Qualities
-import javax.crypto.Cipher
-import javax.crypto.SecretKeyFactory
-import javax.crypto.spec.IvParameterSpec
-import javax.crypto.spec.PBEKeySpec
-import javax.crypto.spec.SecretKeySpec
-
-class Moviesapi : Chillx() {
- override val name = "Moviesapi"
- override val mainUrl = "https://w1.moviesapi.club"
-}
-
-class Bestx : Chillx() {
- override val name = "Bestx"
- override val mainUrl = "https://bestx.stream"
-}
-
-class Watchx : Chillx() {
- override val name = "Watchx"
- override val mainUrl = "https://watchx.top"
-}
-open class Chillx : ExtractorApi() {
- override val name = "Chillx"
- override val mainUrl = "https://chillx.top"
- override val requiresReferer = true
-
- companion object {
- private const val KEY = "11x&W5UBrcqn\$9Yl"
- }
-
- override suspend fun getUrl(
- url: String,
- referer: String?,
- subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
- ) {
- val master = Regex("MasterJS\\s*=\\s*'([^']+)").find(
- app.get(
- url,
- referer = referer
- ).text
- )?.groupValues?.get(1)
- val encData = AppUtils.tryParseJson(base64Decode(master ?: return))
- val decrypt = cryptoAESHandler(encData ?: return, KEY, false)
-
- val source = Regex(""""?file"?:\s*"([^"]+)""").find(decrypt)?.groupValues?.get(1)
- val tracks = Regex("""tracks:\s*\[(.+)]""").find(decrypt)?.groupValues?.get(1)
-
- // required
- val headers = mapOf(
- "Accept" to "*/*",
- "Connection" to "keep-alive",
- "Sec-Fetch-Dest" to "empty",
- "Sec-Fetch-Mode" to "cors",
- "Sec-Fetch-Site" to "cross-site",
- "Origin" to mainUrl,
- )
-
- callback.invoke(
- ExtractorLink(
- name,
- name,
- source ?: return,
- "$mainUrl/",
- Qualities.P1080.value,
- headers = headers,
- isM3u8 = true
- )
- )
-
- AppUtils.tryParseJson>("[$tracks]")
- ?.filter { it.kind == "captions" }?.map { track ->
- subtitleCallback.invoke(
- SubtitleFile(
- track.label ?: "",
- track.file ?: return@map null
- )
- )
- }
- }
-
- private fun cryptoAESHandler(
- data: AESData,
- pass: String,
- encrypt: Boolean = true
- ): String {
- val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512")
- val spec = PBEKeySpec(
- pass.toCharArray(),
- data.salt?.hexToByteArray(),
- data.iterations?.toIntOrNull() ?: 1,
- 256
- )
- val key = factory.generateSecret(spec)
- val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
- return if (!encrypt) {
- cipher.init(
- Cipher.DECRYPT_MODE,
- SecretKeySpec(key.encoded, "AES"),
- IvParameterSpec(data.iv?.hexToByteArray())
- )
- String(cipher.doFinal(base64DecodeArray(data.ciphertext.toString())))
- } else {
- cipher.init(
- Cipher.ENCRYPT_MODE,
- SecretKeySpec(key.encoded, "AES"),
- IvParameterSpec(data.iv?.hexToByteArray())
- )
- base64Encode(cipher.doFinal(data.ciphertext?.toByteArray()))
- }
- }
-
- private fun String.hexToByteArray(): ByteArray {
- check(length % 2 == 0) { "Must have an even length" }
- return chunked(2)
- .map { it.toInt(16).toByte() }
-
- .toByteArray()
- }
-
- data class AESData(
- @JsonProperty("ciphertext") val ciphertext: String? = null,
- @JsonProperty("iv") val iv: String? = null,
- @JsonProperty("salt") val salt: String? = null,
- @JsonProperty("iterations") val iterations: String? = null,
- )
-
- data class Tracks(
- @JsonProperty("file") val file: String? = null,
- @JsonProperty("label") val label: String? = null,
- @JsonProperty("kind") val kind: String? = null,
- )
-}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt
deleted file mode 100644
index 93a280ed..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Mp4Upload.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.Qualities
-import com.lagradost.cloudstream3.utils.getAndUnpack
-
-open class Mp4Upload : ExtractorApi() {
- override var name = "Mp4Upload"
- override var mainUrl = "https://www.mp4upload.com"
- private val srcRegex = Regex("""player\.src\("(.*?)"""")
- override val requiresReferer = true
-
- override suspend fun getUrl(url: String, referer: String?): List? {
- with(app.get(url)) {
- getAndUnpack(this.text).let { unpackedText ->
- val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
- srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link ->
- return listOf(
- ExtractorLink(
- name,
- name,
- link,
- url,
- quality ?: Qualities.Unknown.value,
- )
- )
- }
- }
- }
- return null
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt
deleted file mode 100644
index 70e87fbf..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/OkRuExtractor.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.lagradost.cloudstream3.utils.*
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.AppUtils.parseJson
-
-data class DataOptionsJson (
- @JsonProperty("flashvars") var flashvars : Flashvars? = Flashvars(),
-)
-data class Flashvars (
- @JsonProperty("metadata") var metadata : String? = null,
- @JsonProperty("hlsManifestUrl") var hlsManifestUrl : String? = null, //m3u8
-)
-
-data class MetadataOkru (
- @JsonProperty("videos") var videos: ArrayList = arrayListOf(),
-)
-
-data class Videos (
- @JsonProperty("name") var name : String,
- @JsonProperty("url") var url : String,
- @JsonProperty("seekSchema") var seekSchema : Int? = null,
- @JsonProperty("disallowed") var disallowed : Boolean? = null
-)
-
-class OkRuHttps: OkRu(){
- override var mainUrl = "https://ok.ru"
-}
-
-open class OkRu : ExtractorApi() {
- override var name = "Okru"
- override var mainUrl = "http://ok.ru"
- override val requiresReferer = false
-
- override suspend fun getUrl(url: String, referer: String?): List? {
- val doc = app.get(url).document
- val sources = ArrayList()
- val datajson = doc.select("div[data-options]").attr("data-options")
- if (datajson.isNotBlank()) {
- val main = parseJson(datajson)
- val metadatajson = parseJson(main.flashvars?.metadata!!)
- val servers = metadatajson.videos
- servers.forEach {
- val quality = it.name.uppercase()
- .replace("MOBILE","144p")
- .replace("LOWEST","240p")
- .replace("LOW","360p")
- .replace("SD","480p")
- .replace("HD","720p")
- .replace("FULL","1080p")
- .replace("QUAD","1440p")
- .replace("ULTRA","4k")
- val extractedurl = it.url.replace("\\\\u0026", "&")
- sources.add(ExtractorLink(
- name,
- name = this.name,
- extractedurl,
- url,
- getQualityFromName(quality),
- isM3u8 = false
- ))
- }
- }
- return sources
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Pixeldrain.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Pixeldrain.kt
deleted file mode 100644
index 9b481240..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Pixeldrain.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.lagradost.cloudstream3.SubtitleFile
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.Qualities
-
-open class Pixeldrain : ExtractorApi() {
- override val name = "Pixeldrain"
- override val mainUrl = "https://pixeldrain.com"
- override val requiresReferer = false
- override suspend fun getUrl(
- url: String,
- referer: String?,
- subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
- ) {
- val mId = Regex("/([ul]/[\\da-zA-Z\\-]+)").find(url)?.groupValues?.get(1)?.split("/")
- callback.invoke(
- ExtractorLink(
- this.name,
- this.name,
- "$mainUrl/api/file/${mId?.last() ?: return}?download",
- url,
- Qualities.Unknown.value,
- )
- )
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt
deleted file mode 100644
index a27bf188..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/VidSrcExtractor.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.lagradost.cloudstream3.SubtitleFile
-import com.lagradost.cloudstream3.amap
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.*
-import kotlinx.coroutines.delay
-import java.net.URI
-
-class VidSrcExtractor2 : VidSrcExtractor() {
- override val mainUrl = "https://vidsrc.me/embed"
- override suspend fun getUrl(
- url: String,
- referer: String?,
- subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
- ) {
- val newUrl = url.lowercase().replace(mainUrl, super.mainUrl)
- super.getUrl(newUrl, referer, subtitleCallback, callback)
- }
-}
-
-open class VidSrcExtractor : ExtractorApi() {
- override val name = "VidSrc"
- private val absoluteUrl = "https://v2.vidsrc.me"
- override val mainUrl = "$absoluteUrl/embed"
- override val requiresReferer = false
-
- companion object {
- /** Infinite function to validate the vidSrc pass */
- suspend fun validatePass(url: String) {
- val uri = URI(url)
- val host = uri.host
-
- // Basically turn https://tm3p.vidsrc.stream/ -> https://vidsrc.stream/
- val referer = host.split(".").let {
- val size = it.size
- "https://" + it.subList(maxOf(0, size - 2), size).joinToString(".") + "/"
- }
-
- while (true) {
- app.get(url, referer = referer)
- delay(60_000)
- }
- }
- }
-
- override suspend fun getUrl(
- url: String,
- referer: String?,
- subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
- ) {
- val iframedoc = app.get(url).document
-
- val serverslist =
- iframedoc.select("div#sources.button_content div#content div#list div").map {
- val datahash = it.attr("data-hash")
- if (datahash.isNotBlank()) {
- val links = try {
- app.get(
- "$absoluteUrl/srcrcp/$datahash",
- referer = "https://rcp.vidsrc.me/"
- ).url
- } catch (e: Exception) {
- ""
- }
- links
- } else ""
- }
-
- serverslist.amap { server ->
- val linkfixed = server.replace("https://vidsrc.xyz/", "https://embedsito.com/")
- if (linkfixed.contains("/prorcp")) {
- val srcresponse = app.get(server, referer = absoluteUrl).text
- val m3u8Regex = Regex("((https:|http:)//.*\\.m3u8)")
- val srcm3u8 = m3u8Regex.find(srcresponse)?.value ?: return@amap
- val passRegex = Regex("""['"](.*set_pass[^"']*)""")
- val pass = passRegex.find(srcresponse)?.groupValues?.get(1)?.replace(
- Regex("""^//"""), "https://"
- )
-
- callback.invoke(
- ExtractorLink(
- this.name,
- this.name,
- srcm3u8,
- "https://vidsrc.stream/",
- Qualities.Unknown.value,
- extractorData = pass,
- isM3u8 = true
- )
- )
- } else {
- loadExtractor(linkfixed, url, subtitleCallback, callback)
- }
- }
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt b/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt
deleted file mode 100644
index 2c6998de..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/extractors/Voe.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.lagradost.cloudstream3.extractors
-
-import com.lagradost.cloudstream3.SubtitleFile
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.ExtractorApi
-import com.lagradost.cloudstream3.utils.ExtractorLink
-import com.lagradost.cloudstream3.utils.M3u8Helper
-
-class Tubeless : Voe() {
- override var mainUrl = "https://tubelessceliolymph.com"
-}
-
-open class Voe : ExtractorApi() {
- override val name = "Voe"
- override val mainUrl = "https://voe.sx"
- override val requiresReferer = true
-
- override suspend fun getUrl(
- url: String,
- referer: String?,
- subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
- ) {
- val res = app.get(url, referer = referer).document
- val script = res.select("script").find { it.data().contains("sources =") }?.data()
- val link = Regex("[\"']hls[\"']:\\s*[\"'](.*)[\"']").find(script ?: return)?.groupValues?.get(1)
-
- M3u8Helper.generateM3u8(
- name,
- link ?: return,
- "$mainUrl/",
- headers = mapOf("Origin" to "$mainUrl/")
- ).forEach(callback)
-
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt
deleted file mode 100644
index 8cfe1e9a..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/MultiAnimeProvider.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.lagradost.cloudstream3.metaproviders
-
-import com.lagradost.cloudstream3.*
-import com.lagradost.cloudstream3.LoadResponse.Companion.addAniListId
-import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
-import com.lagradost.cloudstream3.syncproviders.SyncAPI
-import com.lagradost.cloudstream3.syncproviders.providers.AniListApi
-import com.lagradost.cloudstream3.syncproviders.providers.MALApi
-import com.lagradost.cloudstream3.utils.SyncUtil
-
-// wont be implemented
-class MultiAnimeProvider : MainAPI() {
- override var name = "MultiAnime"
- override var lang = "en"
- override val usesWebView = true
- override val supportedTypes = setOf(TvType.Anime)
- private val syncApi: SyncAPI = aniListApi
-
- private val syncUtilType by lazy {
- when (syncApi) {
- is AniListApi -> "anilist"
- is MALApi -> "myanimelist"
- else -> throw ErrorLoadingException("Invalid Api")
- }
- }
-
- private val validApis
- get() =
- synchronized(APIHolder.apis) {
- APIHolder.apis.filter {
- it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains(
- TvType.Anime
- )
- }
- }
-
-
- private fun filterName(name: String): String {
- return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
- }
-
- override suspend fun search(query: String): List? {
- return syncApi.search(query)?.map {
- AnimeSearchResponse(it.name, it.url, this.name, TvType.Anime, it.posterUrl)
- }
- }
-
- override suspend fun load(url: String): LoadResponse? {
- return syncApi.getResult(url)?.let { res ->
- val data = SyncUtil.getUrlsFromId(res.id, syncUtilType).amap { url ->
- validApis.firstOrNull { api -> url.startsWith(api.mainUrl) }?.load(url)
- }.filterNotNull()
-
- val type =
- if (data.any { it.type == TvType.AnimeMovie }) TvType.AnimeMovie else TvType.Anime
-
- newAnimeLoadResponse(
- res.title ?: throw ErrorLoadingException("No Title found"),
- url,
- type
- ) {
- posterUrl = res.posterUrl
- plot = res.synopsis
- tags = res.genres
- rating = res.publicScore
- addTrailer(res.trailers)
- addAniListId(res.id.toIntOrNull())
- recommendations = res.recommendations
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt
index 75e96bec..bc646a8d 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt
@@ -2,15 +2,13 @@ package com.lagradost.cloudstream3.metaproviders
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
-import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
import com.lagradost.cloudstream3.syncproviders.SyncIdName
object SyncRedirector {
- val syncApis = SyncApis
private val syncIds =
listOf(
- SyncIdName.MyAnimeList to Regex("""myanimelist\.net\/anime\/(\d+)"""),
- SyncIdName.Anilist to Regex("""anilist\.co\/anime\/(\d+)""")
+ SyncIdName.MyAnimeList to Regex("""myanimelist\.net/anime/(\d+)"""),
+ SyncIdName.Anilist to Regex("""anilist\.co/anime/(\d+)""")
)
suspend fun redirect(
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt
index 314177af..c5b4d453 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TmdbProvider.kt
@@ -105,6 +105,7 @@ open class TmdbProvider : MainAPI() {
this.id,
episode.episode_number,
episode.season_number,
+ this.name ?: this.original_name,
).toJson(),
episode.name,
episode.season_number,
@@ -122,6 +123,7 @@ open class TmdbProvider : MainAPI() {
this.id,
episodeNum,
season.season_number,
+ this.name ?: this.original_name,
).toJson(),
season = season.season_number
)
@@ -151,6 +153,8 @@ open class TmdbProvider : MainAPI() {
recommendations = (this@toLoadResponse.recommendations
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
addActors(credits?.cast?.toList().toActors())
+
+ contentRating = fetchContentRating(id, "US")
}
}
@@ -193,6 +197,8 @@ open class TmdbProvider : MainAPI() {
recommendations = (this@toLoadResponse.recommendations
?: this@toLoadResponse.similar)?.results?.map { it.toSearchResponse() }
addActors(credits?.cast?.toList().toActors())
+
+ contentRating = fetchContentRating(id, "US")
}
}
@@ -264,6 +270,26 @@ open class TmdbProvider : MainAPI() {
return null
}
+ open suspend fun fetchContentRating(id: Int?, country: String): String? {
+ id ?: return null
+
+ val contentRatings = tmdb.tvService().content_ratings(id).awaitResponse().body()?.results
+ return if (!contentRatings.isNullOrEmpty()) {
+ contentRatings.firstOrNull { it: ContentRating ->
+ it.iso_3166_1 == country
+ }?.rating
+ } else {
+ val releaseDates = tmdb.moviesService().releaseDates(id).awaitResponse().body()?.results
+ val certification = releaseDates?.firstOrNull { it: ReleaseDatesResult ->
+ it.iso_3166_1 == country
+ }?.release_dates?.firstOrNull { it: ReleaseDate ->
+ !it.certification.isNullOrBlank()
+ }?.certification
+
+ certification
+ }
+ }
+
// Possible to add recommendations and such here.
override suspend fun load(url: String): LoadResponse? {
// https://www.themoviedb.org/movie/7445-brothers
diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt
new file mode 100644
index 00000000..addee9a0
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt
@@ -0,0 +1,471 @@
+package com.lagradost.cloudstream3.metaproviders
+
+import android.net.Uri
+import com.fasterxml.jackson.annotation.JsonAlias
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.cloudstream3.APIHolder
+import com.lagradost.cloudstream3.APIHolder.unixTimeMS
+import com.lagradost.cloudstream3.Actor
+import com.lagradost.cloudstream3.ActorData
+import com.lagradost.cloudstream3.Episode
+import com.lagradost.cloudstream3.HomePageResponse
+import com.lagradost.cloudstream3.LoadResponse
+import com.lagradost.cloudstream3.LoadResponse.Companion.addImdbId
+import com.lagradost.cloudstream3.LoadResponse.Companion.addTMDbId
+import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
+import com.lagradost.cloudstream3.MainAPI
+import com.lagradost.cloudstream3.MainPageRequest
+import com.lagradost.cloudstream3.NextAiring
+import com.lagradost.cloudstream3.ProviderType
+import com.lagradost.cloudstream3.SearchResponse
+import com.lagradost.cloudstream3.ShowStatus
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.addDate
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.base64Decode
+import com.lagradost.cloudstream3.mainPageOf
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.newHomePageResponse
+import com.lagradost.cloudstream3.newMovieLoadResponse
+import com.lagradost.cloudstream3.newMovieSearchResponse
+import com.lagradost.cloudstream3.newTvSeriesLoadResponse
+import com.lagradost.cloudstream3.newTvSeriesSearchResponse
+import com.lagradost.cloudstream3.utils.AppUtils.parseJson
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import java.text.SimpleDateFormat
+import java.util.Locale
+import kotlin.math.roundToInt
+
+open class TraktProvider : MainAPI() {
+ override var name = "Trakt"
+ override val hasMainPage = true
+ override val providerType = ProviderType.MetaProvider
+ override val supportedTypes = setOf(
+ TvType.Movie,
+ TvType.TvSeries,
+ TvType.Anime,
+ )
+
+ private val traktClientId =
+ base64Decode("N2YzODYwYWQzNGI4ZTZmOTdmN2I5MTA0ZWQzMzEwOGI0MmQ3MTdlMTM0MmM2NGMxMTg5NGE1MjUyYTQ3NjE3Zg==")
+ private val traktApiUrl = base64Decode("aHR0cHM6Ly9hcGl6LnRyYWt0LnR2")
+
+ override val mainPage = mainPageOf(
+ "$traktApiUrl/movies/trending" to "Trending Movies", //Most watched movies right now
+ "$traktApiUrl/movies/popular" to "Popular Movies", //The most popular movies for all time
+ "$traktApiUrl/shows/trending" to "Trending Shows", //Most watched Shows right now
+ "$traktApiUrl/shows/popular" to "Popular Shows", //The most popular Shows for all time
+ )
+
+ override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse {
+
+ val apiResponse = getApi("${request.data}?extended=cloud9,full&page=$page")
+
+ val results = parseJson>(apiResponse).map { element ->
+ element.toSearchResponse()
+ }
+ return newHomePageResponse(request.name, results)
+ }
+
+ private fun MediaDetails.toSearchResponse(): SearchResponse {
+
+ val media = this.media ?: this
+ val mediaType = if (media.ids?.tvdb == null) TvType.Movie else TvType.TvSeries
+ val poster = media.images?.poster?.firstOrNull()
+
+ if (mediaType == TvType.Movie) {
+ return newMovieSearchResponse(
+ name = media.title!!,
+ url = Data(
+ type = mediaType,
+ mediaDetails = media,
+ ).toJson(),
+ type = TvType.Movie,
+ ) {
+ posterUrl = fixPath(poster)
+ }
+ } else {
+ return newTvSeriesSearchResponse(
+ name = media.title!!,
+ url = Data(
+ type = mediaType,
+ mediaDetails = media,
+ ).toJson(),
+ type = TvType.TvSeries,
+ ) {
+ this.posterUrl = fixPath(poster)
+ }
+ }
+ }
+
+ override suspend fun search(query: String): List? {
+ val apiResponse =
+ getApi("$traktApiUrl/search/movie,show?extended=cloud9,full&limit=20&page=1&query=$query")
+
+ val results = parseJson>(apiResponse).map { element ->
+ element.toSearchResponse()
+ }
+
+ return results
+ }
+
+ override suspend fun load(url: String): LoadResponse {
+
+ val data = parseJson(url)
+ val mediaDetails = data.mediaDetails
+ val moviesOrShows = if (data.type == TvType.Movie) "movies" else "shows"
+
+ val posterUrl = mediaDetails?.images?.poster?.firstOrNull()
+ val backDropUrl = mediaDetails?.images?.fanart?.firstOrNull()
+
+ val resActor =
+ getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/people?extended=cloud9,full")
+
+ val actors = parseJson(resActor).cast?.map {
+ ActorData(
+ Actor(
+ name = it.person?.name!!,
+ image = getWidthImageUrl(it.person.images?.headshot?.firstOrNull(), "w500")
+ ),
+ roleString = it.character
+ )
+ }
+
+ val resRelated =
+ getApi("$traktApiUrl/$moviesOrShows/${mediaDetails?.ids?.trakt}/related?extended=cloud9,full&limit=20")
+
+ val relatedMedia = parseJson>(resRelated).map { it.toSearchResponse() }
+
+ val isCartoon =
+ mediaDetails?.genres?.contains("animation") == true || mediaDetails?.genres?.contains("anime") == true
+ val isAnime =
+ isCartoon && (mediaDetails?.language == "zh" || mediaDetails?.language == "ja")
+ val isAsian = !isAnime && (mediaDetails?.language == "zh" || mediaDetails?.language == "ko")
+ val isBollywood = mediaDetails?.country == "in"
+
+ if (data.type == TvType.Movie) {
+
+ val linkData = LinkData(
+ id = mediaDetails?.ids?.tmdb,
+ traktId = mediaDetails?.ids?.trakt,
+ traktSlug = mediaDetails?.ids?.slug,
+ tmdbId = mediaDetails?.ids?.tmdb,
+ imdbId = mediaDetails?.ids?.imdb.toString(),
+ tvdbId = mediaDetails?.ids?.tvdb,
+ tvrageId = mediaDetails?.ids?.tvrage,
+ type = data.type.toString(),
+ title = mediaDetails?.title,
+ year = mediaDetails?.year,
+ orgTitle = mediaDetails?.title,
+ isAnime = isAnime,
+ //jpTitle = later if needed as it requires another network request,
+ airedDate = mediaDetails?.released
+ ?: mediaDetails?.firstAired,
+ isAsian = isAsian,
+ isBollywood = isBollywood,
+ ).toJson()
+
+ return newMovieLoadResponse(
+ name = mediaDetails?.title!!,
+ url = data.toJson(),
+ dataUrl = linkData.toJson(),
+ type = if (isAnime) TvType.AnimeMovie else TvType.Movie,
+ ) {
+ this.name = mediaDetails.title
+ this.type = if (isAnime) TvType.AnimeMovie else TvType.Movie
+ this.posterUrl = getOriginalWidthImageUrl(posterUrl)
+ this.year = mediaDetails.year
+ this.plot = mediaDetails.overview
+ this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
+ this.tags = mediaDetails.genres
+ this.duration = mediaDetails.runtime
+ this.recommendations = relatedMedia
+ this.actors = actors
+ this.comingSoon = isUpcoming(mediaDetails.released)
+ //posterHeaders
+ this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
+ this.contentRating = mediaDetails.certification
+ addTrailer(mediaDetails.trailer)
+ addImdbId(mediaDetails.ids?.imdb)
+ addTMDbId(mediaDetails.ids?.tmdb.toString())
+ }
+ } else {
+
+ val resSeasons =
+ getApi("$traktApiUrl/shows/${mediaDetails?.ids?.trakt.toString()}/seasons?extended=cloud9,full,episodes")
+ val episodes = mutableListOf()
+ val seasons = parseJson>(resSeasons)
+ var nextAir: NextAiring? = null
+
+ seasons.forEach { season ->
+
+ season.episodes?.map { episode ->
+
+ val linkData = LinkData(
+ id = mediaDetails?.ids?.tmdb,
+ traktId = mediaDetails?.ids?.trakt,
+ traktSlug = mediaDetails?.ids?.slug,
+ tmdbId = mediaDetails?.ids?.tmdb,
+ imdbId = mediaDetails?.ids?.imdb.toString(),
+ tvdbId = mediaDetails?.ids?.tvdb,
+ tvrageId = mediaDetails?.ids?.tvrage,
+ type = data.type.toString(),
+ season = episode.season,
+ episode = episode.number,
+ title = mediaDetails?.title,
+ year = mediaDetails?.year,
+ orgTitle = mediaDetails?.title,
+ isAnime = isAnime,
+ airedYear = mediaDetails?.year,
+ lastSeason = seasons.size,
+ epsTitle = episode.title,
+ //jpTitle = later if needed as it requires another network request,
+ date = episode.firstAired,
+ airedDate = episode.firstAired,
+ isAsian = isAsian,
+ isBollywood = isBollywood,
+ isCartoon = isCartoon
+ ).toJson()
+
+ episodes.add(
+ Episode(
+ data = linkData.toJson(),
+ name = episode.title,
+ season = episode.season,
+ episode = episode.number,
+ posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()),
+ rating = episode.rating?.times(10)?.roundToInt(),
+ description = episode.overview,
+ runTime = episode.runtime
+ ).apply {
+ this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX")
+ if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) {
+ nextAir = NextAiring(
+ episode = this.episode!!,
+ unixTime = this.date!!.div(1000L),
+ season = if (this.season == 1) null else this.season,
+ )
+ }
+ }
+ )
+ }
+ }
+
+ return newTvSeriesLoadResponse(
+ name = mediaDetails?.title!!,
+ url = data.toJson(),
+ type = if (isAnime) TvType.Anime else TvType.TvSeries,
+ episodes = episodes
+ ) {
+ this.name = mediaDetails.title
+ this.type = if (isAnime) TvType.Anime else TvType.TvSeries
+ this.episodes = episodes
+ this.posterUrl = getOriginalWidthImageUrl(posterUrl)
+ this.year = mediaDetails.year
+ this.plot = mediaDetails.overview
+ this.showStatus = getStatus(mediaDetails.status)
+ this.rating = mediaDetails.rating?.times(1000)?.roundToInt()
+ this.tags = mediaDetails.genres
+ this.duration = mediaDetails.runtime
+ this.recommendations = relatedMedia
+ this.actors = actors
+ this.comingSoon = isUpcoming(mediaDetails.released)
+ //posterHeaders
+ this.nextAiring = nextAir
+ this.backgroundPosterUrl = getOriginalWidthImageUrl(backDropUrl)
+ this.contentRating = mediaDetails.certification
+ addTrailer(mediaDetails.trailer)
+ addImdbId(mediaDetails.ids?.imdb)
+ addTMDbId(mediaDetails.ids?.tmdb.toString())
+ }
+ }
+ }
+
+ private suspend fun getApi(url: String): String {
+ return app.get(
+ url = url,
+ headers = mapOf(
+ "Content-Type" to "application/json",
+ "trakt-api-version" to "2",
+ "trakt-api-key" to traktClientId,
+ )
+ ).toString()
+ }
+
+ private fun isUpcoming(dateString: String?): Boolean {
+ return try {
+ val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
+ val dateTime = dateString?.let { format.parse(it)?.time } ?: return false
+ unixTimeMS < dateTime
+ } catch (t: Throwable) {
+ logError(t)
+ false
+ }
+ }
+
+ private fun getStatus(t: String?): ShowStatus {
+ return when (t) {
+ "returning series" -> ShowStatus.Ongoing
+ "continuing" -> ShowStatus.Ongoing
+ else -> ShowStatus.Completed
+ }
+ }
+
+ private fun fixPath(url: String?): String? {
+ url ?: return null
+ return "https://$url"
+ }
+
+ private fun getWidthImageUrl(path: String?, width: String): String? {
+ if (path == null) return null
+ if (!path.contains("image.tmdb.org")) return fixPath(path)
+ val fileName = Uri.parse(path).lastPathSegment ?: return null
+ return "https://image.tmdb.org/t/p/${width}/${fileName}"
+ }
+
+ private fun getOriginalWidthImageUrl(path: String?): String? {
+ if (path == null) return null
+ if (!path.contains("image.tmdb.org")) return fixPath(path)
+ return getWidthImageUrl(path, "original")
+ }
+
+ data class Data(
+ val type: TvType? = null,
+ val mediaDetails: MediaDetails? = null,
+ )
+
+ data class MediaDetails(
+ @JsonProperty("title") val title: String? = null,
+ @JsonProperty("year") val year: Int? = null,
+ @JsonProperty("ids") val ids: Ids? = null,
+ @JsonProperty("tagline") val tagline: String? = null,
+ @JsonProperty("overview") val overview: String? = null,
+ @JsonProperty("released") val released: String? = null,
+ @JsonProperty("runtime") val runtime: Int? = null,
+ @JsonProperty("country") val country: String? = null,
+ @JsonProperty("updatedAt") val updatedAt: String? = null,
+ @JsonProperty("trailer") val trailer: String? = null,
+ @JsonProperty("homepage") val homepage: String? = null,
+ @JsonProperty("status") val status: String? = null,
+ @JsonProperty("rating") val rating: Double? = null,
+ @JsonProperty("votes") val votes: Long? = null,
+ @JsonProperty("comment_count") val commentCount: Long? = null,
+ @JsonProperty("language") val language: String? = null,
+ @JsonProperty("languages") val languages: List? = null,
+ @JsonProperty("available_translations") val availableTranslations: List? = null,
+ @JsonProperty("genres") val genres: List? = null,
+ @JsonProperty("certification") val certification: String? = null,
+ @JsonProperty("aired_episodes") val airedEpisodes: Int? = null,
+ @JsonProperty("first_aired") val firstAired: String? = null,
+ @JsonProperty("airs") val airs: Airs? = null,
+ @JsonProperty("network") val network: String? = null,
+ @JsonProperty("images") val images: Images? = null,
+ @JsonProperty("movie") @JsonAlias("show") val media: MediaDetails? = null
+ )
+
+ data class Airs(
+ @JsonProperty("day") val day: String? = null,
+ @JsonProperty("time") val time: String? = null,
+ @JsonProperty("timezone") val timezone: String? = null,
+ )
+
+ data class Ids(
+ @JsonProperty("trakt") val trakt: Int? = null,
+ @JsonProperty("slug") val slug: String? = null,
+ @JsonProperty("tvdb") val tvdb: Int? = null,
+ @JsonProperty("imdb") val imdb: String? = null,
+ @JsonProperty("tmdb") val tmdb: Int? = null,
+ @JsonProperty("tvrage") val tvrage: String? = null,
+ )
+
+ data class Images(
+ @JsonProperty("fanart") val fanart: List? = null,
+ @JsonProperty("poster") val poster: List? = null,
+ @JsonProperty("logo") val logo: List? = null,
+ @JsonProperty("clearart") val clearart: List? = null,
+ @JsonProperty("banner") val banner: List? = null,
+ @JsonProperty("thumb") val thumb: List? = null,
+ @JsonProperty("screenshot") val screenshot: List? = null,
+ @JsonProperty("headshot") val headshot: List? = null,
+ )
+
+ data class People(
+ @JsonProperty("cast") val cast: List? = null,
+ )
+
+ data class Cast(
+ @JsonProperty("character") val character: String? = null,
+ @JsonProperty("characters") val characters: List? = null,
+ @JsonProperty("episode_count") val episodeCount: Long? = null,
+ @JsonProperty("person") val person: Person? = null,
+ @JsonProperty("images") val images: Images? = null,
+ )
+
+ data class Person(
+ @JsonProperty("name") val name: String? = null,
+ @JsonProperty("ids") val ids: Ids? = null,
+ @JsonProperty("images") val images: Images? = null,
+ )
+
+ data class Seasons(
+ @JsonProperty("aired_episodes") val airedEpisodes: Int? = null,
+ @JsonProperty("episode_count") val episodeCount: Int? = null,
+ @JsonProperty("episodes") val episodes: List? = null,
+ @JsonProperty("first_aired") val firstAired: String? = null,
+ @JsonProperty("ids") val ids: Ids? = null,
+ @JsonProperty("images") val images: Images? = null,
+ @JsonProperty("network") val network: String? = null,
+ @JsonProperty("number") val number: Int? = null,
+ @JsonProperty("overview") val overview: String? = null,
+ @JsonProperty("rating") val rating: Double? = null,
+ @JsonProperty("title") val title: String? = null,
+ @JsonProperty("updated_at") val updatedAt: String? = null,
+ @JsonProperty("votes") val votes: Int? = null,
+ )
+
+ data class TraktEpisode(
+ @JsonProperty("available_translations") val availableTranslations: List? = null,
+ @JsonProperty("comment_count") val commentCount: Int? = null,
+ @JsonProperty("episode_type") val episodeType: String? = null,
+ @JsonProperty("first_aired") val firstAired: String? = null,
+ @JsonProperty("ids") val ids: Ids? = null,
+ @JsonProperty("images") val images: Images? = null,
+ @JsonProperty("number") val number: Int? = null,
+ @JsonProperty("number_abs") val numberAbs: Int? = null,
+ @JsonProperty("overview") val overview: String? = null,
+ @JsonProperty("rating") val rating: Double? = null,
+ @JsonProperty("runtime") val runtime: Int? = null,
+ @JsonProperty("season") val season: Int? = null,
+ @JsonProperty("title") val title: String? = null,
+ @JsonProperty("updated_at") val updatedAt: String? = null,
+ @JsonProperty("votes") val votes: Int? = null,
+ )
+
+ data class LinkData(
+ val id: Int? = null,
+ val traktId: Int? = null,
+ val traktSlug: String? = null,
+ val tmdbId: Int? = null,
+ val imdbId: String? = null,
+ val tvdbId: Int? = null,
+ val tvrageId: String? = null,
+ val type: String? = null,
+ val season: Int? = null,
+ val episode: Int? = null,
+ val aniId: String? = null,
+ val animeId: String? = null,
+ val title: String? = null,
+ val year: Int? = null,
+ val orgTitle: String? = null,
+ val isAnime: Boolean = false,
+ val airedYear: Int? = null,
+ val lastSeason: Int? = null,
+ val epsTitle: String? = null,
+ val jpTitle: String? = null,
+ val date: String? = null,
+ val airedDate: String? = null,
+ val isAsian: Boolean = false,
+ val isBollywood: Boolean = false,
+ val isCartoon: Boolean = false,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
new file mode 100644
index 00000000..3df5197c
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt
@@ -0,0 +1,16 @@
+package com.lagradost.cloudstream3.mvvm
+
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+
+/** NOTE: Only one observer at a time per value */
+fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) {
+ liveData.removeObservers(this)
+ liveData.observe(this) { it?.let { t -> action(t) } }
+}
+
+/** NOTE: Only one observer at a time per value */
+fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) {
+ liveData.removeObservers(this)
+ liveData.observe(this) { action(it) }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt
index 6950d961..85a9db5d 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt
@@ -9,7 +9,10 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.nicehttp.Requests.Companion.await
import com.lagradost.nicehttp.cookies
import kotlinx.coroutines.runBlocking
-import okhttp3.*
+import okhttp3.Headers
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
import java.net.URI
@@ -17,6 +20,8 @@ import java.net.URI
class CloudflareKiller : Interceptor {
companion object {
const val TAG = "CloudflareKiller"
+ private val ERROR_CODES = listOf(403, 503)
+ private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare")
fun parseCookieMap(cookie: String): Map {
return cookie.split(";").associate {
val split = it.split("=")
@@ -48,15 +53,23 @@ class CloudflareKiller : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response = runBlocking {
val request = chain.request()
- val cookies = savedCookies[request.url.host]
- if (cookies == null) {
- bypassCloudflare(request)?.let {
- Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
- return@runBlocking it
+ when (val cookies = savedCookies[request.url.host]) {
+ null -> {
+ val response = chain.proceed(request)
+ if(!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) {
+ return@runBlocking response
+ } else {
+ response.close()
+ bypassCloudflare(request)?.let {
+ Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}")
+ return@runBlocking it
+ }
+ }
+ }
+ else -> {
+ return@runBlocking proceed(request, cookies)
}
- } else {
- return@runBlocking proceed(request, cookies)
}
debugWarning({ true }) { "Failed cloudflare at: ${request.url}" }
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt
index e89ccfeb..ddf5b286 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt
@@ -2,5 +2,4 @@ package com.lagradost.cloudstream3.plugins
@Suppress("unused")
@Target(AnnotationTarget.CLASS)
-annotation class CloudstreamPlugin(
-)
\ No newline at end of file
+annotation class CloudstreamPlugin
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt
index 6b7dc90b..fc836587 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt
@@ -34,7 +34,7 @@ abstract class Plugin {
*/
fun registerMainAPI(element: MainAPI) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI")
- element.sourcePlugin = this.__filename
+ element.sourcePlugin = this.filename
// Race condition causing which would case duplicates if not for distinctBy
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.add(element)
@@ -48,7 +48,7 @@ abstract class Plugin {
*/
fun registerExtractorAPI(element: ExtractorApi) {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi")
- element.sourcePlugin = this.__filename
+ element.sourcePlugin = this.filename
extractorApis.add(element)
}
@@ -67,7 +67,12 @@ abstract class Plugin {
* This will contain your resources if you specified requiresResources in gradle
*/
var resources: Resources? = null
- var __filename: String? = null
+ /** Full file path to the plugin. */
+ @Deprecated("Renamed to `filename` to follow conventions", replaceWith = ReplaceWith("filename"))
+ var __filename: String?
+ get() = filename
+ set(value) {filename = value}
+ var filename: String? = null
/**
* This will add a button in the settings allowing you to add custom settings
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
index 49b5a752..bc2a1780 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
@@ -1,24 +1,25 @@
package com.lagradost.cloudstream3.plugins
+import android.Manifest
import android.app.*
import android.content.Context
+import android.content.pm.PackageManager
import android.content.res.AssetManager
import android.content.res.Resources
import android.os.Build
import android.os.Environment
import android.util.Log
import android.widget.Toast
+import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
import com.google.gson.Gson
import com.lagradost.cloudstream3.*
-import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.APIHolder.removePluginMapping
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
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.CommonActivity.showToast
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
@@ -34,6 +35,7 @@ import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
+import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
@@ -137,6 +139,20 @@ object PluginManager {
}
}
+ /**
+ * Deletes all generated oat files which will force Android to recompile the dex extensions.
+ * This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update.
+ */
+ fun deleteAllOatFiles(context: Context) {
+ File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo ->
+ repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file ->
+ val success = file.deleteRecursively()
+ Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success")
+ }
+ }
+ }
+
+
fun getPluginsOnline(): Array {
return getKey(PLUGINS_KEY) ?: emptyArray()
}
@@ -150,7 +166,7 @@ object PluginManager {
private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins"
- public var currentlyLoading: String? = null
+ var currentlyLoading: String? = null
// Maps filepath to plugin
val plugins: MutableMap =
@@ -165,6 +181,9 @@ object PluginManager {
var loadedLocalPlugins = false
private set
+
+ var loadedOnlinePlugins = false
+ private set
private val gson = Gson()
private suspend fun maybeLoadPlugin(context: Context, file: File) {
@@ -278,6 +297,7 @@ object PluginManager {
}
// ioSafe {
+ loadedOnlinePlugins = true
afterPluginsLoadedEvent.invoke(false)
// }
@@ -290,7 +310,7 @@ object PluginManager {
* 2. Fetch all not downloaded plugins
* 3. Download them and reload plugins
**/
- fun downloadNotExistingPluginsAndLoad(activity: Activity) {
+ fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) {
val newDownloadPlugins = mutableListOf()
val urls = (getKey>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES
@@ -304,6 +324,8 @@ object PluginManager {
// Iterate online repos and returns not downloaded plugins
val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
val sitePlugin = onlineData.second
+ val tvtypes = sitePlugin.tvTypes ?: listOf()
+
//Don't include empty urls
if (sitePlugin.url.isBlank()) {
return@mapNotNull null
@@ -318,22 +340,29 @@ object PluginManager {
return@mapNotNull null
}
- //Omit lang not selected on language setting
- val lang = sitePlugin.language ?: return@mapNotNull null
- //If set to 'universal', don't skip any language
- if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
- return@mapNotNull null
- }
- //Log.i(TAG, "sitePlugin lang => $lang")
-
- //Omit NSFW, if disabled
- sitePlugin.tvTypes?.let { tvtypes ->
- if (!settingsForProvider.enableAdult) {
- if (tvtypes.contains(TvType.NSFW.name)) {
- return@mapNotNull null
- }
+ //Omit non-NSFW if mode is set to NSFW only
+ if (mode == AutoDownloadMode.NsfwOnly) {
+ if (!tvtypes.contains(TvType.NSFW.name)) {
+ return@mapNotNull null
}
}
+ //Omit NSFW, if disabled
+ if (!settingsForProvider.enableAdult) {
+ if (tvtypes.contains(TvType.NSFW.name)) {
+ return@mapNotNull null
+ }
+ }
+
+ //Omit lang not selected on language setting
+ if (mode == AutoDownloadMode.FilterByLang) {
+ val lang = sitePlugin.language ?: return@mapNotNull null
+ //If set to 'universal', don't skip any language
+ if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
+ return@mapNotNull null
+ }
+ //Log.i(TAG, "sitePlugin lang => $lang")
+ }
+
val savedData = PluginData(
url = sitePlugin.url,
internalName = sitePlugin.internalName,
@@ -402,7 +431,6 @@ object PluginManager {
**/
fun loadAllLocalPlugins(context: Context, forceReload: Boolean) {
val dir = File(LOCAL_PLUGINS_PATH)
- removeKey(PLUGINS_KEY_LOCAL)
if (!dir.exists()) {
val res = dir.mkdirs()
@@ -450,6 +478,14 @@ object PluginManager {
Log.i(TAG, "Loading plugin: $data")
return try {
+ // in case of android 14 then
+ try {
+ File(filePath).setReadOnly()
+ } catch (t: Throwable) {
+ Log.e(TAG, "Failed to set dex as readonly")
+ logError(t)
+ }
+
val loader = PathClassLoader(filePath, context.classLoader)
var manifest: Plugin.Manifest
loader.getResourceAsStream("manifest.json").use { stream ->
@@ -471,10 +507,12 @@ object PluginManager {
val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also {
Log.d(TAG, "No manifest version for ${data.internalName}")
}
+
+ @Suppress("UNCHECKED_CAST")
val pluginClass: Class<*> =
loader.loadClass(manifest.pluginClassName) as Class
val pluginInstance: Plugin =
- pluginClass.newInstance() as Plugin
+ pluginClass.getDeclaredConstructor().newInstance() as Plugin
// Sets with the proper version
setPluginData(data.copy(version = version))
@@ -484,14 +522,16 @@ object PluginManager {
return true
}
- pluginInstance.__filename = fileName
+ pluginInstance.filename = file.absolutePath
if (manifest.requiresResources) {
Log.d(TAG, "Loading resources for ${data.internalName}")
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
- val assets = AssetManager::class.java.newInstance()
+ val assets = AssetManager::class.java.getDeclaredConstructor().newInstance()
val addAssetPath =
AssetManager::class.java.getMethod("addAssetPath", String::class.java)
addAssetPath.invoke(assets, file.absolutePath)
+
+ @Suppress("DEPRECATION")
pluginInstance.resources = Resources(
assets,
context.resources.displayMetrics,
@@ -533,14 +573,14 @@ object PluginManager {
// 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)
}
}
synchronized(APIHolder.allProviders) {
- APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename }
+ APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
}
- extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename }
+ extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
classLoaders.values.removeIf { v -> v == plugin }
@@ -687,9 +727,14 @@ object PluginManager {
}
val notification = builder.build()
- with(NotificationManagerCompat.from(context)) {
- // notificationId is a unique int for each notification that you must define
- notify((System.currentTimeMillis() / 1000).toInt(), notification)
+ // notificationId is a unique int for each notification that you must define
+ if (ActivityCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ NotificationManagerCompat.from(context)
+ .notify((System.currentTimeMillis() / 1000).toInt(), notification)
}
return notification
} catch (e: Exception) {
@@ -697,4 +742,4 @@ object PluginManager {
return null
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
index b80a590e..c6ec9df7 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
@@ -73,7 +73,7 @@ object RepositoryManager {
val PREBUILT_REPOSITORIES: Array by lazy {
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
}
- val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
+ private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
/* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */
fun convertRawGitUrl(url: String): String {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt
index f099ad1a..d1b702f4 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt
@@ -8,22 +8,14 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R
import java.security.MessageDigest
import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
-import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
object VotingApi { // please do not cheat the votes lol
private const val LOGKEY = "VotingApi"
- enum class VoteType(val value: Int) {
- UPVOTE(1),
- DOWNVOTE(-1),
- NONE(0)
- }
-
- private val apiDomain = "https://api.countapi.xyz"
+ private const val API_DOMAIN = "https://counterapi.com/api"
private fun transformUrl(url: String): String = // dont touch or all votes get reset
MessageDigest
@@ -35,12 +27,12 @@ object VotingApi { // please do not cheat the votes lol
return getVotes(url)
}
- suspend fun SitePlugin.vote(requestType: VoteType): Int {
- return vote(url, requestType)
+ fun SitePlugin.hasVoted(): Boolean {
+ return hasVoted(url)
}
- fun SitePlugin.getVoteType(): VoteType {
- return getVoteType(url)
+ suspend fun SitePlugin.vote(): Int {
+ return vote(url)
}
fun SitePlugin.canVote(): Boolean {
@@ -50,36 +42,38 @@ object VotingApi { // please do not cheat the votes lol
// Plugin url to Int
private val votesCache = mutableMapOf()
- suspend fun getVotes(pluginUrl: String): Int {
- val url = "${apiDomain}/get/cs3-votes/${transformUrl(pluginUrl)}"
+ private fun getRepository(pluginUrl: String) = pluginUrl
+ .split("/")
+ .drop(2)
+ .take(3)
+ .joinToString("-")
+
+ private suspend fun readVote(pluginUrl: String): Int {
+ val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
Log.d(LOGKEY, "Requesting: $url")
- return votesCache[pluginUrl] ?: app.get(url).parsedSafe()?.value?.also {
- votesCache[pluginUrl] = it
- } ?: (0.also {
- ioSafe {
- createBucket(pluginUrl)
+ return app.get(url).parsedSafe()?.value ?: 0
+ }
+
+ private suspend fun writeVote(pluginUrl: String): Boolean {
+ val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
+ Log.d(LOGKEY, "Requesting: $url")
+ return app.get(url).parsedSafe()?.value != null
+ }
+
+ suspend fun getVotes(pluginUrl: String): Int =
+ votesCache[pluginUrl] ?: readVote(pluginUrl).also {
+ votesCache[pluginUrl] = it
}
- })
- }
- fun getVoteType(pluginUrl: String): VoteType {
- return getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE
- }
-
- private suspend fun createBucket(pluginUrl: String) {
- val url =
- "${apiDomain}/create?namespace=cs3-votes&key=${transformUrl(pluginUrl)}&value=0&update_lowerbound=-2&update_upperbound=2&enable_reset=0"
- Log.d(LOGKEY, "Requesting: $url")
- app.get(url)
- }
+ fun hasVoted(pluginUrl: String) =
+ getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
fun canVote(pluginUrl: String): Boolean {
- if (!PluginManager.urlPlugins.contains(pluginUrl)) return false
- return true
+ return PluginManager.urlPlugins.contains(pluginUrl)
}
private val voteLock = Mutex()
- suspend fun vote(pluginUrl: String, requestType: VoteType): Int {
+ suspend fun vote(pluginUrl: String): Int {
// Prevent multiple requests at the same time.
voteLock.withLock {
if (!canVote(pluginUrl)) {
@@ -90,33 +84,21 @@ object VotingApi { // please do not cheat the votes lol
return getVotes(pluginUrl)
}
- val savedType: VoteType =
- getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE
-
- val newType = if (requestType == savedType) VoteType.NONE else requestType
- val changeValue = if (requestType == savedType) {
- -requestType.value
- } else if (savedType == VoteType.NONE) {
- requestType.value
- } else if (savedType != requestType) {
- -savedType.value + requestType.value
- } else 0
-
- // Pre-emptively set vote key
- setKey("cs3-votes/${transformUrl(pluginUrl)}", newType)
-
- val url =
- "${apiDomain}/update/cs3-votes/${transformUrl(pluginUrl)}?amount=${changeValue}"
- Log.d(LOGKEY, "Requesting: $url")
- val res = app.get(url).parsedSafe()?.value
-
- if (res == null) {
- // "Refund" key if the response is invalid
- setKey("cs3-votes/${transformUrl(pluginUrl)}", savedType)
- } else {
- votesCache[pluginUrl] = res
+ if (hasVoted(pluginUrl)) {
+ main {
+ Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
+ .show()
+ }
+ return getVotes(pluginUrl)
}
- return res ?: 0
+
+
+ if (writeVote(pluginUrl)) {
+ setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
+ votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
+ }
+
+ return getVotes(pluginUrl)
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt
new file mode 100644
index 00000000..4ef841f5
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt
@@ -0,0 +1,96 @@
+package com.lagradost.cloudstream3.services
+
+import android.content.Context
+import androidx.core.app.NotificationCompat
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ForegroundInfo
+import androidx.work.PeriodicWorkRequest
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
+import com.lagradost.cloudstream3.utils.BackupUtils
+import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
+import java.util.concurrent.TimeUnit
+
+const val BACKUP_CHANNEL_ID = "cloudstream3.backups"
+const val BACKUP_WORK_NAME = "work_backup"
+const val BACKUP_CHANNEL_NAME = "Backups"
+const val BACKUP_CHANNEL_DESCRIPTION = "Notifications for background backups"
+const val BACKUP_NOTIFICATION_ID = 938712898 // Random unique
+
+class BackupWorkManager(val context: Context, workerParams: WorkerParameters) :
+ CoroutineWorker(context, workerParams) {
+ companion object {
+ fun enqueuePeriodicWork(context: Context?, intervalHours: Long) {
+ if (context == null) return
+
+ if (intervalHours == 0L) {
+ WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME)
+ return
+ }
+
+ val constraints = Constraints.Builder()
+ .setRequiresStorageNotLow(true)
+ .build()
+
+ val periodicSyncDataWork =
+ PeriodicWorkRequest.Builder(
+ BackupWorkManager::class.java,
+ intervalHours,
+ TimeUnit.HOURS
+ )
+ .addTag(BACKUP_WORK_NAME)
+ .setConstraints(constraints)
+ .build()
+
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ BACKUP_WORK_NAME,
+ ExistingPeriodicWorkPolicy.UPDATE,
+ periodicSyncDataWork
+ )
+
+ // Uncomment below for testing
+
+// val oneTimeBackupWork =
+// OneTimeWorkRequest.Builder(BackupWorkManager::class.java)
+// .addTag(BACKUP_WORK_NAME)
+// .setConstraints(constraints)
+// .build()
+//
+// WorkManager.getInstance(context).enqueue(oneTimeBackupWork)
+ }
+ }
+
+ private val backupNotificationBuilder =
+ NotificationCompat.Builder(context, BACKUP_CHANNEL_ID)
+ .setColorized(true)
+ .setOnlyAlertOnce(true)
+ .setSilent(true)
+ .setAutoCancel(true)
+ .setContentTitle(context.getString(R.string.pref_category_backup))
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setColor(context.colorFromAttribute(R.attr.colorPrimary))
+ .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big)
+
+ override suspend fun doWork(): Result {
+ context.createNotificationChannel(
+ BACKUP_CHANNEL_ID,
+ BACKUP_CHANNEL_NAME,
+ BACKUP_CHANNEL_DESCRIPTION
+ )
+
+ setForeground(
+ ForegroundInfo(
+ BACKUP_NOTIFICATION_ID,
+ backupNotificationBuilder.build()
+ )
+ )
+
+ BackupUtils.backup(context)
+
+ return Result.success()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
index adf5abfa..00c74dff 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt
@@ -1,5 +1,6 @@
package com.lagradost.cloudstream3.services
+import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
@@ -9,13 +10,13 @@ import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.work.*
import com.lagradost.cloudstream3.*
-import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.R
-import com.lagradost.cloudstream3.mvvm.safeApiCall
+import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.result.txt
-import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel
+import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel
+import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
@@ -97,128 +98,138 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
)
}
+ @SuppressLint("UnspecifiedImmutableFlag")
override suspend fun doWork(): Result {
+ try {
// println("Update subscriptions!")
- context.createNotificationChannel(
- SUBSCRIPTION_CHANNEL_ID,
- SUBSCRIPTION_CHANNEL_NAME,
- SUBSCRIPTION_CHANNEL_DESCRIPTION
- )
-
- setForeground(
- ForegroundInfo(
- SUBSCRIPTION_NOTIFICATION_ID,
- progressNotificationBuilder.build()
+ context.createNotificationChannel(
+ SUBSCRIPTION_CHANNEL_ID,
+ SUBSCRIPTION_CHANNEL_NAME,
+ SUBSCRIPTION_CHANNEL_DESCRIPTION
)
- )
- val subscriptions = getAllSubscriptions()
+ setForeground(
+ ForegroundInfo(
+ SUBSCRIPTION_NOTIFICATION_ID,
+ progressNotificationBuilder.build()
+ )
+ )
- if (subscriptions.isEmpty()) {
- WorkManager.getInstance(context).cancelWorkById(this.id)
+ val subscriptions = getAllSubscriptions()
+
+ if (subscriptions.isEmpty()) {
+ WorkManager.getInstance(context).cancelWorkById(this.id)
+ return Result.success()
+ }
+
+ val max = subscriptions.size
+ var progress = 0
+
+ updateProgress(max, progress, true)
+
+ // We need all plugins loaded.
+ PluginManager.loadAllOnlinePlugins(context)
+ PluginManager.loadAllLocalPlugins(context, false)
+
+ subscriptions.apmap { savedData ->
+ try {
+ val id = savedData.id ?: return@apmap null
+ val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
+
+ // Reasonable timeout to prevent having this worker run forever.
+ val response = withTimeoutOrNull(60_000) {
+ api.load(savedData.url) as? EpisodeResponse
+ } ?: return@apmap null
+
+ val dubPreference =
+ getDub(id) ?: if (
+ context.getApiDubstatusSettings().contains(DubStatus.Dubbed)
+ ) {
+ DubStatus.Dubbed
+ } else {
+ DubStatus.Subbed
+ }
+
+ val latestEpisodes = response.getLatestEpisodes()
+ val latestPreferredEpisode = latestEpisodes[dubPreference]
+
+ val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) {
+ val latestSeenEpisode =
+ savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE
+ val shouldUpdate = latestPreferredEpisode > latestSeenEpisode
+ shouldUpdate to latestPreferredEpisode
+ } else {
+ val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE
+ val latestSeenEpisode =
+ savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE
+ val shouldUpdate = latestEpisode > latestSeenEpisode
+ shouldUpdate to latestEpisode
+ }
+
+ DataStoreHelper.updateSubscribedData(
+ id,
+ savedData,
+ response
+ )
+
+ if (shouldUpdate) {
+ val updateHeader = savedData.name
+ val updateDescription = txt(
+ R.string.subscription_episode_released,
+ latestEpisode,
+ savedData.name
+ ).asString(context)
+
+ val intent = Intent(context, MainActivity::class.java).apply {
+ data = savedData.url.toUri()
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+
+ val pendingIntent =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ } else {
+ PendingIntent.getActivity(context, 0, intent, 0)
+ }
+
+ val poster = ioWork {
+ savedData.posterUrl?.let { url ->
+ context.getImageBitmapFromUrl(
+ url,
+ savedData.posterHeaders
+ )
+ }
+ }
+
+ val updateNotification =
+ updateNotificationBuilder.setContentTitle(updateHeader)
+ .setContentText(updateDescription)
+ .setContentIntent(pendingIntent)
+ .setLargeIcon(poster)
+ .build()
+
+ notificationManager.notify(id, updateNotification)
+ }
+
+ // You can probably get some issues here since this is async but it does not matter much.
+ updateProgress(max, ++progress, false)
+ } catch (t: Throwable) {
+ logError(t)
+ }
+ }
+
+ return Result.success()
+ } catch (t: Throwable) {
+ logError(t)
+ // ye, while this is not correct, but because gods know why android just crashes
+ // and this causes major battery usage as it retries it inf times. This is better, just
+ // in case android decides to be android and fuck us
return Result.success()
}
-
- val max = subscriptions.size
- var progress = 0
-
- updateProgress(max, progress, true)
-
- // We need all plugins loaded.
- PluginManager.loadAllOnlinePlugins(context)
- PluginManager.loadAllLocalPlugins(context, false)
-
- subscriptions.apmap { savedData ->
- try {
- val id = savedData.id ?: return@apmap null
- val api = getApiFromNameNull(savedData.apiName) ?: return@apmap null
-
- // Reasonable timeout to prevent having this worker run forever.
- val response = withTimeoutOrNull(60_000) {
- api.load(savedData.url) as? EpisodeResponse
- } ?: return@apmap null
-
- val dubPreference =
- getDub(id) ?: if (
- context.getApiDubstatusSettings().contains(DubStatus.Dubbed)
- ) {
- DubStatus.Dubbed
- } else {
- DubStatus.Subbed
- }
-
- val latestEpisodes = response.getLatestEpisodes()
- val latestPreferredEpisode = latestEpisodes[dubPreference]
-
- val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) {
- val latestSeenEpisode =
- savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE
- val shouldUpdate = latestPreferredEpisode > latestSeenEpisode
- shouldUpdate to latestPreferredEpisode
- } else {
- val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE
- val latestSeenEpisode =
- savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE
- val shouldUpdate = latestEpisode > latestSeenEpisode
- shouldUpdate to latestEpisode
- }
-
- DataStoreHelper.updateSubscribedData(
- id,
- savedData,
- response
- )
-
- if (shouldUpdate) {
- val updateHeader = savedData.name
- val updateDescription = txt(
- R.string.subscription_episode_released,
- latestEpisode,
- savedData.name
- ).asString(context)
-
- val intent = Intent(context, MainActivity::class.java).apply {
- data = savedData.url.toUri()
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
- }
-
- val pendingIntent =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- PendingIntent.getActivity(
- context,
- 0,
- intent,
- PendingIntent.FLAG_IMMUTABLE
- )
- } else {
- PendingIntent.getActivity(context, 0, intent, 0)
- }
-
- val poster = ioWork {
- savedData.posterUrl?.let { url ->
- context.getImageBitmapFromUrl(
- url,
- savedData.posterHeaders
- )
- }
- }
-
- val updateNotification =
- updateNotificationBuilder.setContentTitle(updateHeader)
- .setContentText(updateDescription)
- .setContentIntent(pendingIntent)
- .setLargeIcon(poster)
- .build()
-
- notificationManager.notify(id, updateNotification)
- }
-
- // You can probably get some issues here since this is async but it does not matter much.
- updateProgress(max, ++progress, false)
- } catch (_: Throwable) {
- }
- }
-
- return Result.success()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt
index 77a1b0b5..df64caab 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt
@@ -1,11 +1,23 @@
package com.lagradost.cloudstream3.subtitles
import androidx.annotation.WorkerThread
+import androidx.core.net.toUri
+import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit
+import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.syncproviders.AuthAPI
+import com.lagradost.cloudstream3.ui.player.SubtitleOrigin
+import okio.BufferedSource
+import okio.buffer
+import okio.sink
+import okio.source
+import java.io.File
+import java.util.zip.ZipInputStream
interface AbstractSubProvider {
+ val idPrefix: String
+
@WorkerThread
suspend fun search(query: SubtitleSearch): List? {
throw NotImplementedError()
@@ -15,6 +27,98 @@ interface AbstractSubProvider {
suspend fun load(data: SubtitleEntity): String? {
throw NotImplementedError()
}
+
+ @WorkerThread
+ suspend fun SubtitleResource.getResources(data: SubtitleEntity) {
+ this.addUrl(load(data))
+ }
+
+ @WorkerThread
+ suspend fun getResource(data: SubtitleEntity): SubtitleResource {
+ return SubtitleResource().apply {
+ this.getResources(data)
+ }
+ }
+}
+
+/**
+ * A builder for subtitle files.
+ * @see addUrl
+ * @see addFile
+ */
+class SubtitleResource {
+ fun downloadFile(source: BufferedSource): File {
+ val file = File.createTempFile("temp-subtitle", ".tmp").apply {
+ deleteFileOnExit(this)
+ }
+ val sink = file.sink().buffer()
+ sink.writeAll(source)
+ sink.close()
+ source.close()
+
+ return file
+ }
+
+ private fun unzip(file: File): List> {
+ val entries = mutableListOf>()
+
+ ZipInputStream(file.inputStream()).use { zipInputStream ->
+ var zipEntry = zipInputStream.nextEntry
+
+ while (zipEntry != null) {
+ val tempFile = File.createTempFile("unzipped-subtitle", ".tmp").apply {
+ deleteFileOnExit(this)
+ }
+ entries.add(zipEntry.name to tempFile)
+
+ tempFile.sink().buffer().use { buffer ->
+ buffer.writeAll(zipInputStream.source())
+ }
+
+ zipEntry = zipInputStream.nextEntry
+ }
+ }
+ return entries
+ }
+
+ data class SingleSubtitleResource(
+ val name: String?,
+ val url: String,
+ val origin: SubtitleOrigin
+ )
+
+ private var resources: MutableList = mutableListOf()
+
+ fun getSubtitles(): List {
+ return resources.toList()
+ }
+
+ fun addUrl(url: String?, name: String? = null) {
+ if (url == null) return
+ this.resources.add(
+ SingleSubtitleResource(name, url, SubtitleOrigin.URL)
+ )
+ }
+
+ fun addFile(file: File, name: String? = null) {
+ this.resources.add(
+ SingleSubtitleResource(name, file.toUri().toString(), SubtitleOrigin.DOWNLOADED_FILE)
+ )
+ deleteFileOnExit(file)
+ }
+
+ suspend fun addZipUrl(
+ url: String,
+ nameGenerator: (String, File) -> String? = { _, _ -> null }
+ ) {
+ val source = app.get(url).okhttpResponse.body.source()
+ val zip = downloadFile(source)
+ val realFiles = unzip(zip)
+ zip.deleteRecursively()
+ realFiles.forEach { (name, subtitleFile) ->
+ addFile(subtitleFile, nameGenerator(name, subtitleFile))
+ }
+ }
}
interface AbstractSubApi : AbstractSubProvider, AuthAPI
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt
index f6424c4c..685b499b 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt
@@ -19,8 +19,11 @@ class AbstractSubtitleEntities {
data class SubtitleSearch(
var query: String = "",
- var imdb: Long? = null,
var lang: String? = null,
+ var imdbId: String? = null,
+ var tmdbId: Int? = null,
+ var malId: Int? = null,
+ var aniListId: Int? = null,
var epNumber: Int? = null,
var seasonNumber: Int? = null,
var year: Int? = null
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
index 8ce6bae2..2e14c3c4 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
@@ -3,62 +3,75 @@ package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.syncproviders.providers.*
import java.util.concurrent.TimeUnit
abstract class AccountManager(private val defIndex: Int) : AuthAPI {
companion object {
- val malApi = MALApi(0)
- val aniListApi = AniListApi(0)
+ val malApi = MALApi(0).also { api ->
+ LoadResponse.Companion.malIdPrefix = api.idPrefix
+ }
+ val aniListApi = AniListApi(0).also { api ->
+ LoadResponse.Companion.aniListIdPrefix = api.idPrefix
+ }
+ val simklApi = SimklApi(0).also { api ->
+ LoadResponse.Companion.simklIdPrefix = api.idPrefix
+ }
val openSubtitlesApi = OpenSubtitlesApi(0)
- val indexSubtitlesApi = IndexSubtitleApi()
val addic7ed = Addic7ed()
+ val subDlApi = SubDlApi(0)
val localListApi = LocalList()
+ val subSourceApi = SubSourceApi()
// used to login via app intent
val OAuth2Apis
get() = listOf(
- malApi, aniListApi
+ malApi, aniListApi, simklApi
)
// this needs init with context and can be accessed in settings
val accountManagers
get() = listOf(
- malApi, aniListApi, openSubtitlesApi, //nginxApi
+ malApi, aniListApi, openSubtitlesApi, subDlApi, simklApi //nginxApi
)
// used for active syncing
val SyncApis
get() = listOf(
- SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi)
+ SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
)
val inAppAuths
- get() = listOf(openSubtitlesApi)//, nginxApi)
+ get() = listOf(
+ openSubtitlesApi,
+ subDlApi
+ )//, nginxApi)
val subtitleProviders
get() = listOf(
openSubtitlesApi,
- indexSubtitlesApi, // they got anti scraping measures in place :(
- addic7ed
+ addic7ed,
+ subDlApi,
+ subSourceApi
)
- const val appString = "cloudstreamapp"
- const val appStringRepo = "cloudstreamrepo"
- const val appStringPlayer = "cloudstreamplayer"
+ const val APP_STRING = "cloudstreamapp"
+ const val APP_STRING_REPO = "cloudstreamrepo"
+ const val APP_STRING_PLAYER = "cloudstreamplayer"
// Instantly start the search given a query
- const val appStringSearch = "cloudstreamsearch"
+ const val APP_STRING_SEARCH = "cloudstreamsearch"
// Instantly resume watching a show
- const val appStringResumeWatching = "cloudstreamcontinuewatching"
+ const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching"
val unixTime: Long
get() = System.currentTimeMillis() / 1000L
val unixTimeMs: Long
get() = System.currentTimeMillis()
- const val maxStale = 60 * 10
+ const val MAX_STALE = 60 * 10
fun secondsToReadable(seconds: Int, completedValue: String): String {
var secondsLong = seconds.toLong()
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt
index ef74edfc..3d0bb940 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/OAuth2API.kt
@@ -5,7 +5,23 @@ import androidx.fragment.app.FragmentActivity
interface OAuth2API : AuthAPI {
val key: String
val redirectUrl: String
+ val supportDeviceAuth: Boolean
suspend fun handleRedirect(url: String) : Boolean
fun authenticate(activity: FragmentActivity?)
+ suspend fun getDevicePin() : PinAuthData? {
+ return null
+ }
+
+ suspend fun handleDeviceAuth(pinAuthData: PinAuthData) : Boolean {
+ return false
+ }
+
+ data class PinAuthData(
+ val deviceCode: String,
+ val userCode: String,
+ val verificationUrl: String,
+ val expiresIn: Int,
+ val interval: Int,
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt
similarity index 81%
rename from app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt
rename to app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt
index 8c76c5bf..dcb8bbea 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt
@@ -1,17 +1,11 @@
package com.lagradost.cloudstream3.syncproviders
import com.lagradost.cloudstream3.*
+import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.UiText
import me.xdrop.fuzzywuzzy.FuzzySearch
-
-enum class SyncIdName {
- Anilist,
- MyAnimeList,
- Trakt,
- Imdb,
- LocalList
-}
+import java.util.Date
interface SyncAPI : OAuth2API {
/**
@@ -35,9 +29,9 @@ interface SyncAPI : OAuth2API {
4 -> PlanToWatch
5 -> ReWatching
*/
- suspend fun score(id: String, status: SyncStatus): Boolean
+ suspend fun score(id: String, status: AbstractSyncStatus): Boolean
- suspend fun getStatus(id: String): SyncStatus?
+ suspend fun getStatus(id: String): AbstractSyncStatus?
suspend fun getResult(id: String): SyncResult?
@@ -59,14 +53,25 @@ interface SyncAPI : OAuth2API {
override var id: Int? = null,
) : SearchResponse
- data class SyncStatus(
- val status: Int,
+ abstract class AbstractSyncStatus {
+ abstract var status: SyncWatchType
+
/** 1-10 */
- val score: Int?,
- val watchedEpisodes: Int?,
- var isFavorite: Boolean? = null,
- var maxEpisodes: Int? = null,
- )
+ abstract var score: Int?
+ abstract var watchedEpisodes: Int?
+ abstract var isFavorite: Boolean?
+ abstract var maxEpisodes: Int?
+ }
+
+
+ data class SyncStatus(
+ override var status: SyncWatchType,
+ /** 1-10 */
+ override var score: Int?,
+ override var watchedEpisodes: Int?,
+ override var isFavorite: Boolean? = null,
+ override var maxEpisodes: Int? = null,
+ ) : AbstractSyncStatus()
data class SyncResult(
/**Used to verify*/
@@ -120,6 +125,8 @@ interface SyncAPI : OAuth2API {
ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed()
ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) }
ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime }
+ ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate }
+ ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate }
else -> items
}
}
@@ -154,6 +161,10 @@ interface SyncAPI : OAuth2API {
override var posterUrl: String?,
override var posterHeaders: Map?,
override var quality: SearchQuality?,
+ val releaseDate: Date?,
override var id: Int? = null,
+ val plot : String? = null,
+ val rating: Int? = null,
+ val tags: List? = null
) : SearchResponse
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt
index 85b877e0..9363cb6f 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt
@@ -18,11 +18,11 @@ class SyncRepo(private val repo: SyncAPI) {
repo.requireLibraryRefresh = value
}
- suspend fun score(id: String, status: SyncAPI.SyncStatus): Resource {
+ suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource {
return safeApiCall { repo.score(id, status) }
}
- suspend fun getStatus(id: String): Resource {
+ suspend fun getStatus(id: String): Resource {
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt
index 507c5e2a..db467639 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt
@@ -18,13 +18,13 @@ class Addic7ed : AbstractSubApi {
override fun logOut() {}
companion object {
- const val host = "https://www.addic7ed.com"
+ const val HOST = "https://www.addic7ed.com"
const val TAG = "ADDIC7ED"
}
private fun fixUrl(url: String): String {
- return if (url.startsWith("/")) host + url
- else if (!url.startsWith("http")) "$host/$url"
+ return if (url.startsWith("/")) HOST + url
+ else if (!url.startsWith("http")) "$HOST/$url"
else url
}
@@ -62,7 +62,7 @@ class Addic7ed : AbstractSubApi {
}
val title = queryText.substringBefore("(").trim()
- val url = "$host/search.php?search=${title}&Submit=Search"
+ val url = "$HOST/search.php?search=${title}&Submit=Search"
val hostDocument = app.get(url).document
var searchResult = ""
if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url
@@ -74,8 +74,8 @@ class Addic7ed : AbstractSubApi {
hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(")
?.substringBefore(",")
val doc = app.get(
- "$host/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
- referer = "$host/"
+ "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined",
+ referer = "$HOST/"
).document
doc.select("#season tr:contains($queryLang)").mapNotNull { node ->
if (node.selectFirst("td")?.text()
@@ -97,7 +97,7 @@ class Addic7ed : AbstractSubApi {
val link = fixUrl(node.select("a.buttonDownload").attr("href"))
val isHearingImpaired =
!node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty()
- cleanResources(results, name, link, mapOf("referer" to "$host/"), isHearingImpaired)
+ cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired)
}
return results
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
index 0010ce25..6112c7db 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
@@ -13,17 +13,19 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
+import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt
+import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
-import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
+import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import java.net.URL
import java.net.URLEncoder
-import java.util.*
+import java.util.Locale
class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "AniList"
@@ -31,6 +33,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override val redirectUrl = "anilistlogin"
override val idPrefix = "anilist"
override var requireLibraryRefresh = true
+ override val supportDeviceAuth = false
override var mainUrl = "https://anilist.co"
override val icon = R.drawable.ic_anilist_icon
override val requiresLogin = false
@@ -61,7 +64,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun handleRedirect(url: String): Boolean {
val sanitizer =
- splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
+ splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
val token = sanitizer["access_token"]!!
val expiresIn = sanitizer["expires_in"]!!
@@ -85,7 +88,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun search(name: String): List? {
val data = searchShows(name) ?: return null
- return data.data?.Page?.media?.map {
+ return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult(
it.title.romaji ?: return null,
this.name,
@@ -99,7 +102,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun getResult(id: String): SyncAPI.SyncResult {
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
- val season = getSeason(internalId).data.Media
+ val season = getSeason(internalId).data.media
return SyncAPI.SyncResult(
season.id.toString(),
@@ -158,23 +161,23 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
}
- override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
+ override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null
val data = getDataAboutId(internalId) ?: return null
return SyncAPI.SyncStatus(
score = data.score,
watchedEpisodes = data.progress,
- status = data.type?.value ?: return null,
+ status = SyncWatchType.fromInternalId(data.type?.value ?: return null),
isFavorite = data.isFavourite,
maxEpisodes = data.episodes,
)
}
- override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
+ override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return postDataAboutId(
id.toIntOrNull() ?: return false,
- fromIntToAnimeStatus(status.status),
+ fromIntToAnimeStatus(status.status.internalId),
status.score,
status.watchedEpisodes
).also {
@@ -299,12 +302,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
//println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
val shows = searchShows(name.replace(blackListRegex, ""))
- shows?.data?.Page?.media?.find {
+ shows?.data?.page?.media?.find {
(malId ?: "NONE") == it.idMal.toString()
}?.let { return it }
val filtered =
- shows?.data?.Page?.media?.filter {
+ shows?.data?.page?.media?.filter {
(((it.startDate.year ?: year.toString()) == year.toString()
|| year == null))
}
@@ -494,7 +497,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q, true)
val d = parseJson(data ?: return null)
- val main = d.data?.Media
+ val main = d.data?.media
if (main?.mediaListEntry != null) {
return AniListTitleHolder(
title = main.title,
@@ -534,7 +537,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
headers = mapOf(
"Authorization" to "Bearer " + (getAuth()
?: return@suspendSafeApiCall null),
- if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache"
+ if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache"
),
cacheTime = 0,
data = mapOf(
@@ -595,7 +598,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
//@JsonProperty("source") val source: String,
@JsonProperty("episodes") val episodes: Int,
@JsonProperty("title") val title: Title,
- //@JsonProperty("description") val description: String,
+ @JsonProperty("description") val description: String?,
@JsonProperty("coverImage") val coverImage: CoverImage,
@JsonProperty("synonyms") val synonyms: List,
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
@@ -629,7 +632,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
?: this.media.coverImage.medium,
null,
null,
- null
+ this.media.seasonYear.toYear(),
+ null,
+ plot = this.media.description,
)
}
}
@@ -644,7 +649,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class Data(
- @JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection
+ @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
)
private fun getAniListListCached(): Array? {
@@ -656,7 +661,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
if (checkToken()) return null
return if (requireLibraryRefresh) {
- val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray()
+ val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray()
if (list != null) {
setKey(ANILIST_CACHED_LIST, list)
}
@@ -675,7 +680,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
// To fill empty lists when AniList does not return them
val baseMap =
- AniListStatusType.values().filter { it.value >= 0 }.associate {
+ AniListStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList()
}
@@ -686,6 +691,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
+ ListSorting.ReleaseDateNew,
+ ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
@@ -761,7 +768,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
/** Used to query a saved MediaItem on the list to get the id for removal */
data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null)
- data class MediaListItem(@JsonProperty("MediaList") val MediaList: MediaListId? = null)
+ data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null)
data class MediaListId(@JsonProperty("id") val id: Long? = null)
private suspend fun postDataAboutId(
@@ -784,7 +791,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
"""
val response = postApi(idQuery)
val listId =
- tryParseJson(response)?.data?.MediaList?.id ?: return false
+ tryParseJson(response)?.data?.mediaList?.id ?: return false
"""
mutation(${'$'}id: Int = $listId) {
DeleteMediaListEntry(id: ${'$'}id) {
@@ -833,7 +840,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
val data = postApi(q)
if (data.isNullOrBlank()) return null
val userData = parseJson(data)
- val u = userData.data?.Viewer
+ val u = userData.data?.viewer
val user = AniListUser(
u?.id,
u?.name,
@@ -855,8 +862,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
suspend fun getSeasonRecursive(id: Int) {
val season = getSeason(id)
seasons.add(season)
- if (season.data.Media.format?.startsWith("TV") == true) {
- season.data.Media.relations?.edges?.forEach {
+ if (season.data.media.format?.startsWith("TV") == true) {
+ season.data.media.relations?.edges?.forEach {
if (it.node?.format != null) {
if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) {
getSeasonRecursive(it.node.id)
@@ -875,7 +882,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class SeasonData(
- @JsonProperty("Media") val Media: SeasonMedia,
+ @JsonProperty("Media") val media: SeasonMedia,
)
data class SeasonMedia(
@@ -1047,7 +1054,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class AniListData(
- @JsonProperty("Viewer") val Viewer: AniListViewer?,
+ @JsonProperty("Viewer") val viewer: AniListViewer?,
)
data class AniListRoot(
@@ -1087,7 +1094,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class LikeData(
- @JsonProperty("Viewer") val Viewer: LikeViewer?,
+ @JsonProperty("Viewer") val viewer: LikeViewer?,
)
data class LikeRoot(
@@ -1127,7 +1134,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class GetDataData(
- @JsonProperty("Media") val Media: GetDataMedia?,
+ @JsonProperty("Media") val media: GetDataMedia?,
)
data class GetDataRoot(
@@ -1160,7 +1167,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
data class GetSearchPage(
- @JsonProperty("Page") val Page: GetSearchData?,
+ @JsonProperty("Page") val page: GetSearchData?,
)
data class GetSearchData(
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt
index 7ec168da..94537ea3 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/DropboxApi.kt
@@ -11,6 +11,7 @@ class Dropbox : OAuth2API {
override val key = "zlqsamadlwydvb2"
override val redirectUrl = "dropboxlogin"
override val requiresLogin = true
+ override val supportDeviceAuth = false
override val createAccountUrl: String? = null
override val icon: Int
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt
deleted file mode 100644
index 668d10bd..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/IndexSubtitleApi.kt
+++ /dev/null
@@ -1,265 +0,0 @@
-package com.lagradost.cloudstream3.syncproviders.providers
-
-import android.util.Log
-import com.lagradost.cloudstream3.TvType
-import com.lagradost.cloudstream3.app
-import com.lagradost.cloudstream3.imdbUrlToIdNullable
-import com.lagradost.cloudstream3.subtitles.AbstractSubApi
-import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
-import com.lagradost.cloudstream3.utils.SubtitleHelper
-
-class IndexSubtitleApi : AbstractSubApi {
- override val name = "IndexSubtitle"
- override val idPrefix = "indexsubtitle"
- override val requiresLogin = false
- override val icon: Nothing? = null
- override val createAccountUrl: Nothing? = null
-
- override fun loginInfo(): Nothing? = null
-
- override fun logOut() {}
-
-
- companion object {
- const val host = "https://indexsubtitle.com"
- const val TAG = "INDEXSUBS"
- }
-
- private fun fixUrl(url: String): String {
- if (url.startsWith("http")) {
- return url
- }
- if (url.isEmpty()) {
- return ""
- }
-
- val startsWithNoHttp = url.startsWith("//")
- if (startsWithNoHttp) {
- return "https:$url"
- } else {
- if (url.startsWith('/')) {
- return host + url
- }
- return "$host/$url"
- }
- }
-
- private fun getOrdinal(num: Int?): String? {
- return when (num) {
- 1 -> "First"
- 2 -> "Second"
- 3 -> "Third"
- 4 -> "Fourth"
- 5 -> "Fifth"
- 6 -> "Sixth"
- 7 -> "Seventh"
- 8 -> "Eighth"
- 9 -> "Ninth"
- 10 -> "Tenth"
- 11 -> "Eleventh"
- 12 -> "Twelfth"
- 13 -> "Thirteenth"
- 14 -> "Fourteenth"
- 15 -> "Fifteenth"
- 16 -> "Sixteenth"
- 17 -> "Seventeenth"
- 18 -> "Eighteenth"
- 19 -> "Nineteenth"
- 20 -> "Twentieth"
- 21 -> "Twenty-First"
- 22 -> "Twenty-Second"
- 23 -> "Twenty-Third"
- 24 -> "Twenty-Fourth"
- 25 -> "Twenty-Fifth"
- 26 -> "Twenty-Sixth"
- 27 -> "Twenty-Seventh"
- 28 -> "Twenty-Eighth"
- 29 -> "Twenty-Ninth"
- 30 -> "Thirtieth"
- 31 -> "Thirty-First"
- 32 -> "Thirty-Second"
- 33 -> "Thirty-Third"
- 34 -> "Thirty-Fourth"
- 35 -> "Thirty-Fifth"
- else -> null
- }
- }
-
- private fun isRightEps(text: String, seasonNum: Int?, epNum: Int?): Boolean {
- val FILTER_EPS_REGEX =
- Regex("(?i)((Chapter\\s?0?${epNum})|((Season)?\\s?0?${seasonNum}?\\s?(Episode)\\s?0?${epNum}[^0-9]))|(?i)((S?0?${seasonNum}?E0?${epNum}[^0-9])|(0?${seasonNum}[a-z]0?${epNum}[^0-9]))")
- return text.contains(FILTER_EPS_REGEX)
- }
-
- private fun haveEps(text: String): Boolean {
- val HAVE_EPS_REGEX =
- Regex("(?i)((Chapter\\s?0?\\d)|((Season)?\\s?0?\\d?\\s?(Episode)\\s?0?\\d))|(?i)((S?0?\\d?E0?\\d)|(0?\\d[a-z]0?\\d))")
- return text.contains(HAVE_EPS_REGEX)
- }
-
- override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List {
- val imdbId = query.imdb ?: 0
- val lang = query.lang
- val queryLang = SubtitleHelper.fromTwoLettersToLanguage(lang.toString())
- val queryText = query.query
- val epNum = query.epNumber ?: 0
- val seasonNum = query.seasonNumber ?: 0
- val yearNum = query.year ?: 0
-
- val urlItems = ArrayList()
-
- fun cleanResources(
- results: MutableList,
- name: String,
- link: String
- ) {
- results.add(
- AbstractSubtitleEntities.SubtitleEntity(
- idPrefix = idPrefix,
- name = name,
- lang = queryLang.toString(),
- data = link,
- source = this.name,
- type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie,
- epNumber = epNum,
- seasonNumber = seasonNum,
- year = yearNum,
- )
- )
- }
-
- val document = app.get("$host/?search=$queryText").document
-
- document.select("div.my-3.p-3 div.media").map { block ->
- if (seasonNum > 0) {
- val name = block.select("strong.text-primary, strong.text-info").text().trim()
- val season = getOrdinal(seasonNum)
- if ((block.selectFirst("a")?.attr("href")
- ?.contains(
- "$season",
- ignoreCase = true
- )!! || name.contains(
- "$season",
- ignoreCase = true
- )) && name.contains(queryText, ignoreCase = true)
- ) {
- block.select("div.media").mapNotNull {
- urlItems.add(
- fixUrl(
- it.selectFirst("a")!!.attr("href")
- )
- )
- }
- }
- } else {
- if (block.selectFirst("strong")!!.text().trim()
- .matches(Regex("(?i)^$queryText\$"))
- ) {
- if (block.select("span[title=Release]").isNullOrEmpty()) {
- block.select("div.media").mapNotNull {
- val urlItem = fixUrl(
- it.selectFirst("a")!!.attr("href")
- )
- val itemDoc = app.get(urlItem).document
- val id = imdbUrlToIdNullable(
- itemDoc.selectFirst("div.d-flex span.badge.badge-primary")?.parent()
- ?.attr("href")
- )?.toLongOrNull()
- val year = itemDoc.selectFirst("div.d-flex span.badge.badge-success")
- ?.ownText()
- ?.trim().toString()
- Log.i(TAG, "id => $id \nyear => $year||$yearNum")
- if (imdbId > 0) {
- if (id == imdbId) {
- urlItems.add(urlItem)
- }
- } else {
- if (year.contains("$yearNum")) {
- urlItems.add(urlItem)
- }
- }
- }
- } else {
- if (block.select("span[title=Release]").text().trim()
- .contains("$yearNum")
- ) {
- block.select("div.media").mapNotNull {
- urlItems.add(
- fixUrl(
- it.selectFirst("a")!!.attr("href")
- )
- )
- }
- }
- }
- }
- }
- }
- Log.i(TAG, "urlItems => $urlItems")
- val results = mutableListOf()
-
- urlItems.forEach { url ->
- val request = app.get(url)
- if (request.isSuccessful) {
- request.document.select("div.my-3.p-3 div.media").map { block ->
- if (block.select("span.d-block span[data-original-title=Language]").text()
- .trim()
- .contains("$queryLang")
- ) {
- var name = block.select("strong.text-primary, strong.text-info").text().trim()
- val link = fixUrl(block.selectFirst("a")!!.attr("href"))
- if (seasonNum > 0) {
- when {
- isRightEps(name, seasonNum, epNum) -> {
- cleanResources(results, name, link)
- }
- !(haveEps(name)) -> {
- name = "$name (S${seasonNum}:E${epNum})"
- cleanResources(results, name, link)
- }
- }
- } else {
- cleanResources(results, name, link)
- }
- }
- }
- }
- }
- return results
- }
-
- override suspend fun load(data: AbstractSubtitleEntities.SubtitleEntity): String? {
- val seasonNum = data.seasonNumber
- val epNum = data.epNumber
-
- val req = app.get(data.data)
-
- if (req.isSuccessful) {
- val document = req.document
- val link = if (document.select("div.my-3.p-3 div.media").size == 1) {
- fixUrl(
- document.selectFirst("div.my-3.p-3 div.media a")!!.attr("href")
- )
- } else {
- document.select("div.my-3.p-3 div.media").firstNotNullOf { block ->
- val name =
- block.selectFirst("strong.d-block")?.text()?.trim().toString()
- if (seasonNum!! > 0) {
- if (isRightEps(name, seasonNum, epNum)) {
- fixUrl(block.selectFirst("a")!!.attr("href"))
- } else {
- null
- }
- } else {
- fixUrl(block.selectFirst("a")!!.attr("href"))
- }
- }
- }
- return link
- }
-
- return null
-
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt
index 7dd43fe7..0d9a4d13 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt
@@ -8,7 +8,10 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
@@ -18,6 +21,7 @@ class LocalList : SyncAPI {
override val name = "Local"
override val icon: Int = R.drawable.ic_baseline_storage_24
override val requiresLogin = false
+ override val supportDeviceAuth = false
override val createAccountUrl: Nothing? = null
override val idPrefix = "local"
override var requireLibraryRefresh = true
@@ -45,11 +49,11 @@ class LocalList : SyncAPI {
override val mainUrl = ""
override val syncIdName = SyncIdName.LocalList
- override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
+ override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return true
}
- override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
+ override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
return null
}
@@ -69,31 +73,57 @@ class LocalList : SyncAPI {
}?.distinctBy { it.first } ?: return null
val list = ioWork {
- watchStatusIds.groupBy {
- it.second.stringRes
- }.mapValues { group ->
+ val isTrueTv = isLayout(TV)
+
+ val baseMap = WatchType.entries.filter { it != WatchType.NONE }.associate {
+ // None is not something to display
+ it.stringRes to emptyList()
+ } + mapOf(
+ R.string.favorites_list_name to emptyList()
+ ) + if (!isTrueTv) {
+ mapOf(
+ R.string.subscription_list_name to emptyList()
+ )
+ } else {
+ emptyMap()
+ }
+
+ val watchStatusMap = watchStatusIds.groupBy { it.second.stringRes }.mapValues { group ->
group.value.mapNotNull {
getBookmarkedData(it.first)?.toLibraryItem(it.first.toString())
}
- } + mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
+ }
+
+ val favoritesMap = mapOf(R.string.favorites_list_name to getAllFavorites().mapNotNull {
it.toLibraryItem()
})
+
+ // Don't show subscriptions on TV
+ val result = if (isTrueTv) {
+ baseMap + watchStatusMap + favoritesMap
+ } else {
+ val subscriptionsMap = mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull {
+ it.toLibraryItem()
+ })
+
+ baseMap + watchStatusMap + subscriptionsMap + favoritesMap
+ }
+
+ result
}
- val baseMap = WatchType.values().filter { it != WatchType.NONE }.associate {
- // None is not something to display
- it.stringRes to emptyList()
- } + mapOf(R.string.subscription_list_name to emptyList())
-
return SyncAPI.LibraryMetadata(
- (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) },
+ list.map { SyncAPI.LibraryList(txt(it.key), it.value) },
setOf(
ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ,
-// ListSorting.UpdatedNew,
-// ListSorting.UpdatedOld,
+ ListSorting.UpdatedNew,
+ ListSorting.UpdatedOld,
+ ListSorting.ReleaseDateNew,
+ ListSorting.ReleaseDateOld,
// ListSorting.RatingHigh,
// ListSorting.RatingLow,
+
)
)
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt
index 5164b606..08c18653 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt
@@ -16,16 +16,22 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
+import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt
+import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
-import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import java.net.URL
import java.security.SecureRandom
import java.text.ParseException
import java.text.SimpleDateFormat
-import java.util.*
+import java.time.Instant
+import java.time.format.DateTimeFormatter
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
/** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */
const val MAL_MAX_SEARCH_LIMIT = 25
@@ -39,6 +45,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
private val apiUrl = "https://api.myanimelist.net"
override val icon = R.drawable.mal_logo
override val requiresLogin = false
+ override val supportDeviceAuth = false
override val syncIdName = SyncIdName.MyAnimeList
override var requireLibraryRefresh = true
override val createAccountUrl = "$mainUrl/register.php"
@@ -49,7 +56,6 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
}
override fun loginInfo(): AuthAPI.LoginInfo? {
- //getMalUser(true)?
getKey(accountId, MAL_USER_KEY)?.let { user ->
return AuthAPI.LoginInfo(
profilePicture = user.picture,
@@ -82,7 +88,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
this.name,
node.id.toString(),
"$mainUrl/anime/${node.id}/",
- node.main_picture?.large ?: node.main_picture?.medium
+ node.mainPicture?.large ?: node.mainPicture?.medium
)
}
}
@@ -91,10 +97,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
}
- override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
+ override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return setScoreRequest(
id.toIntOrNull() ?: return false,
- fromIntToAnimeStatus(status.status),
+ fromIntToAnimeStatus(status.status.internalId),
status.score,
status.watchedEpisodes
).also {
@@ -176,7 +182,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
private fun parseDate(string: String?): Long? {
return try {
- SimpleDateFormat("yyyy-MM-dd")?.parse(string ?: return null)?.time
+ SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time
} catch (e: Exception) {
null
}
@@ -188,7 +194,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
apiName = this.name,
syncId = node.id.toString(),
url = "$mainUrl/anime/${node.id}",
- posterUrl = node.main_picture?.large
+ posterUrl = node.mainPicture?.large
)
}
@@ -242,12 +248,12 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
val internalId = id.toIntOrNull() ?: return null
val data =
- getDataAboutMalId(internalId)?.my_list_status //?: throw ErrorLoadingException("No my_list_status")
+ getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status")
return SyncAPI.SyncStatus(
score = data?.score,
- status = malStatusAsString.indexOf(data?.status),
+ status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)),
isFavorite = null,
- watchedEpisodes = data?.num_episodes_watched,
+ watchedEpisodes = data?.numEpisodesWatched,
)
}
@@ -289,7 +295,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
private fun parseDateLong(string: String?): Long? {
return try {
- SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse(
string ?: return null
)?.time?.div(1000)
} catch (e: Exception) {
@@ -300,7 +306,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun handleRedirect(url: String): Boolean {
val sanitizer =
- splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
+ splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR
val state = sanitizer["state"]!!
if (state == "RequestID$requestId") {
val currentCode = sanitizer["code"]!!
@@ -349,9 +355,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
try {
if (response != "") {
val token = parseJson(response)
- setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime))
- setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token)
- setKey(accountId, MAL_TOKEN_KEY, token.access_token)
+ setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime))
+ setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken)
+ setKey(accountId, MAL_TOKEN_KEY, token.accessToken)
requireLibraryRefresh = true
}
} catch (e: Exception) {
@@ -393,55 +399,62 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class Node(
@JsonProperty("id") val id: Int,
@JsonProperty("title") val title: String,
- @JsonProperty("main_picture") val main_picture: MainPicture?,
- @JsonProperty("alternative_titles") val alternative_titles: AlternativeTitles?,
- @JsonProperty("media_type") val media_type: String?,
- @JsonProperty("num_episodes") val num_episodes: Int?,
+ @JsonProperty("main_picture") val mainPicture: MainPicture?,
+ @JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?,
+ @JsonProperty("media_type") val mediaType: String?,
+ @JsonProperty("num_episodes") val numEpisodes: Int?,
@JsonProperty("status") val status: String?,
- @JsonProperty("start_date") val start_date: String?,
- @JsonProperty("end_date") val end_date: String?,
- @JsonProperty("average_episode_duration") val average_episode_duration: Int?,
+ @JsonProperty("start_date") val startDate: String?,
+ @JsonProperty("end_date") val endDate: String?,
+ @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?,
@JsonProperty("synopsis") val synopsis: String?,
@JsonProperty("mean") val mean: Double?,
@JsonProperty("genres") val genres: List?,
@JsonProperty("rank") val rank: Int?,
@JsonProperty("popularity") val popularity: Int?,
- @JsonProperty("num_list_users") val num_list_users: Int?,
- @JsonProperty("num_favorites") val num_favorites: Int?,
- @JsonProperty("num_scoring_users") val num_scoring_users: Int?,
- @JsonProperty("start_season") val start_season: StartSeason?,
+ @JsonProperty("num_list_users") val numListUsers: Int?,
+ @JsonProperty("num_favorites") val numFavorites: Int?,
+ @JsonProperty("num_scoring_users") val numScoringUsers: Int?,
+ @JsonProperty("start_season") val startSeason: StartSeason?,
@JsonProperty("broadcast") val broadcast: Broadcast?,
@JsonProperty("nsfw") val nsfw: String?,
- @JsonProperty("created_at") val created_at: String?,
- @JsonProperty("updated_at") val updated_at: String?
+ @JsonProperty("created_at") val createdAt: String?,
+ @JsonProperty("updated_at") val updatedAt: String?
)
data class ListStatus(
@JsonProperty("status") val status: String?,
@JsonProperty("score") val score: Int,
- @JsonProperty("num_episodes_watched") val num_episodes_watched: Int,
- @JsonProperty("is_rewatching") val is_rewatching: Boolean,
- @JsonProperty("updated_at") val updated_at: String,
+ @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
+ @JsonProperty("is_rewatching") val isRewatching: Boolean,
+ @JsonProperty("updated_at") val updatedAt: String,
)
data class Data(
@JsonProperty("node") val node: Node,
- @JsonProperty("list_status") val list_status: ListStatus?,
+ @JsonProperty("list_status") val listStatus: ListStatus?,
) {
fun toLibraryItem(): SyncAPI.LibraryItem {
return SyncAPI.LibraryItem(
this.node.title,
"https://myanimelist.net/anime/${this.node.id}/",
this.node.id.toString(),
- this.list_status?.num_episodes_watched,
- this.node.num_episodes,
- this.list_status?.score?.times(10),
- parseDateLong(this.list_status?.updated_at),
+ this.listStatus?.numEpisodesWatched,
+ this.node.numEpisodes,
+ this.listStatus?.score?.times(10),
+ parseDateLong(this.listStatus?.updatedAt),
"MAL",
TvType.Anime,
- this.node.main_picture?.large ?: this.node.main_picture?.medium,
+ this.node.mainPicture?.large ?: this.node.mainPicture?.medium,
null,
null,
+ plot = this.node.synopsis,
+ releaseDate = if (this.node.startDate == null) null else try {Date.from(
+ Instant.from(
+ DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd")
+ .parse(this.node.startDate)
+ )
+ )} catch (_: RuntimeException) {null}
)
}
}
@@ -467,8 +480,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
)
data class Broadcast(
- @JsonProperty("day_of_the_week") val day_of_the_week: String?,
- @JsonProperty("start_time") val start_time: String?
+ @JsonProperty("day_of_the_week") val dayOfTheWeek: String?,
+ @JsonProperty("start_time") val startTime: String?
)
private fun getMalAnimeListCached(): Array? {
@@ -488,14 +501,14 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
val list = getMalAnimeListSmart()?.groupBy {
- convertToStatus(it.list_status?.status ?: "").stringRes
+ convertToStatus(it.listStatus?.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.toLibraryItem() }
} ?: emptyMap()
// To fill empty lists when MAL does not return them
val baseMap =
- MalStatusType.values().filter { it.value >= 0 }.associate {
+ MalStatusType.entries.filter { it.value >= 0 }.associate {
it.stringRes to emptyList()
}
@@ -506,6 +519,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
+ ListSorting.ReleaseDateNew,
+ ListSorting.ReleaseDateOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
@@ -570,7 +585,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
).text
val values = parseJson(res)
val titles =
- values.data.map { MalTitleHolder(it.list_status, it.node.id, it.node.title) }
+ values.data.map { MalTitleHolder(it.listStatus, it.node.id, it.node.title) }
for (t in titles) {
allTitles[t.id] = t
}
@@ -579,11 +594,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
}
}
- fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? {
+ private fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? {
// No time remaining if the show has already ended
try {
endDate?.let {
- if (SimpleDateFormat("yyyy-MM-dd").parse(it).time < System.currentTimeMillis()) return@convertJapanTimeToTimeRemaining null
+ if (SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it)
+ ?.before(Date.from(Instant.now())) != false
+ ) return@convertJapanTimeToTimeRemaining null
}
} catch (e: ParseException) {
logError(e)
@@ -600,7 +617,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH)
val currentYear = currentDate.get(Calendar.YEAR)
- val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm")
+ val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm", Locale.getDefault())
dateFormat.timeZone = TimeZone.getTimeZone("Japan")
val parsedDate =
dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null
@@ -644,13 +661,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
id: Int,
status: MalStatusType? = null,
score: Int? = null,
- num_watched_episodes: Int? = null,
+ numWatchedEpisodes: Int? = null,
): Boolean {
val res = setScoreRequest(
id,
if (status == null) null else malStatusAsString[maxOf(0, status.value)],
score,
- num_watched_episodes
+ numWatchedEpisodes
)
return if (res.isNullOrBlank()) {
@@ -667,17 +684,18 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
}
}
+ @Suppress("UNCHECKED_CAST")
private suspend fun setScoreRequest(
id: Int,
status: String? = null,
score: Int? = null,
- num_watched_episodes: Int? = null,
+ numWatchedEpisodes: Int? = null,
): String? {
val data = mapOf(
"status" to status,
"score" to score?.toString(),
- "num_watched_episodes" to num_watched_episodes?.toString()
- ).filter { it.value != null } as Map
+ "num_watched_episodes" to numWatchedEpisodes?.toString()
+ ).filterValues { it != null } as Map
return app.put(
"$apiUrl/v2/anime/$id/my_list_status",
@@ -690,10 +708,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class ResponseToken(
- @JsonProperty("token_type") val token_type: String,
- @JsonProperty("expires_in") val expires_in: Int,
- @JsonProperty("access_token") val access_token: String,
- @JsonProperty("refresh_token") val refresh_token: String,
+ @JsonProperty("token_type") val tokenType: String,
+ @JsonProperty("expires_in") val expiresIn: Int,
+ @JsonProperty("access_token") val accessToken: String,
+ @JsonProperty("refresh_token") val refreshToken: String,
)
data class MalRoot(
@@ -702,7 +720,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class MalDatum(
@JsonProperty("node") val node: MalNode,
- @JsonProperty("list_status") val list_status: MalStatus,
+ @JsonProperty("list_status") val listStatus: MalStatus,
)
data class MalNode(
@@ -719,16 +737,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class MalStatus(
@JsonProperty("status") val status: String,
@JsonProperty("score") val score: Int,
- @JsonProperty("num_episodes_watched") val num_episodes_watched: Int,
- @JsonProperty("is_rewatching") val is_rewatching: Boolean,
- @JsonProperty("updated_at") val updated_at: String,
+ @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int,
+ @JsonProperty("is_rewatching") val isRewatching: Boolean,
+ @JsonProperty("updated_at") val updatedAt: String,
)
data class MalUser(
@JsonProperty("id") val id: Int,
@JsonProperty("name") val name: String,
@JsonProperty("location") val location: String,
- @JsonProperty("joined_at") val joined_at: String,
+ @JsonProperty("joined_at") val joinedAt: String,
@JsonProperty("picture") val picture: String?,
)
@@ -741,9 +759,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
data class SmallMalAnime(
@JsonProperty("id") val id: Int,
@JsonProperty("title") val title: String?,
- @JsonProperty("num_episodes") val num_episodes: Int,
- @JsonProperty("my_list_status") val my_list_status: MalStatus?,
- @JsonProperty("main_picture") val main_picture: MalMainPicture?,
+ @JsonProperty("num_episodes") val numEpisodes: Int,
+ @JsonProperty("my_list_status") val myListStatus: MalStatus?,
+ @JsonProperty("main_picture") val mainPicture: MalMainPicture?,
)
data class MalSearchNode(
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt
index 3e372c2d..37b95614 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt
@@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty
-import com.google.common.collect.BiMap
-import com.google.common.collect.HashBiMap
-import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.ErrorLoadingException
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
@@ -15,8 +16,8 @@ import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
import com.lagradost.cloudstream3.utils.AppUtils
-import java.net.URLEncoder
-import java.nio.charset.StandardCharsets
+import okhttp3.Interceptor
+import okhttp3.Response
class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
override val idPrefix = "opensubtitles"
@@ -28,14 +29,31 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
companion object {
const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile
- const val apiKey = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
- const val host = "https://api.opensubtitles.com/api/v1"
+ const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2"
+ const val HOST = "https://api.opensubtitles.com/api/v1"
const val TAG = "OPENSUBS"
- const val coolDownDuration: Long = 1000L * 30L // CoolDown if 429 error code in ms
+ const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms
var currentCoolDown: Long = 0L
var currentSession: SubtitleOAuthEntity? = null
}
+ private val headerInterceptor = OpenSubtitleInterceptor()
+
+ /** Automatically adds required api headers */
+ private class OpenSubtitleInterceptor : Interceptor {
+ /** Required user agent! */
+ private val userAgent = "Cloudstream3 v0.1"
+ override fun intercept(chain: Interceptor.Chain): Response {
+ return chain.proceed(
+ chain.request().newBuilder()
+ .removeHeader("user-agent")
+ .addHeader("user-agent", userAgent)
+ .addHeader("Api-Key", API_KEY)
+ .build()
+ )
+ }
+ }
+
private fun canDoRequest(): Boolean {
return unixTimeMs > currentCoolDown
}
@@ -47,7 +65,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
}
private fun throwGotTooManyRequests() {
- currentCoolDown = unixTimeMs + coolDownDuration
+ currentCoolDown = unixTimeMs + COOLDOWN_DURATION
throw ErrorLoadingException("Too many requests")
}
@@ -96,15 +114,15 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
private suspend fun initLogin(username: String, password: String): Boolean {
//Log.i(TAG, "DATA = [$username] [$password]")
val response = app.post(
- url = "$host/login",
+ url = "$HOST/login",
headers = mapOf(
- "Api-Key" to apiKey,
- "Content-Type" to "application/json"
+ "Content-Type" to "application/json",
),
data = mapOf(
"username" to username,
"password" to password
- )
+ ),
+ interceptor = headerInterceptor
)
//Log.i(TAG, "Responsecode = ${response.code}")
//Log.i(TAG, "Result => ${response.text}")
@@ -115,7 +133,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
SubtitleOAuthEntity(
user = username,
pass = password,
- access_token = token.token ?: run {
+ accessToken = token.token ?: run {
return false
})
)
@@ -149,11 +167,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
// "pt" to "pt-PT",
// "pt" to "pt-BR"
)
- private fun fixLanguage(language: String?) : String? {
+
+ 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? {
+ private fun fixLanguageReverse(language: String?): String? {
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
}
@@ -165,7 +185,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
throwIfCantDoRequest()
val fixedLang = fixLanguage(query.lang)
- val imdbId = query.imdb ?: 0
+ val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0
val queryText = query.query
val epNum = query.epNumber ?: 0
val seasonNum = query.seasonNumber ?: 0
@@ -176,16 +196,16 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
val searchQueryUrl = when (imdbId > 0) {
//Use imdb_id to search if its valid
- true -> "$host/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
- false -> "$host/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
+ true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
+ false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery"
}
val req = app.get(
url = searchQueryUrl,
headers = mapOf(
- Pair("Api-Key", apiKey),
Pair("Content-Type", "application/json")
- )
+ ),
+ interceptor = headerInterceptor
)
Log.i(TAG, "Search Req => ${req.text}")
if (!req.isSuccessful) {
@@ -207,12 +227,12 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
//Use any valid name/title in hierarchy
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
?: featureDetails?.parentTitle ?: attr.release ?: query.query
- val lang = fixLanguageReverse(attr.language)?: ""
+ val lang = fixLanguageReverse(attr.language) ?: ""
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
val year = featureDetails?.year ?: query.year
val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
- val isHearingImpaired = attr.hearing_impaired ?: false
+ val isHearingImpaired = attr.hearingImpaired ?: false
//Log.i(TAG, "Result id/name => ${item.id} / $name")
item.attributes?.files?.forEach { file ->
val resultData = file.fileId?.toString() ?: ""
@@ -245,19 +265,19 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
throwIfCantDoRequest()
val req = app.post(
- url = "$host/download",
+ url = "$HOST/download",
headers = mapOf(
Pair(
"Authorization",
- "Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}"
+ "Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}"
),
- Pair("Api-Key", apiKey),
Pair("Content-Type", "application/json"),
Pair("Accept", "*/*")
),
data = mapOf(
Pair("file_id", data.data)
- )
+ ),
+ interceptor = headerInterceptor
)
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
//Log.i(TAG, "Request headers => ${req.headers}")
@@ -278,7 +298,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
data class SubtitleOAuthEntity(
var user: String,
var pass: String,
- var access_token: String,
+ var accessToken: String,
)
data class OAuthToken(
@@ -303,7 +323,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
@JsonProperty("url") var url: String? = null,
@JsonProperty("files") var files: List? = listOf(),
@JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(),
- @JsonProperty("hearing_impaired") var hearing_impaired: Boolean? = null,
+ @JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null,
)
data class ResultFiles(
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt
new file mode 100644
index 00000000..50517f9d
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt
@@ -0,0 +1,1112 @@
+package com.lagradost.cloudstream3.syncproviders.providers
+
+import androidx.annotation.StringRes
+import androidx.core.net.toUri
+import androidx.fragment.app.FragmentActivity
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.annotation.JsonProperty
+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.openBrowser
+import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
+import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
+import com.lagradost.cloudstream3.BuildConfig
+import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.SimklSyncServices
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.mapper
+import com.lagradost.cloudstream3.mvvm.debugAssert
+import com.lagradost.cloudstream3.mvvm.debugPrint
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
+import com.lagradost.cloudstream3.syncproviders.AccountManager
+import com.lagradost.cloudstream3.syncproviders.AuthAPI
+import com.lagradost.cloudstream3.syncproviders.OAuth2API
+import com.lagradost.cloudstream3.syncproviders.SyncAPI
+import com.lagradost.cloudstream3.syncproviders.SyncIdName
+import com.lagradost.cloudstream3.ui.SyncWatchType
+import com.lagradost.cloudstream3.ui.library.ListSorting
+import com.lagradost.cloudstream3.ui.result.txt
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.math.BigInteger
+import java.security.SecureRandom
+import java.text.SimpleDateFormat
+import java.time.Instant
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+import kotlin.time.Duration
+import kotlin.time.DurationUnit
+import kotlin.time.toDuration
+
+class SimklApi(index: Int) : AccountManager(index), SyncAPI {
+ override var name = "Simkl"
+ override val key = "simkl-key"
+ override val redirectUrl = "simkl"
+ override val supportDeviceAuth = true
+ override val idPrefix = "simkl"
+ override var requireLibraryRefresh = true
+ override var mainUrl = "https://api.simkl.com"
+ override val icon = R.drawable.simkl_logo
+ override val requiresLogin = false
+ override val createAccountUrl = "$mainUrl/signup"
+ override val syncIdName = SyncIdName.Simkl
+ private val token: String?
+ get() = getKey(accountId, SIMKL_TOKEN_KEY).also {
+ debugAssert({ it == null }) { "No ${this.name} token!" }
+ }
+
+ /** Automatically adds simkl auth headers */
+ private val interceptor = HeaderInterceptor()
+
+ /**
+ * This is required to override the reported last activity as simkl activites
+ * may not always update based on testing.
+ */
+ private var lastScoreTime = -1L
+
+ private object SimklCache {
+ private const val SIMKL_CACHE_KEY = "SIMKL_API_CACHE"
+
+ enum class CacheTimes(val value: String) {
+ OneMonth("30d"),
+ ThirtyMinutes("30m")
+ }
+
+ private class SimklCacheWrapper(
+ @JsonProperty("obj") val obj: T?,
+ @JsonProperty("validUntil") val validUntil: Long,
+ @JsonProperty("cacheTime") val cacheTime: Long = unixTime,
+ ) {
+ /** Returns true if cache is newer than cacheDays */
+ fun isFresh(): Boolean {
+ return validUntil > unixTime
+ }
+
+ fun remainingTime(): Duration {
+ val unixTime = unixTime
+ return if (validUntil > unixTime) {
+ (validUntil - unixTime).toDuration(DurationUnit.SECONDS)
+ } else {
+ Duration.ZERO
+ }
+ }
+ }
+
+ fun cleanOldCache() {
+ getKeys(SIMKL_CACHE_KEY)?.forEach {
+ val isOld = AcraApplication.getKey>(it)?.isFresh() == false
+ if (isOld) {
+ removeKey(it)
+ }
+ }
+ }
+
+ fun setKey(path: String, value: T, cacheTime: Duration) {
+ debugPrint { "Set cache: $SIMKL_CACHE_KEY/$path for ${cacheTime.inWholeDays} days or ${cacheTime.inWholeSeconds} seconds." }
+ setKey(
+ SIMKL_CACHE_KEY,
+ path,
+ // Storing as plain sting is required to make generics work.
+ SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson()
+ )
+ }
+
+ /**
+ * Gets cached object, if object is not fresh returns null and removes it from cache
+ */
+ inline fun getKey(path: String): T? {
+ // Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject"
+ val type = mapper.typeFactory.constructParametricType(
+ SimklCacheWrapper::class.java,
+ T::class.java
+ )
+ val cache = getKey(SIMKL_CACHE_KEY, path)?.let {
+ mapper.readValue>(it, type)
+ }
+
+ return if (cache?.isFresh() == true) {
+ debugPrint {
+ "Cache hit at: $SIMKL_CACHE_KEY/$path. " +
+ "Remains fresh for ${cache.remainingTime().inWholeDays} days or ${cache.remainingTime().inWholeSeconds} seconds."
+ }
+ cache.obj
+ } else {
+ debugPrint { "Cache miss at: $SIMKL_CACHE_KEY/$path" }
+ removeKey(SIMKL_CACHE_KEY, path)
+ null
+ }
+ }
+ }
+
+ companion object {
+ private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID
+ private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET
+ private var lastLoginState = ""
+
+ const val SIMKL_TOKEN_KEY: String = "simkl_token"
+ const val SIMKL_USER_KEY: String = "simkl_user"
+ const val SIMKL_CACHED_LIST: String = "simkl_cached_list"
+ const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time"
+
+ /** 2014-09-01T09:10:11Z -> 1409562611 */
+ private const val SIMKL_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'"
+ fun getUnixTime(string: String?): Long? {
+ return try {
+ SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply {
+ this.timeZone = TimeZone.getTimeZone("UTC")
+ }.parse(
+ string ?: return null
+ )?.toInstant()?.epochSecond
+ } catch (e: Exception) {
+ logError(e)
+ return null
+ }
+ }
+
+ /** 1409562611 -> 2014-09-01T09:10:11Z */
+ fun getDateTime(unixTime: Long?): String? {
+ return try {
+ SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply {
+ this.timeZone = TimeZone.getTimeZone("UTC")
+ }.format(
+ Date.from(
+ Instant.ofEpochSecond(
+ unixTime ?: return null
+ )
+ )
+ )
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ fun getPosterUrl(poster: String): String {
+ return "https://wsrv.nl/?url=https://simkl.in/posters/${poster}_m.webp"
+ }
+
+ private fun getUrlFromId(id: Int): String {
+ return "https://simkl.com/shows/$id"
+ }
+
+ enum class SimklListStatusType(
+ var value: Int,
+ @StringRes val stringRes: Int,
+ val originalName: String?
+ ) {
+ Watching(0, R.string.type_watching, "watching"),
+ Completed(1, R.string.type_completed, "completed"),
+ Paused(2, R.string.type_on_hold, "hold"),
+ Dropped(3, R.string.type_dropped, "dropped"),
+ Planning(4, R.string.type_plan_to_watch, "plantowatch"),
+ ReWatching(5, R.string.type_re_watching, "watching"),
+ None(-1, R.string.none, null);
+
+ companion object {
+ fun fromString(string: String): SimklListStatusType? {
+ return SimklListStatusType.entries.firstOrNull {
+ it.originalName == string
+ }
+ }
+ }
+ }
+
+ // -------------------
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ data class TokenRequest(
+ @JsonProperty("code") val code: String,
+ @JsonProperty("client_id") val clientId: String = CLIENT_ID,
+ @JsonProperty("client_secret") val clientSecret: String = CLIENT_SECRET,
+ @JsonProperty("redirect_uri") val redirectUri: String = "$APP_STRING://simkl",
+ @JsonProperty("grant_type") val grantType: String = "authorization_code"
+ )
+
+ data class TokenResponse(
+ /** No expiration date */
+ @JsonProperty("access_token") val accessToken: String,
+ @JsonProperty("token_type") val tokenType: String,
+ @JsonProperty("scope") val scope: String
+ )
+ // -------------------
+
+ /** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */
+ data class SettingsResponse(
+ val user: User
+ ) {
+ data class User(
+ val name: String,
+ /** Url */
+ val avatar: String
+ )
+ }
+
+ data class PinAuthResponse(
+ @JsonProperty("result") val result: String,
+ @JsonProperty("device_code") val deviceCode: String,
+ @JsonProperty("user_code") val userCode: String,
+ @JsonProperty("verification_url") val verificationUrl: String,
+ @JsonProperty("expires_in") val expiresIn: Int,
+ @JsonProperty("interval") val interval: Int,
+ )
+
+ data class PinExchangeResponse(
+ @JsonProperty("result") val result: String,
+ @JsonProperty("message") val message: String? = null,
+ @JsonProperty("access_token") val accessToken: String? = null,
+ )
+
+ // -------------------
+ data class ActivitiesResponse(
+ @JsonProperty("all") val all: String?,
+ @JsonProperty("tv_shows") val tvShows: UpdatedAt,
+ @JsonProperty("anime") val anime: UpdatedAt,
+ @JsonProperty("movies") val movies: UpdatedAt,
+ ) {
+ data class UpdatedAt(
+ @JsonProperty("all") val all: String?,
+ @JsonProperty("removed_from_list") val removedFromList: String?,
+ @JsonProperty("rated_at") val ratedAt: String?,
+ )
+ }
+
+ /** https://simkl.docs.apiary.io/#reference/tv/episodes/get-tv-show-episodes */
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ data class EpisodeMetadata(
+ @JsonProperty("title") val title: String?,
+ @JsonProperty("description") val description: String?,
+ @JsonProperty("season") val season: Int?,
+ @JsonProperty("episode") val episode: Int,
+ @JsonProperty("img") val img: String?
+ ) {
+ companion object {
+ fun convertToEpisodes(list: List?): List? {
+ return list?.map {
+ MediaObject.Season.Episode(it.episode)
+ }
+ }
+
+ fun convertToSeasons(list: List?): List? {
+ return list?.filter { it.season != null }?.groupBy {
+ it.season
+ }?.mapNotNull { (season, episodes) ->
+ convertToEpisodes(episodes)?.let { MediaObject.Season(season!!, it) }
+ }?.ifEmpty { null }
+ }
+ }
+ }
+
+ /**
+ * https://simkl.docs.apiary.io/#introduction/about-simkl-api/standard-media-objects
+ * Useful for finding shows from metadata
+ */
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ open class MediaObject(
+ @JsonProperty("title") val title: String?,
+ @JsonProperty("year") val year: Int?,
+ @JsonProperty("ids") val ids: Ids?,
+ @JsonProperty("total_episodes") val totalEpisodes: Int? = null,
+ @JsonProperty("status") val status: String? = null,
+ @JsonProperty("poster") val poster: String? = null,
+ @JsonProperty("type") val type: String? = null,
+ @JsonProperty("seasons") val seasons: List? = null,
+ @JsonProperty("episodes") val episodes: List? = null
+ ) {
+ fun hasEnded(): Boolean {
+ return status == "released" || status == "ended"
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ data class Season(
+ @JsonProperty("number") val number: Int,
+ @JsonProperty("episodes") val episodes: List
+ ) {
+ data class Episode(@JsonProperty("number") val number: Int)
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ data class Ids(
+ @JsonProperty("simkl") val simkl: Int?,
+ @JsonProperty("imdb") val imdb: String? = null,
+ @JsonProperty("tmdb") val tmdb: String? = null,
+ @JsonProperty("mal") val mal: String? = null,
+ @JsonProperty("anilist") val anilist: String? = null,
+ ) {
+ companion object {
+ fun fromMap(map: Map): Ids {
+ return Ids(
+ simkl = map[SimklSyncServices.Simkl]?.toIntOrNull(),
+ imdb = map[SimklSyncServices.Imdb],
+ tmdb = map[SimklSyncServices.Tmdb],
+ mal = map[SimklSyncServices.Mal],
+ anilist = map[SimklSyncServices.AniList]
+ )
+ }
+ }
+ }
+
+ fun toSyncSearchResult(): SyncAPI.SyncSearchResult? {
+ return SyncAPI.SyncSearchResult(
+ this.title ?: return null,
+ "Simkl",
+ this.ids?.simkl?.toString() ?: return null,
+ getUrlFromId(this.ids.simkl),
+ this.poster?.let { getPosterUrl(it) },
+ if (this.type == "movie") TvType.Movie else TvType.TvSeries
+ )
+ }
+ }
+
+ class SimklScoreBuilder private constructor() {
+ data class Builder(
+ private var url: String? = null,
+ private var interceptor: Interceptor? = null,
+ private var ids: MediaObject.Ids? = null,
+ private var score: Int? = null,
+ private var status: Int? = null,
+ private var addEpisodes: Pair?, List?>? = null,
+ private var removeEpisodes: Pair?, List?>? = null,
+ // Required for knowing if the status should be overwritten
+ private var onList: Boolean = false
+ ) {
+ fun interceptor(interceptor: Interceptor) = apply { this.interceptor = interceptor }
+ fun apiUrl(url: String) = apply { this.url = url }
+ fun ids(ids: MediaObject.Ids) = apply { this.ids = ids }
+ fun score(score: Int?, oldScore: Int?) = apply {
+ if (score != oldScore) {
+ this.score = score
+ }
+ }
+
+ fun status(newStatus: Int?, oldStatus: Int?) = apply {
+ onList = oldStatus != null
+ // Only set status if its new
+ if (newStatus != oldStatus) {
+ this.status = newStatus
+ } else {
+ this.status = null
+ }
+ }
+
+ fun episodes(
+ allEpisodes: List?,
+ newEpisodes: Int?,
+ oldEpisodes: Int?,
+ ) = apply {
+ if (allEpisodes == null || newEpisodes == null) return@apply
+
+ fun getEpisodes(rawEpisodes: List) =
+ if (rawEpisodes.any { it.season != null }) {
+ EpisodeMetadata.convertToSeasons(rawEpisodes) to null
+ } else {
+ null to EpisodeMetadata.convertToEpisodes(rawEpisodes)
+ }
+
+ // Do not add episodes if there is no change
+ if (newEpisodes > (oldEpisodes ?: 0)) {
+ this.addEpisodes = getEpisodes(allEpisodes.take(newEpisodes))
+
+ // Set to watching if episodes are added and there is no current status
+ if (!onList) {
+ status = SimklListStatusType.Watching.value
+ }
+ }
+ if ((oldEpisodes ?: 0) > newEpisodes) {
+ this.removeEpisodes = getEpisodes(allEpisodes.drop(newEpisodes))
+ }
+ }
+
+ suspend fun execute(): Boolean {
+ val time = getDateTime(unixTime)
+
+ return if (this.status == SimklListStatusType.None.value) {
+ app.post(
+ "$url/sync/history/remove",
+ json = StatusRequest(
+ shows = listOf(HistoryMediaObject(ids = ids)),
+ movies = emptyList()
+ ),
+ interceptor = interceptor
+ ).isSuccessful
+ } else {
+ val statusResponse = this.status?.let { setStatus ->
+ val newStatus =
+ SimklListStatusType.entries
+ .firstOrNull { it.value == setStatus }?.originalName
+ ?: SimklListStatusType.Watching.originalName!!
+
+ app.post(
+ "${this.url}/sync/add-to-list",
+ json = StatusRequest(
+ shows = listOf(
+ StatusMediaObject(
+ null,
+ null,
+ ids,
+ newStatus,
+ )
+ ), movies = emptyList()
+ ),
+ interceptor = interceptor
+ ).isSuccessful
+ } ?: true
+
+ val episodeRemovalResponse = removeEpisodes?.let { (seasons, episodes) ->
+ app.post(
+ "${this.url}/sync/history/remove",
+ json = StatusRequest(
+ shows = listOf(
+ HistoryMediaObject(
+ ids = ids,
+ seasons = seasons,
+ episodes = episodes
+ )
+ ),
+ movies = emptyList()
+ ),
+ interceptor = interceptor
+ ).isSuccessful
+ } ?: true
+
+ // You cannot rate if you are planning to watch it.
+ val shouldRate =
+ score != null && status != SimklListStatusType.Planning.value
+ val realScore = if (shouldRate) score else null
+
+ val historyResponse =
+ // Only post if there are episodes or score to upload
+ if (addEpisodes != null || shouldRate) {
+ app.post(
+ "${this.url}/sync/history",
+ json = StatusRequest(
+ shows = listOf(
+ HistoryMediaObject(
+ null,
+ null,
+ ids,
+ addEpisodes?.first,
+ addEpisodes?.second,
+ realScore,
+ realScore?.let { time },
+ )
+ ), movies = emptyList()
+ ),
+ interceptor = interceptor
+ ).isSuccessful
+ } else {
+ true
+ }
+
+ statusResponse && episodeRemovalResponse && historyResponse
+ }
+ }
+ }
+ }
+
+ suspend fun getEpisodes(
+ simklId: Int?,
+ type: String?,
+ episodes: Int?,
+ hasEnded: Boolean?
+ ): Array? {
+ if (simklId == null) return null
+
+ val cacheKey = "Episodes/$simklId"
+ val cache = SimklCache.getKey>(cacheKey)
+
+ // Return cached result if its higher or equal the amount of episodes.
+ if (cache != null && cache.size >= (episodes ?: 0)) {
+ return cache
+ }
+
+ // There is always one season in Anime -> no request necessary
+ if (type == "anime" && episodes != null) {
+ return episodes.takeIf { it > 0 }?.let {
+ (1..it).map { episode ->
+ EpisodeMetadata(
+ null, null, null, episode, null
+ )
+ }.toTypedArray()
+ }
+ }
+ val url = when (type) {
+ "anime" -> "https://api.simkl.com/anime/episodes/$simklId"
+ "tv" -> "https://api.simkl.com/tv/episodes/$simklId"
+ "movie" -> return null
+ else -> return null
+ }
+
+ debugPrint { "Requesting episodes from $url" }
+ return app.get(url, params = mapOf("client_id" to CLIENT_ID))
+ .parsedSafe>()?.also {
+ val cacheTime =
+ if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value
+
+ // 1 Month cache
+ SimklCache.setKey(cacheKey, it, Duration.parse(cacheTime))
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ class HistoryMediaObject(
+ @JsonProperty("title") title: String? = null,
+ @JsonProperty("year") year: Int? = null,
+ @JsonProperty("ids") ids: Ids? = null,
+ @JsonProperty("seasons") seasons: List? = null,
+ @JsonProperty("episodes") episodes: List? = null,
+ @JsonProperty("rating") val rating: Int? = null,
+ @JsonProperty("rated_at") val ratedAt: String? = null,
+ ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes)
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ class RatingMediaObject(
+ @JsonProperty("title") title: String?,
+ @JsonProperty("year") year: Int?,
+ @JsonProperty("ids") ids: Ids?,
+ @JsonProperty("rating") val rating: Int,
+ @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime)
+ ) : MediaObject(title, year, ids)
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ class StatusMediaObject(
+ @JsonProperty("title") title: String?,
+ @JsonProperty("year") year: Int?,
+ @JsonProperty("ids") ids: Ids?,
+ @JsonProperty("to") val to: String,
+ @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime)
+ ) : MediaObject(title, year, ids)
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ data class StatusRequest(
+ @JsonProperty("movies") val movies: List,
+ @JsonProperty("shows") val shows: List
+ )
+
+ /** https://simkl.docs.apiary.io/#reference/sync/get-all-items/get-all-items-in-the-user's-watchlist */
+ data class AllItemsResponse(
+ @JsonProperty("shows")
+ val shows: List = emptyList(),
+ @JsonProperty("anime")
+ val anime: List = emptyList(),
+ @JsonProperty("movies")
+ val movies: List = emptyList(),
+ ) {
+ companion object {
+ fun merge(first: AllItemsResponse?, second: AllItemsResponse?): AllItemsResponse {
+
+ // Replace the first item with the same id, or add the new item
+ fun MutableList.replaceOrAddItem(newItem: T, predicate: (T) -> Boolean) {
+ for (i in this.indices) {
+ if (predicate(this[i])) {
+ this[i] = newItem
+ return
+ }
+ }
+ this.add(newItem)
+ }
+
+ //
+ fun merge(
+ first: List?,
+ second: List?
+ ): List {
+ return (first?.toMutableList() ?: mutableListOf()).apply {
+ second?.forEach { secondShow ->
+ this.replaceOrAddItem(secondShow) {
+ it.getIds().simkl == secondShow.getIds().simkl
+ }
+ }
+ }
+ }
+
+ return AllItemsResponse(
+ merge(first?.shows, second?.shows),
+ merge(first?.anime, second?.anime),
+ merge(first?.movies, second?.movies),
+ )
+ }
+ }
+
+ interface Metadata {
+ val lastWatchedAt: String?
+ val status: String?
+ val userRating: Int?
+ val lastWatched: String?
+ val watchedEpisodesCount: Int?
+ val totalEpisodesCount: Int?
+
+ fun getIds(): ShowMetadata.Show.Ids
+ fun toLibraryItem(): SyncAPI.LibraryItem
+ }
+
+ data class MovieMetadata(
+ @JsonProperty("last_watched_at") override val lastWatchedAt: String?,
+ @JsonProperty("status") override val status: String,
+ @JsonProperty("user_rating") override val userRating: Int?,
+ @JsonProperty("last_watched") override val lastWatched: String?,
+ @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?,
+ @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?,
+ val movie: ShowMetadata.Show
+ ) : Metadata {
+ override fun getIds(): ShowMetadata.Show.Ids {
+ return this.movie.ids
+ }
+
+ override fun toLibraryItem(): SyncAPI.LibraryItem {
+ return SyncAPI.LibraryItem(
+ this.movie.title,
+ "https://simkl.com/tv/${movie.ids.simkl}",
+ movie.ids.simkl.toString(),
+ this.watchedEpisodesCount,
+ this.totalEpisodesCount,
+ this.userRating?.times(10),
+ getUnixTime(lastWatchedAt) ?: 0,
+ "Simkl",
+ TvType.Movie,
+ this.movie.poster?.let { getPosterUrl(it) },
+ null,
+ null,
+ this.movie.year?.toYear(),
+ movie.ids.simkl
+ )
+ }
+ }
+
+ data class ShowMetadata(
+ @JsonProperty("last_watched_at") override val lastWatchedAt: String?,
+ @JsonProperty("status") override val status: String,
+ @JsonProperty("user_rating") override val userRating: Int?,
+ @JsonProperty("last_watched") override val lastWatched: String?,
+ @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?,
+ @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?,
+ @JsonProperty("show") val show: Show
+ ) : Metadata {
+ override fun getIds(): Show.Ids {
+ return this.show.ids
+ }
+
+ override fun toLibraryItem(): SyncAPI.LibraryItem {
+ return SyncAPI.LibraryItem(
+ this.show.title,
+ "https://simkl.com/tv/${show.ids.simkl}",
+ show.ids.simkl.toString(),
+ this.watchedEpisodesCount,
+ this.totalEpisodesCount,
+ this.userRating?.times(10),
+ getUnixTime(lastWatchedAt) ?: 0,
+ "Simkl",
+ TvType.Anime,
+ this.show.poster?.let { getPosterUrl(it) },
+ null,
+ null,
+ this.show.year?.toYear(),
+ show.ids.simkl
+ )
+ }
+
+ data class Show(
+ @JsonProperty("title") val title: String,
+ @JsonProperty("poster") val poster: String?,
+ @JsonProperty("year") val year: Int?,
+ @JsonProperty("ids") val ids: Ids,
+ ) {
+ data class Ids(
+ @JsonProperty("simkl") val simkl: Int,
+ @JsonProperty("slug") val slug: String?,
+ @JsonProperty("imdb") val imdb: String?,
+ @JsonProperty("zap2it") val zap2it: String?,
+ @JsonProperty("tmdb") val tmdb: String?,
+ @JsonProperty("offen") val offen: String?,
+ @JsonProperty("tvdb") val tvdb: String?,
+ @JsonProperty("mal") val mal: String?,
+ @JsonProperty("anidb") val anidb: String?,
+ @JsonProperty("anilist") val anilist: String?,
+ @JsonProperty("traktslug") val traktslug: String?
+ ) {
+ fun matchesId(database: SimklSyncServices, id: String): Boolean {
+ return when (database) {
+ SimklSyncServices.Simkl -> this.simkl == id.toIntOrNull()
+ SimklSyncServices.AniList -> this.anilist == id
+ SimklSyncServices.Mal -> this.mal == id
+ SimklSyncServices.Tmdb -> this.tmdb == id
+ SimklSyncServices.Imdb -> this.imdb == id
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Appends api keys to the requests
+ **/
+ private inner class HeaderInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" }
+ return chain.proceed(
+ chain.request()
+ .newBuilder()
+ .addHeader("Authorization", "Bearer $token")
+ .addHeader("simkl-api-key", CLIENT_ID)
+ .build()
+ )
+ }
+ }
+
+ private suspend fun getUser(): SettingsResponse.User? {
+ return suspendSafeApiCall {
+ app.post("$mainUrl/users/settings", interceptor = interceptor)
+ .parsedSafe()?.user
+ }
+ }
+
+ /**
+ * Useful to get episodes on demand to prevent unnecessary requests.
+ */
+ class SimklEpisodeConstructor(
+ private val simklId: Int?,
+ private val type: String?,
+ private val totalEpisodeCount: Int?,
+ private val hasEnded: Boolean?
+ ) {
+ suspend fun getEpisodes(): Array? {
+ return getEpisodes(simklId, type, totalEpisodeCount, hasEnded)
+ }
+ }
+
+ class SimklSyncStatus(
+ override var status: SyncWatchType,
+ override var score: Int?,
+ val oldScore: Int?,
+ override var watchedEpisodes: Int?,
+ val episodeConstructor: SimklEpisodeConstructor,
+ override var isFavorite: Boolean? = null,
+ override var maxEpisodes: Int? = null,
+ /** Save seen episodes separately to know the change from old to new.
+ * Required to remove seen episodes if count decreases */
+ val oldEpisodes: Int,
+ val oldStatus: String?
+ ) : SyncAPI.AbstractSyncStatus()
+
+ override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
+ val realIds = readIdFromString(id)
+
+ // Key which assumes all ids are the same each time :/
+ // This could be some sort of reference system to make multiple IDs
+ // point to the same key.
+ val idKey =
+ realIds.toList().map { "${it.first.originalName}=${it.second}" }.sorted().joinToString()
+
+ val cachedObject = SimklCache.getKey(idKey)
+ val searchResult: MediaObject = cachedObject
+ ?: (searchByIds(realIds)?.firstOrNull()?.also { result ->
+ val cacheTime =
+ if (result.hasEnded()) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value
+ SimklCache.setKey(idKey, result, Duration.parse(cacheTime))
+ }) ?: return null
+
+ val episodeConstructor = SimklEpisodeConstructor(
+ searchResult.ids?.simkl,
+ searchResult.type,
+ searchResult.totalEpisodes,
+ searchResult.hasEnded()
+ )
+
+ val foundItem = getSyncListSmart()?.let { list ->
+ listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show ->
+ realIds.any { (database, id) ->
+ show.getIds().matchesId(database, id)
+ }
+ }
+ }
+
+ if (foundItem != null) {
+ return SimklSyncStatus(
+ status = foundItem.status?.let {
+ SyncWatchType.fromInternalId(
+ SimklListStatusType.fromString(
+ it
+ )?.value
+ )
+ }
+ ?: return null,
+ score = foundItem.userRating,
+ watchedEpisodes = foundItem.watchedEpisodesCount,
+ maxEpisodes = searchResult.totalEpisodes,
+ episodeConstructor = episodeConstructor,
+ oldEpisodes = foundItem.watchedEpisodesCount ?: 0,
+ oldScore = foundItem.userRating,
+ oldStatus = foundItem.status
+ )
+ } else {
+ return SimklSyncStatus(
+ status = SyncWatchType.fromInternalId(SimklListStatusType.None.value),
+ score = 0,
+ watchedEpisodes = 0,
+ maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes,
+ episodeConstructor = episodeConstructor,
+ oldEpisodes = 0,
+ oldStatus = null,
+ oldScore = null
+ )
+ }
+ }
+
+ override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
+ val parsedId = readIdFromString(id)
+ lastScoreTime = unixTime
+ val simklStatus = status as? SimklSyncStatus
+
+ val builder = SimklScoreBuilder.Builder()
+ .apiUrl(this.mainUrl)
+ .score(status.score, simklStatus?.oldScore)
+ .status(
+ status.status.internalId,
+ (status as? SimklSyncStatus)?.oldStatus?.let { oldStatus ->
+ SimklListStatusType.entries.firstOrNull {
+ it.originalName == oldStatus
+ }?.value
+ })
+ .interceptor(interceptor)
+ .ids(MediaObject.Ids.fromMap(parsedId))
+
+
+ // Get episodes only when required
+ val episodes = simklStatus?.episodeConstructor?.getEpisodes()
+
+ // All episodes if marked as completed
+ val watchedEpisodes = if (status.status.internalId == SimklListStatusType.Completed.value) {
+ episodes?.size
+ } else {
+ status.watchedEpisodes
+ }
+
+ builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes)
+
+ requireLibraryRefresh = true
+ return builder.execute()
+ }
+
+
+ /** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */
+ private suspend fun searchByIds(serviceMap: Map): Array? {
+ if (serviceMap.isEmpty()) return emptyArray()
+
+ return app.get(
+ "$mainUrl/search/id",
+ params = mapOf("client_id" to CLIENT_ID) + serviceMap.map { (service, id) ->
+ service.originalName to id
+ }
+ ).parsedSafe()
+ }
+
+ override suspend fun search(name: String): List? {
+ return app.get(
+ "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name)
+ ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() }
+ }
+
+ override fun authenticate(activity: FragmentActivity?) {
+ lastLoginState = BigInteger(130, SecureRandom()).toString(32)
+ val url =
+ "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}&state=$lastLoginState"
+ openBrowser(url, activity)
+ }
+
+ override fun loginInfo(): AuthAPI.LoginInfo? {
+ return getKey(accountId, SIMKL_USER_KEY)?.let { user ->
+ AuthAPI.LoginInfo(
+ name = user.name,
+ profilePicture = user.avatar,
+ accountIndex = accountIndex
+ )
+ }
+ }
+
+ override fun logOut() {
+ requireLibraryRefresh = true
+ removeAccountKeys()
+ }
+
+ override suspend fun getResult(id: String): SyncAPI.SyncResult? {
+ return null
+ }
+
+ private suspend fun getSyncListSince(since: Long?): AllItemsResponse? {
+ val params = getDateTime(since)?.let {
+ mapOf("date_from" to it)
+ } ?: emptyMap()
+
+ // Can return null on no change.
+ return app.get(
+ "$mainUrl/sync/all-items/",
+ params = params,
+ interceptor = interceptor
+ ).parsedSafe()
+ }
+
+ private suspend fun getActivities(): ActivitiesResponse? {
+ return app.post("$mainUrl/sync/activities", interceptor = interceptor).parsedSafe()
+ }
+
+ private fun getSyncListCached(): AllItemsResponse? {
+ return getKey(accountId, SIMKL_CACHED_LIST)
+ }
+
+ private suspend fun getSyncListSmart(): AllItemsResponse? {
+ if (token == null) return null
+
+ val activities = getActivities()
+ val lastCacheUpdate = getKey(accountId, SIMKL_CACHED_LIST_TIME)
+ val lastRemoval = listOf(
+ activities?.tvShows?.removedFromList,
+ activities?.anime?.removedFromList,
+ activities?.movies?.removedFromList
+ ).maxOf {
+ getUnixTime(it) ?: -1
+ }
+ val lastRealUpdate =
+ listOf(
+ activities?.tvShows?.all,
+ activities?.anime?.all,
+ activities?.movies?.all,
+ ).maxOf {
+ getUnixTime(it) ?: -1
+ }
+
+ debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" }
+ val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) {
+ debugPrint { "Full list update in ${this.name}." }
+ setKey(accountId, SIMKL_CACHED_LIST_TIME, lastRemoval)
+ getSyncListSince(null)
+ } else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) {
+ debugPrint { "Partial list update in ${this.name}." }
+ setKey(accountId, SIMKL_CACHED_LIST_TIME, lastCacheUpdate)
+ AllItemsResponse.merge(getSyncListCached(), getSyncListSince(lastCacheUpdate))
+ } else {
+ debugPrint { "Cached list update in ${this.name}." }
+ getSyncListCached()
+ }
+ debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" }
+
+ setKey(accountId, SIMKL_CACHED_LIST, list)
+
+ return list
+ }
+
+
+ override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? {
+ val list = getSyncListSmart() ?: return null
+
+ val baseMap =
+ SimklListStatusType.entries
+ .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value }
+ .associate {
+ it.stringRes to emptyList()
+ }
+
+ val syncMap = listOf(list.anime, list.movies, list.shows)
+ .flatten()
+ .groupBy {
+ it.status
+ }
+ .mapNotNull { (status, list) ->
+ val stringRes =
+ status?.let { SimklListStatusType.fromString(it)?.stringRes }
+ ?: return@mapNotNull null
+ val libraryList = list.map { it.toLibraryItem() }
+ stringRes to libraryList
+ }.toMap()
+
+ return SyncAPI.LibraryMetadata(
+ (baseMap + syncMap).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf(
+ ListSorting.AlphabeticalA,
+ ListSorting.AlphabeticalZ,
+ ListSorting.UpdatedNew,
+ ListSorting.UpdatedOld,
+ ListSorting.ReleaseDateNew,
+ ListSorting.ReleaseDateOld,
+ ListSorting.RatingHigh,
+ ListSorting.RatingLow,
+ )
+ )
+ }
+
+ override fun getIdFromUrl(url: String): String {
+ val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""")
+ return simklUrlRegex.find(url)?.groupValues?.get(1) ?: ""
+ }
+
+ override suspend fun getDevicePin(): OAuth2API.PinAuthData? {
+ val pinAuthResp = app.get(
+ "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}"
+ ).parsedSafe() ?: return null
+
+ return OAuth2API.PinAuthData(
+ deviceCode = pinAuthResp.deviceCode,
+ userCode = pinAuthResp.userCode,
+ verificationUrl = pinAuthResp.verificationUrl,
+ expiresIn = pinAuthResp.expiresIn,
+ interval = pinAuthResp.interval
+ )
+ }
+
+ override suspend fun handleDeviceAuth(pinAuthData: OAuth2API.PinAuthData): Boolean {
+ val pinAuthResp = app.get(
+ "$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID"
+ ).parsedSafe() ?: return false
+
+ if (pinAuthResp.accessToken != null) {
+ switchToNewAccount()
+ setKey(accountId, SIMKL_TOKEN_KEY, pinAuthResp.accessToken)
+
+ val user = getUser()
+ if (user == null) {
+ removeKey(accountId, SIMKL_TOKEN_KEY)
+ switchToOldAccount()
+ return false
+ }
+
+ setKey(accountId, SIMKL_USER_KEY, user)
+ registerAccount()
+ requireLibraryRefresh = true
+ return true
+ }
+ return false
+ }
+
+ override suspend fun handleRedirect(url: String): Boolean {
+ val uri = url.toUri()
+ val state = uri.getQueryParameter("state")
+ // Ensure consistent state
+ if (state != lastLoginState) return false
+ lastLoginState = ""
+
+ val code = uri.getQueryParameter("code") ?: return false
+ val token = app.post(
+ "$mainUrl/oauth/token", json = TokenRequest(code)
+ ).parsedSafe() ?: return false
+
+ switchToNewAccount()
+ setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken)
+
+ val user = getUser()
+ if (user == null) {
+ removeKey(accountId, SIMKL_TOKEN_KEY)
+ switchToOldAccount()
+ return false
+ }
+
+ setKey(accountId, SIMKL_USER_KEY, user)
+ registerAccount()
+ requireLibraryRefresh = true
+
+ return true
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt
new file mode 100644
index 00000000..8dad1f88
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt
@@ -0,0 +1,159 @@
+package com.lagradost.cloudstream3.syncproviders.providers
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.subtitles.AbstractSubProvider
+import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
+import com.lagradost.cloudstream3.subtitles.SubtitleResource
+import com.lagradost.cloudstream3.utils.AppUtils.parseJson
+import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import com.lagradost.cloudstream3.utils.SubtitleHelper
+
+class SubSourceApi : AbstractSubProvider {
+ override val idPrefix = "subsource"
+ val name = "SubSource"
+
+ companion object {
+ const val APIURL = "https://api.subsource.net/api"
+ const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
+ }
+
+ override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? {
+
+ //Only supports Imdb Id search for now
+ if (query.imdbId == null) return null
+ val queryLang = SubtitleHelper.fromTwoLettersToLanguage(query.lang!!)
+ val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
+
+ val searchRes = app.post(
+ url = "$APIURL/searchMovie",
+ data = mapOf(
+ "query" to query.imdbId!!
+ )
+ ).parsedSafe() ?: return null
+
+ val postData = if (type == TvType.TvSeries) {
+ mapOf(
+ "langs" to "[]",
+ "movieName" to searchRes.found.first().linkName,
+ "season" to "season-${query.seasonNumber}"
+ )
+ } else {
+ mapOf(
+ "langs" to "[]",
+ "movieName" to searchRes.found.first().linkName,
+ )
+ }
+
+ val getMovieRes = app.post(
+ url = "$APIURL/getMovie",
+ data = postData
+ ).parsedSafe().let {
+ // api doesn't has episode number or lang filtering
+ if (type == TvType.Movie) {
+ it?.subs?.filter { sub ->
+ sub.lang == queryLang
+ }
+ } else {
+ it?.subs?.filter { sub ->
+ sub.releaseName!!.contains(
+ String.format(
+ null,
+ "E%02d",
+ query.epNumber
+ )
+ ) && sub.lang == queryLang
+ }
+ }
+ } ?: return null
+
+ return getMovieRes.map { subtitle ->
+ AbstractSubtitleEntities.SubtitleEntity(
+ idPrefix = this.idPrefix,
+ name = subtitle.releaseName!!,
+ lang = subtitle.lang!!,
+ data = SubData(
+ movie = subtitle.linkName!!,
+ lang = subtitle.lang,
+ id = subtitle.subId.toString(),
+ ).toJson(),
+ type = type,
+ source = this.name,
+ epNumber = query.epNumber,
+ seasonNumber = query.seasonNumber,
+ isHearingImpaired = subtitle.hi == 1,
+ )
+ }
+ }
+
+ override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
+
+ val parsedSub = parseJson(data.data)
+
+ val subRes = app.post(
+ url = "$APIURL/getSub",
+ data = mapOf(
+ "movie" to parsedSub.movie,
+ "lang" to data.lang,
+ "id" to parsedSub.id
+ )
+ ).parsedSafe() ?: return
+
+ this.addZipUrl(
+ "$DOWNLOADENDPOINT/${subRes.sub.downloadToken}"
+ ) { name, _ ->
+ name
+ }
+ }
+
+ data class ApiSearch(
+ @JsonProperty("success") val success: Boolean,
+ @JsonProperty("found") val found: List,
+ )
+
+ data class Found(
+ @JsonProperty("id") val id: Long,
+ @JsonProperty("title") val title: String,
+ @JsonProperty("seasons") val seasons: Long,
+ @JsonProperty("type") val type: String,
+ @JsonProperty("releaseYear") val releaseYear: Long,
+ @JsonProperty("linkName") val linkName: String,
+ )
+
+ data class ApiResponse(
+ @JsonProperty("success") val success: Boolean,
+ @JsonProperty("movie") val movie: Movie,
+ @JsonProperty("subs") val subs: List,
+ )
+
+ data class Movie(
+ @JsonProperty("id") val id: Long? = null,
+ @JsonProperty("type") val type: String? = null,
+ @JsonProperty("year") val year: Long? = null,
+ @JsonProperty("fullName") val fullName: String? = null,
+ )
+
+ data class Sub(
+ @JsonProperty("hi") val hi: Int? = null,
+ @JsonProperty("fullLink") val fullLink: String? = null,
+ @JsonProperty("linkName") val linkName: String? = null,
+ @JsonProperty("lang") val lang: String? = null,
+ @JsonProperty("releaseName") val releaseName: String? = null,
+ @JsonProperty("subId") val subId: Long? = null,
+ )
+
+ data class SubData(
+ @JsonProperty("movie") val movie: String,
+ @JsonProperty("lang") val lang: String,
+ @JsonProperty("id") val id: String,
+ )
+
+ data class SubTitleLink(
+ @JsonProperty("sub") val sub: SubToken,
+ )
+
+ data class SubToken(
+ @JsonProperty("downloadToken") val downloadToken: String,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt
new file mode 100644
index 00000000..29544e65
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt
@@ -0,0 +1,247 @@
+package com.lagradost.cloudstream3.syncproviders.providers
+
+import com.fasterxml.jackson.annotation.JsonProperty
+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.ErrorLoadingException
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.subtitles.AbstractSubApi
+import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
+import com.lagradost.cloudstream3.subtitles.SubtitleResource
+import com.lagradost.cloudstream3.syncproviders.AuthAPI.LoginInfo
+import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
+import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
+
+class SubDlApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
+ override val idPrefix = "subdl"
+ override val name = "SubDL"
+ override val icon = R.drawable.subdl_logo_big
+ override val requiresPassword = true
+ override val requiresEmail = true
+ override val createAccountUrl = "https://subdl.com/login"
+
+ companion object {
+ const val APIURL = "https://api.subdl.com"
+ const val APIENDPOINT = "$APIURL/api/v1/subtitles"
+ const val DOWNLOADENDPOINT = "https://dl.subdl.com"
+ const val SUBDL_SUBTITLES_USER_KEY: String = "subdl_user"
+ var currentSession: SubtitleOAuthEntity? = null
+ }
+
+ override suspend fun initialize() {
+ currentSession = getAuthKey()
+ }
+
+ override fun logOut() {
+ setAuthKey(null)
+ removeAccountKeys()
+ currentSession = getAuthKey()
+ }
+ override suspend fun login(data: InAppAuthAPI.LoginData): Boolean {
+ val email = data.email ?: throw ErrorLoadingException("Requires Email")
+ val password = data.password ?: throw ErrorLoadingException("Requires Password")
+ switchToNewAccount()
+ try {
+ if (initLogin(email, password)) {
+ registerAccount()
+ return true
+ }
+ } catch (e: Exception) {
+ logError(e)
+ switchToOldAccount()
+ }
+ switchToOldAccount()
+ return false
+ }
+
+ override fun getLatestLoginData(): InAppAuthAPI.LoginData? {
+ val current = getAuthKey() ?: return null
+ return InAppAuthAPI.LoginData(
+ email = current.userEmail,
+ password = current.pass
+ )
+ }
+
+ override fun loginInfo(): LoginInfo? {
+ getAuthKey()?.let { user ->
+ return LoginInfo(
+ profilePicture = null,
+ name = user.name ?: user.userEmail,
+ accountIndex = accountIndex
+ )
+ }
+ return null
+ }
+
+ override suspend fun search(query: AbstractSubtitleEntities.SubtitleSearch): List? {
+
+ val queryText = query.query
+ val epNum = query.epNumber ?: 0
+ val seasonNum = query.seasonNumber ?: 0
+ val yearNum = query.year ?: 0
+
+ val idQuery = when {
+ query.imdbId != null -> "&imdb_id=${query.imdbId}"
+ query.tmdbId != null -> "&tmdb_id=${query.tmdbId}"
+ else -> null
+ }
+
+ val epQuery = if (epNum > 0) "&episode_number=$epNum" else ""
+ val seasonQuery = if (seasonNum > 0) "&season_number=$seasonNum" else ""
+ val yearQuery = if (yearNum > 0) "&year=$yearNum" else ""
+
+ val searchQueryUrl = when (idQuery) {
+ //Use imdb/tmdb id to search if its valid
+ null -> "$APIENDPOINT?api_key=${currentSession?.apiKey}&film_name=$queryText&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
+ else -> "$APIENDPOINT?api_key=${currentSession?.apiKey}$idQuery&languages=${query.lang}$epQuery$seasonQuery$yearQuery"
+ }
+
+ val req = app.get(
+ url = searchQueryUrl,
+ headers = mapOf(
+ "Accept" to "application/json"
+ )
+ )
+
+ return req.parsedSafe()?.subtitles?.map { subtitle ->
+
+ val lang = subtitle.lang.replaceFirstChar { it.uppercase() }
+ val resEpNum = subtitle.episode ?: query.epNumber
+ val resSeasonNum = subtitle.season ?: query.seasonNumber
+ val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie
+
+ AbstractSubtitleEntities.SubtitleEntity(
+ idPrefix = this.idPrefix,
+ name = subtitle.releaseName,
+ lang = lang,
+ data = "${DOWNLOADENDPOINT}${subtitle.url}",
+ type = type,
+ source = this.name,
+ epNumber = resEpNum,
+ seasonNumber = resSeasonNum,
+ isHearingImpaired = subtitle.hearingImpaired ?: false,
+ )
+ }
+ }
+
+ override suspend fun SubtitleResource.getResources(data: AbstractSubtitleEntities.SubtitleEntity) {
+ this.addZipUrl(data.data) { name, _ ->
+ name
+ }
+ }
+
+ private suspend fun initLogin(useremail: String, password: String): Boolean {
+
+ val tokenResponse = app.post(
+ url = "$APIURL/login",
+ data = mapOf(
+ "email" to useremail,
+ "password" to password
+ )
+ ).parsedSafe()
+
+ if (tokenResponse?.token == null) return false
+
+ val apiResponse = app.get(
+ url = "$APIURL/user/userApi",
+ headers = mapOf(
+ "Authorization" to "Bearer ${tokenResponse.token}"
+ )
+ ).parsedSafe()
+
+ if (apiResponse?.ok == false) return false
+
+ setAuthKey(
+ SubtitleOAuthEntity(
+ userEmail = useremail,
+ pass = password,
+ name = tokenResponse.userData?.username ?: tokenResponse.userData?.name,
+ accessToken = tokenResponse.token,
+ apiKey = apiResponse?.apiKey
+ )
+ )
+ return true
+ }
+
+ private fun getAuthKey(): SubtitleOAuthEntity? {
+ return getKey(accountId, SUBDL_SUBTITLES_USER_KEY)
+ }
+
+ private fun setAuthKey(data: SubtitleOAuthEntity?) {
+ if (data == null) removeKey(
+ accountId,
+ SUBDL_SUBTITLES_USER_KEY
+ )
+ currentSession = data
+ setKey(accountId, SUBDL_SUBTITLES_USER_KEY, data)
+ }
+
+ data class SubtitleOAuthEntity(
+ @JsonProperty("userEmail") var userEmail: String,
+ @JsonProperty("pass") var pass: String,
+ @JsonProperty("name") var name: String? = null,
+ @JsonProperty("accessToken") var accessToken: String? = null,
+ @JsonProperty("apiKey") var apiKey: String? = null,
+ )
+
+ data class OAuthTokenResponse(
+ @JsonProperty("token") val token: String? = null,
+ @JsonProperty("userData") val userData: UserData? = null,
+ @JsonProperty("status") val status: Boolean? = null,
+ @JsonProperty("message") val message: String? = null,
+ )
+
+ data class UserData(
+ @JsonProperty("email") val email: String,
+ @JsonProperty("name") val name: String,
+ @JsonProperty("country") val country: String,
+ @JsonProperty("scStepCode") val scStepCode: String,
+ @JsonProperty("scVerified") val scVerified: Boolean,
+ @JsonProperty("username") val username: String? = null,
+ @JsonProperty("scUsername") val scUsername: String,
+ )
+
+ data class ApiKeyResponse(
+ @JsonProperty("ok") val ok: Boolean? = false,
+ @JsonProperty("api_key") val apiKey: String? = null,
+ @JsonProperty("usage") val usage: Usage? = null,
+ )
+
+ data class Usage(
+ @JsonProperty("total") val total: Long? = 0,
+ @JsonProperty("today") val today: Long? = 0,
+ )
+
+ data class ApiResponse(
+ @JsonProperty("status") val status: Boolean? = null,
+ @JsonProperty("results") val results: List? = null,
+ @JsonProperty("subtitles") val subtitles: List? = null,
+ )
+
+ data class Result(
+ @JsonProperty("sd_id") val sdId: Int? = null,
+ @JsonProperty("type") val type: String? = null,
+ @JsonProperty("name") val name: String? = null,
+ @JsonProperty("imdb_id") val imdbId: String? = null,
+ @JsonProperty("tmdb_id") val tmdbId: Long? = null,
+ @JsonProperty("first_air_date") val firstAirDate: String? = null,
+ @JsonProperty("year") val year: Int? = null,
+ )
+
+ data class Subtitle(
+ @JsonProperty("release_name") val releaseName: String,
+ @JsonProperty("name") val name: String,
+ @JsonProperty("lang") val lang: String,
+ @JsonProperty("author") val author: String? = null,
+ @JsonProperty("url") val url: String? = null,
+ @JsonProperty("subtitlePage") val subtitlePage: String? = null,
+ @JsonProperty("season") val season: Int? = null,
+ @JsonProperty("episode") val episode: Int? = null,
+ @JsonProperty("language") val language: String? = null,
+ @JsonProperty("hi") val hearingImpaired: Boolean? = null,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt
index 4ab2e8e2..9150cfc5 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt
@@ -1,16 +1,24 @@
package com.lagradost.cloudstream3.ui
-import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
+import com.lagradost.cloudstream3.DubStatus
+import com.lagradost.cloudstream3.ErrorLoadingException
+import com.lagradost.cloudstream3.HomePageResponse
+import com.lagradost.cloudstream3.LoadResponse
+import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
+import com.lagradost.cloudstream3.MainPageRequest
+import com.lagradost.cloudstream3.SearchResponse
+import com.lagradost.cloudstream3.SubtitleFile
+import com.lagradost.cloudstream3.TvType
+import com.lagradost.cloudstream3.fixUrl
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.ExtractorLink
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope.coroutineContext
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
@@ -42,7 +50,7 @@ class APIRepository(val api: MainAPI) {
private val cache = threadSafeListOf()
private var cacheIndex: Int = 0
- const val cacheSize = 20
+ const val CACHE_SIZE = 20
}
private fun afterPluginsLoaded(forceReload: Boolean) {
@@ -86,9 +94,9 @@ class APIRepository(val api: MainAPI) {
val add = SavedLoadResponse(unixTime, response, lookingForHash)
synchronized(cache) {
- if (cache.size > cacheSize) {
+ if (cache.size > CACHE_SIZE) {
cache[cacheIndex] = add // rolling cache
- cacheIndex = (cacheIndex + 1) % cacheSize
+ cacheIndex = (cacheIndex + 1) % CACHE_SIZE
} else {
cache.add(add)
}
@@ -174,7 +182,7 @@ class APIRepository(val api: MainAPI) {
data: String,
isCasting: Boolean,
subtitleCallback: (SubtitleFile) -> Unit,
- callback: (ExtractorLink) -> Unit
+ callback: (ExtractorLink) -> Unit,
): Boolean {
if (isInvalidData(data)) return false // this makes providers cleaner
return try {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt
new file mode 100644
index 00000000..e930961c
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt
@@ -0,0 +1,252 @@
+package com.lagradost.cloudstream3.ui
+
+import android.view.View
+import android.view.ViewGroup
+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.AsyncListDiffer
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import androidx.viewbinding.ViewBinding
+import java.util.concurrent.CopyOnWriteArrayList
+
+open class ViewHolderState(val view: ViewBinding) : ViewHolder(view.root) {
+ open fun save(): T? = null
+ open fun restore(state: T) = Unit
+ open fun onViewAttachedToWindow() = Unit
+ open fun onViewDetachedFromWindow() = Unit
+ open fun onViewRecycled() = Unit
+}
+
+
+// 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() {
+ val layoutManagerStates = hashMapOf>()
+}
+
+abstract class NoStateAdapter(fragment: Fragment) : BaseAdapter(fragment, 0)
+
+/**
+ * BaseAdapter is a persistent state stored adapter that supports headers and footers.
+ * This should be used for restoring eg scroll or focus related to a view when it is recreated.
+ *
+ * Id is a per fragment based unique id used to store the underlying data done in an internal ViewModel.
+ *
+ * diffCallback is how the view should be handled when updating, override onUpdateContent for updates
+ *
+ * NOTE:
+ *
+ * By default it should save automatically, but you can also call save(recycle)
+ *
+ * By default no state is stored, but doing an id != 0 will store
+ *
+ * By default no headers or footers exist, override footers and headers count
+ */
+abstract class BaseAdapter<
+ T : Any,
+ S : Any>(
+ fragment: Fragment,
+ val id: Int = 0,
+ diffCallback: DiffUtil.ItemCallback = BaseDiffCallback()
+) : RecyclerView.Adapter>() {
+ open val footers: Int = 0
+ open val headers: Int = 0
+
+ fun getItem(position: Int): T {
+ return mDiffer.currentList[position]
+ }
+
+ fun getItemOrNull(position: Int): T? {
+ return mDiffer.currentList.getOrNull(position)
+ }
+
+ private val mDiffer: AsyncListDiffer = AsyncListDiffer(
+ object : NonFinalAdapterListUpdateCallback(this) {
+ override fun onMoved(fromPosition: Int, toPosition: Int) {
+ super.onMoved(fromPosition + headers, toPosition + headers)
+ }
+
+ override fun onRemoved(position: Int, count: Int) {
+ super.onRemoved(position + headers, count)
+ }
+
+ override fun onChanged(position: Int, count: Int, payload: Any?) {
+ super.onChanged(position + headers, count, payload)
+ }
+
+ override fun onInserted(position: Int, count: Int) {
+ super.onInserted(position + headers, count)
+ }
+ },
+ AsyncDifferConfig.Builder(diffCallback).build()
+ )
+
+ open fun submitList(list: List?) {
+ // deep copy at least the top list, because otherwise adapter can go crazy
+ mDiffer.submitList(list?.let { CopyOnWriteArrayList(it) })
+ }
+
+ override fun getItemCount(): Int {
+ return mDiffer.currentList.size + footers + headers
+ }
+
+ open fun onUpdateContent(holder: ViewHolderState, item: T, position: Int) =
+ onBindContent(holder, item, position)
+
+ open fun onBindContent(holder: ViewHolderState, item: T, position: Int) = Unit
+ open fun onBindFooter(holder: ViewHolderState) = Unit
+ open fun onBindHeader(holder: ViewHolderState) = Unit
+ open fun onCreateContent(parent: ViewGroup): ViewHolderState = throw NotImplementedError()
+ open fun onCreateFooter(parent: ViewGroup): ViewHolderState = throw NotImplementedError()
+ open fun onCreateHeader(parent: ViewGroup): ViewHolderState = throw NotImplementedError()
+
+ override fun onViewAttachedToWindow(holder: ViewHolderState) {
+ holder.onViewAttachedToWindow()
+ }
+
+ override fun onViewDetachedFromWindow(holder: ViewHolderState) {
+ holder.onViewDetachedFromWindow()
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ fun save(recyclerView: RecyclerView) {
+ for (child in recyclerView.children) {
+ val holder =
+ recyclerView.findContainingViewHolder(child) as? ViewHolderState ?: continue
+ setState(holder)
+ }
+ }
+
+ fun clear() {
+ stateViewModel.layoutManagerStates[id]?.clear()
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun getState(holder: ViewHolderState): S? =
+ stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S
+
+ private fun setState(holder: ViewHolderState) {
+ if(id == 0) return
+
+ if (!stateViewModel.layoutManagerStates.contains(id)) {
+ stateViewModel.layoutManagerStates[id] = HashMap()
+ }
+ stateViewModel.layoutManagerStates[id]?.let { map ->
+ map[holder.absoluteAdapterPosition] = holder.save()
+ }
+ }
+
+ private val attachListener = object : View.OnAttachStateChangeListener {
+ override fun onViewAttachedToWindow(v: View) = Unit
+ override fun onViewDetachedFromWindow(v: View) {
+ if (v !is RecyclerView) return
+ save(v)
+ }
+ }
+
+ final override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
+ recyclerView.addOnAttachStateChangeListener(attachListener)
+ super.onAttachedToRecyclerView(recyclerView)
+ }
+
+ final override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
+ recyclerView.removeOnAttachStateChangeListener(attachListener)
+ super.onDetachedFromRecyclerView(recyclerView)
+ }
+
+ final override fun getItemViewType(position: Int): Int {
+ if (position < headers) {
+ return HEADER
+ }
+ if (position - headers >= mDiffer.currentList.size) {
+ return FOOTER
+ }
+
+ return CONTENT
+ }
+
+ private val stateViewModel: StateViewModel by fragment.viewModels()
+
+ final override fun onViewRecycled(holder: ViewHolderState) {
+ setState(holder)
+ holder.onViewRecycled()
+ super.onViewRecycled(holder)
+ }
+
+ final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState {
+ return when (viewType) {
+ CONTENT -> onCreateContent(parent)
+ HEADER -> onCreateHeader(parent)
+ FOOTER -> onCreateFooter(parent)
+ else -> throw NotImplementedError()
+ }
+ }
+
+ // https://medium.com/@domen.lanisnik/efficiently-updating-recyclerview-items-using-payloads-1305f65f3068
+ override fun onBindViewHolder(
+ holder: ViewHolderState,
+ position: Int,
+ payloads: MutableList
+ ) {
+ if (payloads.isEmpty()) {
+ super.onBindViewHolder(holder, position, payloads)
+ return
+ }
+ when (getItemViewType(position)) {
+ CONTENT -> {
+ val realPosition = position - headers
+ val item = getItem(realPosition)
+ onUpdateContent(holder, item, realPosition)
+ }
+
+ FOOTER -> {
+ onBindFooter(holder)
+ }
+
+ HEADER -> {
+ onBindHeader(holder)
+ }
+ }
+ }
+
+ final override fun onBindViewHolder(holder: ViewHolderState, position: Int) {
+ when (getItemViewType(position)) {
+ CONTENT -> {
+ val realPosition = position - headers
+ val item = getItem(realPosition)
+ onBindContent(holder, item, realPosition)
+ }
+
+ FOOTER -> {
+ onBindFooter(holder)
+ }
+
+ HEADER -> {
+ onBindHeader(holder)
+ }
+ }
+
+ getState(holder)?.let { state ->
+ holder.restore(state)
+ }
+ }
+
+ companion object {
+ private const val HEADER: Int = 1
+ private const val FOOTER: Int = 2
+ private const val CONTENT: Int = 0
+ }
+}
+
+class BaseDiffCallback(
+ val itemSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() },
+ val contentSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() }
+) : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem)
+ override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem)
+ override fun getChangePayload(oldItem: T, newItem: T): Any = Any()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt
index 46ddce09..1eaac505 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt
@@ -6,9 +6,10 @@ import android.view.Menu
import android.view.View.*
import android.widget.*
import androidx.appcompat.app.AlertDialog
+import androidx.media3.common.util.UnstableApi
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
-import com.fasterxml.jackson.module.kotlin.KotlinModule
+import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.cast.MediaSeekOptions
import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF
@@ -23,12 +24,13 @@ import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall
-import com.lagradost.cloudstream3.sortSubs
import com.lagradost.cloudstream3.sortUrls
+import com.lagradost.cloudstream3.ui.player.LoadType
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment
+import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks
import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo
@@ -97,7 +99,7 @@ data class MetadataHolder(
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
UIController() {
- private val mapper: JsonMapper = JsonMapper.builder().addModule(KotlinModule())
+ private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
init {
@@ -262,6 +264,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
var isLoadingMore = false
+
override fun onMediaStatusUpdated() {
super.onMediaStatusUpdated()
val meta = getCurrentMetaData()
@@ -294,7 +297,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val generator = RepoLinkGenerator(listOf(epData))
val isSuccessful = safeApiCall {
- generator.generateLinks(clearCache = false, isCasting = true,
+ generator.generateLinks(
+ clearCache = false, type = LoadType.Chromecast,
callback = {
it.first?.let { link ->
currentLinks.add(link)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt
similarity index 78%
rename from app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt
rename to app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt
index 28ced48c..78ad2a6b 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/AutofitRecyclerView.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt
@@ -3,12 +3,13 @@ package com.lagradost.cloudstream3.ui
import android.content.Context
import android.util.AttributeSet
import android.view.View
+import androidx.core.view.children
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
-class GrdLayoutManager(val context: Context, _spanCount: Int) :
- GridLayoutManager(context, _spanCount) {
+class GrdLayoutManager(val context: Context, spanCount: Int) :
+ GridLayoutManager(context, spanCount) {
override fun onFocusSearchFailed(
focused: View,
focusDirection: Int,
@@ -70,8 +71,8 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
val orientation = this.orientation
// fixes arabic by inverting left and right layout focus
- val correctDirection = if(this.isLayoutRTL) {
- when(direction) {
+ val correctDirection = if (this.isLayoutRTL) {
+ when (direction) {
View.FOCUS_RIGHT -> View.FOCUS_LEFT
View.FOCUS_LEFT -> View.FOCUS_RIGHT
else -> direction
@@ -83,12 +84,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
View.FOCUS_DOWN -> {
return spanCount
}
+
View.FOCUS_UP -> {
return -spanCount
}
+
View.FOCUS_RIGHT -> {
return 1
}
+
View.FOCUS_LEFT -> {
return -1
}
@@ -98,12 +102,15 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
View.FOCUS_DOWN -> {
return 1
}
+
View.FOCUS_UP -> {
return -1
}
+
View.FOCUS_RIGHT -> {
return spanCount
}
+
View.FOCUS_LEFT -> {
return -spanCount
}
@@ -155,4 +162,32 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
layoutManager = manager
}
+}
+
+/**
+ * Recyclerview wherein the max item width or height is set by the biggest view to prevent inconsistent view sizes.
+ */
+class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) {
+ private var biggestObserved: Int = 0
+ private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation
+ private val isHorizontal = orientation == HORIZONTAL
+ private fun View.updateMaxSize() {
+ if (isHorizontal) {
+ this.minimumHeight = biggestObserved
+ } else {
+ this.minimumWidth = biggestObserved
+ }
+ }
+
+ override fun onChildAttachedToWindow(child: View) {
+ child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
+ val observed = if (isHorizontal) child.measuredHeight else child.measuredWidth
+ if (observed > biggestObserved) {
+ biggestObserved = observed
+ children.forEach { it.updateMaxSize() }
+ } else {
+ child.updateMaxSize()
+ }
+ super.onChildAttachedToWindow(child)
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt
index c7041776..4879d2e0 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt
@@ -51,7 +51,7 @@ class EasterEggMonke : AppCompatActivity() {
FrameLayout.LayoutParams.WRAP_CONTENT)
binding.frame.addView(newStar)
- newStar.scaleX = Math.random().toFloat() * 1.5f + newStar.scaleX
+ newStar.scaleX += Math.random().toFloat() * 1.5f
newStar.scaleY = newStar.scaleX
starW *= newStar.scaleX
starH *= newStar.scaleY
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt
new file mode 100644
index 00000000..12a5ae2a
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt
@@ -0,0 +1,39 @@
+package com.lagradost.cloudstream3.ui
+
+import android.annotation.SuppressLint
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListUpdateCallback
+import androidx.recyclerview.widget.RecyclerView
+
+
+/**
+ * ListUpdateCallback that dispatches update events to the given adapter.
+ *
+ * @see DiffUtil.DiffResult.dispatchUpdatesTo
+ */
+open class NonFinalAdapterListUpdateCallback
+/**
+ * Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter.
+ *
+ * @param mAdapter The Adapter to send updates to.
+ */(private var mAdapter: RecyclerView.Adapter<*>) :
+ ListUpdateCallback {
+
+ override fun onInserted(position: Int, count: Int) {
+ mAdapter.notifyItemRangeInserted(position, count)
+ }
+
+ override fun onRemoved(position: Int, count: Int) {
+ mAdapter.notifyItemRangeRemoved(position, count)
+ }
+
+ override fun onMoved(fromPosition: Int, toPosition: Int) {
+ mAdapter.notifyItemMoved(fromPosition, toPosition)
+ }
+
+ @SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly
+ override fun onChanged(position: Int, count: Int, payload: Any?) {
+ mAdapter.notifyItemRangeChanged(position, count, payload)
+ }
+}
+
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt
index eb4eb666..b778ba5a 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt
@@ -13,6 +13,29 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab
NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24);
companion object {
- fun fromInternalId(id: Int?) = values().find { value -> value.internalId == id } ?: NONE
+ fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE
}
-}
\ No newline at end of file
+}
+
+enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) {
+ /*
+ -1 -> None
+ 0 -> Watching
+ 1 -> Completed
+ 2 -> OnHold
+ 3 -> Dropped
+ 4 -> PlanToWatch
+ 5 -> ReWatching
+ */
+ NONE(-1, R.string.type_none, R.drawable.ic_baseline_add_24),
+ WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24),
+ COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24),
+ ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24),
+ DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24),
+ PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24),
+ REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24);
+
+ companion object {
+ fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE
+ }
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt
index 9ed58e2c..5e2b97e5 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt
@@ -8,14 +8,16 @@ import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
+import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
+import androidx.media3.common.util.UnstableApi
import androidx.navigation.fragment.findNavController
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
import com.lagradost.cloudstream3.network.WebViewResolver
-import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository
class WebviewFragment : Fragment() {
@@ -29,6 +31,7 @@ class WebviewFragment : Fragment() {
}
binding?.webView?.webViewClient = object : WebViewClient() {
+ @OptIn(UnstableApi::class)
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt
new file mode 100644
index 00000000..de0b5c05
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt
@@ -0,0 +1,200 @@
+package com.lagradost.cloudstream3.ui.account
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewbinding.ViewBinding
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding
+import com.lagradost.cloudstream3.databinding.AccountListItemBinding
+import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding
+import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog
+import com.lagradost.cloudstream3.ui.result.setImage
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.UIHelper.setImage
+
+class AccountAdapter(
+ private val accounts: List,
+ private val accountSelectCallback: (DataStoreHelper.Account) -> Unit,
+ private val accountCreateCallback: (DataStoreHelper.Account) -> Unit,
+ private val accountEditCallback: (DataStoreHelper.Account) -> Unit,
+ private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit
+) : RecyclerView.Adapter() {
+
+ companion object {
+ const val VIEW_TYPE_SELECT_ACCOUNT = 0
+ const val VIEW_TYPE_ADD_ACCOUNT = 1
+ const val VIEW_TYPE_EDIT_ACCOUNT = 2
+ }
+
+ inner class AccountViewHolder(private val binding: ViewBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(account: DataStoreHelper.Account?) {
+ when (binding) {
+ is AccountListItemBinding -> binding.apply {
+ if (account == null) return@apply
+
+ val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
+
+ val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
+
+ accountName.text = account.name
+ accountImage.setImage(account.image)
+ lockIcon.isVisible = account.lockPin != null
+ outline.isVisible = !isTv && isLastUsedAccount
+
+ if (isTv) {
+ // For emulator but this is fine on TV also
+ root.isFocusableInTouchMode = true
+ if (isLastUsedAccount) {
+ root.requestFocus()
+ }
+
+ root.foreground = ContextCompat.getDrawable(
+ root.context,
+ R.drawable.outline_drawable
+ )
+ } else {
+ root.setOnLongClickListener {
+ showAccountEditDialog(
+ context = root.context,
+ account = account,
+ isNewAccount = false,
+ accountEditCallback = { account -> accountEditCallback.invoke(account) },
+ accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
+ )
+
+ true
+ }
+ }
+
+ root.setOnClickListener {
+ accountSelectCallback.invoke(account)
+ }
+ }
+
+ is AccountListItemEditBinding -> binding.apply {
+ if (account == null) return@apply
+
+ val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode
+
+ val isLastUsedAccount = account.keyIndex == DataStoreHelper.selectedKeyIndex
+
+ accountName.text = account.name
+ accountImage.setImage(
+ account.image,
+ fadeIn = false,
+ radius = 10
+ )
+ lockIcon.isVisible = account.lockPin != null
+ outline.isVisible = !isTv && isLastUsedAccount
+
+ if (isTv) {
+ // For emulator but this is fine on TV also
+ root.isFocusableInTouchMode = true
+ if (isLastUsedAccount) {
+ root.requestFocus()
+ }
+
+ root.foreground = ContextCompat.getDrawable(
+ root.context,
+ R.drawable.outline_drawable
+ )
+ }
+
+ root.setOnClickListener {
+ showAccountEditDialog(
+ context = root.context,
+ account = account,
+ isNewAccount = false,
+ accountEditCallback = { account -> accountEditCallback.invoke(account) },
+ accountDeleteCallback = { account -> accountDeleteCallback.invoke(account) }
+ )
+ }
+ }
+
+ is AccountListItemAddBinding -> binding.apply {
+ root.setOnClickListener {
+ val remainingImages =
+ DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null }
+ .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) }.toSet()
+
+ val image =
+ DataStoreHelper.profileImages.indexOf(remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random())
+ val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1
+
+ val accountName = root.context.getString(R.string.account)
+
+ showAccountEditDialog(
+ root.context,
+ DataStoreHelper.Account(
+ keyIndex = keyIndex,
+ name = "$accountName $keyIndex",
+ customImage = null,
+ defaultImageIndex = image
+ ),
+ isNewAccount = true,
+ accountEditCallback = { account -> accountCreateCallback.invoke(account) },
+ accountDeleteCallback = {}
+ )
+ }
+ }
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder =
+ AccountViewHolder(
+ binding = when (viewType) {
+ VIEW_TYPE_SELECT_ACCOUNT -> {
+ AccountListItemBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ }
+ VIEW_TYPE_ADD_ACCOUNT -> {
+ AccountListItemAddBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ }
+ VIEW_TYPE_EDIT_ACCOUNT -> {
+ AccountListItemEditBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ }
+ else -> throw IllegalArgumentException("Invalid view type")
+ }
+ )
+
+ 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt
new file mode 100644
index 00000000..d2aca862
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt
@@ -0,0 +1,356 @@
+package com.lagradost.cloudstream3.ui.account
+
+import android.app.Activity
+import android.content.Context
+import android.content.DialogInterface
+import android.content.Intent
+import android.text.Editable
+import android.view.LayoutInflater
+import android.view.inputmethod.EditorInfo
+import android.widget.TextView
+import androidx.annotation.StringRes
+import androidx.appcompat.app.AlertDialog
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.core.widget.doOnTextChanged
+import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
+import com.lagradost.cloudstream3.MainActivity
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding
+import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding
+import com.lagradost.cloudstream3.databinding.LockPinDialogBinding
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.mvvm.observe
+import com.lagradost.cloudstream3.ui.result.setImage
+import com.lagradost.cloudstream3.ui.result.setLinearListLayout
+import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
+import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
+import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
+
+object AccountHelper {
+ fun showAccountEditDialog(
+ context: Context,
+ account: DataStoreHelper.Account,
+ isNewAccount: Boolean,
+ accountEditCallback: (DataStoreHelper.Account) -> Unit,
+ accountDeleteCallback: (DataStoreHelper.Account) -> Unit
+ ) {
+ val binding = AccountEditDialogBinding.inflate(LayoutInflater.from(context), null, false)
+ val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom)
+ .setView(binding.root)
+
+ var currentEditAccount = account
+ val dialog = builder.show()
+
+ if (!isNewAccount) binding.title.setText(R.string.edit_account)
+
+ // Set up the dialog content
+ binding.accountName.text = Editable.Factory.getInstance()?.newEditable(account.name)
+ binding.accountName.doOnTextChanged { text, _, _, _ ->
+ currentEditAccount = currentEditAccount.copy(name = text?.toString() ?: "")
+ }
+
+ binding.deleteBtt.isGone = isNewAccount
+ binding.deleteBtt.setOnClickListener {
+ val dialogClickListener = DialogInterface.OnClickListener { _, which ->
+ when (which) {
+ DialogInterface.BUTTON_POSITIVE -> {
+ accountDeleteCallback.invoke(account)
+ dialog?.dismissSafe()
+ }
+
+ DialogInterface.BUTTON_NEGATIVE -> {
+ dialog?.dismissSafe()
+ }
+ }
+ }
+
+ try {
+ AlertDialog.Builder(context).setTitle(R.string.delete).setMessage(
+ context.getString(R.string.delete_message).format(
+ currentEditAccount.name
+ )
+ )
+ .setPositiveButton(R.string.delete, dialogClickListener)
+ .setNegativeButton(R.string.cancel, dialogClickListener)
+ .show().setDefaultFocus()
+ } catch (t: Throwable) {
+ logError(t)
+ }
+ }
+
+ binding.cancelBtt.setOnClickListener {
+ dialog?.dismissSafe()
+ }
+
+ // Handle the profile picture and its interactions
+ binding.accountImage.setImage(account.image)
+ binding.accountImage.setOnClickListener {
+ // Roll the image forwards once
+ currentEditAccount =
+ currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size)
+ binding.accountImage.setImage(currentEditAccount.image)
+ }
+
+ // Handle applying changes
+ binding.applyBtt.setOnClickListener {
+ if (currentEditAccount.lockPin != null) {
+ // Ask for the current PIN
+ showPinInputDialog(context, currentEditAccount.lockPin, false) { pin ->
+ if (pin == null) return@showPinInputDialog
+ // PIN is correct, proceed to update the account
+ accountEditCallback.invoke(currentEditAccount)
+ dialog.dismissSafe()
+ }
+ } else {
+ // No lock PIN set, proceed to update the account
+ accountEditCallback.invoke(currentEditAccount)
+ dialog.dismissSafe()
+ }
+ }
+
+ // Handle setting or changing the PIN
+ if (currentEditAccount.keyIndex == getDefaultAccount(context).keyIndex) {
+ binding.lockProfileCheckbox.isVisible = false
+ if (currentEditAccount.lockPin != null) {
+ currentEditAccount = currentEditAccount.copy(lockPin = null)
+ }
+ }
+
+ var canSetPin = true
+
+ binding.lockProfileCheckbox.isChecked = currentEditAccount.lockPin != null
+
+ binding.lockProfileCheckbox.setOnCheckedChangeListener { _, isChecked ->
+ if (isChecked) {
+ if (canSetPin) {
+ showPinInputDialog(context, null, true) { pin ->
+ if (pin == null) {
+ binding.lockProfileCheckbox.isChecked = false
+ return@showPinInputDialog
+ }
+
+ currentEditAccount = currentEditAccount.copy(lockPin = pin)
+ }
+ }
+ } else {
+ if (currentEditAccount.lockPin != null) {
+ // Ask for the current PIN
+ showPinInputDialog(context, currentEditAccount.lockPin, true) { pin ->
+ if (pin == null || pin != currentEditAccount.lockPin) {
+ canSetPin = false
+ binding.lockProfileCheckbox.isChecked = true
+ } else {
+ currentEditAccount = currentEditAccount.copy(lockPin = null)
+ }
+ }
+ }
+ }
+ }
+
+ canSetPin = true
+ }
+
+ fun showPinInputDialog(
+ context: Context,
+ currentPin: String?,
+ editAccount: Boolean,
+ forStartup: Boolean = false,
+ errorText: String? = null,
+ callback: (String?) -> Unit
+ ) {
+ fun TextView.visibleWithText(@StringRes textRes: Int) {
+ isVisible = true
+ setText(textRes)
+ }
+
+ fun TextView.visibleWithText(text: String?) {
+ isVisible = true
+ setText(text)
+ }
+
+ val binding = LockPinDialogBinding.inflate(LayoutInflater.from(context))
+
+ val isPinSet = currentPin != null
+ val isNewPin = editAccount && !isPinSet
+ val isEditPin = editAccount && isPinSet
+
+ val titleRes = if (isEditPin) R.string.enter_current_pin else R.string.enter_pin
+
+ var isPinValid = false
+
+ val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom)
+ .setView(binding.root)
+ .setTitle(titleRes)
+ .setNegativeButton(R.string.cancel) { _, _ ->
+ callback.invoke(null)
+ }
+ .setOnCancelListener {
+ callback.invoke(null)
+ }
+ .setOnDismissListener {
+ if (!isPinValid) {
+ callback.invoke(null)
+ }
+ }
+
+ if (forStartup) {
+ val currentAccount = DataStoreHelper.accounts.firstOrNull {
+ it.keyIndex == DataStoreHelper.selectedKeyIndex
+ }
+
+ builder.setTitle(context.getString(R.string.enter_pin_with_name, currentAccount?.name))
+ builder.setOnDismissListener {
+ if (!isPinValid) {
+ context.getActivity()?.finish()
+ }
+ }
+ // So that if they don't know the PIN for the current account,
+ // they don't get completely locked out
+ builder.setNeutralButton(R.string.use_default_account) { _, _ ->
+ val activity = context.getActivity()
+ if (activity is AccountSelectActivity) {
+ isPinValid = true
+ activity.viewModel.handleAccountSelect(getDefaultAccount(context), activity)
+ }
+ }
+ }
+
+ if (isNewPin) {
+ if (errorText != null) binding.pinEditTextError.visibleWithText(errorText)
+ builder.setPositiveButton(R.string.setup_done) { _, _ ->
+ if (!isPinValid) {
+ // If the done button is pressed and there is an error,
+ // ask again, and mention the error that caused this.
+ showPinInputDialog(
+ context = binding.root.context,
+ currentPin = null,
+ editAccount = true,
+ errorText = binding.pinEditTextError.text.toString(),
+ callback = callback
+ )
+ } else {
+ val enteredPin = binding.pinEditText.text.toString()
+ callback.invoke(enteredPin)
+ }
+ }
+ }
+
+ val dialog = builder.create()
+
+ binding.pinEditText.doOnTextChanged { text, _, _, _ ->
+ val enteredPin = text.toString()
+ val isEnteredPinValid = enteredPin.length == 4
+
+ if (isEnteredPinValid) {
+ if (isPinSet) {
+ if (enteredPin != currentPin) {
+ binding.pinEditTextError.visibleWithText(R.string.pin_error_incorrect)
+ binding.pinEditText.text = null
+ isPinValid = false
+ } else {
+ binding.pinEditTextError.isVisible = false
+ isPinValid = true
+
+ callback.invoke(enteredPin)
+ dialog.dismissSafe()
+ }
+ } else {
+ binding.pinEditTextError.isVisible = false
+ isPinValid = true
+ }
+ } else if (isNewPin) {
+ binding.pinEditTextError.visibleWithText(R.string.pin_error_length)
+ isPinValid = false
+ }
+ }
+
+ // Detect IME_ACTION_DONE
+ binding.pinEditText.setOnEditorActionListener { _, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_DONE && isPinValid) {
+ val enteredPin = binding.pinEditText.text.toString()
+ callback.invoke(enteredPin)
+ dialog.dismissSafe()
+ }
+ true
+ }
+
+ // We don't want to accidentally have the dialog dismiss when clicking outside of it.
+ // That is what the cancel button is for.
+ dialog.setCanceledOnTouchOutside(false)
+
+ dialog.show()
+
+ // Auto focus on PIN input and show keyboard
+ binding.pinEditText.requestFocus()
+ binding.pinEditText.postDelayed({
+ showInputMethod(binding.pinEditText)
+ }, 200)
+ }
+
+ fun Activity?.showAccountSelectLinear() {
+ val activity = this as? MainActivity ?: return
+ val viewModel = ViewModelProvider(activity)[AccountViewModel::class.java]
+
+ val binding: AccountSelectLinearBinding = AccountSelectLinearBinding.inflate(
+ LayoutInflater.from(activity)
+ )
+
+ val builder = BottomSheetDialog(activity)
+ builder.setContentView(binding.root)
+ builder.show()
+
+ binding.manageAccountsButton.setOnClickListener {
+ val accountSelectIntent = Intent(activity, AccountSelectActivity::class.java)
+ accountSelectIntent.putExtra("isEditingFromMainActivity", true)
+ activity.startActivity(accountSelectIntent)
+ builder.dismissSafe()
+ }
+
+ val recyclerView: RecyclerView = binding.accountRecyclerView
+
+ val itemSize = recyclerView.resources.getDimensionPixelSize(
+ R.dimen.account_select_linear_item_size
+ )
+
+ recyclerView.addItemDecoration(AccountSelectLinearItemDecoration(itemSize))
+
+ recyclerView.setLinearListLayout(isHorizontal = true)
+
+ val currentAccount = DataStoreHelper.accounts.firstOrNull {
+ it.keyIndex == DataStoreHelper.selectedKeyIndex
+ } ?: getDefaultAccount(activity)
+
+ // We want to make sure the accounts are up-to-date
+ viewModel.handleAccountSelect(
+ currentAccount,
+ activity,
+ reloadForActivity = true
+ )
+
+ activity.observe(viewModel.accounts) { liveAccounts ->
+ recyclerView.adapter = AccountAdapter(
+ liveAccounts,
+ accountSelectCallback = { account ->
+ viewModel.handleAccountSelect(account, activity)
+ builder.dismissSafe()
+ },
+ accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) },
+ accountEditCallback = { viewModel.handleAccountUpdate(it, activity) },
+ accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) }
+ )
+
+ activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
+ // Scroll to current account (which is focused by default)
+ val layoutManager = recyclerView.layoutManager as LinearLayoutManager
+ layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt
new file mode 100644
index 00000000..0da69f9c
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt
@@ -0,0 +1,199 @@
+package com.lagradost.cloudstream3.ui.account
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.ViewModelProvider
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.GridLayoutManager
+import com.lagradost.cloudstream3.CommonActivity
+import com.lagradost.cloudstream3.CommonActivity.loadThemes
+import com.lagradost.cloudstream3.CommonActivity.showToast
+import com.lagradost.cloudstream3.MainActivity
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.databinding.ActivityAccountSelectBinding
+import com.lagradost.cloudstream3.mvvm.observe
+import com.lagradost.cloudstream3.ui.AutofitRecyclerView
+import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_EDIT_ACCOUNT
+import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_SELECT_ACCOUNT
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo
+import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication
+import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts
+import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex
+import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
+import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
+
+class AccountSelectActivity : AppCompatActivity(), BiometricCallback {
+
+ lateinit var viewModel: AccountViewModel
+
+ @SuppressLint("NotifyDataSetChanged")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ loadThemes(this)
+
+ window.navigationBarColor = colorFromAttribute(R.attr.primaryBlackBackground)
+
+ // Are we editing and coming from MainActivity?
+ val isEditingFromMainActivity = intent.getBooleanExtra(
+ "isEditingFromMainActivity",
+ false
+ )
+
+ val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
+ val skipStartup = settingsManager.getBoolean(getString(R.string.skip_startup_account_select_key), false
+ ) || accounts.count() <= 1
+
+ viewModel = ViewModelProvider(this)[AccountViewModel::class.java]
+
+ fun askBiometricAuth() {
+
+ if (isLayout(PHONE) && isAuthEnabled(this)) {
+ if (deviceHasPasswordPinLock(this)) {
+ startBiometricAuthentication(
+ this,
+ R.string.biometric_authentication_title,
+ false
+ )
+
+ promptInfo?.let { prompt ->
+ biometricPrompt?.authenticate(prompt)
+ }
+ }
+ }
+ }
+
+ observe(viewModel.isAllowedLogin) { isAllowedLogin ->
+ if (isAllowedLogin) {
+ // We are allowed to continue to MainActivity
+ navigateToMainActivity()
+ }
+ }
+
+ // Don't show account selection if there is only
+ // one account that exists
+ if (!isEditingFromMainActivity && skipStartup) {
+ val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex }
+ if (currentAccount?.lockPin != null) {
+ CommonActivity.init(this)
+ viewModel.handleAccountSelect(currentAccount, this, true)
+ } else {
+ if (accounts.count() > 1) {
+ showToast(this, getString(
+ R.string.logged_account,
+ currentAccount?.name
+ ))
+ }
+
+ navigateToMainActivity()
+ }
+
+ return
+ }
+
+ CommonActivity.init(this)
+
+ val binding = ActivityAccountSelectBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val recyclerView: AutofitRecyclerView = binding.accountRecyclerView
+
+ observe(viewModel.accounts) { liveAccounts ->
+ val adapter = AccountAdapter(
+ liveAccounts,
+ // Handle the selected account
+ accountSelectCallback = {
+ viewModel.handleAccountSelect(it, this)
+ },
+ accountCreateCallback = { viewModel.handleAccountUpdate(it, this) },
+ accountEditCallback = {
+ viewModel.handleAccountUpdate(it, this)
+
+ // We came from MainActivity, return there
+ // and switch to the edited account
+ if (isEditingFromMainActivity) {
+ setAccount(it)
+ navigateToMainActivity()
+ }
+ },
+ accountDeleteCallback = { viewModel.handleAccountDelete(it,this) }
+ )
+
+ recyclerView.adapter = adapter
+
+ if (isLayout(TV or EMULATOR)) {
+ binding.editAccountButton.setBackgroundResource(
+ R.drawable.player_button_tv_attr_no_bg
+ )
+ }
+
+ observe(viewModel.selectedKeyIndex) { selectedKeyIndex ->
+ // Scroll to current account (which is focused by default)
+ val layoutManager = recyclerView.layoutManager as GridLayoutManager
+ layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0)
+ }
+
+ observe(viewModel.isEditing) { isEditing ->
+ if (isEditing) {
+ binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24)
+ binding.title.setText(R.string.manage_accounts)
+ adapter.viewType = VIEW_TYPE_EDIT_ACCOUNT
+ } else {
+ binding.editAccountButton.setImageResource(R.drawable.ic_baseline_edit_24)
+ binding.title.setText(R.string.select_an_account)
+ adapter.viewType = VIEW_TYPE_SELECT_ACCOUNT
+ }
+
+ adapter.notifyDataSetChanged()
+ }
+
+ if (isEditingFromMainActivity) {
+ viewModel.setIsEditing(true)
+ }
+
+ binding.editAccountButton.setOnClickListener {
+ // We came from MainActivity, return there
+ // and resume its state
+ if (isEditingFromMainActivity) {
+ navigateToMainActivity()
+ return@setOnClickListener
+ }
+
+ viewModel.toggleIsEditing()
+ }
+
+ if (isLayout(TV or EMULATOR)) {
+ recyclerView.spanCount = if (liveAccounts.count() + 1 <= 6) {
+ liveAccounts.count() + 1
+ } else 6
+ }
+ }
+
+ askBiometricAuth()
+ }
+
+ private fun navigateToMainActivity() {
+ val mainIntent = Intent(this, MainActivity::class.java)
+ startActivity(mainIntent)
+ finish() // Finish the account selection activity
+ }
+
+ override fun onAuthenticationSuccess() {
+ Log.i(BiometricAuthenticator.TAG,"Authentication successful in AccountSelectActivity")
+ }
+
+ override fun onAuthenticationError() {
+ finish()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt
new file mode 100644
index 00000000..eb907b34
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt
@@ -0,0 +1,14 @@
+package com.lagradost.cloudstream3.ui.account
+
+import android.graphics.Rect
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+class AccountSelectLinearItemDecoration(private val size: Int) : RecyclerView.ItemDecoration() {
+ override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
+ val layoutParams = view.layoutParams as RecyclerView.LayoutParams
+ layoutParams.width = size
+ layoutParams.height = size
+ view.layoutParams = layoutParams
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt
new file mode 100644
index 00000000..14559607
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt
@@ -0,0 +1,123 @@
+package com.lagradost.cloudstream3.ui.account
+
+import android.content.Context
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.lagradost.cloudstream3.AcraApplication.Companion.context
+import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys
+import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog
+import com.lagradost.cloudstream3.utils.DataStoreHelper
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getAccounts
+import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
+import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount
+
+class AccountViewModel : ViewModel() {
+ private fun getAllAccounts(): List {
+ return context?.let { getAccounts(it) } ?: DataStoreHelper.accounts.toList()
+ }
+
+ private val _accounts: MutableLiveData> = MutableLiveData(getAllAccounts())
+ val accounts: LiveData> = _accounts
+
+ private val _isEditing = MutableLiveData(false)
+ val isEditing: LiveData = _isEditing
+
+ private val _isAllowedLogin = MutableLiveData(false)
+ val isAllowedLogin: LiveData = _isAllowedLogin
+
+ private val _selectedKeyIndex = MutableLiveData(
+ getAllAccounts().indexOfFirst {
+ it.keyIndex == DataStoreHelper.selectedKeyIndex
+ }
+ )
+ val selectedKeyIndex: LiveData = _selectedKeyIndex
+
+ fun setIsEditing(value: Boolean) {
+ _isEditing.postValue(value)
+ }
+
+ fun toggleIsEditing() {
+ _isEditing.postValue(!(_isEditing.value ?: false))
+ }
+
+ fun handleAccountUpdate(
+ account: DataStoreHelper.Account,
+ context: Context
+ ) {
+ val currentAccounts = getAccounts(context).toMutableList()
+
+ val overrideIndex = currentAccounts.indexOfFirst { it.keyIndex == account.keyIndex }
+
+ if (overrideIndex != -1) {
+ currentAccounts[overrideIndex] = account
+ } else currentAccounts.add(account)
+
+ val currentHomePage = DataStoreHelper.currentHomePage
+
+ setAccount(account)
+
+ DataStoreHelper.currentHomePage = currentHomePage
+ DataStoreHelper.accounts = currentAccounts.toTypedArray()
+
+ _accounts.postValue(getAccounts(context))
+ _selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
+ }
+
+ fun handleAccountDelete(
+ account: DataStoreHelper.Account,
+ context: Context
+ ) {
+ removeKeys(account.keyIndex.toString())
+
+ val currentAccounts = getAccounts(context).toMutableList()
+
+ currentAccounts.removeIf { it.keyIndex == account.keyIndex }
+
+ DataStoreHelper.accounts = currentAccounts.toTypedArray()
+
+ if (account.keyIndex == DataStoreHelper.selectedKeyIndex) {
+ setAccount(getDefaultAccount(context))
+ }
+
+ _accounts.postValue(getAccounts(context))
+ _selectedKeyIndex.postValue(getAllAccounts().indexOfFirst {
+ it.keyIndex == DataStoreHelper.selectedKeyIndex
+ })
+ }
+
+ fun handleAccountSelect(
+ account: DataStoreHelper.Account,
+ context: Context,
+ forStartup: Boolean = false,
+ reloadForActivity: Boolean = false
+ ) {
+ if (reloadForActivity) {
+ _accounts.postValue(getAccounts(context))
+ _selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
+ return
+ }
+
+ // Check if the selected account has a lock PIN set
+ if (account.lockPin != null) {
+ // The selected account has a PIN set, prompt the user to enter the PIN
+ showPinInputDialog(
+ context,
+ account.lockPin,
+ false,
+ forStartup
+ ) { pin ->
+ if (pin == null) return@showPinInputDialog
+ // Pin is correct, proceed
+ _isAllowedLogin.postValue(true)
+ _selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
+ setAccount(account)
+ }
+ } else {
+ // No PIN set for the selected account, proceed
+ _isAllowedLogin.postValue(true)
+ _selectedKeyIndex.postValue(getAccounts(context).indexOf(account))
+ setAccount(account)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt
new file mode 100644
index 00000000..d211cb87
--- /dev/null
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt
@@ -0,0 +1,414 @@
+package com.lagradost.cloudstream3.ui.download
+
+import android.text.format.Formatter.formatShortFileSize
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.CheckBox
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewbinding.ViewBinding
+import com.lagradost.cloudstream3.R
+import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
+import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell
+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.UIHelper.setImage
+import com.lagradost.cloudstream3.utils.VideoDownloadHelper
+
+const val DOWNLOAD_ACTION_PLAY_FILE = 0
+const val DOWNLOAD_ACTION_DELETE_FILE = 1
+const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
+const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
+const val DOWNLOAD_ACTION_DOWNLOAD = 4
+const val DOWNLOAD_ACTION_LONG_CLICK = 5
+
+const val DOWNLOAD_ACTION_GO_TO_CHILD = 0
+const val DOWNLOAD_ACTION_LOAD_RESULT = 1
+
+sealed class VisualDownloadCached {
+ abstract val currentBytes: Long
+ abstract val totalBytes: Long
+ abstract val data: VideoDownloadHelper.DownloadCached
+ abstract var isSelected: Boolean
+
+ data class Child(
+ override val currentBytes: Long,
+ override val totalBytes: Long,
+ override val data: VideoDownloadHelper.DownloadEpisodeCached,
+ override var isSelected: Boolean,
+ ) : VisualDownloadCached()
+
+ data class Header(
+ override val currentBytes: Long,
+ override val totalBytes: Long,
+ override val data: VideoDownloadHelper.DownloadHeaderCached,
+ override var isSelected: Boolean,
+ val child: VideoDownloadHelper.DownloadEpisodeCached?,
+ val currentOngoingDownloads: Int,
+ val totalDownloads: Int,
+ ) : VisualDownloadCached()
+}
+
+data class DownloadClickEvent(
+ val action: Int,
+ val data: VideoDownloadHelper.DownloadEpisodeCached
+)
+
+data class DownloadHeaderClickEvent(
+ val action: Int,
+ val data: VideoDownloadHelper.DownloadHeaderCached
+)
+
+class DownloadAdapter(
+ private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit,
+ private val onItemClickEvent: (DownloadClickEvent) -> Unit,
+ private val onItemSelectionChanged: (Int, Boolean) -> Unit,
+) : ListAdapter(DiffCallback()) {
+
+ private var isMultiDeleteState: Boolean = false
+
+ companion object {
+ private const val VIEW_TYPE_HEADER = 0
+ private const val VIEW_TYPE_CHILD = 1
+ }
+
+ inner class DownloadViewHolder(
+ private val binding: ViewBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ 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
+
+ val data = card.data
+ binding.apply {
+ episodeHolder.apply {
+ if (isMultiDeleteState) {
+ setOnClickListener {
+ toggleIsChecked(deleteCheckbox, data.id)
+ }
+ }
+
+ setOnLongClickListener {
+ toggleIsChecked(deleteCheckbox, data.id)
+ true
+ }
+ }
+
+ downloadHeaderPoster.apply {
+ setImage(data.poster)
+ if (isMultiDeleteState) {
+ setOnClickListener {
+ toggleIsChecked(deleteCheckbox, data.id)
+ }
+ } else {
+ setOnClickListener {
+ onHeaderClickEvent.invoke(
+ DownloadHeaderClickEvent(
+ DOWNLOAD_ACTION_LOAD_RESULT,
+ data
+ )
+ )
+ }
+ }
+
+ setOnLongClickListener {
+ toggleIsChecked(deleteCheckbox, data.id)
+ true
+ }
+ }
+ downloadHeaderTitle.text = data.name
+ val formattedSize = formatShortFileSize(itemView.context, card.totalBytes)
+
+ if (card.child != null) {
+ handleChildDownload(card, formattedSize)
+ } else handleParentDownload(card, formattedSize)
+
+ if (isMultiDeleteState) {
+ deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
+ onItemSelectionChanged.invoke(data.id, isChecked)
+ }
+ } else deleteCheckbox.setOnCheckedChangeListener(null)
+
+ deleteCheckbox.apply {
+ isVisible = isMultiDeleteState
+ isChecked = card.isSelected
+ }
+ }
+ }
+
+ private fun DownloadHeaderEpisodeBinding.handleChildDownload(
+ card: VisualDownloadCached.Header,
+ formattedSize: String
+ ) {
+ card.child ?: return
+ downloadHeaderGotoChild.isVisible = false
+
+ val posDur = getViewPos(card.data.id)
+ downloadHeaderEpisodeProgress.apply {
+ isVisible = posDur != null
+ posDur?.let {
+ val visualPos = it.fixVisual()
+ max = (visualPos.duration / 1000).toInt()
+ progress = (visualPos.position / 1000).toInt()
+ }
+ }
+
+ val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes)
+ if (status == DownloadStatusTell.IsDone) {
+ // We do this here instead if we are finished downloading
+ // so that we can use the value from the view model
+ // rather than extra unneeded disk operations and to prevent a
+ // delay in updating download icon state.
+ downloadButton.setProgress(card.currentBytes, card.totalBytes)
+ downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes)
+ // We will let the view model handle this
+ downloadButton.doSetProgress = false
+ downloadButton.progressBar.progressDrawable =
+ downloadButton.getDrawableFromStatus(status)
+ ?.let { ContextCompat.getDrawable(downloadButton.context, it) }
+ downloadHeaderInfo.text = formattedSize
+ } else {
+ // We need to make sure we restore the correct progress
+ // when we refresh data in the adapter.
+ downloadButton.resetView()
+ val drawable = downloadButton.getDrawableFromStatus(status)?.let {
+ ContextCompat.getDrawable(downloadButton.context, it)
+ }
+ downloadButton.statusView.setImageDrawable(drawable)
+ downloadButton.progressBar.progressDrawable =
+ ContextCompat.getDrawable(
+ downloadButton.context,
+ downloadButton.progressDrawable
+ )
+ }
+
+ downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent)
+ downloadButton.isVisible = !isMultiDeleteState
+
+ if (!isMultiDeleteState) {
+ episodeHolder.setOnClickListener {
+ onItemClickEvent.invoke(
+ DownloadClickEvent(
+ DOWNLOAD_ACTION_PLAY_FILE,
+ card.child
+ )
+ )
+ }
+ }
+ }
+
+ private fun DownloadHeaderEpisodeBinding.handleParentDownload(
+ card: VisualDownloadCached.Header,
+ formattedSize: String
+ ) {
+ downloadButton.isVisible = false
+ downloadHeaderEpisodeProgress.isVisible = false
+ downloadHeaderGotoChild.isVisible = !isMultiDeleteState
+
+ try {
+ downloadHeaderInfo.text =
+ downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
+ card.totalDownloads,
+ downloadHeaderInfo.context.resources.getQuantityString(
+ R.plurals.episodes,
+ card.totalDownloads
+ ),
+ formattedSize
+ )
+ } catch (e: Exception) {
+ downloadHeaderInfo.text = null
+ logError(e)
+ }
+
+ if (!isMultiDeleteState) {
+ episodeHolder.setOnClickListener {
+ onHeaderClickEvent.invoke(
+ DownloadHeaderClickEvent(
+ DOWNLOAD_ACTION_GO_TO_CHILD,
+ card.data
+ )
+ )
+ }
+ }
+ }
+
+ private fun bindChild(card: VisualDownloadCached.Child?) {
+ if (binding !is DownloadChildEpisodeBinding || card == null) return
+
+ val data = card.data
+ binding.apply {
+ val posDur = getViewPos(data.id)
+ downloadChildEpisodeProgress.apply {
+ isVisible = posDur != null
+ posDur?.let {
+ val visualPos = it.fixVisual()
+ max = (visualPos.duration / 1000).toInt()
+ progress = (visualPos.position / 1000).toInt()
+ }
+ }
+
+ val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes)
+ if (status == DownloadStatusTell.IsDone) {
+ // We do this here instead if we are finished downloading
+ // so that we can use the value from the view model
+ // rather than extra unneeded disk operations and to prevent a
+ // delay in updating download icon state.
+ downloadButton.setProgress(card.currentBytes, card.totalBytes)
+ downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes)
+ // We will let the view model handle this
+ downloadButton.doSetProgress = false
+ downloadButton.progressBar.progressDrawable =
+ downloadButton.getDrawableFromStatus(status)
+ ?.let { ContextCompat.getDrawable(downloadButton.context, it) }
+ downloadChildEpisodeTextExtra.text =
+ formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes)
+ } else {
+ // We need to make sure we restore the correct progress
+ // when we refresh data in the adapter.
+ downloadButton.resetView()
+ val drawable = downloadButton.getDrawableFromStatus(status)?.let {
+ ContextCompat.getDrawable(downloadButton.context, it)
+ }
+ downloadButton.statusView.setImageDrawable(drawable)
+ downloadButton.progressBar.progressDrawable =
+ ContextCompat.getDrawable(
+ downloadButton.context,
+ downloadButton.progressDrawable
+ )
+ }
+
+ downloadButton.setDefaultClickListener(
+ data,
+ downloadChildEpisodeTextExtra,
+ onItemClickEvent
+ )
+ downloadButton.isVisible = !isMultiDeleteState
+
+ downloadChildEpisodeText.apply {
+ text = context.getNameFull(data.name, data.episode, data.season)
+ isSelected = true // Needed for text repeating
+ }
+
+ downloadChildEpisodeHolder.setOnClickListener {
+ onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data))
+ }
+
+ downloadChildEpisodeHolder.apply {
+ when {
+ isMultiDeleteState -> {
+ setOnClickListener {
+ toggleIsChecked(deleteCheckbox, data.id)
+ }
+ }
+
+ else -> {
+ setOnClickListener {
+ onItemClickEvent.invoke(
+ DownloadClickEvent(
+ DOWNLOAD_ACTION_PLAY_FILE,
+ data
+ )
+ )
+ }
+ }
+ }
+
+ setOnLongClickListener {
+ toggleIsChecked(deleteCheckbox, data.id)
+ true
+ }
+ }
+
+ if (isMultiDeleteState) {
+ deleteCheckbox.setOnCheckedChangeListener { _, isChecked ->
+ onItemSelectionChanged.invoke(data.id, isChecked)
+ }
+ } else deleteCheckbox.setOnCheckedChangeListener(null)
+
+ deleteCheckbox.apply {
+ isVisible = isMultiDeleteState
+ isChecked = card.isSelected
+ }
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DownloadViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = when (viewType) {
+ VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false)
+ VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false)
+ else -> throw IllegalArgumentException("Invalid view type")
+ }
+ return DownloadViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return when (getItem(position)) {
+ is VisualDownloadCached.Child -> VIEW_TYPE_CHILD
+ is VisualDownloadCached.Header -> VIEW_TYPE_HEADER
+ else -> throw IllegalArgumentException("Invalid data type at position $position")
+ }
+ }
+
+ fun setIsMultiDeleteState(value: Boolean) {
+ if (isMultiDeleteState == value) return
+ isMultiDeleteState = value
+ 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) {
+ val isChecked = !checkbox.isChecked
+ checkbox.isChecked = isChecked
+ onItemSelectionChanged.invoke(itemId, isChecked)
+ }
+
+ class DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: VisualDownloadCached,
+ newItem: VisualDownloadCached
+ ): Boolean {
+ return oldItem.data.id == newItem.data.id
+ }
+
+ override fun areContentsTheSame(
+ oldItem: VisualDownloadCached,
+ newItem: VisualDownloadCached
+ ): Boolean {
+ return oldItem == newItem
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt
index 10ce67a7..494e82e5 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt
@@ -1,28 +1,30 @@
package com.lagradost.cloudstream3.ui.download
-import android.app.Activity
import android.content.DialogInterface
-import android.widget.Toast
+import android.net.Uri
import androidx.appcompat.app.AlertDialog
+import com.google.android.material.snackbar.Snackbar
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
+import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys
import com.lagradost.cloudstream3.CommonActivity.activity
-import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator
+import com.lagradost.cloudstream3.ui.player.ExtractorUri
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
-import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
-import com.lagradost.cloudstream3.utils.AppUtils.setDefaultFocus
+import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
+import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
+import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
-import com.lagradost.cloudstream3.utils.ExtractorUri
+import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
+import kotlinx.coroutines.MainScope
object DownloadButtonSetup {
fun handleDownloadClick(click: DownloadClickEvent) {
val id = click.data.id
- if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return
when (click.action) {
DOWNLOAD_ACTION_DELETE_FILE -> {
activity?.let { ctx ->
@@ -31,9 +33,15 @@ object DownloadButtonSetup {
DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
- VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id)
+ VideoDownloadManager.deleteFilesAndUpdateSettings(
+ ctx,
+ setOf(id),
+ MainScope()
+ )
}
+
DialogInterface.BUTTON_NEGATIVE -> {
+ // Do nothing on cancel
}
}
}
@@ -58,11 +66,13 @@ object DownloadButtonSetup {
}
}
}
+
DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> {
VideoDownloadManager.downloadEvent.invoke(
Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause)
)
}
+
DOWNLOAD_ACTION_RESUME_DOWNLOAD -> {
activity?.let { ctx ->
if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) {
@@ -81,6 +91,7 @@ object DownloadButtonSetup {
}
}
}
+
DOWNLOAD_ACTION_LONG_CLICK -> {
activity?.let { act ->
val length =
@@ -90,64 +101,80 @@ object DownloadButtonSetup {
)?.fileLength
?: 0
if (length > 0) {
- showToast(R.string.delete, Toast.LENGTH_LONG)
- } else {
- showToast(R.string.download, Toast.LENGTH_LONG)
+ showSnackbar(
+ act,
+ R.string.offline_file,
+ Snackbar.LENGTH_LONG
+ )
}
}
}
+
DOWNLOAD_ACTION_PLAY_FILE -> {
activity?.let { act ->
- val info =
- VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(
- act,
- click.data.id
- ) ?: return
- val keyInfo = getKey(
- VideoDownloadManager.KEY_DOWNLOAD_INFO,
- click.data.id.toString()
- ) ?: return
val parent = getKey(
DOWNLOAD_HEADER_CACHE,
click.data.parentId.toString()
) ?: return
- act.navigate(
- R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
- DownloadFileGenerator(
- listOf(
- ExtractorUri(
- uri = info.path,
+ val episodes = getKeys(DOWNLOAD_EPISODE_CACHE)
+ ?.mapNotNull {
+ getKey(it)
+ }
+ ?.filter { it.parentId == click.data.parentId }
- id = click.data.id,
- parentId = click.data.parentId,
- name = act.getString(R.string.downloaded_file), //click.data.name ?: keyInfo.displayName
- season = click.data.season,
- episode = click.data.episode,
- headerName = parent.name,
- tvType = parent.type,
+ val currentSeason = click.data.season ?: 0
+ val currentEpisode = click.data.episode
- basePath = keyInfo.basePath,
- displayName = keyInfo.displayName,
- relativePath = keyInfo.relativePath,
- )
- )
+ val items = mutableListOf()
+
+ // Make sure we only get this episode and episodes after it,
+ // and that we can go to the next season if we need to.
+ val allRelevantEpisodes = episodes
+ ?.sortedWith(
+ compareByDescending { it.id == click.data.id }
+ .thenBy { it.season ?: 0 }
+ .thenBy { it.episode }
+ )
+ ?.filter {
+ if (it.season == null) return@filter true
+ val isCurrentOrLaterInSeason = it.season == currentSeason && (it.episode >= currentEpisode || it.id == click.data.id)
+ val isInFutureSeasons = it.season > currentSeason
+
+ isCurrentOrLaterInSeason || isInFutureSeasons
+ }
+
+ allRelevantEpisodes?.forEach {
+ val keyInfo = getKey(
+ VideoDownloadManager.KEY_DOWNLOAD_INFO,
+ it.id.toString()
+ ) ?: return@forEach
+
+ items.add(
+ ExtractorUri(
+ // We just use a temporary placeholder for the URI,
+ // it will be updated in generateLinks().
+ // We just do this for performance since getting
+ // all paths at once can be quite expensive.
+ uri = Uri.EMPTY,
+ id = it.id,
+ parentId = it.parentId,
+ name = act.getString(R.string.downloaded_file),
+ season = it.season,
+ episode = it.episode,
+ headerName = parent.name,
+ tvType = parent.type,
+ basePath = keyInfo.basePath,
+ displayName = keyInfo.displayName,
+ relativePath = keyInfo.relativePath,
)
)
- //R.id.global_to_navigation_player, PlayerFragment.newInstance(
- // UriData(
- // info.path.toString(),
- // keyInfo.basePath,
- // keyInfo.relativePath,
- // keyInfo.displayName,
- // click.data.parentId,
- // click.data.id,
- // headerName ?: "null",
- // if (click.data.episode <= 0) null else click.data.episode,
- // click.data.season
- // ),
- // getViewPos(click.data.id)?.position ?: 0
- //)
+ }
+
+ act.navigate(
+ R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
+ DownloadFileGenerator(items)
+ )
)
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt
deleted file mode 100644
index b4774cf8..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildAdapter.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-package com.lagradost.cloudstream3.ui.download
-
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
-import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
-import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
-import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
-import com.lagradost.cloudstream3.utils.VideoDownloadHelper
-
-const val DOWNLOAD_ACTION_PLAY_FILE = 0
-const val DOWNLOAD_ACTION_DELETE_FILE = 1
-const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2
-const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3
-const val DOWNLOAD_ACTION_DOWNLOAD = 4
-const val DOWNLOAD_ACTION_LONG_CLICK = 5
-
-data class VisualDownloadChildCached(
- val currentBytes: Long,
- val totalBytes: Long,
- val data: VideoDownloadHelper.DownloadEpisodeCached,
-)
-
-data class DownloadClickEvent(val action: Int, val data: EasyDownloadButton.IMinimumData)
-
-class DownloadChildAdapter(
- var cardList: List,
- private val clickCallback: (DownloadClickEvent) -> Unit,
-) : RecyclerView.Adapter() {
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
- return DownloadChildViewHolder(
- DownloadChildEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false),
- clickCallback
- )
- }
-
- override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
- when (holder) {
- is DownloadChildViewHolder -> {
- holder.bind(cardList[position])
- }
- }
- }
-
- override fun getItemCount(): Int {
- return cardList.size
- }
-
- class DownloadChildViewHolder
- constructor(
- val binding: DownloadChildEpisodeBinding,
- private val clickCallback: (DownloadClickEvent) -> Unit,
- ) : RecyclerView.ViewHolder(binding.root) {
-
- /*private val title: TextView = itemView.download_child_episode_text
- private val extraInfo: TextView = itemView.download_child_episode_text_extra
- private val holder: CardView = itemView.download_child_episode_holder
- private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress
- private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded
- private val downloadImage: ImageView = itemView.download_child_episode_download*/
-
-
- fun bind(card: VisualDownloadChildCached) {
- val d = card.data
-
- val posDur = getViewPos(d.id)
- binding.downloadChildEpisodeProgress.apply {
- if (posDur != null) {
- val visualPos = posDur.fixVisual()
- max = (visualPos.duration / 1000).toInt()
- progress = (visualPos.position / 1000).toInt()
- visibility = View.VISIBLE
- } else {
- visibility = View.GONE
- }
- }
-
- binding.downloadButton.setDefaultClickListener(card.data, binding.downloadChildEpisodeTextExtra, clickCallback)
-
- binding.downloadChildEpisodeText.apply {
- text = context.getNameFull(d.name, d.episode, d.season)
- isSelected = true // is needed for text repeating
- }
-
-
- binding.downloadChildEpisodeHolder.setOnClickListener {
- clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
- }
- }
- }
-}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt
index 1d813ef1..09c48a04 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt
@@ -1,25 +1,33 @@
package com.lagradost.cloudstream3.ui.download
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
-import androidx.recyclerview.widget.GridLayoutManager
-import androidx.recyclerview.widget.RecyclerView
+import androidx.lifecycle.ViewModelProvider
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
+import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
-import com.lagradost.cloudstream3.utils.Coroutines.main
-import com.lagradost.cloudstream3.utils.DataStore.getKey
-import com.lagradost.cloudstream3.utils.DataStore.getKeys
+import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
+import com.lagradost.cloudstream3.ui.result.setLinearListLayout
+import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
+import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
-import com.lagradost.cloudstream3.utils.VideoDownloadHelper
-import com.lagradost.cloudstream3.utils.VideoDownloadManager
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
+import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
class DownloadChildFragment : Fragment() {
+ private lateinit var downloadsViewModel: DownloadViewModel
+ private var binding: FragmentChildDownloadsBinding? = null
+
companion object {
fun newInstance(headerName: String, folder: String): Bundle {
return Bundle().apply {
@@ -30,88 +38,170 @@ class DownloadChildFragment : Fragment() {
}
override fun onDestroyView() {
- downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
+ detachBackPressedCallback()
binding = null
super.onDestroyView()
}
- var binding: FragmentChildDownloadsBinding? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
+ downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
- return localBinding.root//inflater.inflate(R.layout.fragment_child_downloads, container, false)
+ return localBinding.root
}
- private fun updateList(folder: String) = main {
- context?.let { ctx ->
- val data = withContext(Dispatchers.IO) { ctx.getKeys(folder) }
- val eps = withContext(Dispatchers.IO) {
- data.mapNotNull { key ->
- context?.getKey(key)
- }.mapNotNull {
- val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id)
- ?: return@mapNotNull null
- VisualDownloadChildCached(info.fileLength, info.totalBytes, it)
- }
- }.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 }
- if (eps.isEmpty()) {
- activity?.onBackPressed()
- return@main
- }
-
- (binding?.downloadChildList?.adapter as DownloadChildAdapter? ?: return@main).cardList =
- eps
- binding?.downloadChildList?.adapter?.notifyDataSetChanged()
- }
- }
-
- private var downloadDeleteEventListener: ((Int) -> Unit)? = null
-
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 name = arguments?.getString("name")
if (folder == null) {
- activity?.onBackPressed() // TODO FIX
+ activity?.onBackPressedDispatcher?.onBackPressed()
return
}
- fixPaddingStatusbar(binding?.downloadChildRoot)
binding?.downloadChildToolbar?.apply {
title = name
- setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
- setNavigationOnClickListener {
- activity?.onBackPressed()
- }
- }
-
-
- val adapter: RecyclerView.Adapter =
- DownloadChildAdapter(
- ArrayList(),
- ) { click ->
- handleDownloadClick(click)
- }
-
- downloadDeleteEventListener = { id: Int ->
- val list = (binding?.downloadChildList?.adapter as DownloadChildAdapter?)?.cardList
- if (list != null) {
- if (list.any { it.data.id == id }) {
- updateList(folder)
+ if (isLayout(PHONE or EMULATOR)) {
+ setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
+ setNavigationOnClickListener {
+ activity?.onBackPressedDispatcher?.onBackPressed()
}
}
+ setAppBarNoScrollFlagsOnTV()
}
- downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
+ binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
- binding?.downloadChildList?.adapter = adapter
- binding?.downloadChildList?.layoutManager = GridLayoutManager(context, 1)
+ observe(downloadsViewModel.childCards) {
+ if (it.isEmpty()) {
+ activity?.onBackPressedDispatcher?.onBackPressed()
+ return@observe
+ }
- updateList(folder)
+ (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it)
+ }
+ observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
+ val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter
+ adapter?.setIsMultiDeleteState(isMultiDeleteState)
+ binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
+ if (!isMultiDeleteState) {
+ detachBackPressedCallback()
+ downloadsViewModel.clearSelectedItems()
+ binding?.downloadChildToolbar?.isVisible = true
+ }
+ }
+ observe(downloadsViewModel.selectedBytes) {
+ updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
+ }
+ observe(downloadsViewModel.selectedItemIds) {
+ handleSelectedChange(it)
+ updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
+
+ binding?.btnDelete?.isVisible = it.isNotEmpty()
+ binding?.selectItemsText?.isVisible = it.isEmpty()
+
+ val allSelected = downloadsViewModel.isAllSelected()
+ if (allSelected) {
+ binding?.btnToggleAll?.setText(R.string.deselect_all)
+ } else binding?.btnToggleAll?.setText(R.string.select_all)
+ }
+
+ val adapter = DownloadAdapter(
+ {},
+ { click ->
+ if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
+ context?.let { ctx ->
+ downloadsViewModel.handleSingleDelete(ctx, click.data.id)
+ }
+ } else handleDownloadClick(click)
+ },
+ { itemId, isChecked ->
+ if (isChecked) {
+ downloadsViewModel.addSelected(itemId)
+ } else downloadsViewModel.removeSelected(itemId)
+ }
+ )
+
+ binding?.downloadChildList?.apply {
+ setHasFixedSize(true)
+ setItemViewCacheSize(20)
+ this.adapter = adapter
+ setLinearListLayout(
+ isHorizontal = false,
+ nextRight = FOCUS_SELF,
+ nextDown = FOCUS_SELF,
+ )
+ }
+
+ context?.let { downloadsViewModel.updateChildList(it, folder) }
+ fixPaddingStatusbar(binding?.downloadChildRoot)
+ }
+
+ private fun handleSelectedChange(selected: MutableSet) {
+ if (selected.isNotEmpty()) {
+ binding?.downloadDeleteAppbar?.isVisible = true
+ binding?.downloadChildToolbar?.isVisible = false
+ activity?.attachBackPressedCallback {
+ 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) {
+ val formattedSize = formatShortFileSize(context, selectedBytes)
+ binding?.btnDelete?.text =
+ getString(R.string.delete_format).format(count, formattedSize)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt
index c8b381a6..447b4f13 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt
@@ -1,53 +1,62 @@
package com.lagradost.cloudstream3.ui.download
+import android.app.Activity
import android.app.Dialog
import android.content.ClipboardManager
import android.content.Context
+import android.content.Intent
+import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.os.Build
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
+import android.widget.TextView
import android.widget.Toast
-import androidx.appcompat.app.AppCompatActivity
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.StringRes
import androidx.core.view.isGone
import androidx.core.view.isVisible
+import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
-import androidx.recyclerview.widget.GridLayoutManager
-import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R
-import com.lagradost.cloudstream3.isMovieType
+import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
+import com.lagradost.cloudstream3.databinding.StreamInputBinding
+import com.lagradost.cloudstream3.isEpisodeBased
+import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
+import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
import com.lagradost.cloudstream3.ui.player.LinkGenerator
-import com.lagradost.cloudstream3.utils.AppUtils.loadResult
-import com.lagradost.cloudstream3.utils.Coroutines.main
+import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri
+import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
+import com.lagradost.cloudstream3.ui.result.setLinearListLayout
+import com.lagradost.cloudstream3.ui.settings.Globals.TV
+import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
+import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
+import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE
-import com.lagradost.cloudstream3.utils.DataStore
+import com.lagradost.cloudstream3.utils.DataStore.getFolderName
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
-import com.lagradost.cloudstream3.utils.VideoDownloadHelper
-import com.lagradost.cloudstream3.utils.VideoDownloadManager
-import android.text.format.Formatter.formatShortFileSize
-import androidx.core.widget.doOnTextChanged
-import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
-import com.lagradost.cloudstream3.databinding.StreamInputBinding
-import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
-import com.lagradost.cloudstream3.ui.player.BasicLink
-import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
+import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV
import java.net.URI
-
const val DOWNLOAD_NAVIGATE_TO = "downloadpage"
class DownloadFragment : Fragment() {
private lateinit var downloadsViewModel: DownloadViewModel
+ private var binding: FragmentDownloadsBinding? = null
private fun View.setLayoutWidth(weight: Long) {
val param = LinearLayout.LayoutParams(
@@ -58,214 +67,325 @@ class DownloadFragment : Fragment() {
this.layoutParams = param
}
- private fun setList(list: List) {
- main {
- (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList = list
- binding?.downloadList?.adapter?.notifyDataSetChanged()
- }
- }
-
override fun onDestroyView() {
- if (downloadDeleteEventListener != null) {
- VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!!
- downloadDeleteEventListener = null
- }
+ detachBackPressedCallback()
binding = null
super.onDestroyView()
}
- var binding : FragmentDownloadsBinding? = null
-
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
- downloadsViewModel =
- ViewModelProvider(this)[DownloadViewModel::class.java]
-
+ ): View {
+ downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java]
val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
- return localBinding.root//inflater.inflate(R.layout.fragment_downloads, container, false)
+ return localBinding.root
}
- private var downloadDeleteEventListener: ((Int) -> Unit)? = null
-
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
hideKeyboard()
+ binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV()
+ binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV()
- observe(downloadsViewModel.noDownloadsText) {
- binding?.textNoDownloads?.text = it
+ /**
+ * 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()
+
observe(downloadsViewModel.headerCards) {
- setList(it)
+ (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it)
binding?.downloadLoading?.isVisible = false
+ binding?.textNoDownloads?.isVisible = it.isEmpty()
}
observe(downloadsViewModel.availableBytes) {
- binding?.downloadFreeTxt?.text =
- getString(R.string.storage_size_format).format(
- getString(R.string.free_storage),
- formatShortFileSize(view.context, it)
- )
- binding?.downloadFree?.setLayoutWidth(it)
+ updateStorageInfo(
+ view.context,
+ it,
+ R.string.free_storage,
+ binding?.downloadFreeTxt,
+ binding?.downloadFree
+ )
}
observe(downloadsViewModel.usedBytes) {
- binding?.apply {
- downloadUsedTxt.text =
- getString(R.string.storage_size_format).format(
- getString(R.string.used_storage),
- formatShortFileSize(view.context, it)
- )
- downloadUsed.setLayoutWidth(it)
- downloadStorageAppbar.isVisible = it > 0
- }
- }
- observe(downloadsViewModel.downloadBytes) {
- binding?.apply {
- downloadAppTxt.text =
- getString(R.string.storage_size_format).format(
- getString(R.string.app_storage),
- formatShortFileSize(view.context, it)
- )
- downloadApp.setLayoutWidth(it)
- }
- }
-
- val adapter: RecyclerView.Adapter =
- DownloadHeaderAdapter(
- ArrayList(),
- { click ->
- when (click.action) {
- 0 -> {
- if (click.data.type.isMovieType()) {
- //wont be called
- } else {
- val folder = DataStore.getFolderName(
- DOWNLOAD_EPISODE_CACHE,
- click.data.id.toString()
- )
- activity?.navigate(
- R.id.action_navigation_downloads_to_navigation_download_child,
- DownloadChildFragment.newInstance(click.data.name, folder)
- )
- }
- }
- 1 -> {
- (activity as AppCompatActivity?)?.loadResult(
- click.data.url,
- click.data.apiName
- )
- }
- }
-
- },
- { downloadClickEvent ->
- if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter
- handleDownloadClick(downloadClickEvent)
- if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
- context?.let { ctx ->
- downloadsViewModel.updateList(ctx)
- }
- }
- }
+ updateStorageInfo(
+ view.context,
+ it,
+ R.string.used_storage,
+ binding?.downloadUsedTxt,
+ binding?.downloadUsed
)
- downloadDeleteEventListener = { id ->
- val list = (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList
- if (list != null) {
- if (list.any { it.data.id == id }) {
- context?.let { ctx ->
- setList(ArrayList())
- downloadsViewModel.updateList(ctx)
- }
+ // Prevent race condition and make sure
+ // we don't display it early
+ if (
+ downloadsViewModel.isMultiDeleteState.value == null ||
+ downloadsViewModel.isMultiDeleteState.value == false
+ ) binding?.downloadStorageAppbar?.isVisible = it > 0
+ }
+ observe(downloadsViewModel.downloadBytes) {
+ updateStorageInfo(
+ view.context,
+ it,
+ R.string.app_storage,
+ binding?.downloadAppTxt,
+ binding?.downloadApp
+ )
+ }
+ observe(downloadsViewModel.selectedBytes) {
+ updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it)
+ }
+ observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState ->
+ val adapter = binding?.downloadList?.adapter as? DownloadAdapter
+ adapter?.setIsMultiDeleteState(isMultiDeleteState)
+ binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState
+ if (!isMultiDeleteState) {
+ detachBackPressedCallback()
+ downloadsViewModel.clearSelectedItems()
+ // Prevent race condition and make sure
+ // we don't display it early
+ if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) {
+ binding?.downloadStorageAppbar?.isVisible = true
}
}
}
+ observe(downloadsViewModel.selectedItemIds) {
+ handleSelectedChange(it)
+ updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L)
- downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
+ binding?.btnDelete?.isVisible = it.isNotEmpty()
+ binding?.selectItemsText?.isVisible = it.isEmpty()
+
+ val allSelected = downloadsViewModel.isAllSelected()
+ if (allSelected) {
+ binding?.btnToggleAll?.setText(R.string.deselect_all)
+ } else binding?.btnToggleAll?.setText(R.string.select_all)
+ }
+
+ val adapter = DownloadAdapter(
+ { click -> handleItemClick(click) },
+ { click ->
+ if (click.action == DOWNLOAD_ACTION_DELETE_FILE) {
+ context?.let { ctx ->
+ downloadsViewModel.handleSingleDelete(ctx, click.data.id)
+ }
+ } else handleDownloadClick(click)
+ },
+ { itemId, isChecked ->
+ if (isChecked) {
+ downloadsViewModel.addSelected(itemId)
+ } else downloadsViewModel.removeSelected(itemId)
+ }
+ )
binding?.downloadList?.apply {
+ setHasFixedSize(true)
+ setItemViewCacheSize(20)
this.adapter = adapter
- layoutManager = GridLayoutManager(context, 1)
+ setLinearListLayout(
+ isHorizontal = false,
+ nextRight = FOCUS_SELF,
+ nextDown = FOCUS_SELF,
+ )
}
- // Should be visible in emulator layout
- binding?.downloadStreamButton?.isGone = isTrueTvSettings()
- binding?.downloadStreamButton?.setOnClickListener {
- val dialog =
- Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom)
-
- val binding = StreamInputBinding.inflate(dialog.layoutInflater)
-
- dialog.setContentView(binding.root)
-
- dialog.show()
-
- // If user has clicked the switch do not interfere
- var preventAutoSwitching = false
- binding.hlsSwitch.setOnClickListener {
- preventAutoSwitching = true
+ binding?.apply {
+ openLocalVideoButton.apply {
+ isGone = isLayout(TV)
+ setOnClickListener { openLocalVideo() }
}
-
- fun activateSwitchOnHls(text: String?) {
- binding.hlsSwitch.isChecked = normalSafeApiCall {
- URI(text).path?.substringAfterLast(".")?.contains("m3u")
- } == true
+ downloadStreamButton.apply {
+ isGone = isLayout(TV)
+ setOnClickListener { showStreamInputDialog(it.context) }
}
+ }
- binding.streamReferer.doOnTextChanged { text, _, _, _ ->
- if (!preventAutoSwitching)
- activateSwitchOnHls(text?.toString())
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
+ handleScroll(scrollY - oldScrollY)
}
+ }
- (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager?)?.primaryClip?.getItemAt(
- 0
- )?.text?.toString()?.let { copy ->
- val fixedText = copy.trim()
- binding.streamUrl.setText(fixedText)
- activateSwitchOnHls(fixedText)
- }
-
- binding.applyBtt.setOnClickListener {
- val url = binding.streamUrl.text?.toString()
- if (url.isNullOrEmpty()) {
- showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
- } else {
- val referer = binding.streamReferer.text?.toString()
+ context?.let { downloadsViewModel.updateHeaderList(it) }
+ fixPaddingStatusbar(binding?.downloadRoot)
+ }
+ private fun handleItemClick(click: DownloadHeaderClickEvent) {
+ when (click.action) {
+ DOWNLOAD_ACTION_GO_TO_CHILD -> {
+ if (click.data.type.isEpisodeBased()) {
+ val folder =
+ getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString())
activity?.navigate(
- R.id.global_to_navigation_player,
- GeneratorPlayer.newInstance(
- LinkGenerator(
- listOf(BasicLink(url)),
- extract = true,
- referer = referer,
- isM3u8 = binding.hlsSwitch.isChecked
- )
- )
+ R.id.action_navigation_downloads_to_navigation_download_child,
+ DownloadChildFragment.newInstance(click.data.name, folder)
)
-
- dialog.dismissSafe(activity)
}
}
- binding.cancelBtt.setOnClickListener {
+ DOWNLOAD_ACTION_LOAD_RESULT -> {
+ activity?.loadResult(click.data.url, click.data.apiName)
+ }
+ }
+ }
+
+ private fun handleSelectedChange(selected: MutableSet) {
+ if (selected.isNotEmpty()) {
+ binding?.downloadDeleteAppbar?.isVisible = true
+ binding?.downloadStorageAppbar?.isVisible = false
+ activity?.attachBackPressedCallback {
+ 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) {
+ val formattedSize = formatShortFileSize(context, selectedBytes)
+ binding?.btnDelete?.text =
+ getString(R.string.delete_format).format(count, formattedSize)
+ }
+
+ private fun updateStorageInfo(
+ context: Context,
+ bytes: Long,
+ @StringRes stringRes: Int,
+ textView: TextView?,
+ view: View?
+ ) {
+ textView?.text = getString(R.string.storage_size_format).format(
+ getString(stringRes),
+ formatShortFileSize(context, bytes)
+ )
+ view?.setLayoutWidth(bytes)
+ }
+
+ private fun openLocalVideo() {
+ val intent = Intent()
+ .setAction(Intent.ACTION_GET_CONTENT)
+ .setType("video/*")
+ .addCategory(Intent.CATEGORY_OPENABLE)
+ .addFlags(FLAG_GRANT_READ_URI_PERMISSION) // Request temporary access
+ normalSafeApiCall {
+ videoResultLauncher.launch(
+ Intent.createChooser(
+ intent,
+ getString(R.string.open_local_video)
+ )
+ )
+ }
+ }
+
+ private fun showStreamInputDialog(context: Context) {
+ val dialog = Dialog(context, R.style.AlertDialogCustom)
+ val binding = StreamInputBinding.inflate(dialog.layoutInflater)
+ dialog.setContentView(binding.root)
+ dialog.show()
+
+ var preventAutoSwitching = false
+ binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true }
+
+ binding.streamReferer.doOnTextChanged { text, _, _, _ ->
+ if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding)
+ }
+
+ (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(
+ 0
+ )?.text?.toString()?.let { copy ->
+ val fixedText = copy.trim()
+ binding.streamUrl.setText(fixedText)
+ activateSwitchOnHls(fixedText, binding)
+ }
+
+ binding.applyBtt.setOnClickListener {
+ val url = binding.streamUrl.text?.toString()
+ if (url.isNullOrEmpty()) {
+ showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
+ } else {
+ val referer = binding.streamReferer.text?.toString()
+ activity?.navigate(
+ R.id.global_to_navigation_player,
+ GeneratorPlayer.newInstance(
+ LinkGenerator(
+ listOf(BasicLink(url)),
+ extract = true,
+ referer = referer,
+ isM3u8 = binding.hlsSwitch.isChecked
+ )
+ )
+ )
dialog.dismissSafe(activity)
}
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
- val dy = scrollY - oldScrollY
- if (dy > 0) { //check for scroll down
- binding?.downloadStreamButton?.shrink() // hide
- } else if (dy < -5) {
- binding?.downloadStreamButton?.extend() // show
- }
- }
- }
- downloadsViewModel.updateList(requireContext())
- fixPaddingStatusbar(binding?.downloadRoot)
+ binding.cancelBtt.setOnClickListener {
+ dialog.dismissSafe(activity)
+ }
+ }
+
+ private fun activateSwitchOnHls(text: String?, binding: StreamInputBinding) {
+ binding.hlsSwitch.isChecked = normalSafeApiCall {
+ URI(text).path?.substringAfterLast(".")?.contains("m3u")
+ } == true
+ }
+
+ private fun handleScroll(dy: Int) {
+ if (dy > 0) {
+ binding?.downloadStreamButton?.shrink()
+ } else if (dy < -5) {
+ binding?.downloadStreamButton?.extend()
+ }
+ }
+
+ // Open local video from files using content provider x safeFile
+ private val videoResultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
+ val selectedVideoUri = result?.data?.data ?: return@registerForActivityResult
+ playUri(activity ?: return@registerForActivityResult, selectedVideoUri)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt
deleted file mode 100644
index 65a6441f..00000000
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadHeaderAdapter.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-package com.lagradost.cloudstream3.ui.download
-
-import android.annotation.SuppressLint
-import android.text.format.Formatter.formatShortFileSize
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.view.isVisible
-import androidx.recyclerview.widget.RecyclerView
-import com.lagradost.cloudstream3.R
-import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
-import com.lagradost.cloudstream3.mvvm.logError
-import com.lagradost.cloudstream3.utils.UIHelper.setImage
-import com.lagradost.cloudstream3.utils.VideoDownloadHelper
-import java.util.*
-
-data class VisualDownloadHeaderCached(
- val currentOngoingDownloads: Int,
- val totalDownloads: Int,
- val totalBytes: Long,
- val currentBytes: Long,
- val data: VideoDownloadHelper.DownloadHeaderCached,
- val child: VideoDownloadHelper.DownloadEpisodeCached?,
-)
-
-data class DownloadHeaderClickEvent(
- val action: Int,
- val data: VideoDownloadHelper.DownloadHeaderCached
-)
-
-class DownloadHeaderAdapter(
- var cardList: List,
- private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
- private val movieClickCallback: (DownloadClickEvent) -> Unit,
-) : RecyclerView.Adapter