mirror of
https://github.com/recloudstream/cloudstream.git
synced 2026-06-19 20:05:41 +00:00
Compare commits
1 commit
master
...
fixsharedp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf20a1ade |
279 changed files with 7751 additions and 11971 deletions
7
.github/workflows/build_to_archive.yml
vendored
7
.github/workflows/build_to_archive.yml
vendored
|
|
@ -9,9 +9,6 @@ on:
|
|||
- '**/wcokey.txt'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: "Archive-build"
|
||||
cancel-in-progress: true
|
||||
|
|
@ -64,15 +61,13 @@ jobs:
|
|||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Run Gradle
|
||||
run: ./gradlew assemblePrereleaseRelease
|
||||
run: ./gradlew assemblePrerelease
|
||||
env:
|
||||
SIGNING_KEY_ALIAS: "key0"
|
||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
||||
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
|
|
|||
6
.github/workflows/generate_dokka.yml
vendored
6
.github/workflows/generate_dokka.yml
vendored
|
|
@ -6,9 +6,6 @@ on:
|
|||
paths-ignore:
|
||||
- '*.md'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: "dokka"
|
||||
cancel-in-progress: true
|
||||
|
|
@ -54,6 +51,9 @@ jobs:
|
|||
with:
|
||||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Generate Dokka
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE/src/
|
||||
|
|
|
|||
94
.github/workflows/issue_action.yml
vendored
Normal file
94
.github/workflows/issue_action.yml
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
name: Issue automatic actions
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
issue-moderator:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate access token
|
||||
id: generate_token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
|
||||
- name: Similarity analysis
|
||||
id: similarity
|
||||
uses: actions-cool/issues-similarity-analysis@v1
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
filter-threshold: 0.60
|
||||
title-excludes: ''
|
||||
comment-title: |
|
||||
### Your issue looks similar to these issues:
|
||||
Please close if duplicate.
|
||||
comment-body: '${index}. ${similarity} #${number}'
|
||||
|
||||
- name: Label if possible duplicate
|
||||
if: steps.similarity.outputs.similar-issues-found =='true'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.generate_token.outputs.token }}
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ["possible duplicate"]
|
||||
})
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Automatically close issues that dont follow the issue template
|
||||
uses: lucasbento/auto-close-issues@v1.0.2
|
||||
with:
|
||||
github-token: ${{ steps.generate_token.outputs.token }}
|
||||
issue-close-message: |
|
||||
@${issue.user.login}: hello! :wave:
|
||||
This issue is being automatically closed because it does not follow the issue template."
|
||||
closed-issues-label: "invalid"
|
||||
|
||||
- name: Check if issue mentions a provider
|
||||
id: provider_check
|
||||
env:
|
||||
GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}"
|
||||
run: |
|
||||
wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py"
|
||||
pip3 install httpx
|
||||
RES="$(python3 ./check_issue.py)"
|
||||
echo "name=${RES}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment if issue mentions a provider
|
||||
if: steps.provider_check.outputs.name != 'none'
|
||||
uses: actions-cool/issues-helper@v3
|
||||
with:
|
||||
actions: 'create-comment'
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
body: |
|
||||
Hello ${{ github.event.issue.user.login }}.
|
||||
Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM).
|
||||
|
||||
Found provider name: `${{ steps.provider_check.outputs.name }}`
|
||||
|
||||
- name: Label if mentions provider
|
||||
if: steps.provider_check.outputs.name != 'none'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.generate_token.outputs.token }}
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ["possible provider issue"]
|
||||
})
|
||||
|
||||
- name: Add eyes reaction to all issues
|
||||
uses: actions-cool/emoji-helper@v1.0.0
|
||||
with:
|
||||
type: 'issue'
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
emoji: 'eyes'
|
||||
6
.github/workflows/prerelease.yml
vendored
6
.github/workflows/prerelease.yml
vendored
|
|
@ -12,9 +12,6 @@ concurrency:
|
|||
group: "pre-release"
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -55,14 +52,13 @@ jobs:
|
|||
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Run Gradle
|
||||
run: ./gradlew assemblePrereleaseRelease androidSourcesJar makeJar
|
||||
run: ./gradlew assemblePrerelease build androidSourcesJar makeJar
|
||||
env:
|
||||
SIGNING_KEY_ALIAS: "key0"
|
||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||
TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }}
|
||||
MDL_API_KEY: ${{ secrets.MDL_API_KEY }}
|
||||
|
||||
- name: Create pre-release
|
||||
|
|
|
|||
7
.github/workflows/pull_request.yml
vendored
7
.github/workflows/pull_request.yml
vendored
|
|
@ -2,9 +2,6 @@ name: Artifact Build
|
|||
|
||||
on: [pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -27,10 +24,10 @@ jobs:
|
|||
cache-read-only: false
|
||||
|
||||
- name: Run Gradle
|
||||
run: ./gradlew assemblePrereleaseDebug lint check
|
||||
run: ./gradlew assemblePrereleaseDebug lint
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: pull-request-build
|
||||
path: "app/build/outputs/apk/prerelease/debug/*.apk"
|
||||
|
|
|
|||
3
.github/workflows/update_locales.yml
vendored
3
.github/workflows/update_locales.yml
vendored
|
|
@ -11,9 +11,6 @@ concurrency:
|
|||
group: "locale"
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
create:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -8,89 +8,47 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
|||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.dokka)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
|
||||
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
|
||||
|
||||
abstract class GenerateGitHashTask : DefaultTask() {
|
||||
fun getGitCommitHash(): String {
|
||||
return try {
|
||||
val headFile = file("${project.rootDir}/.git/HEAD")
|
||||
|
||||
@get:InputFile
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val headFile: RegularFileProperty
|
||||
|
||||
@get:InputDirectory
|
||||
@get:PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract val headsDir: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val head = headFile.get().asFile
|
||||
|
||||
val hash = try {
|
||||
if (head.exists()) {
|
||||
// Read the commit hash from .git/HEAD
|
||||
val headContent = head.readText().trim()
|
||||
if (headFile.exists()) {
|
||||
val headContent = headFile.readText().trim()
|
||||
if (headContent.startsWith("ref:")) {
|
||||
val refPath = headContent.substring(5) // e.g., refs/heads/main
|
||||
val commitFile = File(head.parentFile, refPath)
|
||||
val commitFile = file("${project.rootDir}/.git/$refPath")
|
||||
if (commitFile.exists()) commitFile.readText().trim() else ""
|
||||
} else headContent // If it's a detached HEAD (commit hash directly)
|
||||
} else "" // If .git/HEAD doesn't exist
|
||||
} else {
|
||||
"" // If .git/HEAD doesn't exist
|
||||
}.take(7) // Return the short commit hash
|
||||
} catch (_: Throwable) {
|
||||
"" // Just set to an empty string if any exception occurs
|
||||
}.take(7) // Get the short commit hash
|
||||
|
||||
val outFile = outputDir.file("git-hash.txt").get().asFile
|
||||
outFile.parentFile.mkdirs()
|
||||
outFile.writeText(hash)
|
||||
"" // Just return an empty string if any exception occurs
|
||||
}
|
||||
}
|
||||
|
||||
val generateGitHash = tasks.register<GenerateGitHashTask>("generateGitHash") {
|
||||
val gitDir = layout.projectDirectory.dir("../.git")
|
||||
|
||||
headFile.set(gitDir.file("HEAD"))
|
||||
headsDir.set(gitDir.dir("refs/heads"))
|
||||
|
||||
outputDir.set(layout.buildDirectory.dir("generated/git"))
|
||||
}
|
||||
|
||||
android {
|
||||
@Suppress("UnstableApiUsage")
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
|
||||
// Looks like google likes to add metadata only they can read https://gitlab.com/IzzyOnDroid/repo/-/work_items/491
|
||||
dependenciesInfo {
|
||||
// Disables dependency metadata when building APKs.
|
||||
includeInApk = false
|
||||
// Disables dependency metadata when building Android App Bundles.
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(
|
||||
generateGitHash,
|
||||
GenerateGitHashTask::outputDir
|
||||
)
|
||||
}
|
||||
viewBinding {
|
||||
enable = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// We just use SIGNING_KEY_ALIAS here since it won't change
|
||||
// so won't kill the configuration cache.
|
||||
if (System.getenv("SIGNING_KEY_ALIAS") != null) {
|
||||
if (prereleaseStoreFile != null) {
|
||||
create("prerelease") {
|
||||
val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/"
|
||||
val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first()
|
||||
|
||||
storeFile = prereleaseStoreFile?.let { file(it) }
|
||||
storeFile = file(prereleaseStoreFile)
|
||||
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
||||
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
||||
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
||||
|
|
@ -104,8 +62,10 @@ android {
|
|||
applicationId = "com.lagradost.cloudstream3"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = libs.versions.versionCode.get().toInt()
|
||||
versionName = libs.versions.versionName.get()
|
||||
versionCode = 67
|
||||
versionName = "4.6.2"
|
||||
|
||||
resValue("string", "commit_hash", getGitCommitHash())
|
||||
|
||||
manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get()
|
||||
|
||||
|
|
@ -183,12 +143,13 @@ android {
|
|||
}
|
||||
|
||||
lint {
|
||||
abortOnError = false
|
||||
checkReleaseBuilds = false
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
viewBinding = true
|
||||
resValues = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
|
|
@ -207,22 +168,17 @@ dependencies {
|
|||
testImplementation(libs.junit)
|
||||
testImplementation(libs.json)
|
||||
androidTestImplementation(libs.core)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
implementation(libs.junit.ktx)
|
||||
androidTestImplementation(libs.ext.junit)
|
||||
androidTestImplementation(libs.instancio.core)
|
||||
androidTestImplementation(libs.junit.ktx)
|
||||
androidTestImplementation(libs.kotlin.test)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
|
||||
// Android Core & Lifecycle
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.activity.ktx)
|
||||
implementation(libs.annotation)
|
||||
implementation(libs.appcompat)
|
||||
implementation(libs.fragment.ktx)
|
||||
implementation(libs.bundles.lifecycle)
|
||||
implementation(libs.bundles.navigation)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.kotlinx.serialization.json) // JSON Parser
|
||||
|
||||
// Design & UI
|
||||
implementation(libs.preference.ktx)
|
||||
|
|
@ -239,9 +195,6 @@ dependencies {
|
|||
// FFmpeg Decoding
|
||||
implementation(libs.bundles.nextlib)
|
||||
|
||||
// Anime-db for filler
|
||||
implementation(libs.anime.db)
|
||||
|
||||
// PlayBack
|
||||
implementation(libs.colorpicker) // Subtitle Color Picker
|
||||
implementation(libs.newpipeextractor) // For Trailers
|
||||
|
|
@ -259,15 +212,13 @@ dependencies {
|
|||
// Extensions & Other Libs
|
||||
implementation(libs.jsoup) // HTML Parser
|
||||
implementation(libs.rhino) // Run JavaScript
|
||||
implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance
|
||||
implementation(libs.safefile) // To Prevent the URI File Fu*kery
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor
|
||||
implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9
|
||||
implementation(libs.jackson.module.kotlin) // JSON Parser
|
||||
implementation(libs.zipline)
|
||||
|
||||
// Deprecated; will be removed once extensions have time to migrate from using it
|
||||
implementation("me.xdrop:fuzzywuzzy:1.4.0")
|
||||
|
||||
// Torrent Support
|
||||
implementation(libs.torrentserver)
|
||||
|
||||
|
|
@ -275,7 +226,16 @@ dependencies {
|
|||
implementation(libs.work.runtime.ktx)
|
||||
implementation(libs.nicehttp) // HTTP Lib
|
||||
|
||||
implementation(project(":library"))
|
||||
implementation(project(":library") {
|
||||
// There does not seem to be a good way of getting the android flavor.
|
||||
val isDebug = gradle.startParameter.taskRequests.any { task ->
|
||||
task.args.any { arg ->
|
||||
arg.contains("debug", true)
|
||||
}
|
||||
}
|
||||
|
||||
this.extra.set("isDebug", isDebug)
|
||||
})
|
||||
}
|
||||
|
||||
tasks.register<Jar>("androidSourcesJar") {
|
||||
|
|
@ -312,22 +272,16 @@ tasks.withType<KotlinJvmCompile> {
|
|||
compilerOptions {
|
||||
jvmTarget.set(javaTarget)
|
||||
jvmDefault.set(JvmDefaultMode.ENABLE)
|
||||
optIn.add("com.lagradost.cloudstream3.Prerelease")
|
||||
freeCompilerArgs.add("-Xannotation-default-target=param-property")
|
||||
optIn.addAll(
|
||||
"com.lagradost.cloudstream3.InternalAPI",
|
||||
"com.lagradost.cloudstream3.Prerelease",
|
||||
"kotlin.uuid.ExperimentalUuidApi",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dokka {
|
||||
moduleName = "App"
|
||||
dokkaSourceSets {
|
||||
configureEach {
|
||||
suppress = name != "prereleaseDebug"
|
||||
main {
|
||||
analysisPlatform = KotlinPlatform.JVM
|
||||
displayName = "JVM"
|
||||
documentedVisibilities(
|
||||
VisibilityModifier.Public,
|
||||
VisibilityModifier.Protected
|
||||
|
|
|
|||
|
|
@ -5,9 +5,4 @@
|
|||
|
||||
<!-- We don't care about MissingTranslation since it's handled by weblate. -->
|
||||
<issue id="MissingTranslation" severity="ignore" />
|
||||
|
||||
<!-- We only care about the source language here. -->
|
||||
<issue id="StringFormatInvalid">
|
||||
<ignore path="**/res/values-*/**" />
|
||||
</issue>
|
||||
</lint>
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import dalvik.system.DexFile
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.serializer
|
||||
import kotlinx.serialization.serializerOrNull
|
||||
import org.instancio.Instancio
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.jvm.jvmName
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SerializationClassTester {
|
||||
// Same as app, or using app reference
|
||||
val jacksonMapper = mapper
|
||||
val kotlinxMapper = json
|
||||
|
||||
@Test
|
||||
fun isIdenticalSerialization() {
|
||||
val serializableClasses = findSerializableClasses("com.lagradost")
|
||||
println("Number of serializable classes: ${serializableClasses.size}")
|
||||
|
||||
serializableClasses.forEach { kClass ->
|
||||
val instance = Instancio.create(kClass.java)
|
||||
|
||||
val jacksonJson = jacksonMapper.writeValueAsString(instance)
|
||||
val kotlinxJson = serializeWithKotlinx(kClass, instance)
|
||||
|
||||
assertEquals(
|
||||
jacksonJson,
|
||||
kotlinxJson,
|
||||
"""
|
||||
Serialization mismatch for:
|
||||
${kClass.qualifiedName}
|
||||
|
||||
Jackson:
|
||||
$jacksonJson
|
||||
|
||||
Kotlinx:
|
||||
$kotlinxJson
|
||||
|
||||
""".trimIndent()
|
||||
)
|
||||
println("Identical serialization for: ${kClass.jvmName}")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
|
||||
@Test
|
||||
fun isIdenticalDeserialization() {
|
||||
val serializableClasses = findSerializableClasses("com.lagradost")
|
||||
println("Number of serializable classes: ${serializableClasses.size}")
|
||||
|
||||
serializableClasses.forEach { kClass ->
|
||||
val instance = Instancio.create(kClass.java)
|
||||
// Convert to JSON to get example JSON object
|
||||
// We prefer jackson here because the app may have many jackson JSON strings in local storage
|
||||
val originalJson = jacksonMapper.writeValueAsString(instance)
|
||||
|
||||
// Create an object from the JSON using kotlinx
|
||||
val serializer =
|
||||
kClass.serializerOrNull() ?: kotlinxMapper.serializersModule.getContextual(kClass)
|
||||
assertNotNull(serializer, "The class: ${kClass.jvmName} must be serializable!")
|
||||
val kotlinxDecoded = kotlinxMapper.decodeFromString(serializer, originalJson)
|
||||
|
||||
// Create an object from the JSON using jackson
|
||||
val mapperDecoded = jacksonMapper.readValue(originalJson, kClass.java)
|
||||
|
||||
|
||||
// Deep inspect both object using the mapper toJson function.
|
||||
// This deep equality check can be performed using other methods, but this just works.
|
||||
val jacksonJson = mapperDecoded.toJson()
|
||||
val kotlinxJson = kotlinxDecoded.toJson()
|
||||
|
||||
assertEquals(
|
||||
jacksonJson,
|
||||
kotlinxJson,
|
||||
"""
|
||||
Serialization mismatch for:
|
||||
${kClass.qualifiedName}
|
||||
|
||||
Jackson:
|
||||
$jacksonJson
|
||||
|
||||
Kotlinx:
|
||||
$kotlinxJson
|
||||
|
||||
""".trimIndent()
|
||||
)
|
||||
println("Identical deserialization for: ${kClass.jvmName}")
|
||||
}
|
||||
}
|
||||
|
||||
// DEX files are the best solution to read all our classes dynamically.
|
||||
// classgraph could be used instead, but it only gives results on the JVM, not Android.
|
||||
@Suppress("DEPRECATION")
|
||||
private fun findSerializableClasses(packageName: String): List<KClass<*>> {
|
||||
val context = InstrumentationRegistry
|
||||
.getInstrumentation()
|
||||
.targetContext
|
||||
|
||||
val dexFile = DexFile(context.packageCodePath)
|
||||
return dexFile.entries()
|
||||
.toList()
|
||||
.filter { it.startsWith(packageName) }
|
||||
.mapNotNull {
|
||||
runCatching { Class.forName(it).kotlin }.getOrNull()
|
||||
}.filter { kClass ->
|
||||
// Not possible to use .hasAnnotation() on newer Android versions.
|
||||
kClass.java.annotations.any {
|
||||
it is Serializable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun serializeWithKotlinx(
|
||||
kClass: KClass<*>,
|
||||
value: Any
|
||||
): String {
|
||||
val serializer = kClass.serializer() as KSerializer<Any>
|
||||
return kotlinxMapper.encodeToString(serializer, value)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
package com.lagradost.cloudstream3.utils.serializers
|
||||
|
||||
import android.net.Uri
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KeepGeneratedSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@KeepGeneratedSerializer
|
||||
@Serializable(with = NonEmptyData.Serializer::class)
|
||||
data class NonEmptyData(
|
||||
val title: String = "",
|
||||
val tags: List<String> = emptyList(),
|
||||
val meta: Map<String, String> = emptyMap(),
|
||||
val name: String = "hello",
|
||||
) {
|
||||
object Serializer : NonEmptySerializer<NonEmptyData>(NonEmptyData.generatedSerializer())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@KeepGeneratedSerializer
|
||||
@Serializable(with = WriteOnlyData.Serializer::class)
|
||||
data class WriteOnlyData(
|
||||
val fieldA: String = "",
|
||||
val fieldB: String = "",
|
||||
) {
|
||||
object Serializer : WriteOnlySerializer<WriteOnlyData>(
|
||||
WriteOnlyData.generatedSerializer(),
|
||||
setOf("fieldB"),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@KeepGeneratedSerializer
|
||||
@Serializable(with = MultiWriteOnly.Serializer::class)
|
||||
data class MultiWriteOnly(
|
||||
val fieldA: String = "",
|
||||
val fieldB: String = "",
|
||||
val fieldC: String = "",
|
||||
) {
|
||||
object Serializer : WriteOnlySerializer<MultiWriteOnly>(
|
||||
MultiWriteOnly.generatedSerializer(),
|
||||
setOf("fieldB", "fieldC"),
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class UriData(
|
||||
@Serializable(with = UriSerializer::class)
|
||||
val uri: Uri = Uri.EMPTY,
|
||||
)
|
||||
|
||||
class SerializerTest {
|
||||
|
||||
@Test
|
||||
fun nonEmptySerializerOmitsEmptyStrings() {
|
||||
val data = NonEmptyData(title = "", name = "hello")
|
||||
val result = data.toJson()
|
||||
assertFalse(result.contains("title"))
|
||||
assertTrue(result.contains("name"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonEmptySerializerOmitsEmptyLists() {
|
||||
val data = NonEmptyData(tags = emptyList(), name = "hello")
|
||||
val result = data.toJson()
|
||||
assertFalse(result.contains("tags"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonEmptySerializerOmitsEmptyMaps() {
|
||||
val data = NonEmptyData(meta = emptyMap(), name = "hello")
|
||||
val result = data.toJson()
|
||||
assertFalse(result.contains("meta"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonEmptySerializerKeepsNonEmptyFields() {
|
||||
val data = NonEmptyData(title = "hello", tags = listOf("a"), meta = mapOf("k" to "v"))
|
||||
val result = data.toJson()
|
||||
assertTrue(result.contains("title"))
|
||||
assertTrue(result.contains("tags"))
|
||||
assertTrue(result.contains("meta"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonEmptySerializerDoesNotAffectDeserialization() {
|
||||
val input = """{"title":"hello","tags":["a"],"meta":{"k":"v"},"name":"world"}"""
|
||||
val result = parseJson<NonEmptyData>(input)
|
||||
assertEquals("hello", result.title)
|
||||
assertEquals(listOf("a"), result.tags)
|
||||
assertEquals(mapOf("k" to "v"), result.meta)
|
||||
assertEquals("world", result.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writeOnlySerializerOmitsFieldOnSerialize() {
|
||||
val data = WriteOnlyData(fieldA = "hello", fieldB = "secret")
|
||||
val result = data.toJson()
|
||||
assertTrue(result.contains("fieldA"))
|
||||
assertFalse(result.contains("fieldB"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writeOnlySerializerDeserializesNormally() {
|
||||
val input = """{"fieldA":"hello","fieldB":"secret"}"""
|
||||
val result = parseJson<WriteOnlyData>(input)
|
||||
assertEquals("hello", result.fieldA)
|
||||
assertEquals("secret", result.fieldB)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writeOnlySerializerDeserializesMissingAsDefault() {
|
||||
val input = """{"fieldA":"hello"}"""
|
||||
val result = parseJson<WriteOnlyData>(input)
|
||||
assertEquals("hello", result.fieldA)
|
||||
assertEquals("", result.fieldB)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writeOnlySerializerHandlesMultipleKeys() {
|
||||
val data = MultiWriteOnly(fieldA = "hello", fieldB = "secret1", fieldC = "secret2")
|
||||
val result = data.toJson()
|
||||
assertTrue(result.contains("fieldA"))
|
||||
assertFalse(result.contains("fieldB"))
|
||||
assertFalse(result.contains("fieldC"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uriSerializerSerializesUriToString() {
|
||||
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
|
||||
val result = data.toJson()
|
||||
assertTrue(result.contains("https://example.com/path?query=1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uriSerializerDeserializesStringToUri() {
|
||||
val input = """{"uri":"https://example.com/path?query=1"}"""
|
||||
val result = parseJson<UriData>(input)
|
||||
assertEquals(Uri.parse("https://example.com/path?query=1"), result.uri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uriSerializerRoundtripsCorrectly() {
|
||||
val data = UriData(uri = Uri.parse("https://example.com/path?query=1"))
|
||||
val encoded = data.toJson()
|
||||
val decoded = parseJson<UriData>(encoded)
|
||||
assertEquals(data.uri, decoded.uri)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,47 +22,6 @@
|
|||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<queries>
|
||||
<!--
|
||||
QUERY_ALL_PACKAGES does not work on some devices running Android 11+ (like Google TV 14),
|
||||
so we must explicitly specify the packages and intent patterns we query to ensure visibility.
|
||||
-->
|
||||
<!-- For external video players -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="application/x-mpegURL" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="application/vnd.apple.mpegurl" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="magnet" />
|
||||
</intent>
|
||||
|
||||
<!-- Common players supported in actions/temp -->
|
||||
<package android:name="org.videolan.vlc" />
|
||||
<package android:name="org.videolan.vlc.debug" />
|
||||
<package android:name="is.xyz.mpv" />
|
||||
<package android:name="is.xyz.mpv.ytdl" />
|
||||
<package android:name="app.marlboroadvance.mpvex" />
|
||||
<package android:name="live.mehiz.mpvkt" />
|
||||
<package android:name="live.mehiz.mpvkt.preview" />
|
||||
<package android:name="com.brouken.player" />
|
||||
<package android:name="dev.anilbeesetti.nextplayer" />
|
||||
<package android:name="com.instantbits.cast.webvideo" />
|
||||
<package android:name="com.gianlu.aria2android" />
|
||||
|
||||
<!-- Torrent clients -->
|
||||
<package android:name="org.proninyaroslav.libretorrent" />
|
||||
<package android:name="com.biglybt.android.client" />
|
||||
</queries>
|
||||
|
||||
<!-- Fixes android tv fuckery -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
|
|
@ -149,31 +108,14 @@
|
|||
android:launchMode="singleTask"
|
||||
is a bit experimental, it makes loading repositories from browser still stay on the same page
|
||||
no idea about side effects
|
||||
|
||||
Not exported to prevent bypassing the AccountSelectActivity
|
||||
-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|uiMode"
|
||||
android:exported="false"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true"
|
||||
android:supportsPictureInPicture="true" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.account.AccountSelectActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
||||
android:exported="true">
|
||||
<intent-filter android:exported="true">
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
android:supportsPictureInPicture="true">
|
||||
|
||||
<!-- cloudstreamplayer://encodedUrl?name=Dune -->
|
||||
<intent-filter>
|
||||
|
|
@ -231,7 +173,7 @@
|
|||
<data android:scheme="cloudstreamcontinuewatching" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
|
@ -244,6 +186,21 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.account.AccountSelectActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
|
||||
android:exported="true">
|
||||
<intent-filter android:exported="true">
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".receivers.VideoDownloadRestartReceiver"
|
||||
android:enabled="false"
|
||||
|
|
|
|||
|
|
@ -1,78 +1,103 @@
|
|||
package com.lagradost.cloudstream3
|
||||
|
||||
import android.content.Context
|
||||
import com.lagradost.api.setContext
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
|
||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Deprecated alias for CloudStreamApp for backwards compatibility with plugins.
|
||||
* Use CloudStreamApp instead.
|
||||
*/
|
||||
@Deprecated(
|
||||
// Deprecate after next stable
|
||||
/*@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
)*/
|
||||
class AcraApplication {
|
||||
// All methods here can be changed to be a wrapper around CloudStream app
|
||||
// without a seperate deprecation after next stable. All methods should
|
||||
// also be deprecated at that time.
|
||||
companion object {
|
||||
|
||||
@Deprecated(
|
||||
// This can be removed without deprecation after next stable
|
||||
private var _context: WeakReference<Context>? = null
|
||||
/*@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
val context get() = CloudStreamApp.context
|
||||
)*/
|
||||
var context
|
||||
get() = _context?.get()
|
||||
internal set(value) {
|
||||
_context = WeakReference(value)
|
||||
setContext(WeakReference(value))
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
/*@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
fun removeKeys(folder: String): Int? =
|
||||
CloudStreamApp.removeKeys(folder)
|
||||
)*/
|
||||
fun removeKeys(folder: String): Int? {
|
||||
return context?.removeKeys(folder)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
/*@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
fun <T> setKey(path: String, value: T) =
|
||||
CloudStreamApp.setKey(path, value)
|
||||
)*/
|
||||
fun <T> setKey(path: String, value: T) {
|
||||
context?.setKey(path, value)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
/*@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
fun <T> setKey(folder: String, path: String, value: T) =
|
||||
CloudStreamApp.setKey(folder, path, value)
|
||||
)*/
|
||||
fun <T> setKey(folder: String, path: String, value: T) {
|
||||
context?.setKey(folder, path, value)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
/*@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? =
|
||||
CloudStreamApp.getKey(path, defVal)
|
||||
)*/
|
||||
inline fun <reified T : Any> getKey(path: String, defVal: T?): T? {
|
||||
return context?.getKey(path, defVal)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
/*@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
inline fun <reified T : Any> getKey(path: String): T? =
|
||||
CloudStreamApp.getKey(path)
|
||||
)*/
|
||||
inline fun <reified T : Any> getKey(path: String): T? {
|
||||
return context?.getKey(path)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
/*@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String): T? =
|
||||
CloudStreamApp.getKey(folder, path)
|
||||
)*/
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String): T? {
|
||||
return context?.getKey(folder, path)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
/*@Deprecated(
|
||||
message = "AcraApplication is deprecated, use CloudStreamApp instead",
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? =
|
||||
CloudStreamApp.getKey(folder, path, defVal)
|
||||
)*/
|
||||
inline fun <reified T : Any> getKey(folder: String, path: String, defVal: T?): T? {
|
||||
return context?.getKey(folder, path, defVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import coil3.ImageLoader
|
|||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import com.lagradost.api.setContext
|
||||
import com.lagradost.cloudstream3.BuildConfig
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.mvvm.safeAsync
|
||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||
|
|
@ -21,7 +20,6 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
|||
import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser
|
||||
import com.lagradost.cloudstream3.utils.AppDebug
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||
|
|
@ -67,6 +65,7 @@ class ExceptionHandler(
|
|||
}
|
||||
}
|
||||
|
||||
@Prerelease
|
||||
class CloudStreamApp : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
override fun onCreate() {
|
||||
|
|
@ -82,13 +81,13 @@ class CloudStreamApp : Application(), SingletonImageLoader.Factory {
|
|||
exceptionHandler = it
|
||||
Thread.setDefaultUncaughtExceptionHandler(it)
|
||||
}
|
||||
|
||||
AppDebug.isDebug = BuildConfig.DEBUG
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
context = base
|
||||
// This can be removed without deprecation after next stable
|
||||
AcraApplication.context = context
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import android.content.res.Configuration
|
|||
import android.content.res.Resources
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
|
|
@ -41,6 +39,7 @@ import com.lagradost.cloudstream3.mvvm.logError
|
|||
import com.lagradost.cloudstream3.syncproviders.AccountManager
|
||||
import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter
|
||||
import com.lagradost.cloudstream3.ui.home.ParentItemAdapter
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerEventType
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible
|
||||
import com.lagradost.cloudstream3.ui.player.Torrent
|
||||
import com.lagradost.cloudstream3.ui.result.ActorAdaptor
|
||||
|
|
@ -116,6 +115,7 @@ object CommonActivity {
|
|||
val onColorSelectedEvent = Event<Pair<Int, Int>>()
|
||||
val onDialogDismissedEvent = Event<Int>()
|
||||
|
||||
var playerEventListener: ((PlayerEventType) -> Unit)? = null
|
||||
var keyEventListener: ((Pair<KeyEvent?, Boolean>) -> Boolean)? = null
|
||||
var appliedTheme: Int = 0
|
||||
var appliedColor: Int = 0
|
||||
|
|
@ -191,16 +191,6 @@ object CommonActivity {
|
|||
currentToast = toast
|
||||
toast.show()
|
||||
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
val ref = WeakReference(toast)
|
||||
|
||||
/* Clean up activity leak */
|
||||
handler.postDelayed({
|
||||
if (ref.get() == currentToast) {
|
||||
currentToast = null
|
||||
}
|
||||
}, 10_000)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
|
|
@ -532,7 +522,87 @@ object CommonActivity {
|
|||
|
||||
|
||||
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? {
|
||||
|
||||
// 149 keycode_numpad 5
|
||||
val playerEvent = when (keyCode) {
|
||||
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
PlayerEventType.SeekForward
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
PlayerEventType.SeekBack
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> {
|
||||
PlayerEventType.NextEpisode
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> {
|
||||
PlayerEventType.PrevEpisode
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||
PlayerEventType.Pause
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
|
||||
PlayerEventType.Play
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
|
||||
PlayerEventType.Lock
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
|
||||
PlayerEventType.ToggleHide
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
|
||||
PlayerEventType.ToggleMute
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
|
||||
PlayerEventType.ShowMirrors
|
||||
}
|
||||
// OpenSubtitles shortcut
|
||||
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
|
||||
PlayerEventType.SearchSubtitlesOnline
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
|
||||
PlayerEventType.ShowSpeed
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
|
||||
PlayerEventType.Resize
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
|
||||
PlayerEventType.SkipOp
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
|
||||
PlayerEventType.SkipCurrentChapter
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
|
||||
PlayerEventType.PlayPauseToggle
|
||||
}
|
||||
|
||||
else -> return null
|
||||
}
|
||||
val listener = playerEventListener
|
||||
if (listener != null) {
|
||||
listener.invoke(playerEvent)
|
||||
return true
|
||||
}
|
||||
return null
|
||||
|
||||
//when (keyCode) {
|
||||
// KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||
// println("DPAD PRESSED")
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
/** overrides focus and custom key events */
|
||||
|
|
@ -579,10 +649,8 @@ object CommonActivity {
|
|||
|
||||
// TODO: Figure out why removing the check for SearchAutoComplete seems
|
||||
// to break focus on TV as it shouldn't need to be used.
|
||||
// Also handle KEYCODE_ENTER here because some remotes (e.g. LG Magic Remote)
|
||||
// send KEYCODE_ENTER instead of KEYCODE_DPAD_CENTER when clicking the OK button.
|
||||
@SuppressLint("RestrictedApi")
|
||||
if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) &&
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
|
||||
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
|
||||
) {
|
||||
showInputMethod(act.currentFocus?.findFocus())
|
||||
|
|
|
|||
|
|
@ -189,8 +189,6 @@ import kotlin.math.abs
|
|||
import kotlin.math.absoluteValue
|
||||
import kotlin.system.exitProcess
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
|
||||
class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback {
|
||||
companion object {
|
||||
|
|
@ -276,6 +274,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
* @return true if the str has launched an app task (be it successful or not)
|
||||
* @param isWebview does not handle providers and opening download page if true. Can still add repos and login.
|
||||
* */
|
||||
@Suppress("DEPRECATION_ERROR")
|
||||
fun handleAppIntentUrl(
|
||||
activity: FragmentActivity?,
|
||||
str: String?,
|
||||
|
|
@ -362,8 +361,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
LinkGenerator(
|
||||
listOf(BasicLink(url, name)),
|
||||
extract = true,
|
||||
id = url.hashCode()
|
||||
), 0
|
||||
)
|
||||
)
|
||||
)
|
||||
} else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) {
|
||||
|
|
@ -408,14 +406,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
return true
|
||||
}
|
||||
|
||||
val matchedApi = apis.filter { str.startsWith(it.mainUrl) }.firstOrNull()
|
||||
if (matchedApi != null) {
|
||||
loadResult(str, matchedApi.name, "")
|
||||
synchronized(apis) {
|
||||
for (api in apis) {
|
||||
if (str.startsWith(api.mainUrl)) {
|
||||
loadResult(str, api.name, "")
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -440,7 +441,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
|
||||
|
||||
var lastPopup: SearchResponse? = null
|
||||
var lastPopupJob: Job? = null
|
||||
fun loadPopup(result: SearchResponse, load: Boolean = true) {
|
||||
lastPopup = result
|
||||
val syncName = syncViewModel.syncName(result.apiName)
|
||||
|
|
@ -456,8 +456,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
syncViewModel.clear()
|
||||
}
|
||||
|
||||
lastPopupJob?.cancel()
|
||||
lastPopupJob = if (load) {
|
||||
if (load) {
|
||||
viewModel.load(
|
||||
this, result.url, result.apiName, false, if (getApiDubstatusSettings()
|
||||
.contains(DubStatus.Dubbed)
|
||||
|
|
@ -557,10 +556,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
navView.isVisible = isNavVisible && !isLandscape()
|
||||
navHostFragment.apply {
|
||||
val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width)
|
||||
layoutParams =
|
||||
(navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||
marginStart =
|
||||
if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
|
||||
layoutParams = (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply {
|
||||
marginStart = if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -570,11 +567,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
* highlight the wrong one in UI.
|
||||
*/
|
||||
when (destination.id) {
|
||||
in listOf(
|
||||
R.id.navigation_downloads,
|
||||
R.id.navigation_download_child,
|
||||
R.id.navigation_download_queue
|
||||
) -> {
|
||||
in listOf(R.id.navigation_downloads, R.id.navigation_download_child, R.id.navigation_download_queue) -> {
|
||||
navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
||||
navView.menu.findItem(R.id.navigation_downloads).isChecked = true
|
||||
}
|
||||
|
|
@ -806,11 +799,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private val pluginsLock = Mutex()
|
||||
private fun onAllPluginsLoaded(success: Boolean = false) {
|
||||
ioSafe {
|
||||
pluginsLock.withLock {
|
||||
allProviders.withLock {
|
||||
synchronized(allProviders) {
|
||||
// Load cloned sites after plugins have been loaded since clones depend on plugins.
|
||||
try {
|
||||
getKey<Array<SettingsGeneral.CustomSite>>(USER_PROVIDER_API)?.let { list ->
|
||||
|
|
@ -856,8 +850,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
|
||||
private fun hidePreviewPopupDialog() {
|
||||
bottomPreviewPopup.dismissSafe(this)
|
||||
lastPopupJob?.cancel()
|
||||
lastPopupJob = null
|
||||
bottomPreviewPopup = null
|
||||
bottomPreviewBinding = null
|
||||
}
|
||||
|
|
@ -1177,11 +1169,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION_ERROR")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
app.initClient(this, ignoreSSL = false)
|
||||
@OptIn(UnsafeSSL::class)
|
||||
insecureApp.initClient(this, ignoreSSL = true)
|
||||
|
||||
app.initClient(this)
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
setLastError(this)
|
||||
|
|
@ -1653,7 +1643,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
ioSafe {
|
||||
initAll()
|
||||
// No duplicates (which can happen by registerMainAPI)
|
||||
apis = allProviders.distinctBy { it }
|
||||
apis = synchronized(allProviders) {
|
||||
allProviders.distinctBy { it }
|
||||
}
|
||||
}
|
||||
|
||||
// val navView: BottomNavigationView = findViewById(R.id.nav_view)
|
||||
|
|
@ -1961,7 +1953,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
|
|||
|
||||
if (BuildConfig.DEBUG) {
|
||||
var providersAndroidManifestString = "Current androidmanifest should be:\n"
|
||||
allProviders.withLock {
|
||||
synchronized(allProviders) {
|
||||
for (api in allProviders) {
|
||||
providersAndroidManifestString += "<data android:scheme=\"https\" android:host=\"${
|
||||
api.mainUrl.removePrefix(
|
||||
|
|
|
|||
|
|
@ -20,10 +20,8 @@ import com.lagradost.cloudstream3.actions.temp.MpvExPackage
|
|||
import com.lagradost.cloudstream3.actions.temp.MpvKtPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.MpvPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.MpvRxPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage
|
||||
import com.lagradost.cloudstream3.actions.temp.OnlyPlayer
|
||||
import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction
|
||||
import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction
|
||||
import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action
|
||||
|
|
@ -34,8 +32,8 @@ import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
import com.lagradost.cloudstream3.utils.UiText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -45,7 +43,7 @@ import java.util.concurrent.FutureTask
|
|||
import kotlin.reflect.jvm.jvmName
|
||||
|
||||
object VideoClickActionHolder {
|
||||
val allVideoClickActions = atomicListOf(
|
||||
val allVideoClickActions = threadSafeListOf(
|
||||
// Default
|
||||
PlayInBrowserAction(),
|
||||
CopyClipboardAction(),
|
||||
|
|
@ -66,8 +64,6 @@ object VideoClickActionHolder {
|
|||
MpvYTDLPackage(),
|
||||
MpvKtPackage(),
|
||||
MpvKtPreviewPackage(),
|
||||
OnlyPlayer(),
|
||||
MpvRxPackage(),
|
||||
// Always Ask option
|
||||
AlwaysAskAction(),
|
||||
// added by plugins
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
package com.lagradost.cloudstream3.actions.temp
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import com.lagradost.api.Log
|
||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||
import com.lagradost.cloudstream3.actions.updateDurationAndPosition
|
||||
import com.lagradost.cloudstream3.isEpisodeBased
|
||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
|
||||
/** https://github.com/Riteshp2001/mpvRx
|
||||
*
|
||||
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L132
|
||||
* https://github.com/Riteshp2001/mpvRx/blob/00e0c5e803ab53e5757426cbf2248448ba1f49bf/app/src/main/java/app/gyrolet/mpvrx/utils/media/MediaUtils.kt#L56
|
||||
* */
|
||||
class MpvRxPackage : OpenInAppAction(
|
||||
appName = txt("mpvRx"),
|
||||
packageName = "app.gyrolet.mpvrx",
|
||||
intentClass = "app.gyrolet.mpvrx.ui.player.PlayerActivity"
|
||||
) {
|
||||
override val oneSource = true
|
||||
override suspend fun putExtra(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
video: ResultEpisode,
|
||||
result: LinkLoadingResult,
|
||||
index: Int?
|
||||
) {
|
||||
intent.apply {
|
||||
putExtra("title", video.name)
|
||||
val link = result.links[index!!]
|
||||
val headers = link.headers
|
||||
|
||||
setData(link.url.toUri())
|
||||
if (headers.isNotEmpty()) {
|
||||
// PlayerActivity expects a flat array: [key1, value1, key2, value2, ...]
|
||||
val flat = headers.entries.flatMap { listOf(it.key, it.value) }.toTypedArray()
|
||||
intent.putExtra("headers", flat)
|
||||
}
|
||||
/*val subs = result.subs // disabled due to https://github.com/Riteshp2001/mpvRx/issues/146
|
||||
intent.putExtra("subs", subs.map { it.url.toUri() }.toTypedArray())
|
||||
intent.putExtra(
|
||||
"subs.titles",
|
||||
subs.map { it.name }.toTypedArray(),
|
||||
)
|
||||
intent.putExtra(
|
||||
"subs.langs",
|
||||
subs.map { it.languageCode }.toTypedArray(),
|
||||
)
|
||||
val selected = subs.firstOrNull { it.matchesLanguageCode("en") }?.url?.toUri()
|
||||
intent.putExtra("subs.enable", selected?.let { arrayOf(it) } ?: arrayOf<Uri>() )*/
|
||||
|
||||
if (video.tvType.isEpisodeBased()) {
|
||||
video.season?.let { intent.putExtra("introdb_season", it) }
|
||||
video.episode.let { intent.putExtra("introdb_episode", it) }
|
||||
}
|
||||
|
||||
val position = getViewPos(video.id)?.position
|
||||
if (position != null)
|
||||
putExtra("position", position.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResult(activity: Activity, intent: Intent?) {
|
||||
val position = intent?.getIntExtra("position", -1) ?: -1
|
||||
val duration = intent?.getIntExtra("duration", -1) ?: -1
|
||||
Log.d("MPV", "Position: $position, Duration: $duration")
|
||||
updateDurationAndPosition(position.toLong(), duration.toLong())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
package com.lagradost.cloudstream3.actions.temp
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.core.net.toUri
|
||||
import com.lagradost.cloudstream3.actions.OpenInAppAction
|
||||
import com.lagradost.cloudstream3.ui.result.LinkLoadingResult
|
||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
|
||||
/** https://github.com/Kindness-Kismet/only_player/tree/main
|
||||
* https://github.com/Kindness-Kismet/only_player/blob/main/feature/player/src/main/java/one/only/player/feature/player/PlayerActivity.kt */
|
||||
class OnlyPlayer : OpenInAppAction(
|
||||
txt("Only Player"),
|
||||
"one.only.player",
|
||||
intentClass = "one.only.player.feature.player.PlayerActivity"
|
||||
) {
|
||||
override val oneSource = true
|
||||
override suspend fun putExtra(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
video: ResultEpisode,
|
||||
result: LinkLoadingResult,
|
||||
index: Int?
|
||||
) {
|
||||
/** https://github.com/Kindness-Kismet/only_player/blob/d3f55049a2913fa762d31b311146073cc2da46cb/app/src/main/java/one/only/player/navigation/CloudNavGraph.kt#L39 */
|
||||
intent.apply {
|
||||
val link = result.links[index!!]
|
||||
setData(link.url.toUri())
|
||||
|
||||
putExtra("headers", Bundle().apply {
|
||||
for ((key, value) in link.headers) {
|
||||
putExtra(key, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResult(activity: Activity, intent: Intent?) {
|
||||
/* onResult does not get called */
|
||||
}
|
||||
}
|
||||
|
|
@ -35,11 +35,9 @@ class PlayMirrorAction : VideoClickAction() {
|
|||
) {
|
||||
//Implemented a generator to handle the single
|
||||
val activity = context as? Activity ?: return
|
||||
val link = index?.let { result.links[it] }
|
||||
val generatorMirror = object : VideoGenerator<ResultEpisode>(listOf(video)) {
|
||||
override val hasCache: Boolean = false
|
||||
override val canSkipLoading: Boolean = false
|
||||
override fun getId(index: Int): Int = video.id
|
||||
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
|
|
@ -49,7 +47,7 @@ class PlayMirrorAction : VideoClickAction() {
|
|||
offset: Int,
|
||||
isCasting: Boolean
|
||||
): Boolean {
|
||||
index?.let { callback(link to null) }
|
||||
index?.let { callback(result.links[it] to null) }
|
||||
result.subs.forEach { subtitle -> subtitleCallback(subtitle) }
|
||||
return true
|
||||
}
|
||||
|
|
@ -58,7 +56,7 @@ class PlayMirrorAction : VideoClickAction() {
|
|||
activity.navigate(
|
||||
R.id.global_to_navigation_player,
|
||||
GeneratorPlayer.newInstance(
|
||||
generatorMirror, 0, result.syncData
|
||||
generatorMirror, result.syncData
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,16 @@
|
|||
package com.lagradost.cloudstream3.mvvm
|
||||
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.core.view.doOnAttach
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||
|
||||
/** NOTE: Only one observer at a time per value */
|
||||
fun <T> ComponentActivity.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
||||
observeNullable(liveData) { t -> t?.run(action) }
|
||||
}
|
||||
|
||||
/** NOTE: Only one observer at a time per value */
|
||||
fun <T> ComponentActivity.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
|
||||
fun <T> LifecycleOwner.observe(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||
liveData.removeObservers(this)
|
||||
liveData.observe(this, action)
|
||||
liveData.observe(this) { it?.let { t -> action(t) } }
|
||||
}
|
||||
|
||||
/** NOTE: Only one observer at a time per value */
|
||||
fun <T, V : ViewBinding> BaseFragment<V>.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
||||
observeNullable(liveData) { t -> t?.run(action) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches an observable to the root binding, instead of the fragment. This is more efficient as
|
||||
* it will not call observe if the view is in the background.
|
||||
*
|
||||
* NOTE: Only one observer at a time per value
|
||||
* */
|
||||
fun <T, V : ViewBinding> BaseFragment<V>.observeNullable(
|
||||
liveData: LiveData<T>, action: (T?) -> Unit
|
||||
) {
|
||||
val root = this.binding?.root
|
||||
if (root == null) {
|
||||
fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) -> Unit) {
|
||||
liveData.removeObservers(this)
|
||||
liveData.observe(this, action)
|
||||
} else {
|
||||
root.doOnAttach { view ->
|
||||
// On attach should make findViewTreeLifecycleOwner non-null, but use "this" just in case
|
||||
val owner: LifecycleOwner = view.findViewTreeLifecycleOwner() ?: this@observeNullable
|
||||
liveData.removeObservers(owner)
|
||||
liveData.observe(owner, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** NOTE: Only one observer at a time per value */
|
||||
fun <T> View.observe(liveData: LiveData<T>, action: (T) -> Unit) {
|
||||
observeNullable(liveData) { t -> t?.run(action) }
|
||||
}
|
||||
|
||||
/** NOTE: Only one observer at a time per value */
|
||||
fun <T> View.observeNullable(liveData: LiveData<T>, action: (T?) -> Unit) {
|
||||
doOnAttach { view ->
|
||||
// On attach should make findViewTreeLifecycleOwner non-null
|
||||
val owner: LifecycleOwner? = view.findViewTreeLifecycleOwner()
|
||||
if(owner == null) {
|
||||
debugException { "Expected non-null findViewTreeLifecycleOwner" }
|
||||
return@doOnAttach
|
||||
}
|
||||
liveData.removeObservers(owner)
|
||||
liveData.observe(owner, action)
|
||||
}
|
||||
liveData.observe(this) { action(it) }
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.network
|
|||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.lagradost.cloudstream3.Prerelease
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
|
|
@ -16,26 +15,11 @@ import org.conscrypt.Conscrypt
|
|||
import java.io.File
|
||||
import java.security.Security
|
||||
|
||||
// Backwards compatible constructor, mark as deprecated later
|
||||
fun Requests.initClient(context: Context) {
|
||||
this.baseClient = buildDefaultClient(context)
|
||||
}
|
||||
|
||||
/** Only use ignoreSSL if you know what you are doing*/
|
||||
@Prerelease
|
||||
fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) {
|
||||
this.baseClient = buildDefaultClient(context, ignoreSSL)
|
||||
}
|
||||
|
||||
|
||||
// Backwards compatible constructor, mark as deprecated later
|
||||
fun buildDefaultClient(context: Context): OkHttpClient {
|
||||
return buildDefaultClient(context, false)
|
||||
}
|
||||
|
||||
/** Only use ignoreSSL if you know what you are doing*/
|
||||
@Prerelease
|
||||
fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
|
||||
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
|
||||
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
|
@ -43,11 +27,7 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
|
|||
val baseClient = OkHttpClient.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.apply {
|
||||
if (ignoreSSL) {
|
||||
ignoreAllSSLErrors()
|
||||
}
|
||||
}
|
||||
.ignoreAllSSLErrors()
|
||||
.cache(
|
||||
// Note that you need to add a ResponseInterceptor to make this 100% active.
|
||||
// The server response dictates if and when stuff should be cached.
|
||||
|
|
@ -72,6 +52,11 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie
|
|||
return baseClient
|
||||
}
|
||||
|
||||
//val Request.cookies: Map<String, String>
|
||||
// get() {
|
||||
// return this.headers.getCookies("Cookie")
|
||||
// }
|
||||
|
||||
private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.actions.VideoClickAction
|
|||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||
import kotlin.Throws
|
||||
|
||||
|
||||
abstract class Plugin : BasePlugin() {
|
||||
/**
|
||||
* Called when your Plugin is loaded
|
||||
|
|
@ -25,8 +26,10 @@ abstract class Plugin : BasePlugin() {
|
|||
fun registerVideoClickAction(element: VideoClickAction) {
|
||||
Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction")
|
||||
element.sourcePlugin = this.filename
|
||||
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
||||
VideoClickActionHolder.allVideoClickActions.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This will contain your resources if you specified requiresResources in gradle
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import android.os.Build
|
|||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
|
@ -27,7 +26,6 @@ import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
|||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.InternalAPI
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||
|
|
@ -46,7 +44,6 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDE
|
|||
import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES
|
||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
||||
import com.lagradost.cloudstream3.plugins.RepositoryManager.sha256
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings
|
||||
|
|
@ -80,7 +77,6 @@ data class PluginData(
|
|||
@JsonProperty("filePath") val filePath: String,
|
||||
@JsonProperty("version") val version: Int,
|
||||
) {
|
||||
@WorkerThread
|
||||
fun toSitePlugin(): SitePlugin {
|
||||
return SitePlugin(
|
||||
this.filePath,
|
||||
|
|
@ -95,9 +91,7 @@ data class PluginData(
|
|||
null,
|
||||
null,
|
||||
null,
|
||||
File(this.filePath).length(),
|
||||
// No file hash for local plugins. Local plugins have no use for the hash, and it's expensive to compute.
|
||||
null
|
||||
File(this.filePath).length()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -265,8 +259,12 @@ object PluginManager {
|
|||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
@Throws
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
|
@ -307,7 +305,6 @@ object PluginManager {
|
|||
downloadPlugin(
|
||||
activity,
|
||||
pluginData.onlineData.second.url,
|
||||
pluginData.onlineData.second.fileHash,
|
||||
pluginData.savedData.internalName,
|
||||
File(pluginData.savedData.filePath),
|
||||
true
|
||||
|
|
@ -343,8 +340,12 @@ object PluginManager {
|
|||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
@Throws
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad(
|
||||
activity: Activity,
|
||||
|
|
@ -419,7 +420,6 @@ object PluginManager {
|
|||
downloadPlugin(
|
||||
activity,
|
||||
pluginData.onlineData.second.url,
|
||||
pluginData.onlineData.second.fileHash,
|
||||
pluginData.savedData.internalName,
|
||||
pluginData.onlineData.first,
|
||||
!pluginData.isDisabled
|
||||
|
|
@ -454,8 +454,12 @@ object PluginManager {
|
|||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
@Throws
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
|
@ -476,9 +480,13 @@ object PluginManager {
|
|||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Throws
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
||||
|
|
@ -497,8 +505,12 @@ object PluginManager {
|
|||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
@Throws
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
|
@ -610,7 +622,7 @@ object PluginManager {
|
|||
return false
|
||||
}
|
||||
InputStreamReader(stream).use { reader ->
|
||||
manifest = parseJson<BasePlugin.Manifest>(reader.readText())
|
||||
manifest = parseJson(reader, BasePlugin.Manifest::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -651,15 +663,9 @@ object PluginManager {
|
|||
context.resources.configuration
|
||||
)
|
||||
}
|
||||
synchronized(plugins) {
|
||||
plugins[filePath] = pluginInstance
|
||||
}
|
||||
synchronized(classLoaders) {
|
||||
classLoaders[loader] = pluginInstance
|
||||
}
|
||||
synchronized(urlPlugins) {
|
||||
urlPlugins[data.url ?: filePath] = pluginInstance
|
||||
}
|
||||
if (pluginInstance is Plugin) {
|
||||
pluginInstance.load(context)
|
||||
} else {
|
||||
|
|
@ -695,34 +701,26 @@ object PluginManager {
|
|||
}
|
||||
|
||||
// remove all registered apis
|
||||
synchronized(APIHolder.apis) {
|
||||
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach {
|
||||
removePluginMapping(it)
|
||||
}
|
||||
|
||||
APIHolder.allProviders.withLock {
|
||||
APIHolder.allProviders.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
||||
}
|
||||
synchronized(APIHolder.allProviders) {
|
||||
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename }
|
||||
}
|
||||
|
||||
extractorApis.withLock {
|
||||
extractorApis.removeAll { provider -> provider.sourcePlugin == plugin.filename }
|
||||
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename }
|
||||
|
||||
synchronized(VideoClickActionHolder.allVideoClickActions) {
|
||||
VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename }
|
||||
}
|
||||
|
||||
VideoClickActionHolder.allVideoClickActions.withLock {
|
||||
VideoClickActionHolder.allVideoClickActions.removeAll { action -> action.sourcePlugin == plugin.filename }
|
||||
}
|
||||
|
||||
synchronized(classLoaders) {
|
||||
classLoaders.values.removeIf { v -> v == plugin }
|
||||
}
|
||||
|
||||
synchronized(plugins) {
|
||||
plugins.remove(absolutePath)
|
||||
}
|
||||
|
||||
synchronized(urlPlugins) {
|
||||
urlPlugins.values.removeIf { v -> v == plugin }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spits out a unique and safe filename based on name.
|
||||
|
|
@ -751,27 +749,25 @@ object PluginManager {
|
|||
suspend fun downloadPlugin(
|
||||
activity: Activity,
|
||||
pluginUrl: String,
|
||||
pluginHash: String?,
|
||||
internalName: String,
|
||||
repositoryUrl: String,
|
||||
loadPlugin: Boolean
|
||||
): Boolean {
|
||||
val file = getPluginPath(activity, internalName, repositoryUrl)
|
||||
return downloadPlugin(activity, pluginUrl, pluginHash, internalName, file, loadPlugin)
|
||||
return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin)
|
||||
}
|
||||
|
||||
suspend fun downloadPlugin(
|
||||
activity: Activity,
|
||||
pluginUrl: String,
|
||||
pluginHash: String?,
|
||||
internalName: String,
|
||||
file: File,
|
||||
loadPlugin: Boolean,
|
||||
loadPlugin: Boolean
|
||||
): Boolean {
|
||||
try {
|
||||
Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}")
|
||||
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
|
||||
val newFile = downloadPluginToFile(activity, pluginUrl, file, pluginHash) ?: return false
|
||||
val newFile = downloadPluginToFile(pluginUrl, file) ?: return false
|
||||
|
||||
val data = PluginData(
|
||||
internalName,
|
||||
|
|
@ -818,9 +814,13 @@ object PluginManager {
|
|||
* DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices.
|
||||
* If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT!
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
@InternalAPI
|
||||
@Suppress("FunctionName", "DEPRECATION_ERROR")
|
||||
@Throws
|
||||
@Deprecated(
|
||||
"Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin",
|
||||
replaceWith = ReplaceWith("loadPlugin"),
|
||||
level = DeprecationLevel.ERROR
|
||||
)
|
||||
suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) {
|
||||
assertNonRecursiveCallstack()
|
||||
|
||||
|
|
@ -859,7 +859,6 @@ object PluginManager {
|
|||
if (downloadPlugin(
|
||||
activity,
|
||||
pluginData.onlineData.second.url,
|
||||
pluginData.onlineData.second.fileHash,
|
||||
pluginData.savedData.internalName,
|
||||
existingFile,
|
||||
true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.lagradost.cloudstream3.plugins
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
|
|
@ -19,12 +18,10 @@ import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
|||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.File
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Comes with the app, always available in the app, non removable.
|
||||
|
|
@ -70,7 +67,6 @@ data class SitePlugin(
|
|||
@JsonProperty("iconUrl") val iconUrl: String?,
|
||||
// Automatically generated by the gradle plugin
|
||||
@JsonProperty("fileSize") val fileSize: Long?,
|
||||
@JsonProperty("fileHash") val fileHash: String?,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -79,26 +75,7 @@ object RepositoryManager {
|
|||
val PREBUILT_REPOSITORIES: Array<RepositoryData> by lazy {
|
||||
getKey("PREBUILT_REPOSITORIES") ?: emptyArray()
|
||||
}
|
||||
private val GH_REGEX =
|
||||
Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$")
|
||||
|
||||
|
||||
/** Returns a SHA-256 string of the file content.
|
||||
* Example: "sha256-b70462c264cb7f90fc2860a8e58d7544ce747ff347d1d11fa093623901853573" **/
|
||||
@WorkerThread
|
||||
fun sha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
file.inputStream().use { fis ->
|
||||
val buffer = ByteArray(8192)
|
||||
var read = fis.read(buffer)
|
||||
while (read != -1) {
|
||||
digest.update(buffer, 0, read)
|
||||
read = fis.read(buffer)
|
||||
}
|
||||
}
|
||||
return "sha256-" + digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
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 {
|
||||
|
|
@ -163,52 +140,21 @@ object RepositoryManager {
|
|||
}.flatten()
|
||||
}
|
||||
|
||||
|
||||
suspend fun downloadPluginToFile(
|
||||
context: Context,
|
||||
pluginUrl: String,
|
||||
file: File,
|
||||
expectedFileHash: String?
|
||||
file: File
|
||||
): File? {
|
||||
return safeAsync {
|
||||
val parentDir = file.parentFile ?: return@safeAsync null
|
||||
parentDir.mkdirs()
|
||||
file.mkdirs()
|
||||
|
||||
// Prevent corrupting the plugin file if the operation fails
|
||||
val tempFile = File.createTempFile(file.name, ".tmp", context.cacheDir)
|
||||
// Overwrite if exists
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
file.createNewFile()
|
||||
|
||||
val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body
|
||||
|
||||
body.byteStream().use { body ->
|
||||
tempFile.outputStream().use { fileSteam ->
|
||||
body.copyTo(fileSteam)
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedFileHash != null) {
|
||||
val downloadHash = sha256(tempFile)
|
||||
if (expectedFileHash != downloadHash) {
|
||||
tempFile.delete()
|
||||
throw IllegalStateException("Extension hash mismatch when validating '${file.name}'! Expected: '$expectedFileHash', got: '$downloadHash'.")
|
||||
}
|
||||
}
|
||||
|
||||
// We prefer the operation to be atomic
|
||||
try {
|
||||
Files.move(
|
||||
tempFile.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
)
|
||||
} catch (_: AtomicMoveNotSupportedException) {
|
||||
Files.move(
|
||||
tempFile.toPath(),
|
||||
file.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
}
|
||||
|
||||
write(body.byteStream(), file.outputStream())
|
||||
file
|
||||
}
|
||||
}
|
||||
|
|
@ -256,4 +202,13 @@ object RepositoryManager {
|
|||
|
||||
PluginManager.deleteRepositoryData(file.absolutePath)
|
||||
}
|
||||
|
||||
private fun write(stream: InputStream, output: OutputStream) {
|
||||
val input = BufferedInputStream(stream)
|
||||
val dataBuffer = ByteArray(512)
|
||||
var readBytes: Int
|
||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||
output.write(dataBuffer, 0, readBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.lagradost.cloudstream3.services
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.IBinder
|
||||
|
|
@ -36,7 +34,6 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
|
|
@ -107,10 +104,6 @@ class DownloadQueueService : Service() {
|
|||
|
||||
|
||||
private fun updateNotification(context: Context, downloads: Int, queued: Int) {
|
||||
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) return
|
||||
|
||||
val activeDownloads =
|
||||
resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads)
|
||||
val activeQueue =
|
||||
|
|
@ -187,16 +180,6 @@ class DownloadQueueService : Service() {
|
|||
debugAssert({ timeTaken == null }, { "Downloader startup should not time out" })
|
||||
|
||||
totalDownloadFlow
|
||||
.debounce { (instances, queue) ->
|
||||
// Filter away incorrect transient queue states.
|
||||
// For example when we pop the queue and add a download instance there exists a transient state where
|
||||
// there is no queue and no download instances (leading to an early exit)
|
||||
if (instances.isEmpty() && queue.isEmpty()) {
|
||||
500.milliseconds
|
||||
} else {
|
||||
0.milliseconds
|
||||
}
|
||||
}
|
||||
.takeWhile { (instances, queue) ->
|
||||
// Stop if destroyed
|
||||
isRunning
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
|
|||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION_ERROR")
|
||||
override suspend fun doWork(): Result {
|
||||
try {
|
||||
// println("Update subscriptions!")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
|
|||
import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.videoskip.AnimeSkipAuth
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
abstract class AccountManager {
|
||||
|
|
@ -29,7 +28,6 @@ abstract class AccountManager {
|
|||
val addic7ed = Addic7ed()
|
||||
val subDlApi = SubDlApi()
|
||||
val subSourceApi = SubSourceApi()
|
||||
val animeSkipApi = AnimeSkipAuth()
|
||||
|
||||
var cachedAccounts: MutableMap<String, Array<AuthData>>
|
||||
var cachedAccountIds: MutableMap<String, Int>
|
||||
|
|
@ -69,8 +67,7 @@ abstract class AccountManager {
|
|||
SyncRepo(localListApi),
|
||||
SubtitleRepo(openSubtitlesApi),
|
||||
SubtitleRepo(addic7ed),
|
||||
SubtitleRepo(subDlApi),
|
||||
PlainAuthRepo(animeSkipApi)
|
||||
SubtitleRepo(subDlApi)
|
||||
)
|
||||
|
||||
fun updateAccountIds() {
|
||||
|
|
|
|||
|
|
@ -36,9 +36,11 @@ import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi
|
|||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.UiText
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||
import java.net.URL
|
||||
import java.security.SecureRandom
|
||||
import java.util.Date
|
||||
|
|
|
|||
|
|
@ -9,9 +9,6 @@ import com.lagradost.cloudstream3.mvvm.safe
|
|||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
|
||||
/** General-purpose repo */
|
||||
class PlainAuthRepo(api: AuthAPI) : AuthRepo(api)
|
||||
|
||||
/** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */
|
||||
abstract class AuthRepo(open val api: AuthAPI) {
|
||||
fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.ErrorLoadingException
|
|||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
|
||||
import com.lagradost.cloudstream3.subtitles.SubtitleResource
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
|
||||
/** Stateless safe abstraction of SubtitleAPI */
|
||||
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
||||
|
|
@ -24,30 +24,26 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
|||
)
|
||||
|
||||
// maybe make this a generic struct? right now there is a lot of boilerplate
|
||||
private val searchCache = atomicListOf<SavedSearchResponse>()
|
||||
private val searchCache = threadSafeListOf<SavedSearchResponse>()
|
||||
private var searchCacheIndex: Int = 0
|
||||
private val resourceCache = atomicListOf<SavedResourceResponse>()
|
||||
private val resourceCache = threadSafeListOf<SavedResourceResponse>()
|
||||
private var resourceCacheIndex: Int = 0
|
||||
const val CACHE_SIZE = 20
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun resource(data: SubtitleEntity): Result<SubtitleResource> = runCatching {
|
||||
val cached = resourceCache.withLock {
|
||||
var found: SubtitleResource? = null
|
||||
synchronized(resourceCache) {
|
||||
for (item in resourceCache) {
|
||||
// 20 min save
|
||||
if (item.query == data && (unixTime - item.unixTime) < 60 * 20) {
|
||||
found = item.response
|
||||
break
|
||||
return@runCatching item.response
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
if (cached != null) return@runCatching cached
|
||||
|
||||
val returnValue = api.resource(freshAuth(), data)
|
||||
resourceCache.withLock {
|
||||
synchronized(resourceCache) {
|
||||
val add = SavedResourceResponse(unixTime, returnValue, data)
|
||||
if (resourceCache.size > CACHE_SIZE) {
|
||||
resourceCache[resourceCacheIndex] = add // rolling cache
|
||||
|
|
@ -62,25 +58,22 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
|||
@WorkerThread
|
||||
suspend fun search(query: SubtitleSearch): Result<List<SubtitleEntity>> {
|
||||
return runCatching {
|
||||
val cached = searchCache.withLock {
|
||||
var found: List<SubtitleEntity>? = null
|
||||
synchronized(searchCache) {
|
||||
for (item in searchCache) {
|
||||
// 120 min save
|
||||
if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
|
||||
found = item.response
|
||||
break
|
||||
return@runCatching item.response
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
if (cached != null) return@runCatching cached
|
||||
val returnValue = api.search(freshAuth(), query) ?: emptyList()
|
||||
val returnValue =
|
||||
api.search(freshAuth(), query) ?: emptyList()
|
||||
|
||||
// only cache valid return values
|
||||
if (returnValue.isNotEmpty()) {
|
||||
val add = SavedSearchResponse(unixTime, returnValue, query)
|
||||
searchCache.withLock {
|
||||
synchronized(searchCache) {
|
||||
if (searchCache.size > CACHE_SIZE) {
|
||||
searchCache[searchCacheIndex] = add // rolling cache
|
||||
searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE
|
||||
|
|
@ -93,3 +86,4 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import com.lagradost.cloudstream3.ShowStatus
|
|||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.utils.Levenshtein
|
||||
import com.lagradost.cloudstream3.utils.UiText
|
||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
|
|
@ -138,7 +138,7 @@ abstract class SyncAPI : AuthAPI() {
|
|||
ListSorting.Query ->
|
||||
if (query != null) {
|
||||
items.sortedBy {
|
||||
-Levenshtein.partialRatio(
|
||||
-FuzzySearch.partialRatio(
|
||||
query.lowercase(), it.name.lowercase()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,7 @@ class AniListApi : SyncAPI() {
|
|||
override suspend fun login(redirectUrl: String, payload: String?): AuthToken? {
|
||||
val sanitizer = splitRedirectUrl(redirectUrl)
|
||||
val token = AuthToken(
|
||||
accessToken = sanitizer["access_token"]
|
||||
?: throw ErrorLoadingException("No access token"),
|
||||
accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"),
|
||||
//refreshToken = sanitizer["refresh_token"],
|
||||
accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(),
|
||||
)
|
||||
|
|
@ -84,8 +83,8 @@ class AniListApi : SyncAPI() {
|
|||
return "$mainUrl/anime/$id"
|
||||
}
|
||||
|
||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||
val data = searchShows(query) ?: return null
|
||||
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||
val data = searchShows(name) ?: return null
|
||||
return data.data?.page?.media?.map {
|
||||
SyncAPI.SyncSearchResult(
|
||||
it.title.romaji ?: return null,
|
||||
|
|
@ -97,7 +96,7 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
|
||||
override suspend fun load(auth : AuthData?, 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
|
||||
|
|
@ -159,7 +158,7 @@ class AniListApi : SyncAPI() {
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||
override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||
val internalId = id.toIntOrNull() ?: return null
|
||||
val data = getDataAboutId(auth ?: return null, internalId) ?: return null
|
||||
|
||||
|
|
@ -460,7 +459,7 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun getDataAboutId(auth: AuthData, id: Int): AniListTitleHolder? {
|
||||
private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? {
|
||||
val q =
|
||||
"""query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id)
|
||||
Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query)
|
||||
|
|
@ -507,7 +506,7 @@ class AniListApi : SyncAPI() {
|
|||
|
||||
}
|
||||
|
||||
private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? {
|
||||
private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? {
|
||||
return app.post(
|
||||
"https://graphql.anilist.co/",
|
||||
headers = mapOf(
|
||||
|
|
@ -639,7 +638,7 @@ class AniListApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? {
|
||||
override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? {
|
||||
val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy {
|
||||
convertAniListStringToStatus(it.status ?: "").stringRes
|
||||
}?.mapValues { group ->
|
||||
|
|
@ -667,7 +666,7 @@ class AniListApi : SyncAPI() {
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? {
|
||||
private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? {
|
||||
val userID = auth.user.id
|
||||
val mediaType = "ANIME"
|
||||
|
||||
|
|
@ -715,7 +714,7 @@ class AniListApi : SyncAPI() {
|
|||
return text?.toKotlinObject()
|
||||
}
|
||||
|
||||
suspend fun toggleLike(auth: AuthData, id: Int): Boolean {
|
||||
suspend fun toggleLike(auth : AuthData, id: Int): Boolean {
|
||||
val q = """mutation (${'$'}animeId: Int = $id) {
|
||||
ToggleFavourite (animeId: ${'$'}animeId) {
|
||||
anime {
|
||||
|
|
@ -738,7 +737,7 @@ class AniListApi : SyncAPI() {
|
|||
data class MediaListId(@JsonProperty("id") val id: Long? = null)
|
||||
|
||||
private suspend fun postDataAboutId(
|
||||
auth: AuthData,
|
||||
auth : AuthData,
|
||||
id: Int,
|
||||
type: AniListStatusType,
|
||||
score: Score?,
|
||||
|
|
@ -787,7 +786,7 @@ class AniListApi : SyncAPI() {
|
|||
return data != ""
|
||||
}
|
||||
|
||||
private suspend fun getUser(token: AuthToken): AniListUser? {
|
||||
private suspend fun getUser(token : AuthToken): AniListUser? {
|
||||
val q = """
|
||||
{
|
||||
Viewer {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import androidx.annotation.StringRes
|
|||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.Score
|
||||
import com.lagradost.cloudstream3.ShowStatus
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthData
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement
|
||||
|
|
@ -22,15 +22,18 @@ import com.lagradost.cloudstream3.ui.SyncWatchType
|
|||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.withIndex
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.collections.set
|
||||
|
||||
const val KITSU_MAX_SEARCH_LIMIT = 20
|
||||
|
||||
|
|
@ -39,9 +42,7 @@ class KitsuApi: SyncAPI() {
|
|||
override val idPrefix = "kitsu"
|
||||
|
||||
private val apiUrl = "https://kitsu.io/api/edge"
|
||||
private val fallbackApiUrl = "https://kitsu.app/api/edge"
|
||||
private val oauthUrl = "https://kitsu.io/api/oauth"
|
||||
private val fallbackOauthUrl = "https://kitsu.app/api/oauth"
|
||||
override val hasInApp = true
|
||||
override val mainUrl = "https://kitsu.app"
|
||||
override val icon = R.drawable.kitsu_icon
|
||||
|
|
@ -62,33 +63,6 @@ class KitsuApi: SyncAPI() {
|
|||
email = true
|
||||
)
|
||||
|
||||
private class FallbackInterceptor(private val apiUrl: String, private val fallbackApiUrl: String) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request: Request = chain.request()
|
||||
|
||||
try {
|
||||
|
||||
val response = chain.proceed(request);
|
||||
|
||||
if (response.isSuccessful) return response
|
||||
|
||||
response.close()
|
||||
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
val fallbackRequest: Request = request.newBuilder()
|
||||
.url(request.url.toString().replaceFirst(apiUrl, fallbackApiUrl))
|
||||
.build()
|
||||
|
||||
return chain.proceed(fallbackRequest)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private val apiFallbackInterceptor = FallbackInterceptor(apiUrl, fallbackApiUrl)
|
||||
private val oauthFallbackInterceptor = FallbackInterceptor(oauthUrl, fallbackOauthUrl)
|
||||
|
||||
override suspend fun login(form: AuthLoginResponse): AuthToken? {
|
||||
val username = form.email ?: return null
|
||||
val password = form.password ?: return null
|
||||
|
|
@ -101,10 +75,8 @@ class KitsuApi: SyncAPI() {
|
|||
"grant_type" to grantType,
|
||||
"username" to username,
|
||||
"password" to password
|
||||
),
|
||||
interceptor = oauthFallbackInterceptor
|
||||
)
|
||||
).parsed<ResponseToken>()
|
||||
|
||||
return AuthToken(
|
||||
accessTokenLifetime = unixTime + token.expiresIn.toLong(),
|
||||
refreshToken = token.refreshToken,
|
||||
|
|
@ -118,8 +90,7 @@ class KitsuApi: SyncAPI() {
|
|||
data = mapOf(
|
||||
"grant_type" to "refresh_token",
|
||||
"refresh_token" to token.refreshToken!!
|
||||
),
|
||||
interceptor = oauthFallbackInterceptor
|
||||
)
|
||||
).parsed<ResponseToken>()
|
||||
|
||||
return AuthToken(
|
||||
|
|
@ -134,8 +105,7 @@ class KitsuApi: SyncAPI() {
|
|||
"$apiUrl/users?filter[self]=true",
|
||||
headers = mapOf(
|
||||
"Authorization" to "Bearer ${token?.accessToken ?: return null}"
|
||||
), cacheTime = 0,
|
||||
interceptor = apiFallbackInterceptor
|
||||
), cacheTime = 0
|
||||
).parsed<KitsuResponse>()
|
||||
|
||||
if (user.data.isEmpty()) {
|
||||
|
|
@ -153,14 +123,11 @@ class KitsuApi: SyncAPI() {
|
|||
val auth = auth?.token?.accessToken ?: return null
|
||||
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount")
|
||||
val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
|
||||
|
||||
val res = app.get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer $auth",
|
||||
), cacheTime = 0,
|
||||
interceptor = apiFallbackInterceptor
|
||||
), cacheTime = 0
|
||||
).parsed<KitsuResponse>()
|
||||
|
||||
return res.data.map {
|
||||
val attributes = it.attributes
|
||||
|
||||
|
|
@ -193,15 +160,14 @@ class KitsuApi: SyncAPI() {
|
|||
val anime = app.get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer $auth"
|
||||
),
|
||||
interceptor = apiFallbackInterceptor
|
||||
)
|
||||
).parsed<KitsuResponse>().data.attributes
|
||||
|
||||
return SyncResult(
|
||||
id = id,
|
||||
totalEpisodes = anime.episodeCount,
|
||||
title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(),
|
||||
publicScore = Score.from(anime.ratingTwenty, 20),
|
||||
publicScore = Score.from(anime.ratingTwenty.toString(), 20),
|
||||
duration = anime.episodeLength,
|
||||
synopsis = anime.synopsis,
|
||||
airStatus = when(anime.status) {
|
||||
|
|
@ -235,8 +201,7 @@ class KitsuApi: SyncAPI() {
|
|||
val anime = app.get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer $accessToken"
|
||||
),
|
||||
interceptor = apiFallbackInterceptor
|
||||
)
|
||||
).parsed<KitsuResponse>().data.firstOrNull()?.attributes
|
||||
|
||||
if (anime == null) {
|
||||
|
|
@ -249,7 +214,7 @@ class KitsuApi: SyncAPI() {
|
|||
}
|
||||
|
||||
return SyncStatus(
|
||||
score = Score.from(anime.ratingTwenty, 20),
|
||||
score = Score.from(anime.ratingTwenty.toString(), 20),
|
||||
status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)),
|
||||
isFavorite = null,
|
||||
watchedEpisodes = anime.progress,
|
||||
|
|
@ -259,8 +224,7 @@ class KitsuApi: SyncAPI() {
|
|||
|
||||
val animeSelectedFields = arrayOf("titles","canonicalTitle")
|
||||
val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}"
|
||||
|
||||
val res = app.get(url, interceptor = apiFallbackInterceptor).parsed<KitsuResponse>()
|
||||
val res = app.get(url).parsed<KitsuResponse>()
|
||||
|
||||
return res.data.firstOrNull()?.id
|
||||
|
||||
|
|
@ -305,10 +269,8 @@ class KitsuApi: SyncAPI() {
|
|||
headers = mapOf(
|
||||
"Authorization" to "Bearer ${auth.token.accessToken}"
|
||||
),
|
||||
interceptor = apiFallbackInterceptor
|
||||
)
|
||||
|
||||
|
||||
return res.isSuccessful
|
||||
|
||||
}
|
||||
|
|
@ -354,8 +316,7 @@ class KitsuApi: SyncAPI() {
|
|||
"content-type" to "application/vnd.api+json",
|
||||
"Authorization" to "Bearer ${auth.token.accessToken}"
|
||||
),
|
||||
requestBody = data.toJson().toRequestBody(),
|
||||
interceptor = apiFallbackInterceptor
|
||||
requestBody = data.toJson().toRequestBody()
|
||||
)
|
||||
|
||||
return res.isSuccessful
|
||||
|
|
@ -388,11 +349,9 @@ class KitsuApi: SyncAPI() {
|
|||
"content-type" to "application/vnd.api+json",
|
||||
"Authorization" to "Bearer ${auth.token.accessToken}"
|
||||
),
|
||||
requestBody = data.toJson().toRequestBody(),
|
||||
interceptor = apiFallbackInterceptor
|
||||
requestBody = data.toJson().toRequestBody()
|
||||
)
|
||||
|
||||
|
||||
return res.isSuccessful
|
||||
|
||||
}
|
||||
|
|
@ -406,7 +365,6 @@ class KitsuApi: SyncAPI() {
|
|||
headers = mapOf(
|
||||
"Authorization" to "Bearer ${auth.token.accessToken}"
|
||||
),
|
||||
interceptor = apiFallbackInterceptor
|
||||
).parsed<KitsuResponse>().data.firstOrNull() ?: return null
|
||||
|
||||
return res.id.toInt()
|
||||
|
|
@ -453,8 +411,8 @@ class KitsuApi: SyncAPI() {
|
|||
|
||||
private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array<KitsuNode> {
|
||||
|
||||
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","endDate","episodeCount")
|
||||
val libraryEntriesSelectedFields = arrayOf("progress","ratingTwenty","updatedAt", "status")
|
||||
val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","episodeCount")
|
||||
val libraryEntriesSelectedFields = arrayOf("progress","rating","updatedAt", "status")
|
||||
val limit = 500
|
||||
var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}"
|
||||
|
||||
|
|
@ -481,8 +439,7 @@ class KitsuApi: SyncAPI() {
|
|||
val res = app.get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer ${token.accessToken}",
|
||||
),
|
||||
interceptor = apiFallbackInterceptor
|
||||
)
|
||||
).parsed<KitsuResponse>()
|
||||
return res
|
||||
}
|
||||
|
|
@ -517,7 +474,7 @@ class KitsuApi: SyncAPI() {
|
|||
|
||||
val animeId = animeItem?.id
|
||||
|
||||
val synopsis: String? = animeItem?.attributes?.synopsis
|
||||
val description: String? = animeItem?.attributes?.synopsis
|
||||
|
||||
return LibraryItem(
|
||||
canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(),
|
||||
|
|
@ -525,18 +482,21 @@ class KitsuApi: SyncAPI() {
|
|||
this.id,
|
||||
this.attributes.progress,
|
||||
numEpisodes,
|
||||
Score.from(this.attributes.ratingTwenty, 20),
|
||||
Score.from(this.attributes.ratingTwenty.toString(), 20),
|
||||
parseDateLong(this.attributes.updatedAt),
|
||||
"Kitsu",
|
||||
TvType.Anime,
|
||||
posterImage?.large ?: posterImage?.medium,
|
||||
null,
|
||||
null,
|
||||
plot = synopsis,
|
||||
plot = description,
|
||||
releaseDate = if (startDate == null) null else try {
|
||||
Date.from(LocalDate.parse(startDate).atStartOfDay()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant())
|
||||
Date.from(
|
||||
Instant.from(
|
||||
DateTimeFormatter.ofPattern(if (startDate.length == 4) "yyyy" else if (startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd")
|
||||
.parse(startDate)
|
||||
)
|
||||
)
|
||||
} catch (_: RuntimeException) {
|
||||
null
|
||||
}
|
||||
|
|
@ -579,7 +539,7 @@ class KitsuApi: SyncAPI() {
|
|||
@JsonProperty("avatar") val avatar: KitsuUserAvatar?,
|
||||
/* User list anime attributes */
|
||||
@JsonProperty("progress") val progress: Int?,
|
||||
@JsonProperty("ratingTwenty") val ratingTwenty: Int?,
|
||||
@JsonProperty("ratingTwenty") val ratingTwenty: Float?,
|
||||
@JsonProperty("updatedAt") val updatedAt: String?,
|
||||
@JsonProperty("status") val status: String?,
|
||||
)
|
||||
|
|
@ -628,7 +588,7 @@ class KitsuApi: SyncAPI() {
|
|||
const val KITSU_CACHED_LIST: String = "kitsu_cached_list"
|
||||
private fun parseDateLong(string: String?): Long? {
|
||||
return try {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse(
|
||||
string ?: return null
|
||||
)?.time?.div(1000)
|
||||
} catch (e: Exception) {
|
||||
|
|
|
|||
|
|
@ -98,9 +98,9 @@ class MALApi : SyncAPI() {
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||
val auth = auth?.token?.accessToken ?: return null
|
||||
val url = "$apiUrl/v2/anime?q=$query&limit=$MAL_MAX_SEARCH_LIMIT"
|
||||
val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT"
|
||||
val res = app.get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer $auth",
|
||||
|
|
@ -122,7 +122,7 @@ class MALApi : SyncAPI() {
|
|||
Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
||||
|
||||
override suspend fun updateStatus(
|
||||
auth: AuthData?,
|
||||
auth : AuthData?,
|
||||
id: String,
|
||||
newStatus: SyncAPI.AbstractSyncStatus
|
||||
): Boolean {
|
||||
|
|
@ -225,7 +225,7 @@ class MALApi : SyncAPI() {
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun load(auth: AuthData?, id: String): SyncAPI.SyncResult? {
|
||||
override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? {
|
||||
val auth = auth?.token?.accessToken ?: return null
|
||||
val internalId = id.toIntOrNull() ?: return null
|
||||
val url =
|
||||
|
|
@ -271,7 +271,7 @@ class MALApi : SyncAPI() {
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||
override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
|
||||
val auth = auth?.token?.accessToken ?: return null
|
||||
|
||||
// https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
|
||||
|
|
@ -477,7 +477,7 @@ class MALApi : SyncAPI() {
|
|||
@JsonProperty("start_time") val startTime: String?
|
||||
)
|
||||
|
||||
override suspend fun library(auth: AuthData?): LibraryMetadata? {
|
||||
override suspend fun library(auth : AuthData?): LibraryMetadata? {
|
||||
val list = getMalAnimeListSmart(auth ?: return null)?.groupBy {
|
||||
convertToStatus(it.listStatus?.status ?: "").stringRes
|
||||
}?.mapValues { group ->
|
||||
|
|
@ -505,7 +505,7 @@ class MALApi : SyncAPI() {
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun getMalAnimeListSmart(auth: AuthData): Array<Data>? {
|
||||
private suspend fun getMalAnimeListSmart(auth : AuthData): Array<Data>? {
|
||||
return if (requireLibraryRefresh) {
|
||||
val list = getMalAnimeList(auth.token)
|
||||
setKey(MAL_CACHED_LIST, auth.user.id.toString(), list)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import com.lagradost.cloudstream3.Score
|
|||
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.debugPrint
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING
|
||||
|
|
@ -29,7 +30,6 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
|||
import com.lagradost.cloudstream3.ui.SyncWatchType
|
||||
import com.lagradost.cloudstream3.ui.library.ListSorting
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import java.math.BigInteger
|
||||
|
|
@ -117,8 +117,13 @@ class SimklApi : SyncAPI() {
|
|||
* Gets cached object, if object is not fresh returns null and removes it from cache
|
||||
*/
|
||||
inline fun <reified T : Any> getKey(path: String): T? {
|
||||
// Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject"
|
||||
val type = mapper.typeFactory.constructParametricType(
|
||||
SimklCacheWrapper::class.java,
|
||||
T::class.java
|
||||
)
|
||||
val cache = getKey<String>(SIMKL_CACHE_KEY, path)?.let {
|
||||
tryParseJson<SimklCacheWrapper<T>>(it)
|
||||
mapper.readValue<SimklCacheWrapper<T>>(it, type)
|
||||
}
|
||||
|
||||
return if (cache?.isFresh() == true) {
|
||||
|
|
@ -911,7 +916,7 @@ class SimklApi : SyncAPI() {
|
|||
|
||||
override suspend fun search(auth: AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
|
||||
return app.get(
|
||||
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to query)
|
||||
"$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name)
|
||||
).parsedSafe<Array<MediaObject>>()?.mapNotNull { it.toSyncSearchResult() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import com.lagradost.cloudstream3.mvvm.Resource
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||
import com.lagradost.cloudstream3.newSearchResponseList
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
|
|
@ -55,7 +55,7 @@ class APIRepository(val api: MainAPI) {
|
|||
val hash: Pair<String, String>
|
||||
)
|
||||
|
||||
private val cache = atomicListOf<SavedLoadResponse>()
|
||||
private val cache = threadSafeListOf<SavedLoadResponse>()
|
||||
private var cacheIndex: Int = 0
|
||||
const val CACHE_SIZE = 20
|
||||
|
||||
|
|
@ -66,9 +66,11 @@ class APIRepository(val api: MainAPI) {
|
|||
|
||||
private fun afterPluginsLoaded(forceReload: Boolean) {
|
||||
if (forceReload) {
|
||||
synchronized(cache) {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
afterPluginsLoadedEvent += ::afterPluginsLoaded
|
||||
|
|
@ -89,25 +91,21 @@ class APIRepository(val api: MainAPI) {
|
|||
val fixedUrl = api.fixUrl(url)
|
||||
val lookingForHash = Pair(api.name, fixedUrl)
|
||||
|
||||
val cached = cache.withLock {
|
||||
var found: LoadResponse? = null
|
||||
synchronized(cache) {
|
||||
for (item in cache) {
|
||||
// 10 min save
|
||||
if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) {
|
||||
found = item.response
|
||||
break
|
||||
return@withTimeout item.response
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
if (cached != null) return@withTimeout cached
|
||||
api.load(fixedUrl)?.also { response ->
|
||||
// Remove all blank tags as early as possible
|
||||
response.tags = response.tags?.filter { it.isNotBlank() }
|
||||
val add = SavedLoadResponse(unixTime, response, lookingForHash)
|
||||
|
||||
cache.withLock {
|
||||
synchronized(cache) {
|
||||
if (cache.size > CACHE_SIZE) {
|
||||
cache[cacheIndex] = add // rolling cache
|
||||
cacheIndex = (cacheIndex + 1) % CACHE_SIZE
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import android.widget.ImageView
|
|||
import android.widget.LinearLayout
|
||||
import android.widget.ListView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||
import com.google.android.gms.cast.MediaLoadOptions
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.cast.MediaSeekOptions
|
||||
|
|
@ -102,6 +105,9 @@ data class MetadataHolder(
|
|||
|
||||
class SelectSourceController(val view: ImageView, val activity: ControllerActivity) :
|
||||
UIController() {
|
||||
private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
||||
|
||||
init {
|
||||
view.setImageResource(R.drawable.ic_baseline_playlist_play_24)
|
||||
view.setOnClickListener {
|
||||
|
|
@ -328,7 +334,6 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
}, subtitleCallback = {
|
||||
currentSubs.add(it)
|
||||
},
|
||||
offset = 0,
|
||||
isCasting = true
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,15 +38,15 @@ import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
|||
|
||||
class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
||||
|
||||
companion object {
|
||||
var hasLoggedIn: Boolean = false
|
||||
}
|
||||
|
||||
val accountViewModel: AccountViewModel by viewModels()
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
loadThemes(this)
|
||||
|
||||
enableEdgeToEdgeCompat()
|
||||
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
|
||||
|
||||
// Are we editing and coming from MainActivity?
|
||||
val isEditingFromMainActivity = intent.getBooleanExtra(
|
||||
|
|
@ -54,19 +54,6 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
|||
false
|
||||
)
|
||||
|
||||
// Sometimes we start this activity when we have already logged in
|
||||
// For example when using cloudstreamsearch://
|
||||
// In those cases we want to just go to the main activity instantly
|
||||
if (hasLoggedIn && !isEditingFromMainActivity) {
|
||||
navigateToMainActivity()
|
||||
return
|
||||
}
|
||||
|
||||
loadThemes(this)
|
||||
|
||||
enableEdgeToEdgeCompat()
|
||||
setNavigationBarColorCompat(R.attr.primaryBlackBackground)
|
||||
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val skipStartup = settingsManager.getBoolean(
|
||||
getString(R.string.skip_startup_account_select_key), false
|
||||
|
|
@ -201,11 +188,8 @@ class AccountSelectActivity : FragmentActivity(), BiometricCallback {
|
|||
askBiometricAuth()
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeIntentLaunch")
|
||||
private fun navigateToMainActivity() {
|
||||
hasLoggedIn = true
|
||||
// We want to propagate any intent we get here to MainActivity since this is just an intermediary
|
||||
openActivity(MainActivity::class.java, baseIntent = intent)
|
||||
openActivity(MainActivity::class.java)
|
||||
finish() // Finish the account selection activity
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,8 +162,7 @@ object DownloadButtonSetup {
|
|||
}
|
||||
act.navigate(
|
||||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||
DownloadFileGenerator(items),
|
||||
items.indexOfFirst { it.id == click.data.id }
|
||||
DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,8 +349,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
|
|||
listOf(BasicLink(url)),
|
||||
extract = true,
|
||||
refererUrl = referer,
|
||||
id = url.hashCode()
|
||||
), 0
|
||||
)
|
||||
)
|
||||
)
|
||||
dialog.dismissSafe(activity)
|
||||
|
|
|
|||
|
|
@ -76,10 +76,10 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
currentMetaData.id = id
|
||||
|
||||
if (!doSetProgress) return
|
||||
val appContext = context.applicationContext
|
||||
|
||||
ioSafe {
|
||||
val savedData = VideoDownloadManager.getDownloadFileInfo(appContext, id)
|
||||
val savedData = VideoDownloadManager.getDownloadFileInfo(context, id)
|
||||
|
||||
mainWork {
|
||||
if (savedData != null) {
|
||||
val downloadedBytes = savedData.fileLength
|
||||
|
|
|
|||
|
|
@ -304,8 +304,8 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
|
|||
override fun setStatus(status: DownloadStatusTell?) {
|
||||
currentStatus = status
|
||||
|
||||
// Runs on the main thread, but also instant if it already is.
|
||||
if (Looper.getMainLooper().isCurrentThread) {
|
||||
// Runs on the main thread, but also instant if it already is
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
try {
|
||||
setStatusInternal(status)
|
||||
} catch (t: Throwable) {
|
||||
|
|
|
|||
|
|
@ -651,6 +651,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
|
|||
}
|
||||
|
||||
homeMasterAdapter = HomeParentItemAdapterPreview(
|
||||
fragment = this@HomeFragment,
|
||||
homeViewModel, accountViewModel
|
||||
)
|
||||
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import androidx.core.graphics.toColorInt
|
|||
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||
|
||||
class HomeParentItemAdapterPreview(
|
||||
val fragment: LifecycleOwner,
|
||||
private val viewModel: HomeViewModel,
|
||||
private val accountViewModel: AccountViewModel
|
||||
) : ParentItemAdapter(
|
||||
|
|
@ -104,7 +105,7 @@ class HomeParentItemAdapterPreview(
|
|||
)
|
||||
}
|
||||
|
||||
return HeaderViewHolder(binding, viewModel, accountViewModel)
|
||||
return HeaderViewHolder(binding, viewModel, accountViewModel, fragment)
|
||||
}
|
||||
|
||||
override fun onBindHeader(holder: ViewHolderState<Bundle>) {
|
||||
|
|
@ -131,6 +132,7 @@ class HomeParentItemAdapterPreview(
|
|||
val binding: ViewBinding,
|
||||
val viewModel: HomeViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
fragment: LifecycleOwner,
|
||||
) :
|
||||
ViewHolderState<Bundle>(binding) {
|
||||
|
||||
|
|
@ -542,7 +544,7 @@ class HomeParentItemAdapterPreview(
|
|||
headProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
||||
alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR)
|
||||
|
||||
(headProfilePic ?: alternateHeadProfilePic)?.observe(viewModel.currentAccount) { currentAccount ->
|
||||
fragment.observe(viewModel.currentAccount) { currentAccount ->
|
||||
headProfilePic?.loadImage(currentAccount?.image)
|
||||
alternateHeadProfilePic?.loadImage(currentAccount?.image)
|
||||
}
|
||||
|
|
@ -773,7 +775,7 @@ class HomeParentItemAdapterPreview(
|
|||
fun onViewAttachedToWindow() {
|
||||
previewViewpager.registerOnPageChangeCallback(previewCallback)
|
||||
|
||||
previewViewpager.apply {
|
||||
binding.root.findViewTreeLifecycleOwner()?.apply {
|
||||
observe(viewModel.preview) {
|
||||
updatePreview(it)
|
||||
}
|
||||
|
|
@ -798,7 +800,7 @@ class HomeParentItemAdapterPreview(
|
|||
}
|
||||
toggleListHolder?.isGone = visible.isEmpty()
|
||||
}
|
||||
}
|
||||
} ?: debugException { "Expected findViewTreeLifecycleOwner" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ class HomeViewModel : ViewModel() {
|
|||
private var currentShuffledList: List<SearchResponse> = listOf()
|
||||
|
||||
private fun autoloadRepo(): APIRepository {
|
||||
return APIRepository(apis.withLock { apis.first { it.hasMainPage } })
|
||||
return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } })
|
||||
}
|
||||
|
||||
private val _availableWatchStatusTypes =
|
||||
|
|
|
|||
|
|
@ -210,13 +210,14 @@ class LibraryFragment : BaseFragment<FragmentLibraryBinding>(
|
|||
syncId: SyncIdName,
|
||||
apiName: String? = null,
|
||||
) {
|
||||
val availableProviders = allProviders.filter {
|
||||
val availableProviders = synchronized(allProviders) {
|
||||
allProviders.filter {
|
||||
it.supportedSyncNames.contains(syncId)
|
||||
}.map { it.name } +
|
||||
// Add the api if it exists
|
||||
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
|
||||
?: emptyList())
|
||||
|
||||
}
|
||||
val baseOptions = listOf(
|
||||
LibraryOpenerType.Default,
|
||||
LibraryOpenerType.None,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,64 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.graphics.drawable.AnimatedVectorDrawable
|
||||
import android.media.metrics.PlaybackErrorEvent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import androidx.media3.ui.TimeBar
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||
import com.github.rubensousa.previewseekbar.PreviewBar
|
||||
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
|
||||
import com.lagradost.cloudstream3.CommonActivity.isInPIPMode
|
||||
import com.lagradost.cloudstream3.CommonActivity.keyEventListener
|
||||
import com.lagradost.cloudstream3.CommonActivity.playerEventListener
|
||||
import com.lagradost.cloudstream3.CommonActivity.screenWidth
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
||||
import com.lagradost.cloudstream3.utils.UIHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
enum class PlayerResize(@StringRes val nameRes: Int) {
|
||||
Fit(R.string.resize_fit),
|
||||
|
|
@ -31,131 +79,677 @@ const val NEXT_WATCH_EPISODE_PERCENTAGE = 90
|
|||
const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
abstract class AbstractPlayerFragment<T : ViewBinding>(
|
||||
bindingCreator: BindingCreator<T>
|
||||
) : BaseFragment<T>(bindingCreator), PlayerView.Callbacks {
|
||||
abstract class AbstractPlayerFragment(
|
||||
var player: IPlayer = CS3IPlayer()
|
||||
) : Fragment() {
|
||||
var resizeMode: Int = 0
|
||||
var subView: SubtitleView? = null
|
||||
protected open var hasPipModeSupport = true
|
||||
|
||||
// Stored pre-initialization so subclasses can set them before onBindingCreated.
|
||||
private var _player: IPlayer = CS3IPlayer()
|
||||
var playerPausePlayHolderHolder: FrameLayout? = null
|
||||
var playerPausePlay: ImageView? = null
|
||||
var playerBuffering: ProgressBar? = null
|
||||
var playerView: PlayerView? = null
|
||||
var piphide: FrameLayout? = null
|
||||
var subtitleHolder: FrameLayout? = null
|
||||
var currentPlayerStatus = CSPlayerLoading.IsBuffering
|
||||
|
||||
/** The shared [PlayerView] host that owns all player state and view references. */
|
||||
protected var playerHostView: PlayerView? = null
|
||||
@LayoutRes
|
||||
protected open var layout: Int = R.layout.fragment_player
|
||||
|
||||
var player: IPlayer
|
||||
get() = playerHostView?.player ?: _player
|
||||
set(value) {
|
||||
_player = value
|
||||
playerHostView?.player = value
|
||||
}
|
||||
|
||||
val subView: SubtitleView? get() = playerHostView?.subView
|
||||
val playerPausePlay: ImageView? get() = playerHostView?.playerPausePlay
|
||||
|
||||
/** The underlying [androidx.media3.ui.PlayerView] widget (named to avoid conflict with our [PlayerView]). */
|
||||
val playerView: androidx.media3.ui.PlayerView?
|
||||
get() = playerHostView?.exoPlayerView
|
||||
|
||||
var currentPlayerStatus: CSPlayerLoading
|
||||
get() = playerHostView?.currentPlayerStatus ?: CSPlayerLoading.IsBuffering
|
||||
set(value) { playerHostView?.currentPlayerStatus = value }
|
||||
|
||||
protected var mMediaSession: MediaSession?
|
||||
get() = playerHostView?.mMediaSession
|
||||
set(value) { playerHostView?.mMediaSession = value }
|
||||
|
||||
// No-op callbacks (nextEpisode, prevEpisode, etc.) are intentionally left as
|
||||
// open so subclasses can override only what they need. The ones below throw
|
||||
// to make it obvious when an implementation is missing.
|
||||
|
||||
override fun nextEpisode() {
|
||||
open fun nextEpisode() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun prevEpisode() {
|
||||
open fun prevEpisode() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun playerPositionChanged(position: Long, duration: Long) {
|
||||
open fun playerPositionChanged(position: Long, duration: Long) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun playerDimensionsLoaded(width: Int, height: Int) {
|
||||
open fun playerStatusChanged() {}
|
||||
|
||||
open fun playerDimensionsLoaded(width: Int, height: Int) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun subtitlesChanged() {
|
||||
open fun subtitlesChanged() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
|
||||
open fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun onTracksInfoChanged() {
|
||||
open fun onTracksInfoChanged() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun exitedPipMode() {
|
||||
open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
|
||||
|
||||
}
|
||||
|
||||
open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) {
|
||||
|
||||
}
|
||||
|
||||
open fun exitedPipMode() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun hasNextMirror(): Boolean {
|
||||
throw NotImplementedError()
|
||||
private fun keepScreenOn(on: Boolean) {
|
||||
if (on) {
|
||||
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
override fun nextMirror() {
|
||||
throw NotImplementedError()
|
||||
private fun updateIsPlaying(
|
||||
wasPlaying: CSPlayerLoading,
|
||||
isPlaying: CSPlayerLoading
|
||||
) {
|
||||
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
|
||||
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
|
||||
currentPlayerStatus = isPlaying
|
||||
|
||||
keepScreenOn(!isPausedRightNow)
|
||||
|
||||
val isBuffering = CSPlayerLoading.IsBuffering == isPlaying
|
||||
if (isBuffering) {
|
||||
playerPausePlayHolderHolder?.isVisible = false
|
||||
playerBuffering?.isVisible = true
|
||||
} else {
|
||||
playerPausePlayHolderHolder?.isVisible = true
|
||||
playerBuffering?.isVisible = false
|
||||
|
||||
if(isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)){
|
||||
playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24)
|
||||
} else if (wasPlaying != isPlaying) {
|
||||
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play)
|
||||
val drawable = playerPausePlay?.drawable
|
||||
|
||||
var startedAnimation = false
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
|
||||
if (drawable is AnimatedImageDrawable) {
|
||||
drawable.start()
|
||||
startedAnimation = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Delegates to [PlayerView.playerError] by default; override to customize. */
|
||||
override fun playerError(exception: Throwable) {
|
||||
playerHostView?.playerError(exception)
|
||||
if (drawable is AnimatedVectorDrawable) {
|
||||
drawable.start()
|
||||
startedAnimation = true
|
||||
}
|
||||
|
||||
/** Player fragments don't need system-bar padding adjustment by default. */
|
||||
override fun fixLayout(view: View) = Unit
|
||||
|
||||
override fun onBindingCreated(binding: T, savedInstanceState: Bundle?) {
|
||||
val ctx = context ?: return
|
||||
playerHostView = PlayerView(ctx)
|
||||
playerHostView?.player = _player
|
||||
playerHostView?.callbacks = this
|
||||
playerHostView?.bindViews(binding.root)
|
||||
playerHostView?.initialize()
|
||||
if (drawable is AnimatedVectorDrawableCompat) {
|
||||
drawable.start()
|
||||
startedAnimation = true
|
||||
}
|
||||
|
||||
// somehow the phone is wacked
|
||||
if (!startedAnimation) {
|
||||
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
|
||||
}
|
||||
} else {
|
||||
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
|
||||
}
|
||||
}
|
||||
|
||||
PlayerPipHelper.updatePIPModeActions(
|
||||
activity,
|
||||
isPlaying,
|
||||
hasPipModeSupport,
|
||||
player.getAspectRatio()
|
||||
)
|
||||
}
|
||||
|
||||
private var pipReceiver: BroadcastReceiver? = null
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
playerHostView?.onPictureInPictureModeChanged(isInPictureInPictureMode, activity)
|
||||
try {
|
||||
isInPIPMode = isInPictureInPictureMode
|
||||
if (isInPictureInPictureMode) {
|
||||
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
|
||||
piphide?.isVisible = false
|
||||
pipReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
if (ACTION_MEDIA_CONTROL != intent.action) {
|
||||
return
|
||||
}
|
||||
player.handleEvent(
|
||||
CSPlayerEvent.entries[intent.getIntExtra(
|
||||
EXTRA_CONTROL_TYPE,
|
||||
0
|
||||
)], source = PlayerEventSource.UI
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter()
|
||||
filter.addAction(ACTION_MEDIA_CONTROL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
activity?.registerReceiver(pipReceiver, filter)
|
||||
}
|
||||
|
||||
val isPlaying = player.getIsPlaying()
|
||||
val isPlayingValue =
|
||||
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
|
||||
updateIsPlaying(isPlayingValue, isPlayingValue)
|
||||
} else {
|
||||
// Restore the full-screen UI.
|
||||
piphide?.isVisible = true
|
||||
exitedPipMode()
|
||||
pipReceiver?.let {
|
||||
// Prevents java.lang.IllegalArgumentException: Receiver not registered
|
||||
safe {
|
||||
activity?.unregisterReceiver(it)
|
||||
}
|
||||
}
|
||||
activity?.hideSystemUI()
|
||||
this.view?.let { UIHelper.hideKeyboard(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
|
||||
open fun hasNextMirror(): Boolean {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
open fun nextMirror() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest())
|
||||
}
|
||||
}
|
||||
|
||||
open fun playerError(exception: Throwable) {
|
||||
fun showToast(message: String, gotoNext: Boolean = false) {
|
||||
if (gotoNext && hasNextMirror()) {
|
||||
showToast(
|
||||
message,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
nextMirror()
|
||||
} else {
|
||||
showToast(
|
||||
context?.getString(R.string.no_links_found_toast) + "\n" + message,
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
activity?.popCurrentPage()
|
||||
}
|
||||
}
|
||||
|
||||
val ctx = context ?: return
|
||||
when (exception) {
|
||||
is PlaybackException -> {
|
||||
val msg = exception.message ?: ""
|
||||
val errorName = exception.errorCodeName
|
||||
when (val code = exception.errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
|
||||
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
||||
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg",
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackException.ERROR_CODE_REMOTE_ERROR,
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
|
||||
PlaybackException.ERROR_CODE_TIMEOUT,
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
||||
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg",
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED,
|
||||
PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER,
|
||||
PlaybackException.ERROR_CODE_DECODING_FAILED,
|
||||
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg",
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg",
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg",
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg",
|
||||
gotoNext = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is InvalidFileException -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.source_error)}\n${exception.message}",
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
|
||||
is SocketTimeoutException -> {
|
||||
/**
|
||||
* Ensures this is run on the UI thread to prevent issues
|
||||
* caused by SocketTimeoutException in torrents. Running
|
||||
* on another thread can break player interactions or
|
||||
* prevent switching to the next source.
|
||||
*/
|
||||
activity?.runOnUiThread {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.remote_error)}\n${exception.message}",
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ErrorLoadingException -> {
|
||||
exception.message?.let {
|
||||
showToast(
|
||||
it,
|
||||
gotoNext = true
|
||||
)
|
||||
} ?: showToast(
|
||||
exception.toString(),
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
exception.message?.let {
|
||||
showToast(
|
||||
it,
|
||||
gotoNext = false
|
||||
)
|
||||
} ?: showToast(
|
||||
exception.toString(),
|
||||
gotoNext = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSubStyleChanged(style: SaveCaptionStyle) {
|
||||
player.updateSubtitleStyle(style)
|
||||
// Forcefully update the subtitle encoding in case the edge size is changed
|
||||
player.seekTime(-1)
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
open fun playerUpdated(player: Any?) {
|
||||
if (player is ExoPlayer) {
|
||||
context?.let { ctx ->
|
||||
mMediaSession?.release()
|
||||
mMediaSession = MediaSession.Builder(ctx, player)
|
||||
// Ensure unique ID for concurrent players
|
||||
.setId(System.currentTimeMillis().toString())
|
||||
.build()
|
||||
}
|
||||
|
||||
// Necessary for multiple combined videos
|
||||
@Suppress("DEPRECATION")
|
||||
playerView?.setShowMultiWindowTimeBar(true)
|
||||
playerView?.player = player
|
||||
playerView?.performClick()
|
||||
}
|
||||
}
|
||||
|
||||
protected var mMediaSession: MediaSession? = null
|
||||
|
||||
// this can be used in the future for players other than exoplayer
|
||||
//private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() {
|
||||
// override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
||||
// val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as KeyEvent?
|
||||
// if (keyEvent != null) {
|
||||
// if (keyEvent.action == KeyEvent.ACTION_DOWN) { // NO DOUBLE SKIP
|
||||
// val consumed = when (keyEvent.keyCode) {
|
||||
// KeyEvent.KEYCODE_MEDIA_PAUSE -> callOnPause()
|
||||
// KeyEvent.KEYCODE_MEDIA_PLAY -> callOnPlay()
|
||||
// KeyEvent.KEYCODE_MEDIA_STOP -> callOnStop()
|
||||
// KeyEvent.KEYCODE_MEDIA_NEXT -> callOnNext()
|
||||
// else -> false
|
||||
// }
|
||||
// if (consumed) return true
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return super.onMediaButtonEvent(mediaButtonEvent)
|
||||
// }
|
||||
//}
|
||||
|
||||
open fun onDownload(event: DownloadEvent) = Unit
|
||||
|
||||
/** This receives the events from the player, if you want to append functionality you do it here,
|
||||
* do note that this only receives events for UI changes,
|
||||
* and returning early WONT stop it from changing in eg the player time or pause status */
|
||||
open fun mainCallback(event: PlayerEvent) {
|
||||
// we don't want to spam DownloadEvent
|
||||
if (event !is DownloadEvent) {
|
||||
Log.i(TAG, "Handle event: $event")
|
||||
}
|
||||
when (event) {
|
||||
is DownloadEvent -> {
|
||||
onDownload(event)
|
||||
}
|
||||
|
||||
is ResizedEvent -> {
|
||||
playerDimensionsLoaded(event.width, event.height)
|
||||
}
|
||||
|
||||
is PlayerAttachedEvent -> {
|
||||
playerUpdated(event.player)
|
||||
}
|
||||
|
||||
is SubtitlesUpdatedEvent -> {
|
||||
subtitlesChanged()
|
||||
}
|
||||
|
||||
is TimestampSkippedEvent -> {
|
||||
onTimestampSkipped(event.timestamp)
|
||||
}
|
||||
|
||||
is TimestampInvokedEvent -> {
|
||||
onTimestamp(event.timestamp)
|
||||
}
|
||||
|
||||
is TracksChangedEvent -> {
|
||||
onTracksInfoChanged()
|
||||
}
|
||||
|
||||
is EmbeddedSubtitlesFetchedEvent -> {
|
||||
embeddedSubtitlesFetched(event.tracks)
|
||||
}
|
||||
|
||||
is ErrorEvent -> {
|
||||
playerError(event.error)
|
||||
}
|
||||
|
||||
is RequestAudioFocusEvent -> {
|
||||
requestAudioFocus()
|
||||
}
|
||||
|
||||
is EpisodeSeekEvent -> {
|
||||
when (event.offset) {
|
||||
-1 -> prevEpisode()
|
||||
1 -> nextEpisode()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
is StatusEvent -> {
|
||||
updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying)
|
||||
playerStatusChanged()
|
||||
}
|
||||
|
||||
is PositionEvent -> {
|
||||
playerPositionChanged(position = event.toMs, duration = event.durationMs)
|
||||
}
|
||||
|
||||
is VideoEndedEvent -> {
|
||||
context?.let { ctx ->
|
||||
// Only play next episode if autoplay is on (default)
|
||||
if (PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
?.getBoolean(
|
||||
ctx.getString(R.string.autoplay_next_key),
|
||||
true
|
||||
) == true
|
||||
) {
|
||||
player.handleEvent(
|
||||
CSPlayerEvent.NextEpisode,
|
||||
source = PlayerEventSource.Player
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is PauseEvent -> Unit
|
||||
is PlayEvent -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n", "UnsafeOptInUsageError")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
resizeMode = DataStoreHelper.resizeMode
|
||||
resize(resizeMode, false)
|
||||
|
||||
player.releaseCallbacks()
|
||||
player.initCallbacks(
|
||||
eventHandler = ::mainCallback,
|
||||
requestedListeningPercentages = listOf(
|
||||
SKIP_OP_VIDEO_PERCENTAGE,
|
||||
PRELOAD_NEXT_EPISODE_PERCENTAGE,
|
||||
NEXT_WATCH_EPISODE_PERCENTAGE,
|
||||
UPDATE_SYNC_PROGRESS_PERCENTAGE,
|
||||
),
|
||||
)
|
||||
|
||||
val player = player
|
||||
if (player is CS3IPlayer) {
|
||||
// preview bar
|
||||
val progressBar: PreviewTimeBar? = playerView?.findViewById(R.id.exo_progress)
|
||||
val previewImageView: ImageView? = playerView?.findViewById(R.id.previewImageView)
|
||||
val previewFrameLayout: FrameLayout? = playerView?.findViewById(R.id.previewFrameLayout)
|
||||
if (progressBar != null && previewImageView != null && previewFrameLayout != null) {
|
||||
var resume = false
|
||||
progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener {
|
||||
override fun onScrubStart(previewBar: PreviewBar?) {
|
||||
val hasPreview = player.hasPreview()
|
||||
progressBar.isPreviewEnabled = hasPreview
|
||||
resume = player.getIsPlaying()
|
||||
if (resume) player.handleEvent(
|
||||
CSPlayerEvent.Pause,
|
||||
PlayerEventSource.Player
|
||||
)
|
||||
|
||||
// No clashing UI
|
||||
if (hasPreview) {
|
||||
subView?.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScrubMove(
|
||||
previewBar: PreviewBar?,
|
||||
progress: Int,
|
||||
fromUser: Boolean
|
||||
) {
|
||||
}
|
||||
|
||||
override fun onScrubStop(previewBar: PreviewBar?) {
|
||||
if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player)
|
||||
// Delay to prevent the small flicker of subtitle before seeking
|
||||
subView?.postDelayed({
|
||||
// If we are not scrubbing then show subtitles again
|
||||
if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) {
|
||||
subView?.isVisible = true
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
})
|
||||
progressBar.attachPreviewView(previewFrameLayout)
|
||||
progressBar.setPreviewLoader { currentPosition, max ->
|
||||
val bitmap = player.getPreview(currentPosition.toFloat().div(max.toFloat()))
|
||||
previewImageView.isGone = bitmap == null
|
||||
previewImageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
subView = playerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles)
|
||||
player.initSubtitles(subView, subtitleHolder, CustomDecoder.style)
|
||||
(player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth)
|
||||
|
||||
/*previewImageView?.doOnLayout {
|
||||
(player.imageGenerator as? PreviewGenerator)?.params = ImageParams(
|
||||
it.measuredWidth,
|
||||
it.measuredHeight
|
||||
)
|
||||
}*/
|
||||
/** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player
|
||||
* and once by the UI even if it should only be registered once by the UI */
|
||||
playerView?.findViewById<DefaultTimeBar>(R.id.exo_progress)
|
||||
?.addListener(object : TimeBar.OnScrubListener {
|
||||
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
|
||||
override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit
|
||||
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
|
||||
if (canceled) return
|
||||
val playerDuration = player.getDuration() ?: return
|
||||
val playerPosition = player.getPosition() ?: return
|
||||
mainCallback(
|
||||
PositionEvent(
|
||||
source = PlayerEventSource.UI,
|
||||
durationMs = playerDuration,
|
||||
fromMs = playerPosition,
|
||||
toMs = position
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged
|
||||
|
||||
try {
|
||||
context?.let { ctx ->
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(
|
||||
ctx
|
||||
)
|
||||
|
||||
val currentPrefCacheSize =
|
||||
settingsManager.getInt(getString(R.string.video_buffer_size_key), 0)
|
||||
val currentPrefDiskSize =
|
||||
settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0)
|
||||
val currentPrefBufferSec =
|
||||
settingsManager.getInt(getString(R.string.video_buffer_length_key), 0)
|
||||
|
||||
player.cacheSize = currentPrefCacheSize * 1024L * 1024L
|
||||
player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L
|
||||
player.videoBufferMs = currentPrefBufferSec * 1000L
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/*context?.let { ctx ->
|
||||
player.loadPlayer(
|
||||
ctx,
|
||||
false,
|
||||
ExtractorLink(
|
||||
"idk",
|
||||
"bunny",
|
||||
"http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
|
||||
"",
|
||||
Qualities.P720.value,
|
||||
false
|
||||
),
|
||||
)
|
||||
}*/
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
playerHostView?.release()
|
||||
player.release()
|
||||
player.releaseCallbacks()
|
||||
player = CS3IPlayer()
|
||||
|
||||
playerEventListener = null
|
||||
keyEventListener = null
|
||||
|
||||
PlayerPipHelper.updatePIPModeActions(activity, CSPlayerLoading.IsPaused, false, null)
|
||||
|
||||
mMediaSession?.release()
|
||||
mMediaSession = null
|
||||
playerView?.player = null
|
||||
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
|
||||
|
||||
keepScreenOn(false)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
playerHostView?.releaseKeyEventListener()
|
||||
super.onPause()
|
||||
fun nextResize() {
|
||||
resizeMode = (resizeMode + 1) % PlayerResize.entries.size
|
||||
resize(resizeMode, true)
|
||||
}
|
||||
|
||||
fun resize(resize: Int, showToast: Boolean) {
|
||||
resize(PlayerResize.entries[resize], showToast)
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
open fun resize(resize: PlayerResize, showToast: Boolean) {
|
||||
DataStoreHelper.resizeMode = resize.ordinal
|
||||
val type = when (resize) {
|
||||
PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
}
|
||||
playerView?.resizeMode = type
|
||||
|
||||
if (showToast)
|
||||
showToast(resize.nameRes, Toast.LENGTH_SHORT)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
playerHostView?.onStop()
|
||||
player.onStop()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
context?.let { ctx ->
|
||||
playerHostView?.onResume(ctx)
|
||||
player.onResume(ctx)
|
||||
}
|
||||
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
fun nextResize() {
|
||||
playerHostView?.nextResize()
|
||||
}
|
||||
|
||||
open fun resize(resize: PlayerResize, showToast: Boolean) {
|
||||
playerHostView?.resize(resize, showToast)
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val root = inflater.inflate(layout, container, false)
|
||||
playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder)
|
||||
playerPausePlay = root.findViewById(R.id.player_pause_play)
|
||||
playerBuffering = root.findViewById(R.id.player_buffering)
|
||||
playerView = root.findViewById(R.id.player_view)
|
||||
piphide = root.findViewById(R.id.piphide)
|
||||
subtitleHolder = root.findViewById(R.id.subtitle_holder)
|
||||
return root
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import android.os.Looper
|
|||
import android.util.Log
|
||||
import android.util.Rational
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
|
|
@ -30,7 +29,6 @@ import androidx.media3.common.TrackGroup
|
|||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
// import androidx.media3.common.util.ExperimentalApi
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
|
|
@ -44,7 +42,6 @@ import androidx.media3.datasource.cronet.CronetDataSource
|
|||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DecoderCounters
|
||||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||
import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
|
|
@ -57,7 +54,6 @@ import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
|
|||
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
|
||||
import androidx.media3.exoplayer.drm.HttpMediaDrmCallback
|
||||
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
|
||||
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker
|
||||
import androidx.media3.exoplayer.source.ClippingMediaSource
|
||||
import androidx.media3.exoplayer.source.ConcatenatingMediaSource
|
||||
import androidx.media3.exoplayer.source.ConcatenatingMediaSource2
|
||||
|
|
@ -87,8 +83,6 @@ import com.lagradost.cloudstream3.mvvm.debugAssert
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment
|
||||
import com.lagradost.cloudstream3.ui.player.live.LiveHelper
|
||||
import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
|
|
@ -96,18 +90,19 @@ import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
|||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.CLEARKEY_DRM_UUID
|
||||
import com.lagradost.cloudstream3.utils.CLEARKEY_UUID
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
|
||||
import com.lagradost.cloudstream3.utils.DrmExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
import com.lagradost.cloudstream3.utils.PLAYREADY_DRM_UUID
|
||||
import com.lagradost.cloudstream3.utils.PLAYREADY_UUID
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName
|
||||
import com.lagradost.cloudstream3.utils.WIDEVINE_DRM_UUID
|
||||
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
|
||||
import com.lagradost.cloudstream3.utils.WIDEVINE_UUID
|
||||
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
||||
import kotlinx.coroutines.delay
|
||||
import okhttp3.Interceptor
|
||||
import org.chromium.net.CronetEngine
|
||||
|
|
@ -118,7 +113,6 @@ import java.util.concurrent.Executors
|
|||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSession
|
||||
import kotlin.uuid.toJavaUuid
|
||||
|
||||
const val TAG = "CS3ExoPlayer"
|
||||
const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language"
|
||||
|
|
@ -208,16 +202,18 @@ class CS3IPlayer : IPlayer {
|
|||
private var requestedListeningPercentages: List<Int>? = null
|
||||
|
||||
private var eventHandler: ((PlayerEvent) -> Unit)? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
@AnyThread
|
||||
fun event(event: PlayerEvent) {
|
||||
// Ensure that all work is done on the main thread.
|
||||
if (Looper.getMainLooper().isCurrentThread) {
|
||||
// Ensure that all work is done on the main looper, aka main thread
|
||||
if (Looper.myLooper() == mainHandler.looper) {
|
||||
eventHandler?.invoke(event)
|
||||
} else runOnMainThread {
|
||||
} else {
|
||||
mainHandler.post {
|
||||
eventHandler?.invoke(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* As initCallbacks and releaseCallbacks must always be done,
|
||||
|
|
@ -235,9 +231,8 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
override fun initCallbacks(
|
||||
@MainThread eventHandler: ((PlayerEvent) -> Unit),
|
||||
eventHandler: ((PlayerEvent) -> Unit),
|
||||
requestedListeningPercentages: List<Int>?,
|
||||
) {
|
||||
this.requestedListeningPercentages = requestedListeningPercentages
|
||||
|
|
@ -248,6 +243,23 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
}
|
||||
|
||||
// I know, this is not a perfect solution, however it works for fixing subs
|
||||
private fun reloadSubs() {
|
||||
exoPlayer?.applicationLooper?.let {
|
||||
try {
|
||||
Handler(it).post {
|
||||
try {
|
||||
seekTime(1L, source = PlayerEventSource.Player)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.stripTrackId(): String {
|
||||
return this.replace(Regex("""^\d+:"""), "")
|
||||
}
|
||||
|
|
@ -261,10 +273,6 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
|
||||
override fun hasPreview(): Boolean {
|
||||
// No previews on livestreams because the previews get outdated
|
||||
if (exoPlayer?.isCurrentMediaItemDynamic == true) {
|
||||
return false
|
||||
}
|
||||
return imageGenerator.hasPreview()
|
||||
}
|
||||
|
||||
|
|
@ -392,12 +400,7 @@ class CS3IPlayer : IPlayer {
|
|||
?.let { group ->
|
||||
exoPlayer?.trackSelectionParameters
|
||||
?.buildUpon()
|
||||
?.setOverrideForType(
|
||||
TrackSelectionOverride(
|
||||
group.mediaTrackGroup,
|
||||
trackFormatIndex
|
||||
)
|
||||
)
|
||||
?.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackFormatIndex))
|
||||
?.build()
|
||||
}
|
||||
?.let { newParams ->
|
||||
|
|
@ -416,9 +419,9 @@ class CS3IPlayer : IPlayer {
|
|||
* Gets all supported formats in a list
|
||||
* */
|
||||
private fun List<Tracks.Group>.getFormats(): List<Pair<Format, Int>> {
|
||||
return this.flatMap {
|
||||
return this.map {
|
||||
it.getFormats()
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> {
|
||||
|
|
@ -514,12 +517,10 @@ class CS3IPlayer : IPlayer {
|
|||
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
|
||||
return true
|
||||
}
|
||||
|
||||
SubtitleStatus.NOT_FOUND -> {
|
||||
Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
|
||||
return true
|
||||
}
|
||||
|
||||
SubtitleStatus.IS_ACTIVE -> {
|
||||
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
|
||||
exoPlayer?.currentTracks?.groups
|
||||
|
|
@ -883,10 +884,10 @@ class CS3IPlayer : IPlayer {
|
|||
private var currentTextRenderer: TextRenderer? = null
|
||||
}
|
||||
|
||||
private fun getCurrentTimestamp(writePosition: Long? = null): VideoSkipStamp? {
|
||||
private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? {
|
||||
val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null
|
||||
for (lastTimeStamp in lastTimeStamps) {
|
||||
if (lastTimeStamp.timestamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.timestamp.endMs) {
|
||||
if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) {
|
||||
return lastTimeStamp
|
||||
}
|
||||
}
|
||||
|
|
@ -945,22 +946,6 @@ class CS3IPlayer : IPlayer {
|
|||
when (event) {
|
||||
CSPlayerEvent.Play -> {
|
||||
event(PlayEvent(source))
|
||||
// If the player was stopped (e.g. notification dismissed) it lands in
|
||||
// STATE_IDLE. A bare play() call is a no-op in that state, re-prepare and
|
||||
// then resume to the current position once we are in STATE_READY again.
|
||||
if (playbackState == Player.STATE_IDLE) {
|
||||
val seekPosition = currentPosition
|
||||
exoPlayer?.addListener(object : Player.Listener {
|
||||
private var seekApplied = false
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (seekApplied || playbackState != Player.STATE_READY) return
|
||||
seekApplied = true
|
||||
exoPlayer?.seekTo(currentWindow, seekPosition)
|
||||
exoPlayer?.removeListener(this)
|
||||
}
|
||||
})
|
||||
prepare()
|
||||
}
|
||||
play()
|
||||
}
|
||||
|
||||
|
|
@ -1014,7 +999,7 @@ class CS3IPlayer : IPlayer {
|
|||
if (lastTimeStamp.skipToNextEpisode) {
|
||||
handleEvent(CSPlayerEvent.NextEpisode, source)
|
||||
} else {
|
||||
seekTo(lastTimeStamp.timestamp.endMs + 1L)
|
||||
seekTo(lastTimeStamp.endMs + 1L)
|
||||
}
|
||||
event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source))
|
||||
}
|
||||
|
|
@ -1083,18 +1068,7 @@ class CS3IPlayer : IPlayer {
|
|||
): ExoPlayer {
|
||||
val exoPlayerBuilder =
|
||||
ExoPlayer.Builder(context)
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(context).setLiveTargetOffsetMs(
|
||||
PREFERRED_LIVE_OFFSET
|
||||
)
|
||||
)
|
||||
.setLivePlaybackSpeedControl(
|
||||
DefaultLivePlaybackSpeedControl.Builder()
|
||||
.setFallbackMaxPlaybackSpeed(1.03f)
|
||||
.setFallbackMinPlaybackSpeed(0.97f)
|
||||
.build()
|
||||
)
|
||||
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, _, metadataRendererOutput ->
|
||||
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val current = settingsManager.getInt(
|
||||
context.getString(R.string.software_decoding_key),
|
||||
|
|
@ -1128,7 +1102,7 @@ class CS3IPlayer : IPlayer {
|
|||
// Custom TextOutput to apply cue styling and rules to all subtitles
|
||||
val customTextOutput = TextOutput { cue ->
|
||||
// Do not remove filterNotNull as Java typesystem is fucked
|
||||
val (bitmapCues, textCues) = cue.cues.toList()
|
||||
val (bitmapCues, textCues) = cue.cues.filterNotNull()
|
||||
.partition { it.bitmap != null }
|
||||
|
||||
val styledBitmapCues = bitmapCues.map { bitmapCue ->
|
||||
|
|
@ -1196,7 +1170,6 @@ class CS3IPlayer : IPlayer {
|
|||
CustomDecoder.subtitleOffset = subtitleOffset
|
||||
val decoder = CustomSubtitleDecoderFactory()
|
||||
|
||||
// @OptIn(ExperimentalApi::class)
|
||||
val currentTextRenderer = TextRenderer(
|
||||
customTextOutput,
|
||||
eventHandler.looper,
|
||||
|
|
@ -1279,7 +1252,7 @@ class CS3IPlayer : IPlayer {
|
|||
|
||||
item.drm?.let { drm ->
|
||||
when (drm.uuid) {
|
||||
CLEARKEY_DRM_UUID.toJavaUuid() -> {
|
||||
CLEARKEY_UUID -> {
|
||||
// Use headers from DrmMetadata for media requests
|
||||
val client = dataSourceFactory
|
||||
?: throw IllegalArgumentException("Must supply onlineSource")
|
||||
|
|
@ -1300,8 +1273,8 @@ class CS3IPlayer : IPlayer {
|
|||
.createMediaSource(item.mediaItem)
|
||||
}
|
||||
|
||||
WIDEVINE_DRM_UUID.toJavaUuid(),
|
||||
PLAYREADY_DRM_UUID.toJavaUuid() -> {
|
||||
WIDEVINE_UUID,
|
||||
PLAYREADY_UUID -> {
|
||||
// Use headers from DrmMetadata for media requests
|
||||
val client = dataSourceFactory
|
||||
?: throw IllegalArgumentException("Must supply onlineSource")
|
||||
|
|
@ -1335,7 +1308,7 @@ class CS3IPlayer : IPlayer {
|
|||
} else {
|
||||
try {
|
||||
val source = ConcatenatingMediaSource2.Builder()
|
||||
mediaItemSlices.forEach { item ->
|
||||
mediaItemSlices.map { item ->
|
||||
source.add(
|
||||
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
|
||||
ClippingMediaSource(
|
||||
|
|
@ -1349,7 +1322,7 @@ class CS3IPlayer : IPlayer {
|
|||
@Suppress("DEPRECATION")
|
||||
val source =
|
||||
ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only
|
||||
mediaItemSlices.forEach { item ->
|
||||
mediaItemSlices.map { item ->
|
||||
source.addMediaSource(
|
||||
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
|
||||
ClippingMediaSource(
|
||||
|
|
@ -1414,23 +1387,6 @@ class CS3IPlayer : IPlayer {
|
|||
event(PlayerAttachedEvent(exoPlayer))
|
||||
exoPlayer?.prepare()
|
||||
|
||||
// For offline fragmented MP4s, FLAG_MERGE_FRAGMENTED_SIDX builds the SIDX seek map
|
||||
// incrementally as data is buffered. The initial seek resolves to the nearest merged
|
||||
// entry (~first fragment, 3 s). On STATE_READY, re-seek to the actual saved position.
|
||||
// This may only be reproducible on large and fairly long fragmented MP4 files with
|
||||
// multiple sidx boxes.
|
||||
if (onlineSource == null && playbackPosition > (exoPlayer?.duration ?: 0L)) {
|
||||
exoPlayer?.addListener(object : Player.Listener {
|
||||
private var seekApplied = false
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (seekApplied || playbackState != Player.STATE_READY) return
|
||||
seekApplied = true
|
||||
exoPlayer?.seekTo(currentWindow, playbackPosition)
|
||||
exoPlayer?.removeListener(this)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
exoPlayer?.let { exo ->
|
||||
event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering))
|
||||
isPlaying = exo.isPlaying
|
||||
|
|
@ -1443,8 +1399,6 @@ class CS3IPlayer : IPlayer {
|
|||
return
|
||||
}
|
||||
|
||||
LiveHelper.registerPlayer(exoPlayer)
|
||||
|
||||
exoPlayer?.addListener(object : Player.Listener {
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
safe {
|
||||
|
|
@ -1553,23 +1507,6 @@ class CS3IPlayer : IPlayer {
|
|||
exoPlayer?.prepare()
|
||||
}
|
||||
|
||||
// PlaylistStuckException usually happens when the player position is ahead of the live window.
|
||||
// Seek to the default location in that case
|
||||
error.cause is HlsPlaylistTracker.PlaylistStuckException -> {
|
||||
val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0
|
||||
|
||||
// Seek to live head
|
||||
val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0
|
||||
|
||||
if (aheadOfLive > 100) {
|
||||
exoPlayer?.seekTo(position - aheadOfLive)
|
||||
} else {
|
||||
exoPlayer?.seekToDefaultPosition()
|
||||
}
|
||||
exoPlayer?.prepare()
|
||||
}
|
||||
|
||||
|
||||
else -> {
|
||||
event(ErrorEvent(error))
|
||||
}
|
||||
|
|
@ -1641,9 +1578,9 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
}
|
||||
|
||||
private var lastTimeStamps: List<VideoSkipStamp> = emptyList()
|
||||
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList()
|
||||
|
||||
override fun addTimeStamps(timeStamps: List<VideoSkipStamp>) {
|
||||
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
|
||||
lastTimeStamps = timeStamps
|
||||
timeStamps.forEach { timestamp ->
|
||||
exoPlayer?.createMessage { _, _ ->
|
||||
|
|
@ -1652,7 +1589,7 @@ class CS3IPlayer : IPlayer {
|
|||
// onTimestampInvoked?.invoke(payload)
|
||||
}
|
||||
?.setLooper(Looper.getMainLooper())
|
||||
?.setPosition(timestamp.timestamp.startMs)
|
||||
?.setPosition(timestamp.startMs)
|
||||
//?.setPayload(timestamp)
|
||||
?.setDeleteAfterDelivery(false)
|
||||
?.send()
|
||||
|
|
@ -1771,6 +1708,7 @@ class CS3IPlayer : IPlayer {
|
|||
return exoPlayer != null
|
||||
}
|
||||
|
||||
|
||||
@MainThread
|
||||
private fun loadTorrent(context: Context, link: ExtractorLink) {
|
||||
ioSafe {
|
||||
|
|
@ -1820,7 +1758,7 @@ class CS3IPlayer : IPlayer {
|
|||
defaultSet
|
||||
)
|
||||
?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null }
|
||||
} catch (_: Throwable) {
|
||||
} catch (e: Throwable) {
|
||||
null
|
||||
} ?: default
|
||||
|
||||
|
|
@ -1915,7 +1853,7 @@ class CS3IPlayer : IPlayer {
|
|||
drm = DrmMetadata(
|
||||
kid = link.kid,
|
||||
key = link.key,
|
||||
uuid = link.uuid.toJavaUuid(),
|
||||
uuid = link.uuid,
|
||||
kty = link.kty,
|
||||
licenseUrl = link.licenseUrl,
|
||||
keyRequestParameters = link.keyRequestParameters,
|
||||
|
|
@ -1930,13 +1868,6 @@ class CS3IPlayer : IPlayer {
|
|||
)
|
||||
}
|
||||
|
||||
// For DASH or HLS single streams (non-playlist), prefer the player's default
|
||||
// live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick
|
||||
// the live/default position when no explicit start position was provided.
|
||||
if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) {
|
||||
playbackPosition = TIME_UNSET
|
||||
}
|
||||
|
||||
val provider = getApiFromNameNull(link.source)
|
||||
val interceptor: Interceptor? = provider?.getVideoInterceptor(link)
|
||||
|
||||
|
|
@ -2021,3 +1952,4 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import androidx.media3.common.Format
|
|||
import androidx.media3.common.Format.CueReplacementBehavior
|
||||
import androidx.media3.common.text.Cue
|
||||
import androidx.media3.common.text.Cue.AnchorType
|
||||
import androidx.media3.common.util.Assertions
|
||||
import androidx.media3.common.util.Consumer
|
||||
import androidx.media3.common.util.Log
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
|
|
@ -36,7 +37,6 @@ import androidx.media3.common.util.UnstableApi
|
|||
import androidx.media3.extractor.text.CuesWithTiming
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import androidx.media3.extractor.text.SubtitleParser.OutputOptions
|
||||
import com.google.common.base.Preconditions.checkNotNull
|
||||
import com.google.common.collect.ImmutableList
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
|
@ -115,7 +115,6 @@ class CustomSubripParser : SubtitleParser {
|
|||
currentLine = parsableByteArray.readLine(charset)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val text = Html.fromHtml(textBuilder.toString())
|
||||
|
||||
var alignmentTag: String? = null
|
||||
|
|
@ -260,9 +259,10 @@ class CustomSubripParser : SubtitleParser {
|
|||
private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long {
|
||||
val hours = matcher.group(groupOffset + 1)
|
||||
var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0
|
||||
timestampMs += checkNotNull(matcher.group(groupOffset + 2))
|
||||
timestampMs +=
|
||||
Assertions.checkNotNull<String?>(matcher.group(groupOffset + 2))
|
||||
.toLong() * 60 * 1000
|
||||
timestampMs += checkNotNull(matcher.group(groupOffset + 3))
|
||||
timestampMs += Assertions.checkNotNull<String?>(matcher.group(groupOffset + 3))
|
||||
.toLong() * 1000
|
||||
val millis = matcher.group(groupOffset + 4)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,12 @@ import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFol
|
|||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo
|
||||
|
||||
class DownloadFileGenerator(
|
||||
episodes: List<ExtractorUri>
|
||||
) : VideoGenerator<ExtractorUri>(episodes) {
|
||||
episodes: List<ExtractorUri>,
|
||||
currentIndex: Int = 0
|
||||
) : VideoGenerator<ExtractorUri>(episodes, currentIndex) {
|
||||
override val hasCache = false
|
||||
override val canSkipLoading = false
|
||||
|
||||
override fun getId(index: Int): Int? = this.videos.getOrNull(index)?.id
|
||||
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
sourceTypes: Set<ExtractorLinkType>,
|
||||
|
|
@ -29,7 +28,7 @@ class DownloadFileGenerator(
|
|||
offset: Int,
|
||||
isCasting: Boolean
|
||||
): Boolean {
|
||||
val meta = videos.getOrNull(offset) ?: return false
|
||||
val meta = getCurrent(offset) ?: return false
|
||||
|
||||
if (meta.uri == Uri.EMPTY) {
|
||||
// We do this here so that we only load it when
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPres
|
|||
import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat
|
||||
|
||||
class DownloadedPlayerActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
const val TAG = "DownloadedPlayerActivity"
|
||||
}
|
||||
private val dTAG = "DownloadedPlayerAct"
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
|
||||
CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event)
|
||||
|
|
@ -29,79 +27,49 @@ class DownloadedPlayerActivity : AppCompatActivity() {
|
|||
CommonActivity.onUserLeaveHint(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Ignore same intent so the player doesnt totally
|
||||
// reload if you are playing the same thing.
|
||||
if (isSameIntent(intent)) return
|
||||
setIntent(intent)
|
||||
Log.i(TAG, "onNewIntent")
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
private fun isSameIntent(newIntent: Intent): Boolean {
|
||||
val old = intent ?: return false
|
||||
// Compare URIs first
|
||||
val oldUri = old.data ?: old.clipData?.getItemAt(0)?.uri
|
||||
val newUri = newIntent.data ?: newIntent.clipData?.getItemAt(0)?.uri
|
||||
if (oldUri != null && oldUri == newUri) return true
|
||||
// Fall back to comparing EXTRA_TEXT links
|
||||
val oldText = safe { old.getStringExtra(Intent.EXTRA_TEXT) }
|
||||
val newText = safe { newIntent.getStringExtra(Intent.EXTRA_TEXT) }
|
||||
return oldText != null && oldText == newText
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
CommonActivity.loadThemes(this)
|
||||
CommonActivity.init(this)
|
||||
enableEdgeToEdgeCompat()
|
||||
setContentView(R.layout.empty_layout)
|
||||
Log.i(TAG, "onCreate")
|
||||
handleIntent(intent)
|
||||
Log.i(dTAG, "onCreate")
|
||||
|
||||
/**
|
||||
* Use moveTaskToBack instead of finish() so there is always exactly one task
|
||||
* entry in recents, always reflecting the current file.
|
||||
*
|
||||
* finish() destroys the Activity but may leave the task in recents. Each new file
|
||||
* open can create a new task entry, so recents accumulates stale entries for old
|
||||
* files. The user then taps a stale entry and gets the wrong file.
|
||||
*
|
||||
* moveTaskToBack keeps the Activity alive in the background. There is only ever
|
||||
* one task entry in recents. New files opened from the file manager arrive via
|
||||
* onNewIntent on the live instance, updating the player immediately. The single
|
||||
* recents entry always reflects the current state, ensuring we load the
|
||||
* correct file.
|
||||
*/
|
||||
attachBackPressedCallback("DownloadedPlayerActivity") { moveTaskToBack(true) }
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
val data = intent.data
|
||||
|
||||
if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
intent.action == Intent.ACTION_SEND ||
|
||||
intent.action == Intent.ACTION_OPEN_DOCUMENT ||
|
||||
intent.action == Intent.ACTION_VIEW
|
||||
) {
|
||||
val extraText = safe { intent.getStringExtra(Intent.EXTRA_TEXT) }
|
||||
if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) {
|
||||
val extraText = safe { // I dont trust android
|
||||
intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
}
|
||||
val cd = intent.clipData
|
||||
val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null
|
||||
val url = item?.text?.toString()
|
||||
when {
|
||||
item?.uri != null -> playUri(this, item.uri)
|
||||
url != null -> playLink(this, url)
|
||||
data != null -> playUri(this, data)
|
||||
extraText != null -> playLink(this, extraText)
|
||||
else -> finishAndRemoveTask()
|
||||
|
||||
// idk what I am doing, just hope any of these work
|
||||
if (item?.uri != null)
|
||||
playUri(this, item.uri)
|
||||
else if (url != null)
|
||||
playLink(this, url)
|
||||
else if (data != null)
|
||||
playUri(this, data)
|
||||
else if (extraText != null)
|
||||
playLink(this, extraText)
|
||||
else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
} else if (data?.scheme == "content") {
|
||||
playUri(this, data)
|
||||
} else finishAndRemoveTask()
|
||||
} else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
attachBackPressedCallback("DownloadedPlayerActivity") { finish() }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
|||
class ExtractorLinkGenerator(
|
||||
private val links: List<ExtractorLink>,
|
||||
private val subtitles: List<SubtitleData>,
|
||||
) : NoVideoGenerator(null) {
|
||||
) : NoVideoGenerator() {
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
sourceTypes: Set<ExtractorLinkType>,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,10 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
val LOADTYPE_INAPP = setOf(
|
||||
ExtractorLinkType.VIDEO,
|
||||
|
|
@ -25,27 +28,71 @@ val LOADTYPE_CHROMECAST = setOf(
|
|||
val LOADTYPE_ALL = ExtractorLinkType.entries.toSet()
|
||||
|
||||
|
||||
abstract class NoVideoGenerator(val id : Int?) : VideoGenerator<Nothing>(emptyList()) {
|
||||
abstract class NoVideoGenerator : VideoGenerator<Nothing>(emptyList(), 0) {
|
||||
override val hasCache = false
|
||||
override val canSkipLoading = false
|
||||
override fun getId(index: Int): Int? = id
|
||||
}
|
||||
|
||||
abstract class VideoGenerator<T : Any>(val videos: List<T>) {
|
||||
abstract val hasCache: Boolean
|
||||
abstract val canSkipLoading: Boolean
|
||||
abstract fun getId(index : Int) : Int?
|
||||
abstract class VideoGenerator<T : Any>(val videos: List<T>, var videoIndex: Int = 0) :
|
||||
IGenerator {
|
||||
|
||||
fun hasNext(videoIndex : Int): Boolean = videoIndex < videos.lastIndex
|
||||
fun hasPrev(videoIndex : Int): Boolean = videoIndex > 0
|
||||
override fun hasNext(): Boolean = videoIndex < videos.lastIndex
|
||||
override fun hasPrev(): Boolean = videoIndex > 0
|
||||
override fun getAll(): List<T>? = videos
|
||||
override fun getCurrent(offset: Int): T? = videos.getOrNull(videoIndex + offset)
|
||||
override fun next() {
|
||||
if (hasNext()) {
|
||||
videoIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
@Throws
|
||||
abstract suspend fun generateLinks(
|
||||
override fun prev() {
|
||||
if (hasPrev()) {
|
||||
videoIndex -= 1
|
||||
}
|
||||
}
|
||||
|
||||
override fun goto(index: Int) {
|
||||
videoIndex = min(videos.lastIndex, max(0, index))
|
||||
}
|
||||
|
||||
override fun getCurrentId(): Int? {
|
||||
return when (val current = getCurrent()) {
|
||||
is ResultEpisode -> {
|
||||
current.id
|
||||
}
|
||||
|
||||
is ExtractorUri -> {
|
||||
current.id
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO deprecate/remove IGenerator in favor of a more ergonomic and correct implementation
|
||||
interface IGenerator {
|
||||
val hasCache: Boolean
|
||||
val canSkipLoading: Boolean
|
||||
|
||||
fun hasNext(): Boolean
|
||||
fun hasPrev(): Boolean
|
||||
fun next()
|
||||
fun prev()
|
||||
fun goto(index: Int)
|
||||
|
||||
fun getCurrentId(): Int? // this is used to save data or read data about this id
|
||||
fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null
|
||||
fun getAll(): List<Any>? // this us used to get the metadata about all entries, not needed
|
||||
|
||||
/* not safe, must use try catch */
|
||||
suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
sourceTypes: Set<ExtractorLinkType>,
|
||||
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
||||
subtitleCallback: (SubtitleData) -> Unit,
|
||||
offset: Int,
|
||||
isCasting: Boolean
|
||||
offset: Int = 0,
|
||||
isCasting: Boolean = false
|
||||
): Boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,30 @@ package com.lagradost.cloudstream3.ui.player
|
|||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Rational
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
|
||||
|
||||
enum class PlayerEventType(val value: Int) {
|
||||
Pause(0),
|
||||
Play(1),
|
||||
SeekForward(2),
|
||||
SeekBack(3),
|
||||
|
||||
SkipCurrentChapter(4),
|
||||
NextEpisode(5),
|
||||
PrevEpisode(6),
|
||||
PlayPauseToggle(7),
|
||||
ToggleMute(8),
|
||||
Lock(9),
|
||||
ToggleHide(10),
|
||||
ShowSpeed(11),
|
||||
ShowMirrors(12),
|
||||
Resize(13),
|
||||
SearchSubtitlesOnline(14),
|
||||
SkipOp(15),
|
||||
Restart(16),
|
||||
}
|
||||
|
||||
enum class CSPlayerEvent(val value: Int) {
|
||||
Pause(0),
|
||||
|
|
@ -67,13 +86,13 @@ data class ErrorEvent(
|
|||
|
||||
/** Event when timestamps appear, null when it should disappear */
|
||||
data class TimestampInvokedEvent(
|
||||
val timestamp: VideoSkipStamp,
|
||||
val timestamp: EpisodeSkip.SkipStamp,
|
||||
override val source: PlayerEventSource = PlayerEventSource.Player,
|
||||
) : PlayerEvent()
|
||||
|
||||
/** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */
|
||||
data class TimestampSkippedEvent(
|
||||
val timestamp: VideoSkipStamp,
|
||||
val timestamp: EpisodeSkip.SkipStamp,
|
||||
override val source: PlayerEventSource = PlayerEventSource.Player,
|
||||
) : PlayerEvent()
|
||||
|
||||
|
|
@ -201,6 +220,8 @@ data class CurrentTracks(
|
|||
val allTextTracks: List<TextTrack>,
|
||||
)
|
||||
|
||||
class InvalidFileException(msg: String) : Exception(msg)
|
||||
|
||||
//http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
|
||||
const val ACTION_MEDIA_CONTROL = "media_control"
|
||||
const val EXTRA_CONTROL_TYPE = "control_type"
|
||||
|
|
@ -222,9 +243,8 @@ interface IPlayer {
|
|||
fun getSubtitleOffset(): Long // in ms
|
||||
fun setSubtitleOffset(offset: Long) // in ms
|
||||
|
||||
@AnyThread
|
||||
fun initCallbacks(
|
||||
@MainThread eventHandler: ((PlayerEvent) -> Unit),
|
||||
eventHandler: ((PlayerEvent) -> Unit),
|
||||
/** this is used to request when the player should report back view percentage */
|
||||
requestedListeningPercentages: List<Int>? = null,
|
||||
)
|
||||
|
|
@ -234,7 +254,7 @@ interface IPlayer {
|
|||
fun updateSubtitleStyle(style: SaveCaptionStyle)
|
||||
fun saveData()
|
||||
|
||||
fun addTimeStamps(timeStamps: List<VideoSkipStamp>)
|
||||
fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>)
|
||||
|
||||
fun loadPlayer(
|
||||
context: Context,
|
||||
|
|
@ -287,7 +307,7 @@ interface IPlayer {
|
|||
fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null)
|
||||
|
||||
/** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */
|
||||
fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, formatIndex: Int? = null)
|
||||
fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, trackIndex: Int? = null)
|
||||
|
||||
/** Get the current subtitle cues, for use with syncing */
|
||||
fun getSubtitleCues(): List<SubtitleCue>
|
||||
|
|
|
|||
|
|
@ -40,8 +40,7 @@ class LinkGenerator(
|
|||
private val links: List<BasicLink>,
|
||||
private val extract: Boolean = true,
|
||||
private val refererUrl: String? = null,
|
||||
id: Int?
|
||||
) : NoVideoGenerator(id) {
|
||||
) : NoVideoGenerator() {
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
sourceTypes: Set<ExtractorLinkType>,
|
||||
|
|
@ -79,8 +78,10 @@ class LinkGenerator(
|
|||
class MinimalLinkGenerator(
|
||||
private val links: List<CloudStreamPackage.MinimalVideoLink>,
|
||||
private val subs: List<CloudStreamPackage.MinimalSubtitleLink>,
|
||||
id: Int?
|
||||
) : NoVideoGenerator(id) {
|
||||
private val id: Int? = null
|
||||
) : NoVideoGenerator() {
|
||||
override fun getCurrentId(): Int? = id
|
||||
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
sourceTypes: Set<ExtractorLinkType>,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentUris
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.ContextCompat.getString
|
||||
import androidx.navigation.NavOptions
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
|
|
@ -13,25 +13,15 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
|||
import com.lagradost.safefile.SafeFile
|
||||
|
||||
object OfflinePlaybackHelper {
|
||||
/**
|
||||
* Pop any existing player off the nav back stack before pushing the new one,
|
||||
* keeping the stack flat (at most one player at a time). This prevents an
|
||||
* OOM when many files are opened in sequence via DownloadedPlayerActivity.
|
||||
*/
|
||||
private val replacePlayerNavOptions = NavOptions.Builder()
|
||||
.setPopUpTo(R.id.navigation_player, inclusive = true, saveState = false)
|
||||
.build()
|
||||
|
||||
fun playLink(activity: Activity, url: String) {
|
||||
activity.navigate(
|
||||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||
LinkGenerator(
|
||||
listOf(
|
||||
BasicLink(url)
|
||||
), id = url.hashCode()
|
||||
), 0
|
||||
),
|
||||
replacePlayerNavOptions
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -62,9 +52,8 @@ object OfflinePlaybackHelper {
|
|||
links,
|
||||
subs,
|
||||
if (id != -1) id else null,
|
||||
), 0
|
||||
),
|
||||
replacePlayerNavOptions
|
||||
)
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
|
@ -84,12 +73,12 @@ object OfflinePlaybackHelper {
|
|||
name = name ?: getString(activity, R.string.downloaded_file),
|
||||
// well not the same as a normal id, but we take it as users may want to
|
||||
// play downloaded files and save the location
|
||||
id = uri.lastPathSegment?.toLongOrNull()?.hashCode() ?: uri.lastPathSegment?.hashCode()
|
||||
id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()
|
||||
?.hashCode()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
), 0
|
||||
),
|
||||
replacePlayerNavOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,188 +9,34 @@ import com.lagradost.cloudstream3.LoadResponse
|
|||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority
|
||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
import com.lagradost.cloudstream3.utils.videoskip.SkipAPI
|
||||
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.PersistentSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.annotations.Contract
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
typealias VideoLink = Pair<ExtractorLink?, ExtractorUri?>
|
||||
|
||||
data class GeneratorState(
|
||||
val meta: Any?,
|
||||
val nextMeta: Any?,
|
||||
val allMeta: List<*>?,
|
||||
val response: LoadResponse?,
|
||||
val index: Int,
|
||||
val id: Int?,
|
||||
)
|
||||
|
||||
/** Immutable state of all current links relevant to displaying the video */
|
||||
// @MustUseReturnValues
|
||||
// @Immutable
|
||||
data class VideoState(
|
||||
val subtitles: PersistentSet<SubtitleData> = persistentSetOf(),
|
||||
val links: PersistentSet<VideoLink> = persistentSetOf(),
|
||||
val stamps: PersistentList<VideoSkipStamp> = persistentListOf(),
|
||||
val loading: Resource<Unit> = Resource.Loading(),
|
||||
val generatorState: GeneratorState? = null,
|
||||
val instance: Int,
|
||||
) {
|
||||
/**
|
||||
* This acts as a local cache for sorted links that are not copied over by the copy constructor.
|
||||
*
|
||||
* sortedBy is not exactly expensive, but each hasNextMirror does it again, so this alleviates unnecessary recomputation
|
||||
* */
|
||||
private val sortedLinks: ConcurrentHashMap<Int, List<VideoLink>> = ConcurrentHashMap()
|
||||
|
||||
fun clearSortedLinksCache() = sortedLinks.clear()
|
||||
|
||||
// Modifying sortedLinks is not considered a "visible" side effect, and rerunning it does not change the result
|
||||
// It is by all standards, idempotent and by extension also pure as it has no "visible" side effect
|
||||
/** Returns .links in the sorted order according to the qualityProfile.
|
||||
* Use .links if order is not needed */
|
||||
@Contract(pure = true)
|
||||
fun sortLinks(qualityProfile: Int): List<VideoLink> {
|
||||
return sortedLinks[qualityProfile] ?: links.sortedBy { link ->
|
||||
// negative because we want to sort highest quality first
|
||||
-getLinkPriority(qualityProfile, link.first)
|
||||
}.also { value -> sortedLinks[qualityProfile] = value }
|
||||
}
|
||||
|
||||
@Contract(pure = true)
|
||||
fun add(item: SubtitleData): VideoState = copy(subtitles = subtitles.add(item))
|
||||
|
||||
@Contract(pure = true)
|
||||
fun add(item: VideoLink): VideoState = copy(links = links.add(item))
|
||||
|
||||
@Contract(pure = true)
|
||||
fun add(item: VideoSkipStamp): VideoState = copy(stamps = stamps.add(item))
|
||||
|
||||
@JvmName("addSubtitleData")
|
||||
@Contract(pure = true)
|
||||
fun add(items: Collection<SubtitleData>): VideoState = copy(subtitles = subtitles.addAll(items))
|
||||
|
||||
@JvmName("addVideoLink")
|
||||
@Contract(pure = true)
|
||||
fun add(items: Collection<VideoLink>): VideoState = copy(links = links.addAll(items))
|
||||
|
||||
@JvmName("addVideoSkipStamp")
|
||||
@Contract(pure = true)
|
||||
fun add(items: Collection<VideoSkipStamp>): VideoState = copy(stamps = stamps.addAll(items))
|
||||
|
||||
@Contract(pure = true)
|
||||
fun set(item: SubtitleData): VideoState = copy(subtitles = persistentSetOf(item))
|
||||
|
||||
@Contract(pure = true)
|
||||
fun set(item: VideoLink): VideoState = copy(links = persistentSetOf(item))
|
||||
|
||||
@Contract(pure = true)
|
||||
fun set(item: VideoSkipStamp): VideoState = copy(stamps = persistentListOf(item))
|
||||
|
||||
@JvmName("setSubtitleData")
|
||||
@Contract(pure = true)
|
||||
fun set(items: Collection<SubtitleData>): VideoState = copy(subtitles = items.toPersistentSet())
|
||||
|
||||
@JvmName("setVideoLink")
|
||||
@Contract(pure = true)
|
||||
fun set(items: Collection<VideoLink>): VideoState = copy(links = items.toPersistentSet())
|
||||
|
||||
@JvmName("setVideoSkipStamp")
|
||||
@Contract(pure = true)
|
||||
fun set(items: Collection<VideoSkipStamp>): VideoState = copy(stamps = items.toPersistentList())
|
||||
}
|
||||
|
||||
data class VideoLive<T>(
|
||||
val value: T,
|
||||
val instance: Int,
|
||||
)
|
||||
|
||||
class PlayerGeneratorViewModel : ViewModel() {
|
||||
companion object {
|
||||
const val TAG = "PlayViewGen"
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var generator: VideoGenerator<*>? = null
|
||||
private var generator: IGenerator? = null
|
||||
|
||||
@Volatile
|
||||
var episodeIndex: Int = 0
|
||||
private val _currentLinks = MutableLiveData<Set<Pair<ExtractorLink?, ExtractorUri?>>>(setOf())
|
||||
val currentLinks: LiveData<Set<Pair<ExtractorLink?, ExtractorUri?>>> = _currentLinks
|
||||
|
||||
/**
|
||||
* The state of the video player, only modify it by modifyState to make sure observe is called,
|
||||
* and avoid concurrency issues.
|
||||
*
|
||||
* This value can be used without Synchronized or locking when reading, as all fields are immutable.
|
||||
* */
|
||||
@Volatile
|
||||
var state = VideoState(instance = 0)
|
||||
private set
|
||||
private val _currentSubs = MutableLiveData<Set<SubtitleData>>(setOf())
|
||||
val currentSubs: LiveData<Set<SubtitleData>> = _currentSubs
|
||||
|
||||
private val _currentLinks =
|
||||
MutableLiveData<VideoLive<Set<Pair<ExtractorLink?, ExtractorUri?>>>>(null)
|
||||
val currentLinks: LiveData<VideoLive<Set<Pair<ExtractorLink?, ExtractorUri?>>>> = _currentLinks
|
||||
private val _loadingLinks = MutableLiveData<Resource<Boolean?>>()
|
||||
val loadingLinks: LiveData<Resource<Boolean?>> = _loadingLinks
|
||||
|
||||
private val _currentSubtitles = MutableLiveData<VideoLive<Set<SubtitleData>>>(null)
|
||||
val currentSubtitles: LiveData<VideoLive<Set<SubtitleData>>> = _currentSubtitles
|
||||
|
||||
private val _loadingLinks = MutableLiveData<VideoLive<Resource<Unit>>>()
|
||||
val loadingLinks: LiveData<VideoLive<Resource<Unit>>> = _loadingLinks
|
||||
|
||||
private val _currentStamps = MutableLiveData<VideoLive<List<VideoSkipStamp>>>(null)
|
||||
val currentStamps: LiveData<VideoLive<List<VideoSkipStamp>>> = _currentStamps
|
||||
|
||||
/**
|
||||
* Modifies the `state` variable safely, and with the correct observe behavior.
|
||||
*
|
||||
* Synchronized to avoid concurrency issues, and make this operation atomic.
|
||||
* Otherwise, one update may be lost if they are done in parallel.
|
||||
* */
|
||||
@Synchronized
|
||||
fun modifyState(op: VideoState.() -> VideoState) {
|
||||
val oldState = state
|
||||
state = op.invoke(oldState)
|
||||
|
||||
/** New instance, always push state */
|
||||
if (state.instance != oldState.instance) {
|
||||
_currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
|
||||
_currentStamps.postValue(VideoLive(state.stamps, state.instance))
|
||||
_currentLinks.postValue(VideoLive(state.links, state.instance))
|
||||
_loadingLinks.postValue(VideoLive(state.loading, state.instance))
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Only post the changed values, this makes sure we do not invoke the "observe"
|
||||
*
|
||||
* We do this by "Referential equality" https://kotlinlang.org/docs/equality.html#referential-equality
|
||||
* to avoid comparing the entire set or list as "Persistent" classes will hold the same reference if they are unchanged.
|
||||
* */
|
||||
if (state.links !== oldState.links)
|
||||
_currentLinks.postValue(VideoLive(state.links, state.instance))
|
||||
if (state.stamps !== oldState.stamps)
|
||||
_currentStamps.postValue(VideoLive(state.stamps, state.instance))
|
||||
if (state.subtitles !== oldState.subtitles)
|
||||
_currentSubtitles.postValue(VideoLive(state.subtitles, state.instance))
|
||||
|
||||
/** Normal equality here as it is not a collection */
|
||||
if (state.loading != oldState.loading)
|
||||
_loadingLinks.postValue(VideoLive(state.loading, state.instance))
|
||||
}
|
||||
private val _currentStamps = MutableLiveData<List<EpisodeSkip.SkipStamp>>(emptyList())
|
||||
val currentStamps: LiveData<List<EpisodeSkip.SkipStamp>> = _currentStamps
|
||||
|
||||
private val _currentSubtitleYear = MutableLiveData<Int?>(null)
|
||||
val currentSubtitleYear: LiveData<Int?> = _currentSubtitleYear
|
||||
|
|
@ -206,32 +52,41 @@ class PlayerGeneratorViewModel : ViewModel() {
|
|||
_currentSubtitleYear.postValue(year)
|
||||
}
|
||||
|
||||
fun getId(): Int? {
|
||||
return generator?.getCurrentId()
|
||||
}
|
||||
|
||||
fun loadLinks(episode: Int) {
|
||||
generator?.goto(episode)
|
||||
loadLinks()
|
||||
}
|
||||
|
||||
fun loadLinksPrev() {
|
||||
Log.i(TAG, "loadLinksPrev")
|
||||
if (generator?.hasPrev(episodeIndex) == true) {
|
||||
episodeIndex += 1
|
||||
if (generator?.hasPrev() == true) {
|
||||
generator?.prev()
|
||||
loadLinks()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLinksNext() {
|
||||
Log.i(TAG, "loadLinksNext")
|
||||
if (generator?.hasNext(episodeIndex) == true) {
|
||||
episodeIndex += 1
|
||||
if (generator?.hasNext() == true) {
|
||||
generator?.next()
|
||||
loadLinks()
|
||||
}
|
||||
}
|
||||
|
||||
fun hasNextEpisode(): Boolean? {
|
||||
return generator?.hasNext(episodeIndex)
|
||||
return generator?.hasNext()
|
||||
}
|
||||
|
||||
fun hasPrevEpisode(): Boolean? {
|
||||
return generator?.hasPrev(episodeIndex)
|
||||
return generator?.hasPrev()
|
||||
}
|
||||
|
||||
fun preLoadNextLinks() {
|
||||
val id = generator?.getId(episodeIndex)
|
||||
val id = getId()
|
||||
// Do not preload if already loading
|
||||
if (id == currentLoadingEpisodeId) return
|
||||
|
||||
|
|
@ -241,15 +96,14 @@ class PlayerGeneratorViewModel : ViewModel() {
|
|||
|
||||
currentJob = viewModelScope.launch {
|
||||
try {
|
||||
if (generator?.hasCache == true && generator?.hasNext(episodeIndex) == true) {
|
||||
if (generator?.hasCache == true && generator?.hasNext() == true) {
|
||||
safeApiCall {
|
||||
generator?.generateLinks(
|
||||
sourceTypes = LOADTYPE_INAPP,
|
||||
clearCache = false,
|
||||
isCasting = false,
|
||||
callback = {},
|
||||
subtitleCallback = {},
|
||||
offset = episodeIndex + 1
|
||||
offset = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -263,25 +117,56 @@ class PlayerGeneratorViewModel : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
fun loadThisEpisode(index: Int) {
|
||||
episodeIndex = index
|
||||
fun getLoadResponse(): LoadResponse? {
|
||||
return safe { (generator as? RepoLinkGenerator?)?.page }
|
||||
}
|
||||
|
||||
fun getMeta(): Any? {
|
||||
return safe { generator?.getCurrent() }
|
||||
}
|
||||
|
||||
fun getAllMeta(): List<Any>? {
|
||||
return safe { generator?.getAll() }
|
||||
}
|
||||
|
||||
fun getNextMeta(): Any? {
|
||||
return safe {
|
||||
if (generator?.hasNext() == false) return@safe null
|
||||
generator?.getCurrent(offset = 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadThisEpisode(index:Int) {
|
||||
generator?.goto(index)
|
||||
loadLinks()
|
||||
}
|
||||
|
||||
fun attachGenerator(newGenerator: VideoGenerator<*>, index: Int) {
|
||||
Log.i(TAG, "attachGenerator with generator=$newGenerator and index=$index")
|
||||
generator = newGenerator
|
||||
episodeIndex = index
|
||||
fun getCurrentIndex():Int?{
|
||||
val repoGen = generator as? RepoLinkGenerator ?: return null
|
||||
return repoGen.videoIndex
|
||||
}
|
||||
|
||||
fun attachGenerator(newGenerator: IGenerator?) {
|
||||
if (generator == null) {
|
||||
generator = newGenerator
|
||||
}
|
||||
}
|
||||
|
||||
private var extraSubtitles : MutableSet<SubtitleData> = mutableSetOf()
|
||||
|
||||
/**
|
||||
* If duplicate nothing will happen
|
||||
* */
|
||||
fun addSubtitles(file: Set<SubtitleData>) {
|
||||
val validFile = file.filter(::isValidSubtitle)
|
||||
if (validFile.isNotEmpty())
|
||||
modifyState {
|
||||
add(validFile)
|
||||
fun addSubtitles(file: Set<SubtitleData>) = synchronized(extraSubtitles) {
|
||||
extraSubtitles += file
|
||||
val current = _currentSubs.value ?: emptySet()
|
||||
val next = extraSubtitles + current
|
||||
|
||||
// if it is of a different size then we have added distinct items
|
||||
if (next.size != current.size) {
|
||||
// Posting will refresh subtitles which will in turn
|
||||
// make the subs to english if previously unselected
|
||||
_currentSubs.postValue(next)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -289,111 +174,72 @@ class PlayerGeneratorViewModel : ViewModel() {
|
|||
private var currentStampJob: Job? = null
|
||||
|
||||
fun loadStamps(duration: Long) {
|
||||
//currentStampJob?.cancel()
|
||||
currentStampJob = ioSafe {
|
||||
val genState = state.generatorState ?: return@ioSafe
|
||||
val meta = genState.meta
|
||||
val page = genState.response
|
||||
val id = genState.id
|
||||
if (page == null || meta !is ResultEpisode) {
|
||||
return@ioSafe
|
||||
}
|
||||
val stamps = SkipAPI.videoStamps(
|
||||
val meta = generator?.getCurrent()
|
||||
val page = (generator as? RepoLinkGenerator?)?.page
|
||||
if (page != null && meta is ResultEpisode) {
|
||||
_currentStamps.postValue(listOf())
|
||||
_currentStamps.postValue(
|
||||
EpisodeSkip.getStamps(
|
||||
page,
|
||||
meta,
|
||||
duration,
|
||||
hasNextEpisode() ?: false
|
||||
)
|
||||
|
||||
/** Avoid adding stamps to the wrong video */
|
||||
modifyState {
|
||||
if (id != this.generatorState?.id) {
|
||||
this
|
||||
} else {
|
||||
set(stamps)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var langFilterList = listOf<String>()
|
||||
var filterSubByLang = false
|
||||
|
||||
fun isValidSubtitle(subtitle: SubtitleData): Boolean {
|
||||
if (langFilterList.isEmpty() || !filterSubByLang) {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Only filter out subtitles fetched online */
|
||||
if (subtitle.origin != SubtitleOrigin.URL) {
|
||||
return true
|
||||
}
|
||||
|
||||
return langFilterList.any { lang ->
|
||||
subtitle.originalName.contains(lang, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLinks(sourceTypes: Set<ExtractorLinkType> = LOADTYPE_INAPP) {
|
||||
Log.i(TAG, "loadLinks with generator=$generator and index=$episodeIndex")
|
||||
Log.i(TAG, "loadLinks")
|
||||
currentJob?.cancel()
|
||||
val index = episodeIndex
|
||||
|
||||
// Clear old data and reset the state
|
||||
modifyState {
|
||||
VideoState(
|
||||
loading = Resource.Loading(),
|
||||
generatorState = generator?.let { gen ->
|
||||
GeneratorState(
|
||||
meta = gen.videos.getOrNull(index),
|
||||
nextMeta = gen.videos.getOrNull(index + 1),
|
||||
id = gen.getId(index),
|
||||
response = (gen as? RepoLinkGenerator)?.page,
|
||||
index = index,
|
||||
allMeta = gen.videos
|
||||
)
|
||||
},
|
||||
instance = instance + 1
|
||||
)
|
||||
}
|
||||
|
||||
currentJob = viewModelScope.launchSafe {
|
||||
// Load more data
|
||||
// if we load links then we clear the prev loaded links
|
||||
synchronized(extraSubtitles) {
|
||||
extraSubtitles.clear()
|
||||
}
|
||||
val currentLinks = mutableSetOf<Pair<ExtractorLink?, ExtractorUri?>>()
|
||||
val currentSubs = mutableSetOf<SubtitleData>()
|
||||
|
||||
// clear old data
|
||||
_currentSubs.postValue(emptySet())
|
||||
_currentLinks.postValue(emptySet())
|
||||
|
||||
// load more data
|
||||
_loadingLinks.postValue(Resource.Loading())
|
||||
val loadingState = safeApiCall {
|
||||
generator?.generateLinks(
|
||||
sourceTypes = sourceTypes,
|
||||
clearCache = forceClearCache,
|
||||
callback = { link ->
|
||||
if (isActive)
|
||||
modifyState {
|
||||
add(link)
|
||||
callback = {
|
||||
synchronized(currentLinks) {
|
||||
currentLinks.add(it)
|
||||
// Clone to prevent ConcurrentModificationException
|
||||
safe {
|
||||
// Extra safe since .toSet() iterates.
|
||||
_currentLinks.postValue(currentLinks.toSet())
|
||||
}
|
||||
}
|
||||
},
|
||||
isCasting = false,
|
||||
offset = index,
|
||||
subtitleCallback = { link ->
|
||||
if (isActive && isValidSubtitle(link))
|
||||
modifyState {
|
||||
add(link)
|
||||
subtitleCallback = {
|
||||
synchronized(extraSubtitles) {
|
||||
currentSubs.add(it)
|
||||
safe {
|
||||
_currentSubs.postValue(currentSubs + extraSubtitles)
|
||||
}
|
||||
}
|
||||
})
|
||||
Unit
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
return@launchSafe
|
||||
_loadingLinks.postValue(loadingState)
|
||||
_currentLinks.postValue(currentLinks)
|
||||
synchronized(extraSubtitles) {
|
||||
_currentSubs.postValue(currentSubs + extraSubtitles)
|
||||
}
|
||||
}
|
||||
|
||||
/** Only mark as success if we have not skipped loading */
|
||||
modifyState {
|
||||
if (!isActive) {
|
||||
this
|
||||
} else {
|
||||
when (loading) {
|
||||
is Resource.Loading -> copy(loading = loadingState)
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -128,7 +128,7 @@ object PlayerPipHelper {
|
|||
getRemoteAction(
|
||||
activity,
|
||||
R.drawable.baseline_headphones_24,
|
||||
R.string.audio_singular,
|
||||
R.string.audio_singluar,
|
||||
CSPlayerEvent.PlayAsAudio
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,842 +0,0 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.graphics.drawable.AnimatedVectorDrawable
|
||||
import android.media.metrics.PlaybackErrorEvent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.format.DateUtils
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import androidx.media3.ui.TimeBar
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||
import com.github.rubensousa.previewseekbar.PreviewBar
|
||||
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
|
||||
import com.lagradost.cloudstream3.CommonActivity.isInPIPMode
|
||||
import com.lagradost.cloudstream3.CommonActivity.screenWidth
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.ui.player.live.LivePreviewTimeBar
|
||||
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.subtitles.SaveCaptionStyle
|
||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI
|
||||
import com.lagradost.cloudstream3.utils.UserPreferenceDelegate
|
||||
import com.lagradost.cloudstream3.utils.videoskip.VideoSkipStamp
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* Shared player view - manages ExoPlayer setup, view binding, lifecycle, and event
|
||||
* dispatching. Gesture/volume/brightness/key-event input is handled by [gestureHelper]
|
||||
* ([PlayerGestureHelper]), which is exposed via delegate properties for easier access.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlayerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PlayerView"
|
||||
}
|
||||
|
||||
/** All gesture, volume, brightness and key-event logic lives here. */
|
||||
val gestureHelper = PlayerGestureHelper(this)
|
||||
|
||||
/** Delegate properties (forwarded to gestureHelper for external callers to have easier access) */
|
||||
var isFullScreen: Boolean
|
||||
get() = gestureHelper.isFullScreen
|
||||
set(value) { gestureHelper.isFullScreen = value }
|
||||
|
||||
var isLocked: Boolean
|
||||
get() = gestureHelper.isLocked
|
||||
set(value) { gestureHelper.isLocked = value }
|
||||
|
||||
var videoOutline: View?
|
||||
get() = gestureHelper.videoOutline
|
||||
set(value) { gestureHelper.videoOutline = value }
|
||||
|
||||
/** Delegate methods */
|
||||
fun handleVolumeKey(keyCode: Int) = gestureHelper.handleVolumeKey(keyCode)
|
||||
fun verifyVolume() = gestureHelper.verifyVolume()
|
||||
fun setupKeyEventListener() = gestureHelper.setupKeyEventListener()
|
||||
fun releaseKeyEventListener() = gestureHelper.releaseKeyEventListener()
|
||||
fun requestUpdateBrightnessOverlayOnNextLayout() = gestureHelper.requestUpdateBrightnessOverlayOnNextLayout()
|
||||
fun releaseOverlayLayoutListener() = gestureHelper.releaseOverlayLayoutListener()
|
||||
|
||||
/** Callbacks */
|
||||
|
||||
/** Host-fragment-level callbacks invoked by [mainCallback]. */
|
||||
interface Callbacks {
|
||||
fun nextEpisode() {}
|
||||
fun prevEpisode() {}
|
||||
fun playerPositionChanged(position: Long, duration: Long) {}
|
||||
fun playerStatusChanged() {}
|
||||
fun playerDimensionsLoaded(width: Int, height: Int) {}
|
||||
fun subtitlesChanged() {}
|
||||
fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {}
|
||||
fun onTracksInfoChanged() {}
|
||||
fun onTimestamp(timestamp: VideoSkipStamp?) {}
|
||||
fun onTimestampSkipped(timestamp: VideoSkipStamp) {}
|
||||
fun exitedPipMode() {}
|
||||
fun hasNextMirror(): Boolean = false
|
||||
fun nextMirror() {}
|
||||
fun onDownload(event: DownloadEvent) {}
|
||||
fun playerError(exception: Throwable) {}
|
||||
/** Called after [PlayerView] finishes its own player-attached setup (MediaSession, ExoPlayer view). */
|
||||
fun playerUpdated(player: Any?) {}
|
||||
/** Called on a short single-tap on empty player area (no swipe, no double-tap). */
|
||||
fun onSingleTap() {}
|
||||
/** Called when the hold-for-speedup gesture starts (show=true) or ends (show=false). */
|
||||
fun onHoldSpeedUp(show: Boolean) {}
|
||||
/** Called during brightness swipe with the current extra-brightness alpha (0–1). */
|
||||
fun onBrightnessExtra(alpha: Float) {}
|
||||
|
||||
/** Touch event callbacks */
|
||||
|
||||
/** Returns whether the player UI (controls overlay) is currently visible. */
|
||||
fun isUIShowing(): Boolean = false
|
||||
/** Called on a valid ACTION_DOWN; use for e.g. dismissing an episode overlay. */
|
||||
fun onTouchDown() {}
|
||||
/** Called with seek-preview text during a horizontal-swipe, or null to clear it. */
|
||||
fun onSeekPreviewText(text: String?) {}
|
||||
/** Called when a swipe gesture begins; hide the player UI if desired. */
|
||||
fun onHidePlayerUI() {}
|
||||
/**
|
||||
* Called at the end of each touch sequence.
|
||||
* @param hadSwipe true if a swipe (brightness/volume/time) was in progress.
|
||||
* @param wasUiShowing true if the UI was visible when the swipe began.
|
||||
*/
|
||||
fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {}
|
||||
/**
|
||||
* Called when the auto-hide timer fires: UI is showing, no touch is active.
|
||||
* Implement to hide the player controls.
|
||||
*/
|
||||
fun onAutoHideUI() {}
|
||||
}
|
||||
|
||||
var callbacks: Callbacks? = null
|
||||
|
||||
/** Player state */
|
||||
|
||||
var player: IPlayer = CS3IPlayer()
|
||||
var resizeMode: Int = 0
|
||||
var hasPipModeSupport: Boolean = true
|
||||
var currentPlayerStatus: CSPlayerLoading = CSPlayerLoading.IsBuffering
|
||||
var mMediaSession: MediaSession? = null
|
||||
private var pipReceiver: BroadcastReceiver? = null
|
||||
|
||||
/** Auto-hide */
|
||||
private var autoHideToken = 0
|
||||
private val autoHideHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/** View references (populated by bindViews) */
|
||||
|
||||
var subView: SubtitleView? = null
|
||||
var playerPausePlayHolderHolder: FrameLayout? = null
|
||||
var playerPausePlay: ImageView? = null
|
||||
var playerBuffering: ProgressBar? = null
|
||||
/** The Media3/ExoPlayer [androidx.media3.ui.PlayerView] widget. */
|
||||
var exoPlayerView: androidx.media3.ui.PlayerView? = null
|
||||
var piphide: FrameLayout? = null
|
||||
var subtitleHolder: FrameLayout? = null
|
||||
internal var playerRew: View? = null
|
||||
internal var playerFfwd: View? = null
|
||||
internal var exoRewText: TextView? = null
|
||||
internal var exoFfwdText: TextView? = null
|
||||
internal var playerCenterMenu: View? = null
|
||||
internal var playerRewHolder: View? = null
|
||||
internal var playerFfwdHolder: View? = null
|
||||
internal var playerVideoHolder: View? = null
|
||||
var playerProgressbarLeftHolder: RelativeLayout? = null
|
||||
var playerProgressbarLeftIcon: ImageView? = null
|
||||
var playerProgressbarLeftLevel1: ProgressBar? = null
|
||||
var playerProgressbarLeftLevel2: ProgressBar? = null
|
||||
var playerProgressbarRightHolder: RelativeLayout? = null
|
||||
var playerProgressbarRightIcon: ImageView? = null
|
||||
var playerProgressbarRightLevel1: ProgressBar? = null
|
||||
var playerProgressbarRightLevel2: ProgressBar? = null
|
||||
/** Accessed by [PlayerGestureHelper.showOrHideSpeedUp]. */
|
||||
internal var playerSpeedupButton: View? = null
|
||||
var playerHolder: FrameLayout? = null
|
||||
private var exoDuration: TextView? = null
|
||||
private var timeLeft: TextView? = null
|
||||
private var exoPosition: TextView? = null
|
||||
private var timeLive: View? = null
|
||||
private var exoProgress: LivePreviewTimeBar? = null
|
||||
|
||||
/** Seek delta used by the basic rew/ffwd click listeners. Read from settings in [initialize]. */
|
||||
var seekTime: Long = 10_000L
|
||||
|
||||
/** True when the current video is taller than it is wide. Set by [mainCallback] on [ResizedEvent]. */
|
||||
var isVerticalOrientation: Boolean = false
|
||||
|
||||
/** When true, [dynamicOrientation] returns portrait for portrait videos. Read from settings in [initialize]. */
|
||||
var autoPlayerRotateEnabled: Boolean = false
|
||||
|
||||
var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false)
|
||||
|
||||
// Kept so SubtitlesFragment can unsubscribe the exact same reference.
|
||||
private val subStyleListener: (SaveCaptionStyle) -> Unit = ::onSubStyleChanged
|
||||
|
||||
/** View discovery */
|
||||
|
||||
/**
|
||||
* Discovers player-related views from [root]. IDs absent in compact layouts (e.g. trailer) simply
|
||||
* remain null, all usage is null-safe.
|
||||
*/
|
||||
fun bindViews(root: View) {
|
||||
exoDuration = root.findViewById(androidx.media3.ui.R.id.exo_duration)
|
||||
exoFfwdText = root.findViewById(R.id.exo_ffwd_text)
|
||||
exoPlayerView = root.findViewById(R.id.player_view)
|
||||
exoPosition = root.findViewById(R.id.exo_position)
|
||||
exoRewText = root.findViewById(R.id.exo_rew_text)
|
||||
piphide = root.findViewById(R.id.piphide)
|
||||
playerBuffering = root.findViewById(R.id.player_buffering)
|
||||
playerCenterMenu = root.findViewById(R.id.player_center_menu)
|
||||
playerFfwd = root.findViewById(R.id.player_ffwd)
|
||||
playerFfwdHolder = root.findViewById(R.id.player_ffwd_holder)
|
||||
playerHolder = root.findViewById(R.id.player_holder)
|
||||
playerPausePlay = root.findViewById(R.id.player_pause_play)
|
||||
playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder)
|
||||
playerProgressbarLeftHolder = root.findViewById(R.id.player_progressbar_left_holder)
|
||||
playerProgressbarLeftIcon = root.findViewById(R.id.player_progressbar_left_icon)
|
||||
playerProgressbarLeftLevel1 = root.findViewById(R.id.player_progressbar_left_level1)
|
||||
playerProgressbarLeftLevel2 = root.findViewById(R.id.player_progressbar_left_level2)
|
||||
playerProgressbarRightHolder = root.findViewById(R.id.player_progressbar_right_holder)
|
||||
playerProgressbarRightIcon = root.findViewById(R.id.player_progressbar_right_icon)
|
||||
playerProgressbarRightLevel1 = root.findViewById(R.id.player_progressbar_right_level1)
|
||||
playerProgressbarRightLevel2 = root.findViewById(R.id.player_progressbar_right_level2)
|
||||
playerRew = root.findViewById(R.id.player_rew)
|
||||
playerRewHolder = root.findViewById(R.id.player_rew_holder)
|
||||
playerSpeedupButton = root.findViewById(R.id.player_speedup_button)
|
||||
playerVideoHolder = root.findViewById(R.id.player_video_holder)
|
||||
subtitleHolder = root.findViewById(R.id.subtitle_holder)
|
||||
timeLeft = root.findViewById(R.id.time_left)
|
||||
timeLive = root.findViewById(R.id.time_live)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once after [bindViews]. Sets up the preview seek-bar, subtitle style listener,
|
||||
* player callbacks and basic controls; then delegates gesture/input setup to [gestureHelper].
|
||||
*/
|
||||
fun initialize() {
|
||||
resizeMode = DataStoreHelper.resizeMode
|
||||
resize(resizeMode, false)
|
||||
|
||||
player.releaseCallbacks()
|
||||
player.initCallbacks(
|
||||
eventHandler = ::mainCallback,
|
||||
requestedListeningPercentages = listOf(
|
||||
SKIP_OP_VIDEO_PERCENTAGE,
|
||||
PRELOAD_NEXT_EPISODE_PERCENTAGE,
|
||||
NEXT_WATCH_EPISODE_PERCENTAGE,
|
||||
UPDATE_SYNC_PROGRESS_PERCENTAGE,
|
||||
),
|
||||
)
|
||||
|
||||
if (player is CS3IPlayer) {
|
||||
// Preview bar
|
||||
val progressBar: PreviewTimeBar? = exoPlayerView?.findViewById(R.id.exo_progress)
|
||||
exoProgress = progressBar as? LivePreviewTimeBar
|
||||
val previewImageView: ImageView? = exoPlayerView?.findViewById(R.id.previewImageView)
|
||||
val previewFrameLayout: FrameLayout? =
|
||||
exoPlayerView?.findViewById(R.id.previewFrameLayout)
|
||||
|
||||
/** Hide the previewFrameLayout on TV to make the skip op button not float,
|
||||
* as previewFrameLayout is normally invisible */
|
||||
if(isLayout(TV)) {
|
||||
previewFrameLayout?.isVisible = false
|
||||
}
|
||||
|
||||
if (progressBar != null && previewImageView != null && previewFrameLayout != null && !isLayout(TV)) {
|
||||
var resume = false
|
||||
progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener {
|
||||
override fun onScrubStart(previewBar: PreviewBar?) {
|
||||
val cs3 = player as? CS3IPlayer ?: return
|
||||
val hasPreview = cs3.hasPreview()
|
||||
progressBar.isPreviewEnabled = hasPreview
|
||||
resume = cs3.getIsPlaying()
|
||||
if (resume) cs3.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
|
||||
// No clashing UI
|
||||
if (hasPreview) subView?.isVisible = false
|
||||
}
|
||||
|
||||
override fun onScrubMove(previewBar: PreviewBar?, progress: Int, fromUser: Boolean) {}
|
||||
|
||||
override fun onScrubStop(previewBar: PreviewBar?) {
|
||||
val cs3 = player as? CS3IPlayer ?: return
|
||||
if (resume) cs3.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player)
|
||||
// Delay to prevent the small flicker of subtitle before seeking.
|
||||
subView?.postDelayed({
|
||||
// If we are not scrubbing then show subtitles again.
|
||||
if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) {
|
||||
subView?.isVisible = true
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
})
|
||||
progressBar.attachPreviewView(previewFrameLayout)
|
||||
progressBar.setPreviewLoader { currentPosition, max ->
|
||||
val cs3 = player as? CS3IPlayer ?: return@setPreviewLoader
|
||||
val bitmap = cs3.getPreview(currentPosition.toFloat().div(max.toFloat()))
|
||||
previewImageView.isGone = bitmap == null
|
||||
previewImageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
subView = exoPlayerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles)
|
||||
(player as? CS3IPlayer)?.initSubtitles(subView, subtitleHolder, CustomDecoder.style)
|
||||
(player as? CS3IPlayer)?.let {
|
||||
(it.imageGenerator as? PreviewGenerator)?.params =
|
||||
ImageParams.new16by9(screenWidth)
|
||||
}
|
||||
|
||||
/**
|
||||
* This might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player
|
||||
* and once by the UI even if it should only be registered once by the UI.
|
||||
*/
|
||||
exoPlayerView?.findViewById<DefaultTimeBar>(R.id.exo_progress)
|
||||
?.addListener(object : TimeBar.OnScrubListener {
|
||||
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
|
||||
override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit
|
||||
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
|
||||
if (canceled) return
|
||||
val playerDuration = player.getDuration() ?: return
|
||||
val playerPosition = player.getPosition() ?: return
|
||||
mainCallback(
|
||||
PositionEvent(
|
||||
source = PlayerEventSource.UI,
|
||||
durationMs = playerDuration,
|
||||
fromMs = playerPosition,
|
||||
toMs = position
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Read seek time and rotation settings.
|
||||
try {
|
||||
val sm = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
seekTime = sm.getInt(context.getString(R.string.double_tap_seek_time_key), 10)
|
||||
.toLong() * 1000L
|
||||
autoPlayerRotateEnabled = sm.getBoolean(
|
||||
context.getString(R.string.auto_rotate_video_key), true
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
val seekSecs = (seekTime / 1000).toInt()
|
||||
exoRewText?.text = context.getString(R.string.rew_text_regular_format).format(seekSecs)
|
||||
exoFfwdText?.text = context.getString(R.string.ffw_text_regular_format).format(seekSecs)
|
||||
|
||||
playerPausePlay?.setOnClickListener {
|
||||
scheduleAutoHide()
|
||||
if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) {
|
||||
player.handleEvent(CSPlayerEvent.Restart, PlayerEventSource.UI)
|
||||
} else {
|
||||
player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI)
|
||||
}
|
||||
}
|
||||
playerRew?.setOnClickListener {
|
||||
scheduleAutoHide()
|
||||
gestureHelper.rewind()
|
||||
}
|
||||
playerFfwd?.setOnClickListener {
|
||||
scheduleAutoHide()
|
||||
gestureHelper.fastForward()
|
||||
}
|
||||
|
||||
SubtitlesFragment.applyStyleEvent += subStyleListener
|
||||
|
||||
try {
|
||||
val ctx = context
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
val cs3 = player as? CS3IPlayer ?: return
|
||||
cs3.cacheSize =
|
||||
settingsManager.getInt(context.getString(R.string.video_buffer_size_key), 0) * 1024L * 1024L
|
||||
cs3.simpleCacheSize =
|
||||
settingsManager.getInt(context.getString(R.string.video_buffer_disk_key), 0) * 1024L * 1024L
|
||||
cs3.videoBufferMs =
|
||||
settingsManager.getInt(context.getString(R.string.video_buffer_length_key), 0) * 1000L
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
|
||||
// Duration toggle click listeners
|
||||
exoDuration?.setOnClickListener { setRemainingTimeCounter(true) }
|
||||
timeLeft?.setOnClickListener { setRemainingTimeCounter(false) }
|
||||
// Keep remaining-time text in sync with playback position
|
||||
exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() }
|
||||
|
||||
// Delegate gesture/input setup (settings, brightness overlay, touch gestures, key listener)
|
||||
gestureHelper.initialize()
|
||||
setupKeyEventListener()
|
||||
|
||||
// Apply duration-mode display (remaining time vs elapsed); TV always shows remaining
|
||||
setRemainingTimeCounter(durationMode || isLayout(TV))
|
||||
}
|
||||
}
|
||||
|
||||
/** Lifecycle delegation */
|
||||
|
||||
var fullscreenNotch: Boolean = true // TODO SETTING
|
||||
|
||||
fun enterFullscreen(updateOrientation: () -> Unit = {}) {
|
||||
val activity = context as? Activity
|
||||
if (isFullScreen) {
|
||||
activity?.hideSystemUI()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) {
|
||||
val params = activity?.window?.attributes
|
||||
params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
activity?.window?.attributes = params
|
||||
}
|
||||
}
|
||||
updateOrientation()
|
||||
}
|
||||
|
||||
fun exitFullscreen() {
|
||||
val activity = context as? Activity
|
||||
gestureHelper.resetZoomToDefault()
|
||||
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
|
||||
// Simply resets brightness and notch settings that might have been overridden.
|
||||
val lp = activity?.window?.attributes
|
||||
lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
|
||||
}
|
||||
activity?.window?.attributes = lp
|
||||
activity?.showSystemUI()
|
||||
}
|
||||
|
||||
fun onStop() {
|
||||
player.onStop()
|
||||
}
|
||||
|
||||
fun onResume(ctx: Context) {
|
||||
player.onResume(ctx)
|
||||
}
|
||||
|
||||
/** Releases all player resources. */
|
||||
fun release() {
|
||||
player.release()
|
||||
player.releaseCallbacks()
|
||||
player = CS3IPlayer()
|
||||
|
||||
// keyEventListener is deregistered in onPause so that the incoming player's
|
||||
// onResume can register its own listener without racing against release().
|
||||
|
||||
PlayerPipHelper.updatePIPModeActions(
|
||||
context as? Activity,
|
||||
CSPlayerLoading.IsPaused,
|
||||
false,
|
||||
null
|
||||
)
|
||||
|
||||
mMediaSession?.release()
|
||||
mMediaSession = null
|
||||
exoPlayerView?.player = null
|
||||
|
||||
SubtitlesFragment.applyStyleEvent -= subStyleListener
|
||||
|
||||
gestureHelper.release()
|
||||
autoHideHandler.removeCallbacksAndMessages(null)
|
||||
|
||||
keepScreenOn(false)
|
||||
}
|
||||
|
||||
fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
activity: Activity?
|
||||
) {
|
||||
try {
|
||||
isInPIPMode = isInPictureInPictureMode
|
||||
if (isInPictureInPictureMode) {
|
||||
// Hide the full-screen UI (controls, etc.) while in picture-in-picture mode.
|
||||
piphide?.isVisible = false
|
||||
pipReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (ACTION_MEDIA_CONTROL != intent.action) return
|
||||
player.handleEvent(
|
||||
CSPlayerEvent.entries[intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)],
|
||||
source = PlayerEventSource.UI
|
||||
)
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply { addAction(ACTION_MEDIA_CONTROL) }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
activity?.registerReceiver(pipReceiver, filter)
|
||||
}
|
||||
val isPlaying = player.getIsPlaying()
|
||||
val status = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
|
||||
updateIsPlaying(status, status)
|
||||
} else {
|
||||
// Restore the full-screen UI.
|
||||
piphide?.isVisible = true
|
||||
callbacks?.exitedPipMode()
|
||||
pipReceiver?.let {
|
||||
// Prevents java.lang.IllegalArgumentException: Receiver not registered
|
||||
safe { activity?.unregisterReceiver(it) }
|
||||
}
|
||||
activity?.hideSystemUI()
|
||||
hideKeyboard(this)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Player UI helpers */
|
||||
|
||||
private fun keepScreenOn(on: Boolean) {
|
||||
val window = (context as? Activity)?.window ?: return
|
||||
if (on) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
fun updateIsPlaying(wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading) {
|
||||
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
|
||||
val isBuffering = CSPlayerLoading.IsBuffering == isPlaying
|
||||
currentPlayerStatus = isPlaying
|
||||
|
||||
keepScreenOn(isPlayingRightNow || isBuffering)
|
||||
|
||||
if (isBuffering) {
|
||||
playerPausePlayHolderHolder?.isVisible = false
|
||||
playerBuffering?.isVisible = true
|
||||
} else {
|
||||
playerPausePlayHolderHolder?.isVisible = true
|
||||
playerBuffering?.isVisible = false
|
||||
|
||||
if (isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)) {
|
||||
playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24)
|
||||
} else if (wasPlaying != isPlaying) {
|
||||
playerPausePlay?.setImageResource(
|
||||
if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play
|
||||
)
|
||||
val drawable = playerPausePlay?.drawable
|
||||
var startedAnimation = false
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
|
||||
if (drawable is AnimatedImageDrawable) { drawable.start(); startedAnimation = true }
|
||||
}
|
||||
if (drawable is AnimatedVectorDrawable) { drawable.start(); startedAnimation = true }
|
||||
if (drawable is AnimatedVectorDrawableCompat) { drawable.start(); startedAnimation = true }
|
||||
// Somehow the phone is wacked
|
||||
if (!startedAnimation) {
|
||||
playerPausePlay?.setImageResource(
|
||||
if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play
|
||||
)
|
||||
}
|
||||
} else {
|
||||
playerPausePlay?.setImageResource(
|
||||
if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PlayerPipHelper.updatePIPModeActions(
|
||||
context as? Activity,
|
||||
isPlaying,
|
||||
hasPipModeSupport,
|
||||
player.getAspectRatio()
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
(context as? Activity)?.requestLocalAudioFocus(AppContextUtils.getFocusRequest())
|
||||
}
|
||||
}
|
||||
|
||||
private fun playerUpdated(player: Any?) {
|
||||
if (player is ExoPlayer) {
|
||||
mMediaSession?.release()
|
||||
mMediaSession = MediaSession.Builder(context, player)
|
||||
// Ensure unique ID for concurrent players.
|
||||
.setId(System.currentTimeMillis().toString())
|
||||
.build()
|
||||
|
||||
// Necessary for multiple combined videos.
|
||||
@Suppress("DEPRECATION")
|
||||
exoPlayerView?.setShowMultiWindowTimeBar(true)
|
||||
exoPlayerView?.player = player
|
||||
exoPlayerView?.performClick()
|
||||
}
|
||||
callbacks?.playerUpdated(player)
|
||||
}
|
||||
|
||||
private fun onSubStyleChanged(style: SaveCaptionStyle) {
|
||||
player.updateSubtitleStyle(style)
|
||||
// Forcefully update the subtitle encoding in case the edge size is changed.
|
||||
player.seekTime(-1)
|
||||
}
|
||||
|
||||
/** Error handling */
|
||||
|
||||
@MainThread
|
||||
fun playerError(exception: Throwable) {
|
||||
fun showErrorToast(message: String) {
|
||||
if (callbacks?.hasNextMirror() == true) {
|
||||
showToast(message, Toast.LENGTH_SHORT)
|
||||
callbacks?.nextMirror()
|
||||
} else {
|
||||
showToast(
|
||||
context.getString(R.string.no_links_found_toast) + "\n" + message,
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
(context as? FragmentActivity)?.popCurrentPage()
|
||||
}
|
||||
}
|
||||
|
||||
when (exception) {
|
||||
is PlaybackException -> {
|
||||
val msg = exception.message ?: ""
|
||||
val errorName = exception.errorCodeName
|
||||
when (val code = exception.errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
|
||||
PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
|
||||
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
|
||||
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED ->
|
||||
showErrorToast("${context.getString(R.string.source_error)}\n$errorName ($code)\n$msg")
|
||||
|
||||
PlaybackException.ERROR_CODE_REMOTE_ERROR,
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
|
||||
PlaybackException.ERROR_CODE_TIMEOUT,
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
|
||||
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE ->
|
||||
showErrorToast("${context.getString(R.string.remote_error)}\n$errorName ($code)\n$msg")
|
||||
|
||||
PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED,
|
||||
PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER,
|
||||
PlaybackException.ERROR_CODE_DECODING_FAILED,
|
||||
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED ->
|
||||
showErrorToast("${context.getString(R.string.render_error)}\n$errorName ($code)\n$msg")
|
||||
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES ->
|
||||
showErrorToast("${context.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg")
|
||||
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED ->
|
||||
showErrorToast("${context.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg")
|
||||
|
||||
else ->
|
||||
showErrorToast("${context.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg")
|
||||
}
|
||||
}
|
||||
|
||||
is SocketTimeoutException ->
|
||||
showErrorToast("${context.getString(R.string.remote_error)}\n${exception.message}")
|
||||
|
||||
is ErrorLoadingException ->
|
||||
exception.message?.let { showErrorToast(it) }
|
||||
?: showErrorToast(exception.toString())
|
||||
|
||||
else ->
|
||||
exception.message?.let { showErrorToast(it) }
|
||||
?: showErrorToast(exception.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/** Resize */
|
||||
|
||||
fun nextResize() {
|
||||
resizeMode = (resizeMode + 1) % PlayerResize.entries.size
|
||||
resize(resizeMode, true)
|
||||
}
|
||||
|
||||
fun resize(resize: Int, showToast: Boolean) {
|
||||
// Clear all zoom state before applying the new resize mode
|
||||
gestureHelper.clearZoomState()
|
||||
resize(PlayerResize.entries[resize], showToast)
|
||||
}
|
||||
|
||||
fun resize(resize: PlayerResize, showToast: Boolean) {
|
||||
DataStoreHelper.resizeMode = resize.ordinal
|
||||
val type = when (resize) {
|
||||
PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
}
|
||||
exoPlayerView?.resizeMode = type
|
||||
if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT)
|
||||
}
|
||||
|
||||
/** Orientation */
|
||||
|
||||
/**
|
||||
* Returns the desired [ActivityInfo] orientation constant based on [isVerticalOrientation]
|
||||
* and [autoPlayerRotateEnabled]. TV/emulator always returns sensor-landscape.
|
||||
* Host fragments call this from [Callbacks.playerDimensionsLoaded] to apply rotation.
|
||||
*/
|
||||
fun dynamicOrientation(): Int {
|
||||
if (isLayout(TV or EMULATOR)) return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
return if (autoPlayerRotateEnabled && isVerticalOrientation)
|
||||
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
|
||||
else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
|
||||
/** Event dispatch */
|
||||
|
||||
/**
|
||||
* This receives the events from the player, if you want to append functionality
|
||||
* you do it here, do note that this only receives events for UI changes,
|
||||
* and returning early WON'T stop it from changing in e.g. the player time
|
||||
* or pause status.
|
||||
*/
|
||||
@MainThread
|
||||
fun mainCallback(event: PlayerEvent) {
|
||||
// We don't want to spam DownloadEvent.
|
||||
if (event !is DownloadEvent) Log.i(TAG, "Handle event: $event")
|
||||
when (event) {
|
||||
is DownloadEvent -> callbacks?.onDownload(event)
|
||||
is ResizedEvent -> {
|
||||
// Skip 0x0 dimensions that the player emits when going to STATE_IDLE
|
||||
// to avoid incorrectly resetting the auto-detected orientation.
|
||||
if (event.width > 0 && event.height > 0) {
|
||||
// TV never rotates; otherwise track whether the video is portrait.
|
||||
isVerticalOrientation = !isLayout(TV or EMULATOR) && event.height > event.width
|
||||
}
|
||||
callbacks?.playerDimensionsLoaded(event.width, event.height)
|
||||
}
|
||||
is PlayerAttachedEvent -> playerUpdated(event.player)
|
||||
is SubtitlesUpdatedEvent -> callbacks?.subtitlesChanged()
|
||||
is TimestampSkippedEvent -> callbacks?.onTimestampSkipped(event.timestamp)
|
||||
is TimestampInvokedEvent -> callbacks?.onTimestamp(event.timestamp)
|
||||
is TracksChangedEvent -> callbacks?.onTracksInfoChanged()
|
||||
is EmbeddedSubtitlesFetchedEvent -> callbacks?.embeddedSubtitlesFetched(event.tracks)
|
||||
is ErrorEvent -> callbacks?.playerError(event.error) ?: playerError(event.error)
|
||||
is RequestAudioFocusEvent -> requestAudioFocus()
|
||||
is EpisodeSeekEvent -> when (event.offset) {
|
||||
-1 -> callbacks?.prevEpisode()
|
||||
1 -> callbacks?.nextEpisode()
|
||||
}
|
||||
is StatusEvent -> {
|
||||
updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying)
|
||||
scheduleAutoHide()
|
||||
callbacks?.playerStatusChanged()
|
||||
}
|
||||
is PositionEvent -> callbacks?.playerPositionChanged(
|
||||
position = event.toMs,
|
||||
duration = event.durationMs
|
||||
)
|
||||
is VideoEndedEvent -> {
|
||||
// Only play next episode if autoplay is on (default).
|
||||
val ctx = context
|
||||
if (PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
?.getBoolean(ctx.getString(R.string.autoplay_next_key), true) == true
|
||||
) {
|
||||
player.handleEvent(CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player)
|
||||
}
|
||||
}
|
||||
is PauseEvent -> Unit
|
||||
is PlayEvent -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Duration display */
|
||||
|
||||
fun setRemainingTimeCounter(showRemaining: Boolean) {
|
||||
durationMode = showRemaining
|
||||
exoDuration?.isInvisible = showRemaining
|
||||
timeLeft?.isVisible = showRemaining
|
||||
if (showRemaining) updateRemainingTime()
|
||||
}
|
||||
|
||||
fun updateRemainingTime() {
|
||||
val duration = player.getDuration()
|
||||
val position = player.getPosition()
|
||||
|
||||
if (exoProgress?.isAtLiveEdge() == true) {
|
||||
timeLeft?.alpha = 0f
|
||||
exoDuration?.alpha = 0f
|
||||
timeLive?.isVisible = true
|
||||
} else {
|
||||
timeLeft?.alpha = 1f
|
||||
exoDuration?.alpha = 1f
|
||||
timeLive?.isVisible = false
|
||||
}
|
||||
|
||||
if (duration != null && duration > 1 && position != null) {
|
||||
val remainingTimeSeconds = (duration - position + 500) / 1000
|
||||
@SuppressLint("SetTextI18n")
|
||||
timeLeft?.text = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-hide */
|
||||
|
||||
/**
|
||||
* Schedules a delayed auto-hide of the player UI after [delayMs] ms.
|
||||
* Any previously pending hide is canceled first.
|
||||
* The hide fires only when no touch is active and [Callbacks.isUIShowing] is true;
|
||||
* the actual hide action is delegated to [Callbacks.onAutoHideUI].
|
||||
*/
|
||||
fun scheduleAutoHide(delayMs: Long = 3000L) {
|
||||
val token = ++autoHideToken
|
||||
autoHideHandler.removeCallbacksAndMessages(null)
|
||||
autoHideHandler.postDelayed({
|
||||
if (token != autoHideToken) return@postDelayed
|
||||
if (gestureHelper.isCurrentTouchValid) return@postDelayed
|
||||
if (callbacks?.isUIShowing() != true) return@postDelayed
|
||||
callbacks?.onAutoHideUI()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
/** Cancels any pending auto-hide scheduled by [scheduleAutoHide]. */
|
||||
fun cancelAutoHide() {
|
||||
autoHideToken++
|
||||
autoHideHandler.removeCallbacksAndMessages(null)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,8 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
|||
import com.lagradost.cloudstream3.utils.AppContextUtils.html
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
data class Cache(
|
||||
val linkCache: MutableSet<ExtractorLink>,
|
||||
|
|
@ -23,8 +23,9 @@ data class Cache(
|
|||
|
||||
class RepoLinkGenerator(
|
||||
episodes: List<ResultEpisode>,
|
||||
currentIndex: Int = 0,
|
||||
val page: LoadResponse? = null,
|
||||
) : VideoGenerator<ResultEpisode>(episodes) {
|
||||
) : VideoGenerator<ResultEpisode>(episodes, currentIndex) {
|
||||
companion object {
|
||||
const val TAG = "RepoLink"
|
||||
val cache: HashMap<Pair<String, Int>, Cache> =
|
||||
|
|
@ -33,7 +34,6 @@ class RepoLinkGenerator(
|
|||
|
||||
override val hasCache = true
|
||||
override val canSkipLoading = true
|
||||
override fun getId(index: Int): Int? = videos.getOrNull(index)?.id
|
||||
|
||||
// this is a simple array that is used to instantly load links if they are already loaded
|
||||
//var linkCache = Array<Set<ExtractorLink>>(size = episodes.size, init = { setOf() })
|
||||
|
|
@ -48,7 +48,7 @@ class RepoLinkGenerator(
|
|||
offset: Int,
|
||||
isCasting: Boolean,
|
||||
): Boolean {
|
||||
val current = videos.getOrNull(offset) ?: return false
|
||||
val current = getCurrent(offset) ?: return false
|
||||
|
||||
val currentCache = synchronized(cache) {
|
||||
cache[current.apiName to current.id] ?: Cache(
|
||||
|
|
@ -61,12 +61,10 @@ class RepoLinkGenerator(
|
|||
}
|
||||
}
|
||||
|
||||
// These act as a general filter to prevent duplication of links or names
|
||||
// Avoid any possible ConcurrentModificationException
|
||||
val currentLinksUrls = ConcurrentHashMap.newKeySet<String>()
|
||||
val currentSubsUrls = ConcurrentHashMap.newKeySet<String>()
|
||||
// Use atomics as otherwise we get race conditions when incrementing, while rare it did actually happen!
|
||||
val lastCountedSuffix = ConcurrentHashMap<String, AtomicInteger>()
|
||||
// these act as a general filter to prevent duplication of links or names
|
||||
val currentLinksUrls = mutableSetOf<String>() // makes all urls unique
|
||||
val currentSubsUrls = mutableSetOf<String>() // makes all subs urls unique
|
||||
val lastCountedSuffix = mutableMapOf<String, UInt>()
|
||||
|
||||
synchronized(currentCache) {
|
||||
val outdatedCache =
|
||||
|
|
@ -77,10 +75,7 @@ class RepoLinkGenerator(
|
|||
currentCache.subtitleCache.clear()
|
||||
currentCache.saturated = false
|
||||
} else if (currentCache.linkCache.isNotEmpty()) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago"
|
||||
)
|
||||
Log.d(TAG, "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago")
|
||||
}
|
||||
|
||||
// call all callbacks
|
||||
|
|
@ -93,7 +88,8 @@ class RepoLinkGenerator(
|
|||
|
||||
currentCache.subtitleCache.forEach { sub ->
|
||||
currentSubsUrls.add(sub.url)
|
||||
lastCountedSuffix.getOrPut(sub.originalName) { AtomicInteger(0) }.incrementAndGet()
|
||||
val suffixCount = lastCountedSuffix.getOrDefault(sub.originalName, 0u) + 1u
|
||||
lastCountedSuffix[sub.originalName] = suffixCount
|
||||
subtitleCallback(sub)
|
||||
}
|
||||
|
||||
|
|
@ -112,15 +108,17 @@ class RepoLinkGenerator(
|
|||
subtitleCallback = { file ->
|
||||
Log.d(TAG, "Loaded SubtitleFile: $file")
|
||||
val correctFile = PlayerSubtitleHelper.getSubtitleData(file)
|
||||
if (correctFile.url.isBlank() || !currentSubsUrls.add(correctFile.url)) {
|
||||
if (correctFile.url.isBlank() || currentSubsUrls.contains(correctFile.url)) {
|
||||
return@loadLinks
|
||||
}
|
||||
currentSubsUrls.add(correctFile.url)
|
||||
|
||||
// this part makes sure that all names are unique for UX
|
||||
val nameDecoded = correctFile.originalName.html().toString()
|
||||
.trim() // `%3Ch1%3Esub%20name…` → `<h1>sub name…` → `sub name…`
|
||||
val suffixCount =
|
||||
lastCountedSuffix.getOrPut(nameDecoded) { AtomicInteger(0) }.incrementAndGet()
|
||||
|
||||
val nameDecoded = correctFile.originalName.html().toString().trim() // `%3Ch1%3Esub%20name…` → `<h1>sub name…` → `sub name…`
|
||||
|
||||
val suffixCount = lastCountedSuffix.getOrDefault(nameDecoded, 0u) +1u
|
||||
lastCountedSuffix[nameDecoded] = suffixCount
|
||||
|
||||
val updatedFile =
|
||||
correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount")
|
||||
|
|
@ -134,9 +132,10 @@ class RepoLinkGenerator(
|
|||
},
|
||||
callback = { link ->
|
||||
Log.d(TAG, "Loaded ExtractorLink: $link")
|
||||
if (link.url.isBlank() || !currentLinksUrls.add(link.url)) {
|
||||
if (link.url.isBlank() || currentLinksUrls.contains(link.url)) {
|
||||
return@loadLinks
|
||||
}
|
||||
currentLinksUrls.add(link.url)
|
||||
|
||||
synchronized(currentCache) {
|
||||
if (currentCache.linkCache.add(link)) {
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
package com.lagradost.cloudstream3.ui.player.live
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.lagradost.cloudstream3.mvvm.debugWarning
|
||||
import java.util.WeakHashMap
|
||||
|
||||
object LiveHelper {
|
||||
private val liveManagers = WeakHashMap<Player, Pair<LiveManager, Player.Listener>>()
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun registerPlayer(player: Player?) {
|
||||
if (player == null) {
|
||||
debugWarning { "LiveHelper registerPlayer called with null player!" }
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent duplicates
|
||||
if (liveManagers.contains(player)) {
|
||||
return
|
||||
}
|
||||
|
||||
val liveManager = LiveManager(player)
|
||||
val listener = object : Player.Listener {
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
val window = Timeline.Window()
|
||||
timeline.getWindow(player.currentMediaItemIndex, window)
|
||||
if (window.isDynamic) {
|
||||
liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs))
|
||||
}
|
||||
super.onTimelineChanged(timeline, reason)
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
||||
val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs)
|
||||
|
||||
// Seek back to the optimal live spot
|
||||
if (timeAheadOfLive > 100) {
|
||||
player.seekTo(newPosition.positionMs - timeAheadOfLive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synchronized(liveManagers) {
|
||||
player.addListener(listener)
|
||||
liveManagers[player] = liveManager to listener
|
||||
}
|
||||
}
|
||||
|
||||
fun unregisterPlayer(player: Player?) {
|
||||
if (player == null) {
|
||||
debugWarning { "LiveHelper unregisterPlayer called with null player!" }
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent duplicates
|
||||
if (!liveManagers.contains(player)) {
|
||||
return
|
||||
}
|
||||
|
||||
synchronized(liveManagers) {
|
||||
liveManagers[player]?.let { (_, listener) ->
|
||||
player.removeListener(listener)
|
||||
}
|
||||
liveManagers.remove(player)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLiveManager(player: Player?) = liveManagers[player]?.first
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
package com.lagradost.cloudstream3.ui.player.live
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
// How much margin from the live point is still considered "live"
|
||||
const val LIVE_MARGIN = 6_000L
|
||||
|
||||
// How many ms should we be behind the real live point?
|
||||
// Too low, and we cannot pre-buffer
|
||||
// Too high, and we are no longer live
|
||||
const val PREFERRED_LIVE_OFFSET = 5_000L
|
||||
|
||||
// An extra offset from the optimal calculated timestamp
|
||||
// This is to account for chunk updates not always being the same size
|
||||
const val CHUNK_VARIANCE = 3000L
|
||||
|
||||
// A livestream chunk from the player, the time we get it and the duration can be used to calculate
|
||||
// the expected live timestamp.
|
||||
class LivestreamChunk(
|
||||
durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis()
|
||||
) {
|
||||
// We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point.
|
||||
// If we are ahead of the middle point we will reach the end before the new chunk is expected to be released.
|
||||
val targetPosition = maxOf(0,minOf(
|
||||
durationMs - PREFERRED_LIVE_OFFSET,
|
||||
durationMs / 2 - CHUNK_VARIANCE
|
||||
))
|
||||
|
||||
fun isPositionLive(position: Long): Boolean {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val livePosition = targetPosition + (currentTime - receiveTimeMs)
|
||||
val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET
|
||||
// println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive")
|
||||
return withinLive
|
||||
}
|
||||
|
||||
fun getTimeAheadOfLive(position: Long): Long {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val livePosition = targetPosition + (currentTime - receiveTimeMs)
|
||||
// println("Ahead of live: ${position-livePosition}")
|
||||
return position - livePosition
|
||||
}
|
||||
}
|
||||
|
||||
// There are two types of livestreams we need to manage
|
||||
// 1. A livestream with no history, a continually sliding window.
|
||||
// This livestream has no currentLiveOffset, which means we need to calculate
|
||||
// the real live point based on when we receive the latest update and the size of that update.
|
||||
// 2. A livestream with history.
|
||||
// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point.
|
||||
// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations.
|
||||
class LiveManager {
|
||||
private var _currentPlayer: WeakReference<Player>? = null
|
||||
val currentPlayer: Player? get() = _currentPlayer?.get()
|
||||
|
||||
constructor(player: Player?) {
|
||||
_currentPlayer = WeakReference(player)
|
||||
}
|
||||
|
||||
private var lastLivestreamChunk: LivestreamChunk? = null
|
||||
|
||||
fun submitLivestreamChunk(chunk: LivestreamChunk) {
|
||||
lastLivestreamChunk = chunk
|
||||
}
|
||||
|
||||
/** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */
|
||||
fun getTimeAheadOfLive(position: Long): Long {
|
||||
val player = currentPlayer ?: return 0
|
||||
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0
|
||||
|
||||
// If the currentLiveOffset is wrong we fall back to manual calculations
|
||||
val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
|
||||
val relativeOffset = player.currentLiveOffset - player.currentPosition + position
|
||||
PREFERRED_LIVE_OFFSET - relativeOffset
|
||||
} else {
|
||||
lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0
|
||||
}
|
||||
|
||||
// Ensure min of 0
|
||||
return maxOf(0, ahead)
|
||||
}
|
||||
|
||||
/** Check if the stream is currently at the expected live edge, with margins */
|
||||
fun isAtLiveEdge(): Boolean {
|
||||
val player = currentPlayer ?: return false
|
||||
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false
|
||||
|
||||
// If the currentLiveOffset is wrong we fall back to manual calculations
|
||||
return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
|
||||
player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET
|
||||
} else {
|
||||
lastLivestreamChunk?.isPositionLive(player.currentPosition) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package com.lagradost.cloudstream3.ui.player.live
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.PlayerControlView
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.R
|
||||
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) {
|
||||
|
||||
private var _currentPlayerView: WeakReference<PlayerView>? = null
|
||||
val currentPlayer: Player? get() = _currentPlayerView?.get()?.player
|
||||
|
||||
fun registerPlayerView(player: PlayerView?) {
|
||||
_currentPlayerView = WeakReference(player)
|
||||
val controller =
|
||||
_currentPlayerView?.get()?.findViewById<PlayerControlView>(R.id.exo_controller)
|
||||
|
||||
controller?.setProgressUpdateListener { position, bufferedPosition ->
|
||||
currentPlayer?.let { player ->
|
||||
if (isAtLiveEdge()) {
|
||||
setPosition(player.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isAtLiveEdge(): Boolean {
|
||||
return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,12 @@ import android.annotation.SuppressLint
|
|||
import android.app.Dialog
|
||||
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
|
||||
import android.text.Editable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AlphaAnimation
|
||||
|
|
@ -24,7 +25,6 @@ import androidx.core.view.isVisible
|
|||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.discord.panels.OverlappingPanelsLayout
|
||||
import com.discord.panels.PanelState
|
||||
import com.discord.panels.PanelsChildGestureRegionObserver
|
||||
|
|
@ -45,24 +45,19 @@ import com.lagradost.cloudstream3.databinding.FragmentResultBinding
|
|||
import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding
|
||||
import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding
|
||||
import com.lagradost.cloudstream3.databinding.ResultSyncBinding
|
||||
import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.mvvm.observeNullable
|
||||
import com.lagradost.cloudstream3.mvvm.safe
|
||||
import com.lagradost.cloudstream3.services.SubscriptionWorkManager
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE
|
||||
import com.lagradost.cloudstream3.ui.BaseFragment
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
|
||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
|
||||
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup
|
||||
import com.lagradost.cloudstream3.ui.player.CS3IPlayer
|
||||
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
|
||||
import com.lagradost.cloudstream3.ui.player.IPlayer
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerView
|
||||
import com.lagradost.cloudstream3.ui.player.FullScreenPlayer
|
||||
import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog
|
||||
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
|
||||
import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo
|
||||
|
|
@ -71,8 +66,6 @@ import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent
|
|||
import com.lagradost.cloudstream3.ui.search.SearchAdapter
|
||||
import com.lagradost.cloudstream3.ui.search.SearchHelper
|
||||
import com.lagradost.cloudstream3.ui.setRecycledViewPool
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsGeneral.Companion.pickDownloadPath
|
||||
import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache
|
||||
|
|
@ -97,7 +90,6 @@ import com.lagradost.cloudstream3.utils.UIHelper.populateChips
|
|||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager
|
||||
import com.lagradost.cloudstream3.utils.getImageFromDrawable
|
||||
|
|
@ -105,12 +97,9 @@ import com.lagradost.cloudstream3.utils.setText
|
|||
import com.lagradost.cloudstream3.utils.setTextHtml
|
||||
import com.lagradost.cloudstream3.utils.txt
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.ConcurrentLinkedDeque
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
||||
BindingCreator.Inflate(FragmentResultSwipeBinding::inflate)
|
||||
), PlayerView.Callbacks {
|
||||
open class ResultFragmentPhone : FullScreenPlayer() {
|
||||
private val gestureRegionsListener =
|
||||
object : PanelsChildGestureRegionObserver.GestureRegionsListener {
|
||||
override fun onGestureRegionsUpdate(gestureRegions: List<Rect>) {
|
||||
|
|
@ -118,105 +107,34 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
}
|
||||
}
|
||||
|
||||
/** Queue of pending actions that is deferred to after a custom path is set */
|
||||
private val pendingPathActions = ConcurrentLinkedDeque<Pair<Int, ResultEpisode>>()
|
||||
|
||||
/**
|
||||
* Appends all actions to a queue, and asks for a user to enter the download folder if not already set up.
|
||||
*
|
||||
* Then processes the queue in the given order, only after the user has selected a folder.
|
||||
* This is to defer the download to after a file path is set, due to perms.
|
||||
* */
|
||||
private fun requirePathForActions(list: Collection<Pair<Int, ResultEpisode>>) {
|
||||
pendingPathActions.addAll(list)
|
||||
val (_, path) = context?.getBasePath() ?: return
|
||||
if (path == null) {
|
||||
/** If we have not set any download path, then ask the user for it before we download it */
|
||||
try {
|
||||
/** Give the user some info of what we are doing and why, even if it may be missed */
|
||||
showToast(R.string.download_path_pref)
|
||||
pathPicker.launch(Uri.EMPTY)
|
||||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
/** Something went wrong, TV Device?
|
||||
* Use the fallback behavior of just downloading it even if no path is selected,
|
||||
* and hope it works */
|
||||
processPendingActions()
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* Otherwise dispatch everything, as we already have a valid download path
|
||||
* Even if this is "wrong", we do not care as the user has entered something
|
||||
* */
|
||||
processPendingActions()
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all the items in the queue and dispatch them to the viewmodel in order */
|
||||
private fun processPendingActions() = viewModel.viewModelScope.launchSafe {
|
||||
while (!pendingPathActions.isEmpty()) {
|
||||
try {
|
||||
val (action, data) = pendingPathActions.pop()
|
||||
viewModel.handleAction(
|
||||
EpisodeClickEvent(
|
||||
action,
|
||||
data
|
||||
)
|
||||
)
|
||||
} catch (_: NoSuchElementException) {
|
||||
/** In case of a race */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val pathPicker = getChooseFolderLauncher { uri, path ->
|
||||
if (uri == null) {
|
||||
/** No path selected, clear the list without acting on it, canceling */
|
||||
if (!pendingPathActions.isEmpty()) {
|
||||
/** Only show on non-empty, just in case */
|
||||
showToast(R.string.download_canceled)
|
||||
pendingPathActions.clear()
|
||||
}
|
||||
} else {
|
||||
/** Select the folder, and dispatch everything */
|
||||
pickDownloadPath(uri, path)
|
||||
processPendingActions()
|
||||
}
|
||||
}
|
||||
|
||||
protected lateinit var viewModel: ResultViewModel2
|
||||
protected lateinit var syncModel: SyncViewModel
|
||||
|
||||
protected var binding: FragmentResultSwipeBinding? = null
|
||||
protected var resultBinding: FragmentResultBinding? = null
|
||||
protected var recommendationBinding: ResultRecommendationsBinding? = null
|
||||
protected var syncBinding: ResultSyncBinding? = null
|
||||
|
||||
var player: IPlayer = CS3IPlayer()
|
||||
protected open var hasPipModeSupport: Boolean = false
|
||||
protected open var isFullScreenPlayer: Boolean = true
|
||||
protected open var lockRotation: Boolean = true
|
||||
protected var playerBinding: TrailerCustomLayoutBinding? = null
|
||||
protected var isShowing: Boolean = false
|
||||
override var layout = R.layout.fragment_result_swipe
|
||||
|
||||
protected var playerHostView: PlayerView? = null
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
|
||||
syncModel = ViewModelProvider(this)[SyncViewModel::class.java]
|
||||
updateUIEvent += ::updateUI
|
||||
|
||||
open fun updateUIVisibility() {}
|
||||
|
||||
protected fun uiReset() {
|
||||
isShowing = false
|
||||
updateUIVisibility()
|
||||
val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null
|
||||
FragmentResultSwipeBinding.bind(root).let { bind ->
|
||||
resultBinding = bind.fragmentResult
|
||||
recommendationBinding = bind.resultRecommendations
|
||||
syncBinding = bind.resultSync
|
||||
binding = bind
|
||||
}
|
||||
|
||||
open fun showMirrorsDialogue() {}
|
||||
open fun showTracksDialogue() {}
|
||||
open fun openOnlineSubPicker(
|
||||
context: android.content.Context,
|
||||
loadResponse: LoadResponse?,
|
||||
dismissCallback: () -> Unit
|
||||
) {}
|
||||
|
||||
override fun fixLayout(view: View) {
|
||||
fixSystemBarsPadding(view)
|
||||
return root
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
|
@ -240,7 +158,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
|
||||
override fun playerError(exception: Throwable) {
|
||||
if (player.getIsPlaying()) { // because we don't want random toasts in player
|
||||
playerHostView?.playerError(exception)
|
||||
super.playerError(exception)
|
||||
} else {
|
||||
nextMirror()
|
||||
}
|
||||
|
|
@ -340,8 +258,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
}
|
||||
|
||||
updateUIEvent -= ::updateUI
|
||||
playerHostView?.release()
|
||||
playerBinding = null
|
||||
binding = null
|
||||
resultBinding?.resultScroll?.setOnClickListener(null)
|
||||
resultBinding = null
|
||||
syncBinding = null
|
||||
|
|
@ -365,6 +282,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
|
||||
var selectSeason: String? = null
|
||||
var selectEpisodeRange: String? = null
|
||||
var selectSort: EpisodeSortType? = null
|
||||
|
||||
private fun setUrl(url: String?) {
|
||||
if (url == null) {
|
||||
|
|
@ -407,10 +325,6 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
override fun onResume() {
|
||||
afterPluginsLoadedEvent += ::reloadViewModel
|
||||
activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground)
|
||||
context?.let { ctx ->
|
||||
playerHostView?.onResume(ctx)
|
||||
playerHostView?.setupKeyEventListener()
|
||||
}
|
||||
super.onResume()
|
||||
PanelsChildGestureRegionObserver.Provider.get()
|
||||
.addGestureRegionsUpdateListener(gestureRegionsListener)
|
||||
|
|
@ -418,44 +332,30 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
|
||||
override fun onStop() {
|
||||
afterPluginsLoadedEvent -= ::reloadViewModel
|
||||
playerHostView?.onStop()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
private fun updateUI(id: Int?) {
|
||||
syncModel.updateUserData()
|
||||
viewModel.reloadEpisodes()
|
||||
}
|
||||
|
||||
override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) {
|
||||
// Set up sub-binding references
|
||||
viewModel = ViewModelProvider(this)[ResultViewModel2::class.java]
|
||||
syncModel = ViewModelProvider(this)[SyncViewModel::class.java]
|
||||
updateUIEvent += ::updateUI
|
||||
|
||||
resultBinding = binding.fragmentResult
|
||||
recommendationBinding = binding.resultRecommendations
|
||||
syncBinding = binding.resultSync
|
||||
|
||||
// Set up trailer player
|
||||
val ctx = context ?: return
|
||||
playerHostView = PlayerView(ctx)
|
||||
playerHostView?.player = player
|
||||
playerHostView?.hasPipModeSupport = hasPipModeSupport
|
||||
playerHostView?.callbacks = this
|
||||
playerHostView?.bindViews(binding.root)
|
||||
playerBinding = binding.root.findViewById<View?>(R.id.player_holder)?.let {
|
||||
TrailerCustomLayoutBinding.bind(it)
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
view?.let { fixSystemBarsPadding(it) }
|
||||
}
|
||||
playerHostView?.initialize()
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// ===== setup =====
|
||||
fixSystemBarsPadding(view)
|
||||
val storedData = getStoredData() ?: return
|
||||
activity?.window?.decorView?.clearFocus()
|
||||
activity?.loadCache()
|
||||
context?.updateHasTrailers()
|
||||
hideKeyboard(binding.root)
|
||||
hideKeyboard()
|
||||
if (storedData.restart || !viewModel.hasLoaded())
|
||||
viewModel.load(
|
||||
activity,
|
||||
|
|
@ -473,7 +373,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
// This may not be 100% reliable, and may delay for small period
|
||||
// before resultCastItems will be scrollable again, but this does work
|
||||
// most of the time.
|
||||
binding.resultOverlappingPanels.registerEndPanelStateListeners(
|
||||
binding?.resultOverlappingPanels?.registerEndPanelStateListeners(
|
||||
object : OverlappingPanelsLayout.PanelStateListener {
|
||||
override fun onPanelStateChange(panelState: PanelState) {
|
||||
PanelsChildGestureRegionObserver.Provider.get().apply {
|
||||
|
|
@ -485,8 +385,8 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
|
||||
// ===== ===== =====
|
||||
|
||||
binding.resultSearch.isGone = storedData.name.isBlank()
|
||||
binding.resultSearch.setOnClickListener {
|
||||
binding?.resultSearch?.isGone = storedData.name.isBlank()
|
||||
binding?.resultSearch?.setOnClickListener {
|
||||
QuickSearchFragment.pushSearch(activity, storedData.name)
|
||||
}
|
||||
|
||||
|
|
@ -515,7 +415,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
focused: View?
|
||||
): Boolean {
|
||||
// Make the cast always focus the first visible item when focused
|
||||
// from somewhere else. Otherwise, it jumps to the last item.
|
||||
// from somewhere else. Otherwise it jumps to the last item.
|
||||
return if (parent.focusedChild == null) {
|
||||
scrollToPosition(this.findFirstCompletelyVisibleItemPosition())
|
||||
true
|
||||
|
|
@ -533,13 +433,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
EpisodeAdapter(
|
||||
api?.hasDownloadSupport == true,
|
||||
{ episodeClick ->
|
||||
when (episodeClick.action) {
|
||||
ACTION_DOWNLOAD_EPISODE, ACTION_DOWNLOAD_MIRROR -> {
|
||||
requirePathForActions(listOf(episodeClick.action to episodeClick.data))
|
||||
}
|
||||
|
||||
else -> viewModel.handleAction(episodeClick)
|
||||
}
|
||||
viewModel.handleAction(episodeClick)
|
||||
},
|
||||
{ downloadClickEvent ->
|
||||
DownloadButtonSetup.handleDownloadClick(downloadClickEvent)
|
||||
|
|
@ -574,9 +468,9 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||
val dy = scrollY - oldScrollY
|
||||
if (dy > 0) { //check for scroll down
|
||||
binding.resultBookmarkFab.shrink()
|
||||
binding?.resultBookmarkFab?.shrink()
|
||||
} else if (dy < -5) {
|
||||
binding.resultBookmarkFab.extend()
|
||||
binding?.resultBookmarkFab?.extend()
|
||||
}
|
||||
if (!isFullScreenPlayer && player.getIsPlaying()) {
|
||||
if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height
|
||||
|
|
@ -588,7 +482,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
})
|
||||
}
|
||||
|
||||
binding.apply {
|
||||
binding?.apply {
|
||||
resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE)
|
||||
resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE)
|
||||
resultBack.setOnClickListener {
|
||||
|
|
@ -781,7 +675,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
}
|
||||
|
||||
observeNullable(viewModel.subscribeStatus) { isSubscribed ->
|
||||
binding.resultSubscribe.isVisible = isSubscribed != null
|
||||
binding?.resultSubscribe?.isVisible = isSubscribed != null
|
||||
if (isSubscribed == null) return@observeNullable
|
||||
|
||||
val drawable = if (isSubscribed) {
|
||||
|
|
@ -790,11 +684,11 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
R.drawable.baseline_notifications_none_24
|
||||
}
|
||||
|
||||
binding.resultSubscribe.setImageResource(drawable)
|
||||
binding?.resultSubscribe?.setImageResource(drawable)
|
||||
}
|
||||
|
||||
observeNullable(viewModel.favoriteStatus) { isFavorite ->
|
||||
binding.resultFavorite.isVisible = isFavorite != null
|
||||
binding?.resultFavorite?.isVisible = isFavorite != null
|
||||
if (isFavorite == null) return@observeNullable
|
||||
|
||||
val drawable = if (isFavorite) {
|
||||
|
|
@ -803,7 +697,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
R.drawable.ic_baseline_favorite_border_24
|
||||
}
|
||||
|
||||
binding.resultFavorite.setImageResource(drawable)
|
||||
binding?.resultFavorite?.setImageResource(drawable)
|
||||
}
|
||||
|
||||
observeNullable(viewModel.episodes) { episodes ->
|
||||
|
|
@ -859,12 +753,30 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
.setTitle(R.string.download_all)
|
||||
.setMessage(rangeMessage)
|
||||
.setPositiveButton(R.string.yes) { _, _ ->
|
||||
requirePathForActions(episodes.value.map { ACTION_DOWNLOAD_EPISODE to it })
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> }.show()
|
||||
ioSafe {
|
||||
episodes.value.forEach { episode ->
|
||||
viewModel.handleAction(
|
||||
EpisodeClickEvent(
|
||||
ACTION_DOWNLOAD_EPISODE,
|
||||
episode
|
||||
)
|
||||
)
|
||||
// Join to make the episodes ordered
|
||||
.join()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ ->
|
||||
|
||||
}.show()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
observeNullable(viewModel.movie) { data ->
|
||||
|
|
@ -913,11 +825,18 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
|
||||
when (click.action) {
|
||||
DOWNLOAD_ACTION_DOWNLOAD -> {
|
||||
requirePathForActions(listOf(ACTION_DOWNLOAD_EPISODE to ep))
|
||||
viewModel.handleAction(
|
||||
EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep)
|
||||
)
|
||||
}
|
||||
|
||||
DOWNLOAD_ACTION_LONG_CLICK -> {
|
||||
requirePathForActions(listOf(ACTION_DOWNLOAD_MIRROR to ep))
|
||||
viewModel.handleAction(
|
||||
EpisodeClickEvent(
|
||||
ACTION_DOWNLOAD_MIRROR,
|
||||
ep
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> DownloadButtonSetup.handleDownloadClick(click)
|
||||
|
|
@ -1013,7 +932,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
syncModel.addFromUrl(d.url)
|
||||
}
|
||||
|
||||
binding.apply {
|
||||
binding?.apply {
|
||||
resultSearch.isGone = d.title.isBlank()
|
||||
resultSearch.setOnClickListener {
|
||||
QuickSearchFragment.pushSearch(activity, d.title)
|
||||
|
|
@ -1048,11 +967,10 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
}
|
||||
|
||||
(data as? Resource.Failure)?.let { data ->
|
||||
@SuppressLint("SetTextI18n")
|
||||
resultErrorText.text = storedData.url.plus("\n") + data.errorString
|
||||
}
|
||||
|
||||
binding.resultBookmarkFab.isVisible = data is Resource.Success
|
||||
binding?.resultBookmarkFab?.isVisible = data is Resource.Success
|
||||
resultFinishLoading.isVisible = data is Resource.Success
|
||||
|
||||
resultLoading.isVisible = data is Resource.Loading
|
||||
|
|
@ -1100,7 +1018,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
}
|
||||
|
||||
observe(viewModel.trailers) { trailers ->
|
||||
setTrailers(trailers.flatMap { it.mirros }) // I don't care about subtitles yet!
|
||||
setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet!
|
||||
}
|
||||
|
||||
observe(syncModel.synced) { list ->
|
||||
|
|
@ -1109,7 +1027,8 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
|
||||
val newList = list.filter { it.isSynced && it.hasAccount }
|
||||
|
||||
binding.resultMiniSync.isVisible = newList.isNotEmpty()
|
||||
binding?.resultMiniSync?.isVisible = newList.isNotEmpty()
|
||||
//(binding?.resultMiniSync?.adapter as? ImageAdapter)?.submitList(newList.mapNotNull { it.icon })
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1204,7 +1123,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
}
|
||||
}
|
||||
}
|
||||
binding.resultOverlappingPanels.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED)
|
||||
binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED)
|
||||
}
|
||||
observe(viewModel.recommendations) { recommendations ->
|
||||
setRecommendations(recommendations, null)
|
||||
|
|
@ -1265,7 +1184,7 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
}
|
||||
|
||||
observe(viewModel.watchStatus) { watchType ->
|
||||
binding.resultBookmarkFab.apply {
|
||||
binding?.resultBookmarkFab?.apply {
|
||||
setText(watchType.stringRes)
|
||||
if (watchType == WatchType.NONE) {
|
||||
context?.colorFromAttribute(R.attr.white)
|
||||
|
|
@ -1320,7 +1239,6 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
viewModel.skipLoading()
|
||||
}
|
||||
isVisible = true
|
||||
@SuppressLint("SetTextI18n")
|
||||
text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})"
|
||||
}
|
||||
}
|
||||
|
|
@ -1441,7 +1359,6 @@ open class ResultFragmentPhone : BaseFragment<FragmentResultSwipeBinding>(
|
|||
}
|
||||
|
||||
override fun onPause() {
|
||||
playerHostView?.releaseKeyEventListener()
|
||||
super.onPause()
|
||||
PanelsChildGestureRegionObserver.Provider.get()
|
||||
.addGestureRegionsUpdateListener(gestureRegionsListener)
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ class ResultFragmentTv : BaseFragment<FragmentResultTvBinding>(
|
|||
ExtractorLinkGenerator(
|
||||
extractedTrailerLinks,
|
||||
emptyList()
|
||||
), 0
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -925,12 +925,8 @@ class ResultFragmentTv : BaseFragment<FragmentResultTvBinding>(
|
|||
resultTvComingSoon.isVisible = d.comingSoon
|
||||
|
||||
populateChips(resultTag, d.tags)
|
||||
val prefs =
|
||||
androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context)
|
||||
val showCast = prefs.getBoolean(
|
||||
root.context.getString(R.string.show_cast_in_details_key),
|
||||
true
|
||||
)
|
||||
val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context)
|
||||
val showCast = prefs.getBoolean(root.context.getString(R.string.show_cast_in_details_key), true)
|
||||
|
||||
resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty()
|
||||
(resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList())
|
||||
|
|
|
|||
|
|
@ -5,74 +5,41 @@ import android.content.Context
|
|||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.lagradost.cloudstream3.CommonActivity.screenHeight
|
||||
import com.lagradost.cloudstream3.CommonActivity.screenWidth
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding
|
||||
import com.lagradost.cloudstream3.ui.player.CSPlayerEvent
|
||||
import com.lagradost.cloudstream3.ui.player.CSPlayerLoading
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerEventSource
|
||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||
|
||||
class ResultTrailerPlayer : ResultFragmentPhone() {
|
||||
open class ResultTrailerPlayer : ResultFragmentPhone() {
|
||||
|
||||
override var lockRotation = false
|
||||
override var isFullScreenPlayer = false
|
||||
override var hasPipModeSupport = false
|
||||
|
||||
companion object {
|
||||
const val TAG = "ResultTrailerPlayer"
|
||||
const val TAG = "RESULT_TRAILER"
|
||||
}
|
||||
|
||||
private var playerWidthHeight: Pair<Int, Int>? = null
|
||||
private var introVisible = true
|
||||
|
||||
// Single-tap on empty player area: toggle controls.
|
||||
override fun onSingleTap() {
|
||||
if (introVisible) return
|
||||
if (isShowing) uiReset() else showControls()
|
||||
}
|
||||
|
||||
private fun showControls() {
|
||||
if (introVisible) return
|
||||
isShowing = true
|
||||
updateUIVisibility()
|
||||
playerHostView?.scheduleAutoHide()
|
||||
}
|
||||
|
||||
override fun isUIShowing(): Boolean = isShowing
|
||||
|
||||
override fun onAutoHideUI() {
|
||||
if (player.getIsPlaying()) uiReset()
|
||||
}
|
||||
|
||||
override fun onHidePlayerUI() = uiReset()
|
||||
|
||||
// When the hold-speedup gesture fires, hide controls so the video is unobstructed.
|
||||
// The speedup button show/hide and speed change are handled by PlayerView.
|
||||
override fun onHoldSpeedUp(show: Boolean) {
|
||||
if (show && isShowing) uiReset()
|
||||
}
|
||||
|
||||
override fun onGestureEnd(hadSwipe: Boolean, wasUiShowing: Boolean) {
|
||||
if (!player.getIsPlaying() && hadSwipe && wasUiShowing && !isShowing) {
|
||||
isShowing = true
|
||||
showControls()
|
||||
} else playerHostView?.scheduleAutoHide()
|
||||
}
|
||||
|
||||
override fun nextEpisode() {}
|
||||
|
||||
override fun prevEpisode() {}
|
||||
override fun playerPositionChanged(position: Long, duration: Long) {}
|
||||
|
||||
override fun playerPositionChanged(position: Long, duration : Long) {}
|
||||
|
||||
override fun nextMirror() {}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
|
|
@ -82,28 +49,33 @@ class ResultTrailerPlayer : ResultFragmentPhone() {
|
|||
}
|
||||
|
||||
private fun fixPlayerSize() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
binding?.apply {
|
||||
if (isFullScreenPlayer) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Remove listener
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root, null)
|
||||
root.overlay.clear()
|
||||
}
|
||||
root.setPadding(0, 0, 0, 0)
|
||||
root.overlay.clear() // Clear the cutout overlay
|
||||
root.setPadding(0, 0, 0, 0) // Reset padding for full screen
|
||||
} else {
|
||||
// Reapply padding when not in full screen
|
||||
fixSystemBarsPadding(root)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ViewCompat.requestApplyInsets(root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playerWidthHeight?.let { (w, h) ->
|
||||
if (w <= 0 || h <= 0) return@let
|
||||
if(w <= 0 || h <= 0) return@let
|
||||
|
||||
val orientation = context?.resources?.configuration?.orientation ?: return
|
||||
|
||||
val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) screenWidth else screenHeight
|
||||
val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
screenWidth
|
||||
} else {
|
||||
screenHeight
|
||||
}
|
||||
|
||||
//result_trailer_loading?.isVisible = false
|
||||
resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer
|
||||
binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer
|
||||
|
||||
|
|
@ -111,30 +83,35 @@ class ResultTrailerPlayer : ResultFragmentPhone() {
|
|||
|
||||
resultBinding?.fragmentTrailer?.playerBackground?.apply {
|
||||
isVisible = true
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to
|
||||
)
|
||||
}
|
||||
|
||||
playerBinding?.playerIntroPlay?.apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT
|
||||
resultBinding?.resultTopHolder?.measuredHeight
|
||||
?: FrameLayout.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
|
||||
if (playerBinding?.playerIntroPlay?.isGone == true) {
|
||||
resultBinding?.resultTopHolder?.apply {
|
||||
|
||||
val anim = ValueAnimator.ofInt(
|
||||
measuredHeight,
|
||||
if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to
|
||||
)
|
||||
anim.addUpdateListener { va ->
|
||||
val v = va.animatedValue as Int
|
||||
val lp: ViewGroup.LayoutParams = layoutParams
|
||||
lp.height = v
|
||||
layoutParams = lp
|
||||
anim.addUpdateListener { valueAnimator ->
|
||||
val `val` = valueAnimator.animatedValue as Int
|
||||
val layoutParams: ViewGroup.LayoutParams =
|
||||
layoutParams
|
||||
layoutParams.height = `val`
|
||||
setLayoutParams(layoutParams)
|
||||
}
|
||||
anim.duration = 200
|
||||
anim.start()
|
||||
|
|
@ -143,14 +120,9 @@ class ResultTrailerPlayer : ResultFragmentPhone() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun playerDimensionsLoaded(width: Int, height: Int) {
|
||||
override fun playerDimensionsLoaded(width: Int, height : Int) {
|
||||
playerWidthHeight = width to height
|
||||
fixPlayerSize()
|
||||
// Apply autorotation when fullscreen (lockRotation = true).
|
||||
// PlayerView already set isVerticalOrientation before this callback fires.
|
||||
if (lockRotation) {
|
||||
activity?.requestedOrientation = playerHostView?.dynamicOrientation() ?: return
|
||||
}
|
||||
}
|
||||
|
||||
override fun showMirrorsDialogue() {}
|
||||
|
|
@ -160,39 +132,33 @@ class ResultTrailerPlayer : ResultFragmentPhone() {
|
|||
context: Context,
|
||||
loadResponse: LoadResponse?,
|
||||
dismissCallback: () -> Unit
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun subtitlesChanged() {}
|
||||
|
||||
override fun embeddedSubtitlesFetched(subtitles: List<SubtitleData>) {}
|
||||
override fun onTracksInfoChanged() {}
|
||||
|
||||
override fun exitedPipMode() {}
|
||||
|
||||
override fun onSeekPreviewText(text: String?) {
|
||||
playerBinding?.playerTimeText?.apply {
|
||||
isVisible = text != null
|
||||
if (text != null) this.text = text
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFullscreen(fullscreen: Boolean) {
|
||||
isFullScreenPlayer = fullscreen
|
||||
lockRotation = fullscreen
|
||||
playerHostView?.isFullScreen = fullscreen
|
||||
|
||||
playerBinding?.playerFullscreen?.setImageResource(
|
||||
if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24
|
||||
)
|
||||
playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24)
|
||||
if (fullscreen) {
|
||||
playerHostView?.enterFullscreen()
|
||||
enterFullscreen()
|
||||
binding?.apply {
|
||||
resultTopBar.isVisible = false
|
||||
resultFullscreenHolder.isVisible = true
|
||||
resultMainHolder.isVisible = false
|
||||
}
|
||||
|
||||
resultBinding?.fragmentTrailer?.playerBackground?.let { view ->
|
||||
(view.parent as ViewGroup?)?.removeView(view)
|
||||
binding?.resultFullscreenHolder?.addView(view)
|
||||
}
|
||||
|
||||
} else {
|
||||
binding?.apply {
|
||||
resultTopBar.isVisible = true
|
||||
|
|
@ -203,55 +169,36 @@ class ResultTrailerPlayer : ResultFragmentPhone() {
|
|||
resultBinding?.resultSmallscreenHolder?.addView(view)
|
||||
}
|
||||
}
|
||||
playerHostView?.exitFullscreen()
|
||||
exitFullscreen()
|
||||
}
|
||||
fixPlayerSize()
|
||||
uiReset()
|
||||
|
||||
if (isFullScreenPlayer) {
|
||||
activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) }
|
||||
} else {
|
||||
activity?.detachBackPressedCallback("ResultTrailerPlayer")
|
||||
activity?.attachBackPressedCallback("ResultTrailerPlayer") {
|
||||
updateFullscreen(false)
|
||||
}
|
||||
} else activity?.detachBackPressedCallback("ResultTrailerPlayer")
|
||||
}
|
||||
|
||||
override fun updateUIVisibility() {
|
||||
super.updateUIVisibility()
|
||||
playerBinding?.apply {
|
||||
playerGoBackHolder.isVisible = false
|
||||
val controlsVisible = isShowing && !introVisible
|
||||
playerTopHolder.isVisible = controlsVisible
|
||||
playerVideoHolder.isVisible = controlsVisible
|
||||
shadowOverlay.isVisible = controlsVisible
|
||||
playerPausePlayHolderHolder.isVisible =
|
||||
controlsVisible && playerHostView?.currentPlayerStatus != CSPlayerLoading.IsBuffering
|
||||
}
|
||||
// Fade center controls in/out; also resets stale fillAfter alpha from seek animations.
|
||||
playerHostView?.gestureHelper?.animateCenterControls(if (isShowing && !introVisible) 1f else 0f)
|
||||
playerBinding?.playerGoBackHolder?.isVisible = false
|
||||
}
|
||||
|
||||
override fun playerStatusChanged() {
|
||||
if (introVisible) {
|
||||
playerBinding?.playerPausePlayHolderHolder?.isVisible = false
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
playerBinding?.playerFullscreen?.setOnClickListener {
|
||||
updateFullscreen(!isFullScreenPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindingCreated(binding: FragmentResultSwipeBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
playerHostView?.videoOutline = playerBinding?.videoOutline
|
||||
playerHostView?.requestUpdateBrightnessOverlayOnNextLayout()
|
||||
|
||||
playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) }
|
||||
updateFullscreen(isFullScreenPlayer)
|
||||
uiReset()
|
||||
|
||||
playerBinding?.playerIntroPlay?.setOnClickListener {
|
||||
playerBinding?.playerIntroPlay?.isGone = true
|
||||
introVisible = false
|
||||
player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI)
|
||||
updateUIVisibility()
|
||||
fixPlayerSize()
|
||||
showControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
package com.lagradost.cloudstream3.ui.result
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.*
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.MainThread
|
||||
|
|
@ -11,50 +10,24 @@ import androidx.lifecycle.LiveData
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.actions.AlwaysAskAction
|
||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||
import com.lagradost.cloudstream3.APIHolder.apis
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||
import com.lagradost.cloudstream3.ActorData
|
||||
import com.lagradost.cloudstream3.AnimeLoadResponse
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||
import com.lagradost.cloudstream3.CommonActivity.getCastSession
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.DubStatus
|
||||
import com.lagradost.cloudstream3.EpisodeResponse
|
||||
import com.lagradost.cloudstream3.IDownloadableMinimum
|
||||
import com.lagradost.cloudstream3.LiveStreamLoadResponse
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.MovieLoadResponse
|
||||
import com.lagradost.cloudstream3.ProviderType
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.Score
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.SeasonData
|
||||
import com.lagradost.cloudstream3.ShowStatus
|
||||
import com.lagradost.cloudstream3.SimklSyncServices
|
||||
import com.lagradost.cloudstream3.SubtitleFile
|
||||
import com.lagradost.cloudstream3.TorrentLoadResponse
|
||||
import com.lagradost.cloudstream3.TrackerType
|
||||
import com.lagradost.cloudstream3.TrailerData
|
||||
import com.lagradost.cloudstream3.TvSeriesLoadResponse
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.VPNStatus
|
||||
import com.lagradost.cloudstream3.actions.AlwaysAskAction
|
||||
import com.lagradost.cloudstream3.actions.VideoClickActionHolder
|
||||
import com.lagradost.cloudstream3.amap
|
||||
import com.lagradost.cloudstream3.isEpisodeBased
|
||||
import com.lagradost.cloudstream3.isLiveStream
|
||||
import com.lagradost.cloudstream3.metaproviders.SyncRedirector
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||
|
|
@ -71,7 +44,9 @@ import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
|||
import com.lagradost.cloudstream3.syncproviders.providers.Kitsu
|
||||
import com.lagradost.cloudstream3.ui.APIRepository
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||
import com.lagradost.cloudstream3.ui.player.GeneratorPlayer
|
||||
import com.lagradost.cloudstream3.ui.player.IGenerator
|
||||
import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL
|
||||
import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST
|
||||
import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP
|
||||
|
|
@ -83,7 +58,6 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull
|
|||
import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.CastHelper.startCast
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioWork
|
||||
|
|
@ -131,8 +105,8 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
|||
import com.lagradost.cloudstream3.utils.UiText
|
||||
import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadEpisodeMetadata
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager
|
||||
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle
|
||||
import com.lagradost.cloudstream3.utils.loadExtractor
|
||||
import com.lagradost.cloudstream3.utils.newExtractorLink
|
||||
|
|
@ -319,12 +293,11 @@ fun LoadResponse.toResultData(repo: APIRepository): ResultData {
|
|||
TvType.Live -> R.string.live_singular
|
||||
TvType.Others -> R.string.other_singular
|
||||
TvType.NSFW -> R.string.nsfw_singular
|
||||
TvType.Music -> R.string.music_singular
|
||||
TvType.Music -> R.string.music_singlar
|
||||
TvType.AudioBook -> R.string.audio_book_singular
|
||||
TvType.CustomMedia -> R.string.custom_media_singular
|
||||
TvType.Audio -> R.string.audio_singular
|
||||
TvType.Podcast -> R.string.podcast_singular
|
||||
TvType.Video -> R.string.video_singular
|
||||
TvType.CustomMedia -> R.string.custom_media_singluar
|
||||
TvType.Audio -> R.string.audio_singluar
|
||||
TvType.Podcast -> R.string.podcast_singluar
|
||||
}
|
||||
),
|
||||
yearText = txt(year?.toString()),
|
||||
|
|
@ -449,7 +422,7 @@ fun SelectPopup.getOptions(context: Context): List<String> {
|
|||
}
|
||||
|
||||
data class ExtractedTrailerData(
|
||||
var mirros: List<Pair<ExtractorLink, String>>,//Pair of extracted trailer link and original trailer link
|
||||
var mirros: List<Pair<ExtractorLink,String>>,//Pair of extracted trailer link and original trailer link
|
||||
var subtitles: List<SubtitleFile> = emptyList(),
|
||||
)
|
||||
|
||||
|
|
@ -479,8 +452,8 @@ class ResultViewModel2 : ViewModel() {
|
|||
private var currentShowFillers: Boolean = false
|
||||
var currentRepo: APIRepository? = null
|
||||
private var currentId: Int? = null
|
||||
private var fillers: HashSet<Int> = hashSetOf()
|
||||
private var generator: RepoLinkGenerator? = null
|
||||
private var fillers: Map<Int, Boolean> = emptyMap()
|
||||
private var generator: IGenerator? = null
|
||||
private var preferDubStatus: DubStatus? = null
|
||||
private var preferStartEpisode: Int? = null
|
||||
private var preferStartSeason: Int? = null
|
||||
|
|
@ -1293,10 +1266,9 @@ class ResultViewModel2 : ViewModel() {
|
|||
subs += sub
|
||||
updatePage()
|
||||
},
|
||||
isCasting = isCasting,
|
||||
offset = 0
|
||||
isCasting = isCasting
|
||||
)
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: CancellationException) {
|
||||
// Do nothing
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
|
|
@ -1325,7 +1297,7 @@ class ResultViewModel2 : ViewModel() {
|
|||
episodeIds: Array<String>,
|
||||
watchState: VideoWatchState
|
||||
) {
|
||||
val watchStateString = watchState.toJson()
|
||||
val watchStateString = DataStore.mapper.writeValueAsString(watchState)
|
||||
episodeIds.forEach {
|
||||
if (getVideoWatchState(it.toInt()) != watchState) {
|
||||
editor.setKeyRaw(
|
||||
|
|
@ -1545,24 +1517,26 @@ class ResultViewModel2 : ViewModel() {
|
|||
|
||||
ACTION_PLAY_EPISODE_IN_PLAYER -> {
|
||||
val list = HashMap<String, String>(currentResponse?.syncData ?: emptyMap())
|
||||
val generator = generator ?: return
|
||||
|
||||
// I know kinda shit to iterate all, but it is 100% sure to work
|
||||
val index = generator.videos.indexOfFirst { value -> value.id == click.data.id }
|
||||
|
||||
generator?.also {
|
||||
it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work
|
||||
?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id }
|
||||
?.let { index ->
|
||||
if (index >= 0)
|
||||
it.goto(index)
|
||||
}
|
||||
}
|
||||
if (currentResponse?.type == TvType.CustomMedia) {
|
||||
generator.generateLinks(
|
||||
offset = index,
|
||||
generator?.generateLinks(
|
||||
clearCache = true,
|
||||
isCasting = false,
|
||||
sourceTypes = LOADTYPE_ALL,
|
||||
LOADTYPE_ALL,
|
||||
callback = {},
|
||||
subtitleCallback = {})
|
||||
} else {
|
||||
activity?.navigate(
|
||||
R.id.global_to_navigation_player,
|
||||
GeneratorPlayer.newInstance(
|
||||
generator, index,list
|
||||
generator ?: return, list
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1686,13 +1660,14 @@ class ResultViewModel2 : ViewModel() {
|
|||
}
|
||||
|
||||
val realRecommendations = ArrayList<SearchResponse>()
|
||||
val apiNames = apis.filter {
|
||||
val apiNames = synchronized(apis) {
|
||||
apis.filter {
|
||||
it.name.contains("gogoanime", true) ||
|
||||
it.name.contains("9anime", true)
|
||||
}.map {
|
||||
it.name
|
||||
}
|
||||
|
||||
}
|
||||
meta.recommendations?.forEach { rec ->
|
||||
apiNames.forEach { name ->
|
||||
realRecommendations.add(rec.copy(apiName = name))
|
||||
|
|
@ -1831,10 +1806,11 @@ class ResultViewModel2 : ViewModel() {
|
|||
}
|
||||
|
||||
|
||||
private suspend fun updateFillers(data: LoadResponse) {
|
||||
fillers = ioWorkSafe {
|
||||
FillerEpisodeCheck.getFillerEpisodes(data)
|
||||
} ?: hashSetOf()
|
||||
private suspend fun updateFillers(name: String) {
|
||||
fillers =
|
||||
ioWorkSafe {
|
||||
FillerEpisodeCheck.getFillerEpisodes(name)
|
||||
} ?: emptyMap()
|
||||
}
|
||||
|
||||
fun changeDubStatus(status: DubStatus) {
|
||||
|
|
@ -2171,8 +2147,8 @@ class ResultViewModel2 : ViewModel() {
|
|||
) {
|
||||
_episodes.postValue(Resource.Loading())
|
||||
|
||||
if (updateFillers) {
|
||||
updateFillers(loadResponse)
|
||||
if (updateFillers && loadResponse is AnimeLoadResponse) {
|
||||
updateFillers(loadResponse.name)
|
||||
}
|
||||
|
||||
val allEpisodes = when (loadResponse) {
|
||||
|
|
@ -2213,7 +2189,7 @@ class ResultViewModel2 : ViewModel() {
|
|||
index,
|
||||
i.score,
|
||||
i.description,
|
||||
fillers.contains(episode),
|
||||
fillers.getOrDefault(episode, false),
|
||||
loadResponse.type,
|
||||
mainId,
|
||||
totalIndex,
|
||||
|
|
@ -2453,20 +2429,13 @@ class ResultViewModel2 : ViewModel() {
|
|||
loadResponse.trailers.windowed(limit, limit, true).takeWhile { list ->
|
||||
list.amap { trailerData ->
|
||||
try {
|
||||
val links = arrayListOf<Pair<ExtractorLink, String>>()
|
||||
val links = arrayListOf<Pair<ExtractorLink,String>>()
|
||||
val subs = arrayListOf<SubtitleFile>()
|
||||
if (!loadExtractor(
|
||||
trailerData.extractorUrl,
|
||||
trailerData.referer,
|
||||
{ subs.add(it) },
|
||||
{
|
||||
links.add(
|
||||
Pair(
|
||||
it,
|
||||
trailerData.extractorUrl
|
||||
)
|
||||
)
|
||||
}) && trailerData.raw
|
||||
{ links.add(Pair(it,trailerData.extractorUrl))}) && trailerData.raw
|
||||
) {
|
||||
arrayListOf(
|
||||
Pair(
|
||||
|
|
@ -2479,8 +2448,7 @@ class ResultViewModel2 : ViewModel() {
|
|||
this.referer = trailerData.referer ?: ""
|
||||
this.quality = Qualities.Unknown.value
|
||||
this.headers = trailerData.headers
|
||||
}, trailerData.extractorUrl
|
||||
)
|
||||
},trailerData.extractorUrl)
|
||||
) to arrayListOf()
|
||||
} else {
|
||||
links to subs
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import androidx.preference.PreferenceManager
|
|||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.doOnLayout
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
|
@ -654,11 +653,7 @@ class SearchFragment : BaseFragment<FragmentSearchBinding>(
|
|||
|
||||
sq?.let { query ->
|
||||
if (query.isBlank()) return@let
|
||||
|
||||
// Queries are dropped if you are submitted before layout finishes
|
||||
mainSearch.doOnLayout {
|
||||
mainSearch.setQuery(query, true)
|
||||
}
|
||||
// Clear the query as to not make it request the same query every time the page is opened
|
||||
arguments?.remove(SEARCH_QUERY)
|
||||
savedInstanceState?.remove(SEARCH_QUERY)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class SearchViewModel : ViewModel() {
|
|||
|
||||
private var suggestionJob: Job? = null
|
||||
|
||||
private var repos = apis.withLock { apis.map { APIRepository(it) } }
|
||||
private var repos = synchronized(apis) { apis.map { APIRepository(it) } }
|
||||
|
||||
fun clearSearch() {
|
||||
_searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false)))
|
||||
|
|
@ -68,7 +68,7 @@ class SearchViewModel : ViewModel() {
|
|||
private var onGoingSearch: Job? = null
|
||||
|
||||
fun reloadRepos() {
|
||||
repos = apis.withLock { apis.map { APIRepository(it) } }
|
||||
repos = synchronized(apis) { apis.map { APIRepository(it) } }
|
||||
}
|
||||
|
||||
fun searchAndCancel(
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import com.lagradost.cloudstream3.databinding.AddAccountInputBinding
|
|||
import com.lagradost.cloudstream3.databinding.DeviceAuthBinding
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi
|
||||
|
|
@ -37,7 +36,6 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlAp
|
|||
import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthRepo
|
||||
import com.lagradost.cloudstream3.syncproviders.AuthUser
|
||||
import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo
|
||||
import com.lagradost.cloudstream3.syncproviders.SubtitleRepo
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncRepo
|
||||
import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat
|
||||
|
|
@ -470,7 +468,6 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback {
|
|||
R.string.simkl_key to SyncRepo(simklApi),
|
||||
R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi),
|
||||
R.string.subdl_key to SubtitleRepo(subDlApi),
|
||||
R.string.animeskip_key to PlainAuthRepo(animeSkipApi),
|
||||
)
|
||||
|
||||
for ((key, api) in syncApis) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import com.lagradost.cloudstream3.ui.settings.Globals.TV
|
|||
import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape
|
||||
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper
|
||||
import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash
|
||||
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
|
||||
|
|
@ -248,7 +247,7 @@ class SettingsFragment : BaseFragment<MainSettingsBinding>(
|
|||
}
|
||||
|
||||
val appVersion = BuildConfig.VERSION_NAME
|
||||
val commitHash = activity?.currentCommitHash() ?: ""
|
||||
val commitInfo = getString(R.string.commit_hash)
|
||||
val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG,
|
||||
Locale.getDefault()
|
||||
).apply { timeZone = TimeZone.getTimeZone("UTC")
|
||||
|
|
@ -256,9 +255,8 @@ class SettingsFragment : BaseFragment<MainSettingsBinding>(
|
|||
|
||||
binding.appVersion.text = appVersion
|
||||
binding.buildDate.text = buildTimestamp
|
||||
binding.commitHash.text = commitHash
|
||||
binding.appVersionInfo.setOnLongClickListener {
|
||||
clipboardHelper(txt(R.string.extension_version), "$appVersion $commitHash $buildTimestamp")
|
||||
clipboardHelper(txt(R.string.extension_version), "$appVersion $commitInfo $buildTimestamp")
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import android.widget.Toast
|
|||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.APIHolder.allProviders
|
||||
|
|
@ -156,23 +155,16 @@ class SettingsGeneral : BasePreferenceFragmentCompat() {
|
|||
val lang: String,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun Fragment.pickDownloadPath(uri: Uri?, path: String?) {
|
||||
if (uri == null) return
|
||||
|
||||
val context = context ?: CloudStreamApp.context ?: return
|
||||
val visual = path ?: uri.toString()
|
||||
private val pathPicker = getChooseFolderLauncher { uri, path ->
|
||||
val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher
|
||||
(path ?: uri.toString()).let {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit {
|
||||
putString(getString(R.string.download_path_key), uri.toString())
|
||||
putString(context.getString(R.string.download_path_key_visual), visual)
|
||||
putString(getString(R.string.download_path_key_visual), it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val pathPicker = getChooseFolderLauncher { uri, path ->
|
||||
pickDownloadPath(uri, path)
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
hideKeyboard()
|
||||
setPreferencesFromResource(R.xml.settings_general, rootKey)
|
||||
|
|
@ -219,7 +211,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() {
|
|||
}
|
||||
|
||||
fun showAdd() {
|
||||
val providers = allProviders.distinctBy { it::class }.sortedBy { it.name }
|
||||
val providers = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } }
|
||||
activity?.showDialog(
|
||||
providers.map { "${it.name} (${it.mainUrl})" },
|
||||
-1,
|
||||
|
|
|
|||
|
|
@ -111,10 +111,10 @@ class SettingsProviders : BasePreferenceFragmentCompat() {
|
|||
|
||||
getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener {
|
||||
activity?.getApiProviderLangSettings()?.let { currentLangTags ->
|
||||
val languagesTagName = APIHolder.apis.withLock {
|
||||
listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) +
|
||||
val languagesTagName = synchronized(APIHolder.apis) {
|
||||
listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) +
|
||||
APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) }
|
||||
.toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() }
|
||||
.toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji
|
||||
}
|
||||
|
||||
val currentIndexList = currentLangTags.map { langTag ->
|
||||
|
|
|
|||
|
|
@ -58,8 +58,6 @@ class SettingsUpdates : BasePreferenceFragmentCompat() {
|
|||
}
|
||||
|
||||
private val pathPicker = getChooseFolderLauncher { uri, path ->
|
||||
if(uri == null) return@getChooseFolderLauncher
|
||||
|
||||
val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher
|
||||
(path ?: uri.toString()).let {
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit {
|
||||
|
|
@ -69,6 +67,7 @@ class SettingsUpdates : BasePreferenceFragmentCompat() {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION_ERROR")
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
hideKeyboard()
|
||||
setPreferencesFromResource(R.xml.settings_updates, rootKey)
|
||||
|
|
@ -207,9 +206,8 @@ class SettingsUpdates : BasePreferenceFragmentCompat() {
|
|||
val prefNames = resources.getStringArray(R.array.apk_installer_pref)
|
||||
val prefValues = resources.getIntArray(R.array.apk_installer_values)
|
||||
|
||||
// Use legacy installer as default until we make the new installer completely reliable
|
||||
val currentInstaller =
|
||||
settingsManager.getInt(getString(R.string.apk_installer_key), 1)
|
||||
settingsManager.getInt(getString(R.string.apk_installer_key), 0)
|
||||
|
||||
activity?.showBottomDialog(
|
||||
prefNames.toList(),
|
||||
|
|
|
|||
|
|
@ -119,14 +119,13 @@ class ExtensionsFragment : BaseFragment<FragmentExtensionsBinding>(
|
|||
}, { repo ->
|
||||
// Prompt user before deleting repo
|
||||
main {
|
||||
val uiContext = context ?: binding.root.context
|
||||
val builder = AlertDialog.Builder(uiContext)
|
||||
val builder = AlertDialog.Builder(context ?: binding.root.context)
|
||||
val dialogClickListener =
|
||||
DialogInterface.OnClickListener { _, which ->
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
ioSafe {
|
||||
RepositoryManager.removeRepository(uiContext.applicationContext, repo)
|
||||
RepositoryManager.removeRepository(binding.root.context, repo)
|
||||
extensionViewModel.loadStats()
|
||||
extensionViewModel.loadRepositories()
|
||||
}
|
||||
|
|
@ -137,7 +136,9 @@ class ExtensionsFragment : BaseFragment<FragmentExtensionsBinding>(
|
|||
}
|
||||
|
||||
builder.setTitle(R.string.delete_repository)
|
||||
.setMessage(uiContext.getString(R.string.delete_repository_plugins))
|
||||
.setMessage(
|
||||
context?.getString(R.string.delete_repository_plugins)
|
||||
)
|
||||
.setPositiveButton(R.string.delete, dialogClickListener)
|
||||
.setNegativeButton(R.string.cancel, dialogClickListener)
|
||||
.show().setDefaultFocus()
|
||||
|
|
@ -209,9 +210,9 @@ class ExtensionsFragment : BaseFragment<FragmentExtensionsBinding>(
|
|||
|
||||
binding.applyBtt.setOnClickListener secondListener@{
|
||||
val name = binding.repoNameInput.text?.toString()
|
||||
val urlInput = binding.repoUrlInput.text?.toString()
|
||||
ioSafe {
|
||||
val url = urlInput?.let { it1 -> RepositoryManager.parseRepoUrl(it1) }
|
||||
val url = binding.repoUrlInput.text?.toString()
|
||||
?.let { it1 -> RepositoryManager.parseRepoUrl(it1) }
|
||||
if (url.isNullOrBlank()) {
|
||||
main {
|
||||
showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import com.lagradost.cloudstream3.utils.txt
|
|||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread
|
||||
import com.lagradost.cloudstream3.utils.Levenshtein
|
||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||
import java.io.File
|
||||
|
||||
// String => repository url
|
||||
|
|
@ -128,7 +128,6 @@ class PluginsViewModel : ViewModel() {
|
|||
PluginManager.downloadPlugin(
|
||||
activity,
|
||||
metadata.url,
|
||||
metadata.fileHash,
|
||||
metadata.internalName,
|
||||
repo,
|
||||
metadata.status != PROVIDER_STATUS_DOWN
|
||||
|
|
@ -180,7 +179,6 @@ class PluginsViewModel : ViewModel() {
|
|||
PluginManager.downloadPlugin(
|
||||
activity,
|
||||
metadata.url,
|
||||
metadata.fileHash,
|
||||
metadata.internalName,
|
||||
repo,
|
||||
isEnabled
|
||||
|
|
@ -246,7 +244,7 @@ class PluginsViewModel : ViewModel() {
|
|||
this.sortedBy { it.plugin.second.name }
|
||||
} else {
|
||||
this.sortedBy {
|
||||
-Levenshtein.partialRatio(
|
||||
-FuzzySearch.partialRatio(
|
||||
it.plugin.second.name.lowercase(),
|
||||
query.lowercase()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class TestFragment : BaseFragment<FragmentTestingBinding>(
|
|||
providerTest.setProgress(passed, failed, total)
|
||||
}
|
||||
|
||||
observe(testViewModel.providerResults) {
|
||||
observeNullable(testViewModel.providerResults) {
|
||||
safe {
|
||||
val newItems = it.sortedBy { api -> api.first.name }
|
||||
(providerTestRecyclerView.adapter as? TestResultAdapter)?.submitList(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.ViewModel
|
||||
import com.lagradost.cloudstream3.APIHolder
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.atomicListOf
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.TestingUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -40,7 +40,7 @@ class TestViewModel : ViewModel() {
|
|||
get() = scope != null
|
||||
|
||||
private var filter = ProviderFilter.All
|
||||
private val providers = atomicListOf<Pair<MainAPI, TestingUtils.TestResultProvider>>()
|
||||
private val providers = threadSafeListOf<Pair<MainAPI, TestingUtils.TestResultProvider>>()
|
||||
private var passed = 0
|
||||
private var failed = 0
|
||||
private var total = 0
|
||||
|
|
@ -51,9 +51,9 @@ class TestViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
private fun postProviders() {
|
||||
providers.withLock {
|
||||
synchronized(providers) {
|
||||
val filtered = when (filter) {
|
||||
ProviderFilter.All -> providers.toList()
|
||||
ProviderFilter.All -> providers
|
||||
ProviderFilter.Passed -> providers.filter { it.second.success }
|
||||
ProviderFilter.Failed -> providers.filter { !it.second.success }
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ class TestViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) {
|
||||
providers.withLock {
|
||||
synchronized(providers) {
|
||||
val index = providers.indexOfFirst { it.first == api }
|
||||
if (index == -1) {
|
||||
providers.add(api to results)
|
||||
|
|
@ -81,14 +81,14 @@ class TestViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
fun init() {
|
||||
total = APIHolder.allProviders.withLock { APIHolder.allProviders.size }
|
||||
total = synchronized(APIHolder.allProviders) { APIHolder.allProviders.size }
|
||||
updateProgress()
|
||||
}
|
||||
|
||||
fun startTest() {
|
||||
scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
val apis = APIHolder.allProviders.withLock { APIHolder.allProviders.toTypedArray() }
|
||||
val apis = synchronized(APIHolder.allProviders) { APIHolder.allProviders.toTypedArray() }
|
||||
total = apis.size
|
||||
failed = 0
|
||||
passed = 0
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ import com.lagradost.safefile.SafeFile
|
|||
fun Fragment.getChooseFolderLauncher(dirSelected: (uri: Uri?, path: String?) -> Unit) =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
// It lies, it can be null if file manager quits.
|
||||
if(uri == null) {
|
||||
dirSelected(null, null)
|
||||
return@registerForActivityResult
|
||||
}
|
||||
if (uri == null) return@registerForActivityResult
|
||||
val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult
|
||||
// RW perms for the path
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class SetupFragmentExtensions : BaseFragment<FragmentSetupExtensionsBinding>(
|
|||
if (isSetup)
|
||||
if (
|
||||
// If any available languages
|
||||
apis.distinctBy { it.lang }.size > 1
|
||||
synchronized(apis) { apis.distinctBy { it.lang }.size > 1 }
|
||||
) {
|
||||
findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@ class SetupFragmentProviderLanguage : BaseFragment<FragmentSetupProviderLanguage
|
|||
|
||||
val currentLangTags = ctx.getApiProviderLangSettings()
|
||||
|
||||
val languagesTagName = APIHolder.apis.withLock {
|
||||
listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) +
|
||||
val languagesTagName = synchronized(APIHolder.apis) {
|
||||
listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) +
|
||||
APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) }
|
||||
.toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji
|
||||
.toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji
|
||||
}
|
||||
|
||||
val currentIndexList = currentLangTags.map { langTag ->
|
||||
|
|
|
|||
139
app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt
Normal file
139
app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package com.lagradost.cloudstream3.utils
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
import java.lang.Long.min
|
||||
|
||||
object EpisodeSkip {
|
||||
private const val TAG = "EpisodeSkip"
|
||||
|
||||
enum class SkipType(@StringRes name: Int) {
|
||||
Opening(R.string.skip_type_op),
|
||||
Ending(R.string.skip_type_ed),
|
||||
Recap(R.string.skip_type_recap),
|
||||
MixedOpening(R.string.skip_type_mixed_op),
|
||||
MixedEnding(R.string.skip_type_mixed_ed),
|
||||
Credits(R.string.skip_type_creddits),
|
||||
Intro(R.string.skip_type_creddits),
|
||||
}
|
||||
|
||||
data class SkipStamp(
|
||||
val type: SkipType,
|
||||
val skipToNextEpisode: Boolean,
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
) {
|
||||
val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt(
|
||||
R.string.skip_type_format,
|
||||
txt(type.name)
|
||||
)
|
||||
}
|
||||
|
||||
private val cachedStamps = HashMap<Int, List<SkipStamp>>()
|
||||
|
||||
private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean {
|
||||
return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh
|
||||
}
|
||||
|
||||
suspend fun getStamps(
|
||||
data: LoadResponse,
|
||||
episode: ResultEpisode,
|
||||
episodeDurationMs: Long,
|
||||
hasNextEpisode: Boolean,
|
||||
): List<SkipStamp> {
|
||||
cachedStamps[episode.id]?.let { list ->
|
||||
return list
|
||||
}
|
||||
|
||||
val out = mutableListOf<SkipStamp>()
|
||||
Log.i(TAG, "Requesting SkipStamp from ${data.syncData}")
|
||||
|
||||
if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) {
|
||||
data.getMalId()?.toIntOrNull()?.let { malId ->
|
||||
val (resultLength, stamps) = AniSkip.getResult(
|
||||
malId,
|
||||
episode.episode,
|
||||
episodeDurationMs
|
||||
) ?: return@let null
|
||||
// because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work
|
||||
val dur = min(episodeDurationMs, resultLength)
|
||||
stamps.mapNotNull { stamp ->
|
||||
val skipType = when (stamp.skipType) {
|
||||
"op" -> SkipType.Opening
|
||||
"ed" -> SkipType.Ending
|
||||
"recap" -> SkipType.Recap
|
||||
"mixed-ed" -> SkipType.MixedEnding
|
||||
"mixed-op" -> SkipType.MixedOpening
|
||||
else -> null
|
||||
} ?: return@mapNotNull null
|
||||
val end = (stamp.interval.endTime * 1000.0).toLong()
|
||||
val start = (stamp.interval.startTime * 1000.0).toLong()
|
||||
SkipStamp(
|
||||
type = skipType,
|
||||
skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode(
|
||||
end,
|
||||
dur
|
||||
),
|
||||
startMs = start,
|
||||
endMs = end
|
||||
)
|
||||
}.let { list ->
|
||||
out.addAll(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out.isNotEmpty())
|
||||
cachedStamps[episode.id] = out
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt
|
||||
// the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md
|
||||
object AniSkip {
|
||||
private const val TAG = "AniSkip"
|
||||
suspend fun getResult(
|
||||
malId: Int,
|
||||
episodeNumber: Int,
|
||||
episodeLength: Long
|
||||
): Pair<Long, List<Stamp>>? {
|
||||
return try {
|
||||
val url =
|
||||
"https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}"
|
||||
Log.i(TAG, "Requesting $url")
|
||||
|
||||
val a = app.get(url)
|
||||
val res = a.parsed<AniSkipResponse>()
|
||||
Log.i(TAG, "Found ${res.found} with ${res.results?.size} results")
|
||||
if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null
|
||||
} catch (t: Throwable) {
|
||||
Log.i(TAG, "error = ${t.message}")
|
||||
logError(t)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class AniSkipResponse(
|
||||
@JsonSerialize val found: Boolean,
|
||||
@JsonSerialize val results: List<Stamp>?,
|
||||
@JsonSerialize val message: String?,
|
||||
@JsonSerialize val statusCode: Int
|
||||
)
|
||||
|
||||
data class Stamp(
|
||||
@JsonSerialize val interval: AniSkipInterval,
|
||||
@JsonSerialize val skipType: String,
|
||||
@JsonSerialize val skipId: String,
|
||||
@JsonSerialize val episodeLength: Double
|
||||
)
|
||||
|
||||
data class AniSkipInterval(
|
||||
@JsonSerialize val startTime: Double,
|
||||
@JsonSerialize val endTime: Double
|
||||
)
|
||||
}
|
||||
|
|
@ -369,10 +369,28 @@ object AppContextUtils {
|
|||
}
|
||||
|
||||
fun Context.getApiSettings(): HashSet<String> {
|
||||
//val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
val hashSet = HashSet<String>()
|
||||
val activeLangs = getApiProviderLangSettings()
|
||||
val hasUniversal = activeLangs.contains(AllLanguagesName)
|
||||
hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) }.map { it.name })
|
||||
hashSet.addAll(synchronized(apis) { apis.filter { hasUniversal || activeLangs.contains(it.lang) } }
|
||||
.map { it.name })
|
||||
|
||||
/*val set = settingsManager.getStringSet(
|
||||
this.getString(R.string.search_providers_list_key),
|
||||
hashSet
|
||||
)?.toHashSet() ?: hashSet
|
||||
|
||||
val list = HashSet<String>()
|
||||
for (name in set) {
|
||||
val api = getApiFromNameNull(name) ?: continue
|
||||
if (activeLangs.contains(api.lang)) {
|
||||
list.add(name)
|
||||
}
|
||||
}*/
|
||||
//if (list.isEmpty()) return hashSet
|
||||
//return list
|
||||
return hashSet
|
||||
}
|
||||
|
||||
|
|
@ -431,14 +449,6 @@ object AppContextUtils {
|
|||
return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true)
|
||||
}
|
||||
|
||||
fun Context.shouldShowPlayerMetadata(): Boolean {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
return prefs.getBoolean(
|
||||
getString(R.string.show_player_metadata_key),
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List<MainAPI> {
|
||||
// We are getting the weirdest crash ever done:
|
||||
// java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType
|
||||
|
|
@ -463,7 +473,9 @@ object AppContextUtils {
|
|||
} ?: default
|
||||
val langs = this.getApiProviderLangSettings()
|
||||
val hasUniversal = langs.contains(AllLanguagesName)
|
||||
val allApis = apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) }
|
||||
val allApis = synchronized(apis) {
|
||||
apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) }
|
||||
}
|
||||
return if (currentPrefMedia.isEmpty()) {
|
||||
allApis
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ object BackPressedCallbackHelper {
|
|||
fun ComponentActivity.detachBackPressedCallback(id: String) {
|
||||
val callbackMap = backPressedCallbacks[this] ?: return
|
||||
callbackMap[id]?.let { callback ->
|
||||
callback.remove()
|
||||
callback.isEnabled = false
|
||||
callbackMap.remove(id)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.core.net.toUri
|
|||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.R
|
||||
|
|
@ -20,12 +21,11 @@ import com.lagradost.cloudstream3.syncproviders.AccountManager
|
|||
import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs
|
||||
import com.lagradost.cloudstream3.utils.DataStore.mapper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
||||
import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream
|
||||
|
|
@ -62,7 +62,6 @@ object BackupUtils {
|
|||
AccountManager.ACCOUNT_TOKEN,
|
||||
AccountManager.ACCOUNT_IDS,
|
||||
|
||||
// TODO proper getter for string res keys to ensure that they are updated
|
||||
"biometric_key", // can lock down users if backup is shared on a incompatible device
|
||||
"nginx_user", // Nginx user key
|
||||
|
||||
|
|
@ -104,10 +103,7 @@ object BackupUtils {
|
|||
// Prevent backups from automatically starting downloads
|
||||
KEY_RESUME_IN_QUEUE,
|
||||
KEY_RESUME_PACKAGES,
|
||||
QUEUE_KEY,
|
||||
|
||||
// Prevent automatic plugin download after restoring backup
|
||||
"auto_download_plugins_key2"
|
||||
QUEUE_KEY
|
||||
)
|
||||
|
||||
/** false if key should not be contained in backup */
|
||||
|
|
@ -133,7 +129,9 @@ object BackupUtils {
|
|||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getBackup(context: Context): BackupFile {
|
||||
private fun getBackup(context: Context?): BackupFile? {
|
||||
if (context == null) return null
|
||||
|
||||
val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() }
|
||||
val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() }
|
||||
|
||||
|
|
@ -212,7 +210,7 @@ object BackupUtils {
|
|||
|
||||
fileStream = stream.openNew()
|
||||
printStream = PrintWriter(fileStream)
|
||||
printStream.print(backupFile.toJson())
|
||||
printStream.print(mapper.writeValueAsString(backupFile))
|
||||
|
||||
showToast(
|
||||
R.string.backup_success,
|
||||
|
|
@ -257,8 +255,8 @@ object BackupUtils {
|
|||
val input = activity.contentResolver.openInputStream(uri)
|
||||
?: return@ioSafe
|
||||
|
||||
val text = input.bufferedReader().readText()
|
||||
val restoredValue = parseJson<BackupFile>(text)
|
||||
val restoredValue =
|
||||
mapper.readValue<BackupFile>(input)
|
||||
|
||||
restore(
|
||||
activity,
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@ package com.lagradost.cloudstream3.utils
|
|||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KProperty
|
||||
import androidx.core.content.edit
|
||||
|
||||
/** Used to display metadata about downloads and resume watching */
|
||||
const val DOWNLOAD_HEADER_CACHE = "download_header_cache"
|
||||
|
|
@ -87,18 +88,8 @@ data class Editor(
|
|||
}
|
||||
|
||||
object DataStore {
|
||||
// Extensions shouldn't have really been using this version of it, but it seems
|
||||
// some have. Since there has always been a very easy alternative, we won't
|
||||
// need to deprecate it that long, and should be able to fully remove it
|
||||
// once extensions at least use the other version.
|
||||
@Deprecated(
|
||||
"Please do not use the mapper version from DataStore. Preferably use methods from AppUtils " +
|
||||
"to parse JSON. However, you can use the stable-API version of the mapper at " +
|
||||
"com.lagradost.cloudstream3.mapper to access the mapper directly if necessary.",
|
||||
level = DeprecationLevel.ERROR,
|
||||
replaceWith = ReplaceWith("com.lagradost.cloudstream3.mapper"),
|
||||
)
|
||||
val mapper = com.lagradost.cloudstream3.mapper
|
||||
val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()
|
||||
|
||||
private fun getPreferences(context: Context): SharedPreferences {
|
||||
return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
|
|
@ -108,6 +99,7 @@ object DataStore {
|
|||
return getPreferences(this)
|
||||
}
|
||||
|
||||
|
||||
fun getFolderName(folder: String, path: String): String {
|
||||
return "${folder}/${path}"
|
||||
}
|
||||
|
|
@ -173,17 +165,17 @@ object DataStore {
|
|||
fun <T> Context.setKey(path: String, value: T) {
|
||||
try {
|
||||
getSharedPrefs().edit {
|
||||
putString(path, value?.toJsonLiteral())
|
||||
putString(path, mapper.writeValueAsString(value))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Any> Context.getKey(path: String, valueType: Class<T>): T? {
|
||||
fun <T> Context.getKey(path: String, valueType: Class<T>): T? {
|
||||
try {
|
||||
val json: String = getSharedPrefs().getString(path, null) ?: return null
|
||||
return parseJson(json, valueType.kotlin)
|
||||
return json.toKotlinObject(valueType)
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -194,11 +186,11 @@ object DataStore {
|
|||
}
|
||||
|
||||
inline fun <reified T : Any> String.toKotlinObject(): T {
|
||||
return parseJson(this)
|
||||
return mapper.readValue(this, T::class.java)
|
||||
}
|
||||
|
||||
fun <T : Any> String.toKotlinObject(valueType: Class<T>): T {
|
||||
return parseJson(this, valueType.kotlin)
|
||||
fun <T> String.toKotlinObject(valueType: Class<T>): T {
|
||||
return mapper.readValue(this, valueType)
|
||||
}
|
||||
|
||||
// GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR
|
||||
|
|
|
|||
|
|
@ -1,166 +1,112 @@
|
|||
package com.lagradost.cloudstream3.utils
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId
|
||||
import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.ui.result.getId
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import org.jsoup.Jsoup
|
||||
import java.lang.Thread.sleep
|
||||
import java.util.*
|
||||
import kotlin.concurrent.thread
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import java.io.InputStream
|
||||
import kotlin.let
|
||||
|
||||
object FillerEpisodeCheck {
|
||||
private const val MAIN_URL = "https://www.animefillerlist.com"
|
||||
|
||||
var list: HashMap<String, String>? = null
|
||||
var cache: HashMap<String, HashMap<Int, Boolean>> = hashMapOf()
|
||||
|
||||
private fun fixName(name: String): String {
|
||||
return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ")
|
||||
.replace("[^a-zA-Z0-9 ]".toRegex(), "")
|
||||
}
|
||||
|
||||
private suspend fun getFillerList(): Boolean {
|
||||
if (list != null) return true
|
||||
try {
|
||||
val result = app.get("$MAIN_URL/shows").text
|
||||
val documented = Jsoup.parse(result)
|
||||
val localHTMLList = documented.select("div#ShowList > div.Group > ul > li > a")
|
||||
val localList = HashMap<String, String>()
|
||||
for (i in localHTMLList) {
|
||||
val name = i.text()
|
||||
|
||||
if (name.lowercase(Locale.ROOT).contains("manga only")) continue
|
||||
|
||||
val href = i.attr("href")
|
||||
if (name.isNullOrEmpty() || href.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
val values = "(.*) \\((.*)\\)".toRegex().matchEntire(name)?.groups
|
||||
if (values != null) {
|
||||
for (index in 1 until values.size) {
|
||||
val localName = values[index]?.value ?: continue
|
||||
localList[fixName(localName)] = href
|
||||
}
|
||||
} else {
|
||||
localList[fixName(name)] = href
|
||||
}
|
||||
}
|
||||
if (localList.size > 0) {
|
||||
list = localList
|
||||
return true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun String?.toClassDir(): String {
|
||||
val q = this ?: "null"
|
||||
val z = (6..10).random().calc()
|
||||
return q + "cache" + z
|
||||
}
|
||||
|
||||
data class Show(
|
||||
@JsonProperty("slug")
|
||||
val slug: String,
|
||||
@JsonProperty("title")
|
||||
val title: String,
|
||||
@JsonProperty("filler")
|
||||
val filler: ArrayList<Int>,
|
||||
@JsonProperty("mixedCanon")
|
||||
val mixedCanon: ArrayList<Int>,
|
||||
@JsonProperty("mangaCanon")
|
||||
val mangaCanon: ArrayList<Int>,
|
||||
@JsonProperty("animeCanon")
|
||||
val animeCanon: ArrayList<Int>,
|
||||
)
|
||||
|
||||
data class MappingRoot(
|
||||
@JsonProperty("type")
|
||||
val type: String?,
|
||||
@JsonProperty("anidb_id")
|
||||
val anidbId: Long?,
|
||||
@JsonProperty("anilist_id")
|
||||
val anilistId: Long?,
|
||||
@JsonProperty("animecountdown_id")
|
||||
val animecountdownId: Long?,
|
||||
@JsonProperty("animenewsnetwork_id")
|
||||
val animenewsnetworkId: Long?,
|
||||
@JsonProperty("anime-planet_id")
|
||||
val animePlanetId: String?,
|
||||
@JsonProperty("anisearch_id")
|
||||
val anisearchId: Long?,
|
||||
@JsonProperty("imdb_id")
|
||||
val imdbId: String?,
|
||||
@JsonProperty("kitsu_id")
|
||||
val kitsuId: Long?,
|
||||
@JsonProperty("livechart_id")
|
||||
val livechartId: Long?,
|
||||
@JsonProperty("mal_id")
|
||||
val malId: Long?,
|
||||
@JsonProperty("simkl_id")
|
||||
val simklId: Long?,
|
||||
@JsonProperty("themoviedb_id")
|
||||
val themoviedbId: Long?,
|
||||
@JsonProperty("tvdb_id")
|
||||
val tvdbId: Long?,
|
||||
@JsonProperty("season")
|
||||
val season: Season?,
|
||||
)
|
||||
|
||||
data class Season(
|
||||
@JsonProperty("tvdb")
|
||||
val tvdb: Long?,
|
||||
@JsonProperty("tmdb")
|
||||
val tmdb: Long?,
|
||||
)
|
||||
|
||||
data class CombinedMedia(
|
||||
@JsonProperty("mapping")
|
||||
val mapping: MappingRoot?,
|
||||
@JsonProperty("show")
|
||||
val show: Show
|
||||
)
|
||||
|
||||
data class Database(
|
||||
val mal: HashMap<Long, CombinedMedia> = hashMapOf(),
|
||||
val anilist: HashMap<Long, CombinedMedia> = hashMapOf(),
|
||||
val kitsu: HashMap<Long, CombinedMedia> = hashMapOf(),
|
||||
val tmdb: HashMap<Long, CombinedMedia> = hashMapOf(),
|
||||
val imdb: HashMap<String, CombinedMedia> = hashMapOf(),
|
||||
val name: HashMap<String, CombinedMedia> = hashMapOf(),
|
||||
)
|
||||
|
||||
private var database: Database? = null
|
||||
|
||||
private val strip = Regex("[ :\\-.!]")
|
||||
|
||||
/** Makes names more uniform to make partial matches more still give a result */
|
||||
fun stripName(name: String): String =
|
||||
name.replace(strip, "").lowercase()
|
||||
|
||||
|
||||
@Synchronized
|
||||
@Throws
|
||||
@WorkerThread
|
||||
fun loadJson(): Database {
|
||||
database?.let {
|
||||
suspend fun getFillerEpisodes(query: String): HashMap<Int, Boolean>? {
|
||||
try {
|
||||
cache[query]?.let {
|
||||
return it
|
||||
}
|
||||
if (!getFillerList()) return null
|
||||
val localList = list ?: return null
|
||||
|
||||
/** The entire "database" is stored as a json file we can parse */
|
||||
val stream: InputStream = com.lagradost.AnimeDB.getDatabaseStream()!!
|
||||
val text = stream.reader().readText()
|
||||
// Strips these from the name
|
||||
val blackList = listOf(
|
||||
"TV Dubbed",
|
||||
"(Dub)",
|
||||
"Subbed",
|
||||
"(TV)",
|
||||
"(Uncensored)",
|
||||
"(Censored)",
|
||||
"(\\d+)" // year
|
||||
)
|
||||
val blackListRegex =
|
||||
Regex(
|
||||
""" (${
|
||||
blackList.joinToString(separator = "|").replace("(", "\\(")
|
||||
.replace(")", "\\)")
|
||||
})"""
|
||||
)
|
||||
|
||||
val allMedia = parseJson<Array<CombinedMedia>>(text)
|
||||
val pending = Database()
|
||||
for (media in allMedia) {
|
||||
val lowercase = stripName(media.show.title)
|
||||
pending.name[lowercase] = media
|
||||
val map = media.mapping ?: continue
|
||||
|
||||
map.imdbId?.let { id -> pending.imdb[id] = media }
|
||||
map.malId?.let { id -> pending.mal[id] = media }
|
||||
map.anilistId?.let { id -> pending.anilist[id] = media }
|
||||
map.kitsuId?.let { id -> pending.kitsu[id] = media }
|
||||
map.season?.tmdb?.let { id -> pending.tmdb[id] = media }
|
||||
val realQuery =
|
||||
fixName(query.replace(blackListRegex, "")).replace("shippuuden", "shippuden")
|
||||
if (!localList.containsKey(realQuery)) return null
|
||||
val href = localList[realQuery]?.replace(MAIN_URL, "") ?: return null // JUST IN CASE
|
||||
val result = app.get("$MAIN_URL$href").text
|
||||
val documented = Jsoup.parse(result)
|
||||
val hashMap = HashMap<Int, Boolean>()
|
||||
documented.select("table.EpisodeList > tbody > tr").forEach {
|
||||
val type = it.selectFirst("td.Type > span")?.text() == "Filler"
|
||||
val episodeNumber = it.selectFirst("td.Number")?.text()?.toIntOrNull()
|
||||
if (episodeNumber != null) {
|
||||
hashMap[episodeNumber] = type
|
||||
}
|
||||
database = pending
|
||||
return pending
|
||||
}
|
||||
|
||||
val loadCache: HashMap<Int, HashSet<Int>?> = hashMapOf()
|
||||
|
||||
@Synchronized
|
||||
@Throws
|
||||
@WorkerThread
|
||||
fun getFillerEpisodes(data: LoadResponse): HashSet<Int>? {
|
||||
/** Only for anime */
|
||||
if (data.type != TvType.Anime) {
|
||||
cache[query] = hashMap
|
||||
return hashMap
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
return null
|
||||
}
|
||||
/** Try to hit the cache for this entry, to avoid recreating the hashset */
|
||||
loadCache[data.getId()]?.let { cachedResponse ->
|
||||
return cachedResponse
|
||||
}
|
||||
val db = loadJson()
|
||||
|
||||
val media =
|
||||
db.mal[data.getMalId()?.toLongOrNull()]
|
||||
?: db.anilist[data.getAniListId()?.toLongOrNull()]
|
||||
?: db.kitsu[data.getKitsuId()?.toLongOrNull()]
|
||||
?: db.imdb[data.getImdbId()]
|
||||
?: db.tmdb[data.getTMDbId()?.toLongOrNull()]
|
||||
?: db.name[stripName(data.name)]
|
||||
|
||||
return media?.show?.filler?.toHashSet().also { response ->
|
||||
loadCache[data.getId()] = response
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.calc(): Int {
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
package com.lagradost.cloudstream3.utils
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* Simple helper to get the short commit hash from assets.
|
||||
* The hash is generated at build and stored as an asset
|
||||
* that can be accessed at runtime for Gradle
|
||||
* configuration cache support.
|
||||
*/
|
||||
object GitInfo {
|
||||
fun Context.currentCommitHash(): String = try {
|
||||
assets.open("git-hash.txt")
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
.trim()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package com.lagradost.cloudstream3.utils
|
|||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.util.Log
|
||||
import android.widget.ImageView
|
||||
|
|
@ -12,7 +11,6 @@ import coil3.EventListener
|
|||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.decode.BitmapFactoryDecoder
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.dispose
|
||||
import coil3.load
|
||||
|
|
@ -24,86 +22,82 @@ import coil3.request.CachePolicy
|
|||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowHardware
|
||||
import coil3.request.bitmapConfig
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import com.lagradost.cloudstream3.BuildConfig
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
import com.lagradost.cloudstream3.network.buildDefaultClient
|
||||
import okhttp3.HttpUrl
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object ImageLoader {
|
||||
|
||||
private const val TAG = "CoilImgLoader"
|
||||
internal fun buildImageLoader(context: PlatformContext): ImageLoader {
|
||||
val isBrokenHardware = hasPotentialBrokenHardware()
|
||||
return ImageLoader.Builder(context)
|
||||
|
||||
internal fun buildImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context)
|
||||
.crossfade(200)
|
||||
.allowHardware(SDK_INT >= 28 && !isBrokenHardware)
|
||||
.allowHardware(SDK_INT >= 28) // SDK_INT >= 28, cant use hardware bitmaps for Palette Builder
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.networkCachePolicy(CachePolicy.ENABLED)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder().maxSizePercent(context, 0.1)//10 % of heap for mem-cache
|
||||
.strongReferencesEnabled(false)
|
||||
MemoryCache.Builder().maxSizePercent(context, 0.1) // Use 10 % of the app's available memory for caching
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath())
|
||||
.maxSizeBytes(512L * 1024 * 1024) // 512 MB
|
||||
.maxSizePercent(0.04) // max 4% of storage for disk caching
|
||||
.maxSizePercent(0.04) // Use 4 % of the device's storage space for disk caching
|
||||
.build()
|
||||
}
|
||||
/** Pass interceptors with care, unnecessary passing tokens to servers
|
||||
or image hosting services causes unauthorized exceptions **/
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) }))
|
||||
if (isBrokenHardware) {
|
||||
add(BitmapFactoryDecoder.Factory())
|
||||
} // sw decoder
|
||||
}
|
||||
.apply {
|
||||
if (isBrokenHardware) { // coil will auto choose optimal config on modern device
|
||||
bitmapConfig(Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
setupCoilLogger()
|
||||
.components { add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) }
|
||||
.also {
|
||||
it.setupCoilLogger()
|
||||
Log.d(TAG, "buildImageLoader: Setting COIL Image Loader.")
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
/** DebugLogger on debug builds which won't slow down release builds & use EventListener for
|
||||
/** Use DebugLogger on debug builds which won't slow down release builds & use EventListener for
|
||||
Errors on release builds. **/
|
||||
internal fun ImageLoader.Builder.setupCoilLogger() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
logger(DebugLogger())
|
||||
Log.d(TAG, "setupCoilLogger: Activated DEBUG_LOGGER FOR COIL")
|
||||
} else {
|
||||
eventListener(object : EventListener() {
|
||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||
super.onError(request, result)
|
||||
Log.e(TAG, "Image load error: ${result.throwable.message ?: result.throwable}")
|
||||
Log.e(TAG, " URL: ${request.data}")
|
||||
Log.e(TAG, " allowHardware: ${request.allowHardware}")
|
||||
Log.e(TAG, " hardware: ${Build.HARDWARE}, board: ${Build.BOARD}")
|
||||
Log.e(TAG, "Error loading image: ${result.throwable}")
|
||||
}
|
||||
})
|
||||
Log.d(TAG, "setupCoilLogger: Activated EVENT_LISTENER FOR COIL")
|
||||
}
|
||||
}
|
||||
|
||||
/** coil's built in loader attached w/ global synchronized instance **/
|
||||
/** we use coil's built in loader with our global synchronized instance, this way we achieve
|
||||
latest and complete functionality as well as stability **/
|
||||
private fun ImageView.loadImageInternal(
|
||||
imageData: Any?,
|
||||
headers: Map<String, String>? = null,
|
||||
builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations
|
||||
) {
|
||||
// clear image to avoid loading & flickering issue at fast scrolling (~recycler view/lazy column)
|
||||
// clear image to avoid loading & flickering issue at fast scrolling (e.g, an image recycler)
|
||||
this.dispose()
|
||||
if (imageData == null) return
|
||||
|
||||
if(imageData == null) return // Just in case
|
||||
|
||||
// setImageResource is better than coil3 on resources due to attr
|
||||
if (imageData is Int) {
|
||||
this.setImageResource(imageData); return
|
||||
if(imageData is Int) {
|
||||
this.setImageResource(imageData)
|
||||
return
|
||||
}
|
||||
// headers can be overridden by extensions.
|
||||
|
||||
// Use Coil's built-in load method but with our custom module & a decent USER-AGENT always
|
||||
// which can be overridden by extensions.
|
||||
this.load(imageData, SingletonImageLoader.get(context)) {
|
||||
this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder ->
|
||||
headerBuilder["User-Agent"] = USER_AGENT
|
||||
|
|
@ -111,22 +105,11 @@ object ImageLoader {
|
|||
headerBuilder[key] = value
|
||||
}
|
||||
}.build())
|
||||
|
||||
builder() // if passed
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasPotentialBrokenHardware(): Boolean {
|
||||
val hardware = Build.HARDWARE?.lowercase() ?: ""
|
||||
val board = Build.BOARD?.lowercase() ?: ""
|
||||
val model = Build.MODEL?.lowercase() ?: ""
|
||||
val manufacturer = Build.MANUFACTURER?.lowercase() ?: ""
|
||||
val allwinnerPatterns = listOf("sun50iw9", "h713", "allwinner", "sunxi")
|
||||
val problematicModels =
|
||||
listOf("hy320", "hy300", "a10plus", "magcubic", "sinoy", "android tv box")
|
||||
return allwinnerPatterns.any { it in hardware || it in board || it in manufacturer } ||
|
||||
problematicModels.any { it in model }
|
||||
}
|
||||
|
||||
/** TYPE_SAFE_LOADERS **/
|
||||
fun ImageView.loadImage(
|
||||
imageData: UiImage?,
|
||||
|
|
@ -155,6 +138,12 @@ object ImageLoader {
|
|||
builder: ImageRequest.Builder.() -> Unit = {}
|
||||
) = loadImageInternal(imageData = imageData, headers = headers, builder = builder)
|
||||
|
||||
fun ImageView.loadImage(
|
||||
imageData: HttpUrl?,
|
||||
headers: Map<String, String>? = null,
|
||||
builder: ImageRequest.Builder.() -> Unit = {}
|
||||
) = loadImageInternal(imageData = imageData, headers = headers, builder = builder)
|
||||
|
||||
fun ImageView.loadImage(
|
||||
imageData: File?,
|
||||
builder: ImageRequest.Builder.() -> Unit = {}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import com.lagradost.cloudstream3.services.PackageInstallerService
|
|||
import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.GitInfo.currentCommitHash
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okio.BufferedSink
|
||||
|
|
@ -93,9 +92,9 @@ object InAppUpdater {
|
|||
private suspend fun Activity.getReleaseUpdate(): Update {
|
||||
val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases"
|
||||
val headers = mapOf("Accept" to "application/vnd.github.v3+json")
|
||||
val response = parseJson<Array<GithubRelease>>(
|
||||
val response = parseJson<List<GithubRelease>>(
|
||||
app.get(url, headers = headers).text
|
||||
).toList()
|
||||
)
|
||||
|
||||
val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""")
|
||||
val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""")
|
||||
|
|
@ -103,7 +102,9 @@ object InAppUpdater {
|
|||
!rel.prerelease
|
||||
}.sortedWith(compareBy { release ->
|
||||
release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 ->
|
||||
versionRegex.find(it1)?.groupValues?.let {
|
||||
versionRegex.find(
|
||||
it1
|
||||
)?.groupValues?.let {
|
||||
it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt()
|
||||
}
|
||||
}
|
||||
|
|
@ -148,9 +149,9 @@ object InAppUpdater {
|
|||
"https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release"
|
||||
val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases"
|
||||
val headers = mapOf("Accept" to "application/vnd.github.v3+json")
|
||||
val response = parseJson<Array<GithubRelease>>(
|
||||
val response = parseJson<List<GithubRelease>>(
|
||||
app.get(releaseUrl, headers = headers).text
|
||||
).toList()
|
||||
)
|
||||
|
||||
val found = response.lastOrNull { rel ->
|
||||
rel.prerelease || rel.tagName == "pre-release"
|
||||
|
|
@ -169,7 +170,7 @@ object InAppUpdater {
|
|||
Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash")
|
||||
|
||||
return Update(
|
||||
currentCommitHash() != updateCommitHash,
|
||||
getString(R.string.commit_hash) != updateCommitHash,
|
||||
foundAsset.browserDownloadUrl,
|
||||
updateCommitHash,
|
||||
found.body,
|
||||
|
|
@ -306,7 +307,7 @@ object InAppUpdater {
|
|||
}
|
||||
|
||||
val currentInstaller = settingsManager.getInt(
|
||||
getString(R.string.apk_installer_key), 1
|
||||
getString(R.string.apk_installer_key), 0
|
||||
)
|
||||
|
||||
when (currentInstaller) {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ object SubtitleUtils {
|
|||
cleanDisplay: String
|
||||
): Boolean {
|
||||
// Check if the file has a valid subtitle extension
|
||||
val hasValidExtension = allowedExtensions.any { name.endsWith(it, ignoreCase = true) }
|
||||
val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) }
|
||||
|
||||
// We can't have the exact same file as a subtitle
|
||||
val isNotDisplayName = !name.equals(display, ignoreCase = true)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import com.lagradost.cloudstream3.APIHolder.apis
|
|||
//import com.lagradost.cloudstream3.animeproviders.AniflixProvider
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object SyncUtil {
|
||||
|
|
@ -71,7 +71,7 @@ object SyncUtil {
|
|||
val url =
|
||||
"https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json"
|
||||
val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text
|
||||
val mapped = tryParseJson<MalSyncPage?>(response)
|
||||
val mapped = parseJson<MalSyncPage?>(response)
|
||||
|
||||
val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId
|
||||
val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id
|
||||
|
|
@ -96,10 +96,12 @@ object SyncUtil {
|
|||
.mapNotNull { it.url }.toMutableList()
|
||||
|
||||
if (type == "anilist") { // TODO MAKE BETTER
|
||||
synchronized(apis) {
|
||||
apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach {
|
||||
current.add("${it.mainUrl}/anime/$id")
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package com.lagradost.cloudstream3.utils
|
|||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import kotlinx.coroutines.*
|
||||
import org.junit.Assert
|
||||
import kotlin.random.Random
|
||||
|
||||
object TestingUtils {
|
||||
|
||||
open class TestResult(val success: Boolean) {
|
||||
companion object {
|
||||
val Pass = TestResult(true)
|
||||
|
|
@ -49,10 +49,6 @@ object TestingUtils {
|
|||
}
|
||||
}
|
||||
|
||||
private fun fail(message: String): Nothing = throw AssertionError(message)
|
||||
private fun assertTrue(message: String, condition: Boolean) { if (!condition) fail(message) }
|
||||
private fun assertNotNull(message: String, value: Any?) { if (value == null) fail(message) }
|
||||
|
||||
class TestResultList(val results: List<SearchResponse>) : TestResult(true)
|
||||
class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true)
|
||||
|
||||
|
|
@ -91,7 +87,7 @@ object TestingUtils {
|
|||
} catch (e: Throwable) {
|
||||
when (e) {
|
||||
is NotImplementedError -> {
|
||||
fail("Provider marked as hasMainPage, while in reality is has not been implemented")
|
||||
Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
|
||||
}
|
||||
|
||||
is CancellationException -> {
|
||||
|
|
@ -119,7 +115,7 @@ object TestingUtils {
|
|||
api.search(query, 1)?.items?.takeIf { it.isNotEmpty() }
|
||||
} catch (e: Throwable) {
|
||||
if (e is NotImplementedError) {
|
||||
fail("Provider has not implemented search()")
|
||||
Assert.fail("Provider has not implemented search()")
|
||||
} else if (e is CancellationException) {
|
||||
throw e
|
||||
}
|
||||
|
|
@ -129,7 +125,7 @@ object TestingUtils {
|
|||
}
|
||||
|
||||
return if (searchResults.isNullOrEmpty()) {
|
||||
fail("Api ${api.name} did not return any search responses")
|
||||
Assert.fail("Api ${api.name} did not return any search responses")
|
||||
TestResult.Fail // Should not be reached
|
||||
} else {
|
||||
TestResultList(searchResults)
|
||||
|
|
@ -220,7 +216,7 @@ object TestingUtils {
|
|||
// return TestResult(validResults)
|
||||
} catch (e: Throwable) {
|
||||
if (e is NotImplementedError) {
|
||||
fail("Provider has not implemented load()")
|
||||
Assert.fail("Provider has not implemented load()")
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
|
@ -232,14 +228,14 @@ object TestingUtils {
|
|||
url: String?,
|
||||
logger: Logger
|
||||
): TestResult {
|
||||
assertNotNull("Api ${api.name} has invalid url on episode", url)
|
||||
Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
|
||||
if (url == null) return TestResult.Fail // Should never trigger
|
||||
|
||||
var linksLoaded = 0
|
||||
try {
|
||||
val success = api.loadLinks(url, false, {}) { link ->
|
||||
logger.log("Video loaded: ${link.name}")
|
||||
assertTrue(
|
||||
Assert.assertTrue(
|
||||
"Api ${api.name} returns link with invalid url ${link.url}",
|
||||
link.url.length > 4
|
||||
)
|
||||
|
|
@ -249,12 +245,12 @@ object TestingUtils {
|
|||
logger.log("Links loaded: $linksLoaded")
|
||||
return TestResult(linksLoaded > 0)
|
||||
} else {
|
||||
fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded")
|
||||
Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
when (e) {
|
||||
is NotImplementedError -> {
|
||||
fail("Provider has not implemented loadLinks()")
|
||||
Assert.fail("Provider has not implemented loadLinks()")
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
|
@ -280,7 +276,7 @@ object TestingUtils {
|
|||
|
||||
// Test Homepage
|
||||
val homepage = testHomepage(api, logger)
|
||||
assertTrue("Homepage failed to load", homepage.success)
|
||||
Assert.assertTrue("Homepage failed to load", homepage.success)
|
||||
val homePageList = (homepage as? TestResultList)?.results ?: emptyList()
|
||||
|
||||
// Test Search Results
|
||||
|
|
@ -291,7 +287,7 @@ object TestingUtils {
|
|||
listOf("over", "iron", "guy")).take(3)
|
||||
|
||||
val searchResults = testSearch(api, searchQueries, logger)
|
||||
assertTrue("Failed to get search results", searchResults.success)
|
||||
Assert.assertTrue("Failed to get search results", searchResults.success)
|
||||
searchResults as TestResultList
|
||||
|
||||
// Test Load and LoadLinks
|
||||
|
|
|
|||
|
|
@ -259,12 +259,10 @@ object UIHelper {
|
|||
}
|
||||
|
||||
// Open activities from an activity outside the nav graph
|
||||
fun Context.openActivity(activity: Class<*>, args: Bundle? = null, baseIntent: Intent? = null) {
|
||||
fun Context.openActivity(activity: Class<*>, args: Bundle? = null) {
|
||||
val tag = "NavComponent"
|
||||
try {
|
||||
val intent = baseIntent ?: Intent()
|
||||
intent.setClass(this, activity)
|
||||
|
||||
val intent = Intent(this, activity)
|
||||
if (args != null) {
|
||||
intent.putExtras(args)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import androidx.core.net.toUri
|
|||
import androidx.preference.PreferenceManager
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||
import com.lagradost.cloudstream3.BuildConfig
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey
|
||||
|
|
@ -183,13 +182,6 @@ object VideoDownloadManager {
|
|||
/** the process failed due to some reason, so we retry and also try the next mirror */
|
||||
private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false)
|
||||
|
||||
/** The download only downloaded partial */
|
||||
private val DOWNLOAD_PARTIAL_SUCCESS =
|
||||
DownloadStatus(retrySame = true, tryNext = false, success = true)
|
||||
|
||||
/** 50MB minimum size */
|
||||
const val DOWNLOAD_PARTIAL_MIN_SIZE = 1_048_576L * 50L
|
||||
|
||||
/** bad config, skip all mirrors as every call to download will have the same bad config */
|
||||
private val DOWNLOAD_BAD_CONFIG =
|
||||
DownloadStatus(retrySame = false, tryNext = false, success = false)
|
||||
|
|
@ -531,7 +523,6 @@ object VideoDownloadManager {
|
|||
/** This class handles the notifications, as well as the relevant key */
|
||||
data class DownloadMetaData(
|
||||
private val id: Int?,
|
||||
private val linkHash : Int,
|
||||
var bytesDownloaded: Long = 0,
|
||||
var bytesWritten: Long = 0,
|
||||
|
||||
|
|
@ -543,7 +534,7 @@ object VideoDownloadManager {
|
|||
private val createNotificationCallback: (CreateNotificationMetadata) -> Unit,
|
||||
|
||||
private var internalType: DownloadType = DownloadType.IsPending,
|
||||
val isHLS : Boolean,
|
||||
|
||||
// how many segments that we have downloaded
|
||||
var hlsProgress: Int = 0,
|
||||
// how many segments that exist
|
||||
|
|
@ -561,17 +552,13 @@ object VideoDownloadManager {
|
|||
lastDownloadedBytes = length
|
||||
}
|
||||
|
||||
/** Returns the appropriate failed status based on download progress */
|
||||
fun failedStatus() = if (this.bytesWritten > DOWNLOAD_PARTIAL_MIN_SIZE)
|
||||
DOWNLOAD_PARTIAL_SUCCESS
|
||||
else
|
||||
DOWNLOAD_FAILED
|
||||
|
||||
val approxTotalBytes: Long
|
||||
get() = totalBytes ?: hlsTotal?.let { total ->
|
||||
(bytesDownloaded * (total / hlsProgress.toFloat())).toLong()
|
||||
} ?: bytesDownloaded
|
||||
|
||||
private val isHLS get() = hlsTotal != null
|
||||
|
||||
private var stopListener: (() -> Unit)? = null
|
||||
|
||||
/** on cancel button pressed or failed invoke this once and only once */
|
||||
|
|
@ -606,32 +593,11 @@ object VideoDownloadManager {
|
|||
private fun updateFileInfo() {
|
||||
if (id == null) return
|
||||
downloadFileInfoTemplate?.let { template ->
|
||||
/** This looks strange, but fixes an issue where we do an instant retry, and it fails immediately,
|
||||
* eg. by turning off wifi */
|
||||
val totalBytesValue = if (approxTotalBytes <= bytesDownloaded) {
|
||||
val prevInfo = getKey<DownloadedFileInfo>(
|
||||
KEY_DOWNLOAD_INFO,
|
||||
id.toString()
|
||||
)
|
||||
|
||||
/** If this link is the same as the last cached video link metadata */
|
||||
if (prevInfo != null && prevInfo.linkHash == linkHash) {
|
||||
/** Try to use totalBytes if it exists, otherwise the max of the prev data,
|
||||
* and download size to ensure total >= downloaded */
|
||||
totalBytes ?: maxOf(prevInfo.totalBytes, bytesDownloaded)
|
||||
} else {
|
||||
approxTotalBytes
|
||||
}
|
||||
} else {
|
||||
approxTotalBytes
|
||||
}
|
||||
|
||||
setKey(
|
||||
KEY_DOWNLOAD_INFO,
|
||||
id.toString(),
|
||||
template.copy(
|
||||
linkHash = linkHash,
|
||||
totalBytes = totalBytesValue,
|
||||
totalBytes = approxTotalBytes,
|
||||
extraInfo = if (isHLS) hlsWrittenProgress.toString() else null
|
||||
)
|
||||
)
|
||||
|
|
@ -804,7 +770,6 @@ object VideoDownloadManager {
|
|||
private suspend fun resolve(
|
||||
startByte: Long,
|
||||
endByte: Long?,
|
||||
buffer: ByteArray,
|
||||
callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit)
|
||||
): Long = withContext(Dispatchers.IO) {
|
||||
var currentByte: Long = startByte
|
||||
|
|
@ -823,6 +788,7 @@ object VideoDownloadManager {
|
|||
)
|
||||
val requestStream = request.body.byteStream()
|
||||
|
||||
val buffer = ByteArray(bufferSize)
|
||||
var read: Int
|
||||
|
||||
try {
|
||||
|
|
@ -853,7 +819,6 @@ object VideoDownloadManager {
|
|||
suspend fun resolveSafe(
|
||||
index: Int,
|
||||
retries: Int = 3,
|
||||
buffer: ByteArray,
|
||||
callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit)
|
||||
): Boolean {
|
||||
var start = chuckStartByte.getOrNull(index) ?: return false
|
||||
|
|
@ -862,7 +827,7 @@ object VideoDownloadManager {
|
|||
for (i in 0 until retries) {
|
||||
try {
|
||||
// in case
|
||||
start = resolve(start, end, buffer, callback)
|
||||
start = resolve(start, end, callback)
|
||||
// no end defined, so we don't care exactly where it ended
|
||||
if (end == null) return true
|
||||
// we have download more or exactly what we needed
|
||||
|
|
@ -1017,8 +982,6 @@ object VideoDownloadManager {
|
|||
bytesDownloaded = 0,
|
||||
createNotificationCallback = createNotificationCallback,
|
||||
id = parentId,
|
||||
linkHash = link.url.hashCode(),
|
||||
isHLS = false
|
||||
)
|
||||
try {
|
||||
// get the file path
|
||||
|
|
@ -1040,7 +1003,14 @@ object VideoDownloadManager {
|
|||
startByte = stream.startAt,
|
||||
headers = link.headers.appendAndDontOverride(
|
||||
mapOf(
|
||||
"Accept-Encoding" to "identity",
|
||||
"accept" to "*/*",
|
||||
"user-agent" to USER_AGENT,
|
||||
"sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"",
|
||||
"sec-fetch-mode" to "navigate",
|
||||
"sec-fetch-dest" to "video",
|
||||
"sec-fetch-user" to "?1",
|
||||
"sec-ch-ua-mobile" to "?0",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -1159,29 +1129,13 @@ object VideoDownloadManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Reuse a download buffer to decrease unnecessary alloc
|
||||
val buffer = ByteArray(items.bufferSize)
|
||||
|
||||
// This will take up the first available job and resolve
|
||||
// this will take up the first available job and resolve
|
||||
while (true) {
|
||||
if (!isActive) return@launch
|
||||
|
||||
var isTooFarAhead = false
|
||||
fileMutex.withLock {
|
||||
if (metadata.type == DownloadType.IsStopped
|
||||
|| metadata.type == DownloadType.IsFailed
|
||||
) return@launch
|
||||
|
||||
// Limit RAM usage by throttling if too much data is downloaded but not yet written to disk
|
||||
// 50MB limit
|
||||
if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) {
|
||||
isTooFarAhead = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isTooFarAhead) {
|
||||
delay(500)
|
||||
continue
|
||||
}
|
||||
|
||||
// mutex just in case, we never want this to fail due to multithreading
|
||||
|
|
@ -1192,7 +1146,7 @@ object VideoDownloadManager {
|
|||
|
||||
// in case something has gone wrong set to failed if the fail is not caused by
|
||||
// user cancellation
|
||||
if (!items.resolveSafe(index, buffer = buffer, callback = callback)) {
|
||||
if (!items.resolveSafe(index, callback = callback)) {
|
||||
fileMutex.withLock {
|
||||
if (metadata.type != DownloadType.IsStopped) {
|
||||
metadata.type = DownloadType.IsFailed
|
||||
|
|
@ -1217,7 +1171,7 @@ object VideoDownloadManager {
|
|||
if (!stream.exists) metadata.type = DownloadType.IsStopped
|
||||
|
||||
if (metadata.type == DownloadType.IsFailed) {
|
||||
return@withContext metadata.failedStatus()
|
||||
return@withContext DOWNLOAD_FAILED
|
||||
}
|
||||
|
||||
if (metadata.type == DownloadType.IsStopped) {
|
||||
|
|
@ -1247,11 +1201,11 @@ object VideoDownloadManager {
|
|||
throw e
|
||||
} catch (t: Throwable) {
|
||||
// some sort of network error, will error
|
||||
logError(t)
|
||||
|
||||
// note that when failing we don't want to delete the file,
|
||||
// only user interaction has that power
|
||||
metadata.type = DownloadType.IsFailed
|
||||
return@withContext metadata.failedStatus()
|
||||
return@withContext DOWNLOAD_FAILED
|
||||
} finally {
|
||||
fileStream?.closeQuietly()
|
||||
//requestStream?.closeQuietly()
|
||||
|
|
@ -1273,9 +1227,7 @@ object VideoDownloadManager {
|
|||
|
||||
val metadata = DownloadMetaData(
|
||||
createNotificationCallback = createNotificationCallback,
|
||||
id = parentId,
|
||||
linkHash = link.url.hashCode(),
|
||||
isHLS = true
|
||||
id = parentId
|
||||
)
|
||||
var fileStream: OutputStream? = null
|
||||
try {
|
||||
|
|
@ -1313,6 +1265,8 @@ object VideoDownloadManager {
|
|||
val m3u8 = M3u8Helper.M3u8Stream(
|
||||
link.url, link.quality, link.headers.appendAndDontOverride(
|
||||
mapOf(
|
||||
"Accept-Encoding" to "identity",
|
||||
"accept" to "*/*",
|
||||
"user-agent" to USER_AGENT,
|
||||
) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap()
|
||||
)
|
||||
|
|
@ -1350,23 +1304,10 @@ object VideoDownloadManager {
|
|||
launch(Dispatchers.IO) {
|
||||
while (true) {
|
||||
if (!isActive) return@launch
|
||||
|
||||
var isTooFarAhead = false
|
||||
fileMutex.withLock {
|
||||
if (metadata.type == DownloadType.IsStopped
|
||||
|| metadata.type == DownloadType.IsFailed
|
||||
) return@launch
|
||||
|
||||
// Limit RAM usage by throttling if too much data is downloaded but not yet written to disk
|
||||
// 50MB limit
|
||||
if (metadata.bytesDownloaded - metadata.bytesWritten > 50_000_000) {
|
||||
isTooFarAhead = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isTooFarAhead) {
|
||||
delay(500)
|
||||
continue
|
||||
}
|
||||
|
||||
// mutex just in case, we never want this to fail due to multithreading
|
||||
|
|
@ -1444,7 +1385,7 @@ object VideoDownloadManager {
|
|||
if (!stream.exists) metadata.type = DownloadType.IsStopped
|
||||
|
||||
if (metadata.type == DownloadType.IsFailed) {
|
||||
return@withContext metadata.failedStatus()
|
||||
return@withContext DOWNLOAD_FAILED
|
||||
}
|
||||
|
||||
if (metadata.type == DownloadType.IsStopped) {
|
||||
|
|
@ -1460,7 +1401,7 @@ object VideoDownloadManager {
|
|||
} catch (t: Throwable) {
|
||||
logError(t)
|
||||
metadata.type = DownloadType.IsFailed
|
||||
return@withContext metadata.failedStatus()
|
||||
return@withContext DOWNLOAD_FAILED
|
||||
} finally {
|
||||
fileStream?.closeQuietly()
|
||||
metadata.close()
|
||||
|
|
@ -1755,10 +1696,6 @@ object VideoDownloadManager {
|
|||
companion object {
|
||||
private fun displayNotification(context: Context, id: Int, notification: Notification) {
|
||||
safe {
|
||||
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) return@safe
|
||||
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(DOWNLOAD_NOTIFICATION_TAG, id, notification)
|
||||
}
|
||||
|
|
@ -2030,8 +1967,6 @@ object VideoDownloadManager {
|
|||
|
||||
linkLoadingJob = ioSafe {
|
||||
generator.generateLinks(
|
||||
offset = 0,
|
||||
isCasting = false,
|
||||
clearCache = false,
|
||||
sourceTypes = LOADTYPE_INAPP_DOWNLOAD,
|
||||
callback = {
|
||||
|
|
@ -2048,8 +1983,7 @@ object VideoDownloadManager {
|
|||
linkLoadingJob?.join()
|
||||
|
||||
// Remove link loading notification
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id)
|
||||
NotificationManagerCompat.from(context).cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id)
|
||||
|
||||
if (linkLoadingJob?.isCancelled == true) {
|
||||
// Same as if no links, but no toast.
|
||||
|
|
@ -2075,10 +2009,8 @@ object VideoDownloadManager {
|
|||
}
|
||||
|
||||
// Profiles should always contain a download type
|
||||
val profile = QualityDataHelper.getProfiles().first {
|
||||
it.types.contains(
|
||||
QualityDataHelper.QualityProfileType.Download
|
||||
)
|
||||
val profile = QualityDataHelper.getProfiles().first { it.types.contains(
|
||||
QualityDataHelper.QualityProfileType.Download)
|
||||
}
|
||||
|
||||
val sortedLinks = currentLinks.sortedBy { link ->
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue