Compare commits

..

1 commit

Author SHA1 Message Date
Osten
6bf20a1ade
Fixed shared pool, closes #2082 2026-03-08 01:18:04 +01:00
279 changed files with 7751 additions and 11971 deletions

View file

@ -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:

View file

@ -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
View 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'

View file

@ -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

View file

@ -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"

View file

@ -11,9 +11,6 @@ concurrency:
group: "locale"
cancel-in-progress: true
permissions:
contents: read
jobs:
create:
runs-on: ubuntu-latest

View file

@ -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

View file

@ -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>

View file

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

View file

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

View file

@ -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"

View file

@ -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)
}
}
}

View file

@ -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 {

View file

@ -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())

View file

@ -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(

View file

@ -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

View file

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

View file

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

View file

@ -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
)
)
}

View file

@ -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) }
}

View file

@ -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)
/**

View file

@ -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

View file

@ -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

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -97,7 +97,7 @@ class SubscriptionWorkManager(val context: Context, workerParams: WorkerParamete
.build()
)
}
@Suppress("DEPRECATION_ERROR")
override suspend fun doWork(): Result {
try {
// println("Update subscriptions!")

View file

@ -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() {

View file

@ -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

View file

@ -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

View file

@ -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) {
}
}
}

View file

@ -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()
)
}

View file

@ -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(),
)
@ -85,7 +84,7 @@ class AniListApi : SyncAPI() {
}
override suspend fun search(auth : AuthData?, query: String): List<SyncAPI.SyncSearchResult>? {
val data = searchShows(query) ?: return null
val data = searchShows(name) ?: return null
return data.data?.page?.media?.map {
SyncAPI.SyncSearchResult(
it.title.romaji ?: return null,

View file

@ -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) {

View file

@ -100,7 +100,7 @@ class MALApi : SyncAPI() {
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",

View file

@ -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() }
}

View file

@ -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

View file

@ -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
)
}

View file

@ -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
}

View file

@ -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 }) }
)
)
}

View file

@ -349,8 +349,7 @@ class DownloadFragment : BaseFragment<FragmentDownloadsBinding>(
listOf(BasicLink(url)),
extract = true,
refererUrl = referer,
id = url.hashCode()
), 0
)
)
)
dialog.dismissSafe(activity)

View file

@ -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

View file

@ -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) {

View file

@ -651,6 +651,7 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(
}
homeMasterAdapter = HomeParentItemAdapterPreview(
fragment = this@HomeFragment,
homeViewModel, accountViewModel
)
homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool)

View file

@ -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" }
}
}
}

View file

@ -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 =

View file

@ -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,

View file

@ -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
}
}

View file

@ -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 {
}
}

View file

@ -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)

View file

@ -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

View file

@ -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() {

View file

@ -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>,

View file

@ -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
}

View file

@ -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>

View file

@ -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>,

View file

@ -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
)
}
}

View file

@ -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 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) {
episodeIndex = index
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
}
}
}
}
}
}

View file

@ -128,7 +128,7 @@ object PlayerPipHelper {
getRemoteAction(
activity,
R.drawable.baseline_headphones_24,
R.string.audio_singular,
R.string.audio_singluar,
CSPlayerEvent.PlayAsAudio
)
)

View file

@ -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 (01). */
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)
}
}

View file

@ -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)) {

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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)

View file

@ -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())

View file

@ -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 nextMirror() {}
override fun onConfigurationChanged(newConfig: Configuration) {
@ -82,16 +49,16 @@ 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)
}
}
@ -102,8 +69,13 @@ class ResultTrailerPlayer : ResultFragmentPhone() {
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()
@ -146,11 +123,6 @@ class ResultTrailerPlayer : ResultFragmentPhone() {
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()
}
}
}

View file

@ -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()),
@ -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,
@ -2459,14 +2435,7 @@ class ResultViewModel2 : ViewModel() {
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

View file

@ -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)

View file

@ -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(

View file

@ -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) {

View file

@ -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
}
}

View file

@ -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,

View file

@ -111,10 +111,10 @@ class SettingsProviders : BasePreferenceFragmentCompat() {
getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener {
activity?.getApiProviderLangSettings()?.let { currentLangTags ->
val languagesTagName = APIHolder.apis.withLock {
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 ->

View file

@ -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(),

View file

@ -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)

View file

@ -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()
)

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -36,10 +36,10 @@ class SetupFragmentProviderLanguage : BaseFragment<FragmentSetupProviderLanguage
val currentLangTags = ctx.getApiProviderLangSettings()
val languagesTagName = APIHolder.apis.withLock {
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 ->

View 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
)
}

View file

@ -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 {

View file

@ -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)
}

View file

@ -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,

View file

@ -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

View file

@ -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 {

View file

@ -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) {
""
}
}

View file

@ -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
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 = {}

View file

@ -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) {

View file

@ -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)

View file

@ -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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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