Merge pull request #1 from CranberrySoup/sync

Update remote-sync
This commit is contained in:
CranberrySoup 2023-09-22 11:30:46 +00:00 committed by GitHub
commit f8603a7874
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
352 changed files with 18863 additions and 9766 deletions

View file

@ -32,10 +32,10 @@ jobs:
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/cloudstream-archive" repository: "recloudstream/cloudstream-archive"
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up JDK 11 - name: Set up JDK 17
uses: actions/setup-java@v2 uses: actions/setup-java@v2
with: with:
java-version: '11' java-version: '17'
distribution: 'adopt' distribution: 'adopt'
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
@ -56,6 +56,8 @@ jobs:
SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
repository: "recloudstream/cloudstream-archive" repository: "recloudstream/cloudstream-archive"

View file

@ -42,10 +42,10 @@ jobs:
cd $GITHUB_WORKSPACE/dokka/ cd $GITHUB_WORKSPACE/dokka/
rm -rf "./-cloudstream" rm -rf "./-cloudstream"
- name: Setup JDK 11 - name: Setup JDK 17
uses: actions/setup-java@v1 uses: actions/setup-java@v1
with: with:
java-version: 11 java-version: 17
- name: Setup Android SDK - name: Setup Android SDK
uses: android-actions/setup-android@v2 uses: android-actions/setup-android@v2

View file

@ -24,10 +24,10 @@ jobs:
private_key: ${{ secrets.GH_APP_KEY }} private_key: ${{ secrets.GH_APP_KEY }}
repository: "recloudstream/secrets" repository: "recloudstream/secrets"
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up JDK 11 - name: Set up JDK 17
uses: actions/setup-java@v2 uses: actions/setup-java@v2
with: with:
java-version: '11' java-version: '17'
distribution: 'adopt' distribution: 'adopt'
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
@ -48,6 +48,8 @@ jobs:
SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_ALIAS: "key0"
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
- name: Create pre-release - name: Create pre-release
uses: "marvinpinto/action-automatic-releases@latest" uses: "marvinpinto/action-automatic-releases@latest"
with: with:

View file

@ -7,10 +7,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up JDK 11 - name: Set up JDK 17
uses: actions/setup-java@v2 uses: actions/setup-java@v2
with: with:
java-version: '11' java-version: '17'
distribution: 'adopt' distribution: 'adopt'
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew

2
.idea/compiler.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" /> <bytecodeTargetLevel target="17" />
</component> </component>
</project> </project>

1
.idea/gradle.xml generated
View file

@ -8,7 +8,6 @@
<option name="testRunner" value="GRADLE" /> <option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="11" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

View file

@ -9,8 +9,9 @@
+ **AdFree**, No ads whatsoever + **AdFree**, No ads whatsoever
+ No tracking/analytics + No tracking/analytics
+ Bookmarks + Bookmarks
+ Download and stream movies, tv-shows and anime + Phone and TV support
+ Chromecast + Chromecast
+ Extension system for personal customization
### Supported languages: ### Supported languages:
<a href="https://hosted.weblate.org/engage/cloudstream/"> <a href="https://hosted.weblate.org/engage/cloudstream/">

6
app/CMakeLists.txt Normal file
View file

@ -0,0 +1,6 @@
# Set this to the minimum version your project supports.
cmake_minimum_required(VERSION 3.18)
project(CrashHandler)
find_library(log-lib log)
add_library(native-lib SHARED src/main/cpp/native-lib.cpp)
target_link_libraries(native-lib ${log-lib})

View file

@ -1,4 +1,4 @@
import com.android.build.gradle.api.BaseVariantOutput import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.DokkaTask import org.jetbrains.dokka.gradle.DokkaTask
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.net.URL import java.net.URL
@ -9,7 +9,6 @@ plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
id("kotlin-kapt") id("kotlin-kapt")
id("kotlin-android-extensions")
id("org.jetbrains.dokka") id("org.jetbrains.dokka")
} }
@ -39,6 +38,18 @@ android {
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
} }
viewBinding {
enable = true
}
// disable this for now
//externalNativeBuild {
// cmake {
// path("CMakeLists.txt")
// }
//}
signingConfigs { signingConfigs {
create("prerelease") { create("prerelease") {
if (prereleaseStoreFile != null) { if (prereleaseStoreFile != null) {
@ -51,28 +62,38 @@ android {
} }
compileSdk = 33 compileSdk = 33
buildToolsVersion = "30.0.3" buildToolsVersion = "34.0.0"
defaultConfig { defaultConfig {
applicationId = "com.lagradost.cloudstream3" applicationId = "com.lagradost.cloudstream3"
minSdk = 21 minSdk = 21
targetSdk = 33 targetSdk = 33
versionCode = 59 versionCode = 62
versionName = "4.0.1" versionName = "4.2.0"
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
resValue("bool", "is_prerelease", "false") resValue("bool", "is_prerelease", "false")
// Reads local.properties
val localProperties = gradleLocalProperties(rootDir)
buildConfigField( buildConfigField(
"String", "String",
"BUILDDATE", "BUILDDATE",
"new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));" "new java.text.SimpleDateFormat(\"yyyy-MM-dd HH:mm\").format(new java.util.Date(" + System.currentTimeMillis() + "L));"
) )
buildConfigField(
"String",
"SIMKL_CLIENT_ID",
"\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\""
)
buildConfigField(
"String",
"SIMKL_CLIENT_SECRET",
"\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
kapt { kapt {
@ -85,7 +106,10 @@ android {
isDebuggable = false isDebuggable = false
isMinifyEnabled = false isMinifyEnabled = false
isShrinkResources = false isShrinkResources = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
debug { debug {
isDebuggable = true isDebuggable = true
@ -94,7 +118,6 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
resValue( resValue(
"string", "string",
"debug_gdrive_secret", "debug_gdrive_secret",
@ -123,6 +146,11 @@ android {
versionCode = (System.currentTimeMillis() / 60000).toInt() versionCode = (System.currentTimeMillis() / 60000).toInt()
} }
} }
//toolchain {
// languageVersion.set(JavaLanguageVersion.of(17))
// }
// jvmToolchain(17)
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
@ -146,25 +174,26 @@ repositories {
dependencies { dependencies {
implementation("com.google.android.mediahome:video:1.0.0") implementation("com.google.android.mediahome:video:1.0.0")
implementation("androidx.test.ext:junit-ktx:1.1.3") implementation("androidx.test.ext:junit-ktx:1.1.5")
testImplementation("org.json:json:20180813") testImplementation("org.json:json:20180813")
implementation("androidx.core:core-ktx:1.8.0") implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.appcompat:appcompat:1.4.2") // need target 32 for 1.5.0 implementation("androidx.appcompat:appcompat:1.6.1") // need target 32 for 1.5.0
// dont change this to 1.6.0 it looks ugly af // dont change this to 1.6.0 it looks ugly af
implementation("com.google.android.material:material:1.5.0") implementation("com.google.android.material:material:1.5.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.navigation:navigation-fragment-ktx:2.5.1") implementation("androidx.navigation:navigation-fragment-ktx:2.6.0")
implementation("androidx.navigation:navigation-ui-ktx:2.5.1") implementation("androidx.navigation:navigation-ui-ktx:2.6.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1") implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.test:core")
//implementation("io.karn:khttp-android:0.1.2") //okhttp instead //implementation("io.karn:khttp-android:0.1.2") //okhttp instead
// implementation("org.jsoup:jsoup:1.13.1") // implementation("org.jsoup:jsoup:1.13.1")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1")
implementation("androidx.preference:preference-ktx:1.2.0") implementation("androidx.preference:preference-ktx:1.2.0")
@ -179,19 +208,23 @@ dependencies {
// implementation("androidx.leanback:leanback-paging:1.1.0-alpha09") // implementation("androidx.leanback:leanback-paging:1.1.0-alpha09")
// Exoplayer // Media 3
implementation("com.google.android.exoplayer:exoplayer:2.18.2") implementation("androidx.media3:media3-common:1.1.1")
implementation("com.google.android.exoplayer:extension-cast:2.18.2") implementation("androidx.media3:media3-exoplayer:1.1.1")
implementation("com.google.android.exoplayer:extension-mediasession:2.18.2") implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
implementation("com.google.android.exoplayer:extension-okhttp:2.18.2") implementation("androidx.media3:media3-ui:1.1.1")
// Use the Jellyfin ffmpeg extension for easy ffmpeg audio decoding in exoplayer. Thank you Jellyfin <3 implementation("androidx.media3:media3-session:1.1.1")
// implementation("org.jellyfin.exoplayer:exoplayer-ffmpeg-extension:2.18.2+1") implementation("androidx.media3:media3-cast:1.1.1")
implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
// Custom ffmpeg extension for audio codecs
implementation("com.github.recloudstream:media-ffmpeg:1.1.0")
//implementation("com.google.android.exoplayer:extension-leanback:2.14.0") //implementation("com.google.android.exoplayer:extension-leanback:2.14.0")
// Bug reports // Bug reports
implementation("ch.acra:acra-core:5.8.4") implementation("ch.acra:acra-core:5.11.0")
implementation("ch.acra:acra-toast:5.8.4") implementation("ch.acra:acra-toast:5.11.0")
compileOnly("com.google.auto.service:auto-service-annotations:1.0") compileOnly("com.google.auto.service:auto-service-annotations:1.0")
//either for java sources: //either for java sources:
@ -211,24 +244,24 @@ dependencies {
//implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0") //implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
// Downloading // Downloading
implementation("androidx.work:work-runtime:2.8.0") implementation("androidx.work:work-runtime:2.8.1")
implementation("androidx.work:work-runtime-ktx:2.8.0") implementation("androidx.work:work-runtime-ktx:2.8.1")
// Networking // Networking
// implementation("com.squareup.okhttp3:okhttp:4.9.2") // implementation("com.squareup.okhttp3:okhttp:4.9.2")
// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1") // implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
implementation("com.github.Blatzar:NiceHttp:0.4.2") implementation("com.github.Blatzar:NiceHttp:0.4.3")
// To fix SSL fuckery on android 9 // To fix SSL fuckery on android 9
implementation("org.conscrypt:conscrypt-android:2.2.1") implementation("org.conscrypt:conscrypt-android:2.2.1")
// Util to skip the URI file fuckery 🙏 // Util to skip the URI file fuckery 🙏
implementation("com.github.tachiyomiorg:unifile:17bec43") implementation("com.github.LagradOst:SafeFile:0.0.5")
// API because cba maintaining it myself // API because cba maintaining it myself
implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0") implementation("com.uwetrottmann.tmdb2:tmdb-java:2.6.0")
implementation("com.github.discord:OverlappingPanels:0.1.3") implementation("com.github.discord:OverlappingPanels:0.1.5")
// debugImplementation because LeakCanary should only run in debug builds. // debugImplementation because LeakCanary should only run in debug builds.
// debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' //debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
// for shimmer when loading // for shimmer when loading
implementation("com.facebook.shimmer:shimmer:0.5.0") implementation("com.facebook.shimmer:shimmer:0.5.0")
@ -238,17 +271,15 @@ dependencies {
// used for subtitle decoding https://github.com/albfernandez/juniversalchardet // used for subtitle decoding https://github.com/albfernandez/juniversalchardet
implementation("com.github.albfernandez:juniversalchardet:2.4.0") implementation("com.github.albfernandez:juniversalchardet:2.4.0")
// slow af yt // newpipe yt taken from https://github.com/TeamNewPipe/NewPipeExtractor/commits/dev
//implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT") // this should be updated frequently to avoid trailer fu*kery
implementation("com.github.teamnewpipe:NewPipeExtractor:1f08d28")
// newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L204
implementation("com.github.TeamNewPipe:NewPipeExtractor:master-SNAPSHOT")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6")
// Library/extensions searching with Levenshtein distance // Library/extensions searching with Levenshtein distance
implementation("me.xdrop:fuzzywuzzy:1.4.0") implementation("me.xdrop:fuzzywuzzy:1.4.0")
// color pallette for images -> colors // color palette for images -> colors
implementation("androidx.palette:palette-ktx:1.0.0") implementation("androidx.palette:palette-ktx:1.0.0")
implementation("org.skyscreamer:jsonassert:1.2.3") implementation("org.skyscreamer:jsonassert:1.2.3")

View file

@ -1,6 +1,30 @@
package com.lagradost.cloudstream3 package com.lagradost.cloudstream3
import android.app.Activity
import android.os.Bundle
import android.os.PersistableBundle
import android.view.LayoutInflater
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding
import com.lagradost.cloudstream3.databinding.FragmentResultBinding
import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding
import com.lagradost.cloudstream3.databinding.FragmentSearchBinding
import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
import com.lagradost.cloudstream3.databinding.HomepageParentBinding
import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding
import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding
import com.lagradost.cloudstream3.databinding.RepositoryItemBinding
import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding
import com.lagradost.cloudstream3.databinding.SearchResultGridBinding
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.TestingUtils import com.lagradost.cloudstream3.utils.TestingUtils
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -8,16 +32,23 @@ import org.junit.Assert
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *
* See [testing documentation](http://d.android.com/tools/testing). * See [testing documentation](http://d.android.com/tools/testing).
*/ */
class TestApplication : Activity() {
override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
super.onCreate(savedInstanceState, persistentState)
}
}
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest { class ExampleInstrumentedTest {
private fun getAllProviders(): List<MainAPI> { private fun getAllProviders(): Array<MainAPI> {
println("Providers: ${APIHolder.allProviders.size}") println("Providers: ${APIHolder.allProviders.size}")
return APIHolder.allProviders //.filter { !it.usesWebView } return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView }
} }
@Test @Test
@ -26,6 +57,73 @@ class ExampleInstrumentedTest {
println("Done providersExist") println("Done providersExist")
} }
@Throws
private inline fun <reified T : ViewBinding> testAllLayouts(
activity: Activity,
vararg layouts: Int
) {
val bind = T::class.java.methods.first { it.name == "bind" }
val inflater = LayoutInflater.from(activity)
for (layout in layouts) {
val root = inflater.inflate(layout, null, false)
bind.invoke(null, root)
}
}
@Test
@Throws
fun layoutTest() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity: MainActivity ->
// FragmentHomeHeadBinding and FragmentHomeHeadTvBinding CANT be the same
//testAllLayouts<FragmentHomeHeadBinding>(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv)
//testAllLayouts<FragmentHomeHeadTvBinding>(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv)
// main cant be tested
// testAllLayouts<ActivityMainTvBinding>(activity,R.layout.activity_main, R.layout.activity_main_tv)
// testAllLayouts<ActivityMainBinding>(activity,R.layout.activity_main, R.layout.activity_main_tv)
//testAllLayouts<ActivityMainBinding>(activity, R.layout.activity_main_tv)
testAllLayouts<FragmentPlayerBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
testAllLayouts<FragmentPlayerTvBinding>(activity, R.layout.fragment_player,R.layout.fragment_player_tv)
// testAllLayouts<FragmentResultBinding>(activity, R.layout.fragment_result,R.layout.fragment_result_tv)
// testAllLayouts<FragmentResultTvBinding>(activity, R.layout.fragment_result,R.layout.fragment_result_tv)
testAllLayouts<PlayerCustomLayoutBinding>(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
testAllLayouts<PlayerCustomLayoutTvBinding>(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
testAllLayouts<TrailerCustomLayoutBinding>(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout)
testAllLayouts<RepositoryItemBinding>(activity, R.layout.repository_item_tv, R.layout.repository_item)
testAllLayouts<RepositoryItemTvBinding>(activity, R.layout.repository_item_tv, R.layout.repository_item)
testAllLayouts<RepositoryItemBinding>(activity, R.layout.repository_item_tv, R.layout.repository_item)
testAllLayouts<RepositoryItemTvBinding>(activity, R.layout.repository_item_tv, R.layout.repository_item)
testAllLayouts<FragmentHomeBinding>(activity, R.layout.fragment_home_tv, R.layout.fragment_home)
testAllLayouts<FragmentHomeTvBinding>(activity, R.layout.fragment_home_tv, R.layout.fragment_home)
testAllLayouts<FragmentSearchBinding>(activity, R.layout.fragment_search_tv, R.layout.fragment_search)
testAllLayouts<FragmentSearchTvBinding>(activity, R.layout.fragment_search_tv, R.layout.fragment_search)
testAllLayouts<HomeResultGridBinding>(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid)
//testAllLayouts<HomeResultGridExpandedBinding>(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) ??? fails ???
testAllLayouts<SearchResultGridExpandedBinding>(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded)
testAllLayouts<SearchResultGridBinding>(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded)
// testAllLayouts<HomeScrollViewBinding>(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
// testAllLayouts<HomeScrollViewTvBinding>(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv)
testAllLayouts<HomepageParentTvBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent)
testAllLayouts<HomepageParentBinding>(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent)
}
}
}
@Test @Test
@Throws(AssertionError::class) @Throws(AssertionError::class)
fun providerCorrectData() { fun providerCorrectData() {
@ -49,7 +147,7 @@ class ExampleInstrumentedTest {
@Test @Test
fun providerCorrectHomepage() { fun providerCorrectHomepage() {
runBlocking { runBlocking {
getAllProviders().amap { api -> getAllProviders().toList().amap { api ->
TestingUtils.testHomepage(api, ::println) TestingUtils.testHomepage(api, ::println)
} }
} }

View file

@ -15,7 +15,7 @@
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <!-- Used for updates without prompt --> <uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" /> <!-- Used for updates without prompt -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Used for update service --> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- Used for update service -->
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> &lt;!&ndash; Used for getting if vlc is installed &ndash;&gt; --> <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <!-- Required for getting arbitrary Aniyomi packages -->
<!-- Fixes android tv fuckery --> <!-- Fixes android tv fuckery -->
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"

View file

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

View file

@ -43,16 +43,16 @@ class CustomReportSender : ReportSender {
override fun send(context: Context, errorContent: CrashReportData) { override fun send(context: Context, errorContent: CrashReportData) {
println("Sending report") println("Sending report")
val url = val url =
"https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse" "https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
val data = mapOf( val data = mapOf(
"entry.753293084" to errorContent.toJSON() "entry.1993829403" to errorContent.toJSON()
) )
thread { // to not run it on main thread thread { // to not run it on main thread
runBlocking { runBlocking {
suspendSafeApiCall { suspendSafeApiCall {
val post = app.post(url, data = data) app.post(url, data = data)
println("Report response: $post") //println("Report response: $post")
} }
} }
} }
@ -104,12 +104,17 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
} }
class AcraApplication : Application() { class AcraApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) { //NativeCrashHandler.initCrashHandler()
ExceptionHandler(filesDir.resolve("last_error")) {
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
startActivity(Intent.makeRestartActivityTask(intent!!.component)) startActivity(Intent.makeRestartActivityTask(intent!!.component))
}) }.also {
exceptionHandler = it
Thread.setDefaultUncaughtExceptionHandler(it)
}
} }
override fun attachBaseContext(base: Context?) { override fun attachBaseContext(base: Context?) {
@ -121,10 +126,10 @@ class AcraApplication : Application() {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java
reportFormat = StringFormat.JSON reportFormat = StringFormat.JSON
reportContent = arrayOf( reportContent = listOf(
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE, ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL, ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
ReportField.STACK_TRACE ReportField.STACK_TRACE,
) )
// removed this due to bug when starting the app, moved it to when it actually crashes // removed this due to bug when starting the app, moved it to when it actually crashes
@ -137,6 +142,8 @@ class AcraApplication : Application() {
} }
companion object { companion object {
var exceptionHandler: ExceptionHandler? = null
/** Use to get activity from Context */ /** Use to get activity from Context */
tailrec fun Context.getActivity(): Activity? = this as? Activity tailrec fun Context.getActivity(): Activity? = this as? Activity
?: (this as? ContextWrapper)?.baseContext?.getActivity() ?: (this as? ContextWrapper)?.baseContext?.getActivity()
@ -148,6 +155,14 @@ class AcraApplication : Application() {
_context = WeakReference(value) _context = WeakReference(value)
} }
fun <T : Any> getKeyClass(path: String, valueType: Class<T>): T? {
return context?.getKey(path, valueType)
}
fun <T : Any> setKeyClass(path: String, value: T) {
context?.setKey(path, value)
}
fun removeKeys(folder: String): Int? { fun removeKeys(folder: String): Int? {
return context?.removeKeys(folder) return context?.removeKeys(folder)
} }
@ -203,6 +218,5 @@ class AcraApplication : Application() {
activity?.supportFragmentManager?.fragments?.lastOrNull() activity?.supportFragmentManager?.fragments?.lastOrNull()
) )
} }
} }
} }

View file

@ -7,8 +7,14 @@ import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Resources import android.content.res.Resources
import android.os.Build import android.os.Build
import android.util.DisplayMetrics
import android.util.Log import android.util.Log
import android.view.* import android.view.Gravity
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.View.NO_ID
import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@ -18,8 +24,11 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.CastSession
import com.google.android.material.chip.ChipGroup
import com.google.android.material.navigationrail.NavigationRailView
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
@ -27,6 +36,7 @@ import com.lagradost.cloudstream3.ui.player.PlayerEventType
import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.ui.result.UiText import com.lagradost.cloudstream3.ui.result.UiText
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.updateTv
import com.lagradost.cloudstream3.utils.AppUtils.isRtl
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper
@ -34,14 +44,45 @@ import com.lagradost.cloudstream3.utils.UIHelper.hasPIPPermission
import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode import com.lagradost.cloudstream3.utils.UIHelper.shouldShowPIPMode
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.NewPipe
import java.util.* import java.lang.ref.WeakReference
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
enum class FocusDirection {
Start,
End,
Up,
Down,
}
object CommonActivity { object CommonActivity {
private var _activity: WeakReference<Activity>? = null
var activity
get() = _activity?.get()
private set(value) {
_activity = WeakReference(value)
}
@MainThread @MainThread
fun Activity?.getCastSession(): CastSession? { fun Activity?.getCastSession(): CastSession? {
return (this as MainActivity?)?.mSessionManager?.currentCastSession return (this as MainActivity?)?.mSessionManager?.currentCastSession
} }
val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
// screenWidth and screenHeight does always
// refer to the screen while in landscape mode
val screenWidth: Int
get() {
return max(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
val screenHeight: Int
get() {
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
var canEnterPipMode: Boolean = false var canEnterPipMode: Boolean = false
var canShowPipMode: Boolean = false var canShowPipMode: Boolean = false
@ -56,6 +97,30 @@ object CommonActivity {
var currentToast: Toast? = null var currentToast: Toast? = null
fun showToast(@StringRes message: Int, duration: Int? = null) {
val act = activity ?: return
act.runOnUiThread {
showToast(act, act.getString(message), duration)
}
}
fun showToast(message: String?, duration: Int? = null) {
val act = activity ?: return
act.runOnUiThread {
showToast(act, message, duration)
}
}
fun showToast(message: UiText?, duration: Int? = null) {
val act = activity ?: return
if (message == null) return
act.runOnUiThread {
showToast(act, message.asString(act), duration)
}
}
@MainThread
fun showToast(act: Activity?, text: UiText, duration: Int) { fun showToast(act: Activity?, text: UiText, duration: Int) {
if (act == null) return if (act == null) return
text.asStringNull(act)?.let { text.asStringNull(act)?.let {
@ -140,6 +205,7 @@ object CommonActivity {
fun init(act: ComponentActivity?) { fun init(act: ComponentActivity?) {
if (act == null) return if (act == null) return
activity = act
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission //https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
//https://developer.android.com/guide/topics/ui/picture-in-picture //https://developer.android.com/guide/topics/ui/picture-in-picture
canShowPipMode = canShowPipMode =
@ -222,6 +288,7 @@ object CommonActivity {
"AmoledLight" -> R.style.AmoledModeLight "AmoledLight" -> R.style.AmoledModeLight
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.MonetMode else R.style.AppTheme R.style.MonetMode else R.style.AppTheme
else -> R.style.AppTheme else -> R.style.AppTheme
} }
@ -244,8 +311,10 @@ object CommonActivity {
"Pink" -> R.style.OverlayPrimaryColorPink "Pink" -> R.style.OverlayPrimaryColorPink
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
"Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal
else -> R.style.OverlayPrimaryColorNormal else -> R.style.OverlayPrimaryColorNormal
} }
act.theme.applyStyle(currentTheme, true) act.theme.applyStyle(currentTheme, true)
@ -257,55 +326,138 @@ object CommonActivity {
) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW ) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW
} }
private fun getNextFocus( /** because we want closes find, aka when multiple have the same id, we go to parent
act: Activity?, until the correct one is found */
private fun localLook(from: View, id: Int): View? {
if (id == NO_ID) return null
var currentLook: View = from
// limit to 15 look depth
for (i in 0..15) {
currentLook.findViewById<View?>(id)?.let { return it }
currentLook = (currentLook.parent as? View) ?: break
}
return null
}
/*var currentLook: View = view
while (true) {
val tmpNext = currentLook.findViewById<View?>(nextId)
if (tmpNext != null) {
next = tmpNext
break
}
currentLook = currentLook.parent as? View ?: break
}*/
private fun View.hasContent() : Boolean {
return isShown && when(this) {
//is RecyclerView -> this.childCount > 0
is ViewGroup -> this.childCount > 0
else -> true
}
}
/** skips the initial stage of searching for an id using the view, see getNextFocus for specification */
fun continueGetNextFocus(
root: Any?,
view: View,
direction: FocusDirection,
nextId: Int,
depth: Int = 0
): View? {
if (nextId == NO_ID) return null
// do an initial search for the view, in case the localLook is too deep we can use this as
// an early break and backup view
var next =
when (root) {
is Activity -> root.findViewById(nextId)
is View -> root.rootView.findViewById<View?>(nextId)
else -> null
} ?: return null
next = localLook(view, nextId) ?: next
val shown = next.hasContent()
// if cant focus but visible then break and let android decide
// the exception if is the view is a parent and has children that wants focus
val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent ->
parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.childCount > 0
} ?: false
if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null
// if not shown then continue because we will "skip" over views to get to a replacement
if (!shown) {
// we don't want a while true loop, so we let android decide if we find a recursive view
if (next == view) return null
return getNextFocus(root, next, direction, depth + 1)
}
(when (next) {
is ChipGroup -> {
next.children.firstOrNull { it.isFocusable && it.isShown }
}
is NavigationRailView -> {
next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home)
}
else -> null
})?.let {
return it
}
// nothing wrong with the view found, return it
return next
}
/** recursively looks for a next focus up to a depth of 10,
* this is used to override the normal shit focus system
* because this application has a lot of invisible views that messes with some tv devices*/
fun getNextFocus(
root: Any?,
view: View?, view: View?,
direction: FocusDirection, direction: FocusDirection,
depth: Int = 0 depth: Int = 0
): Int? { ): View? {
if (view == null || depth >= 10 || act == null) { // if input is invalid let android decide + depth test to not crash if loop is found
if (view == null || depth >= 10 || root == null) {
return null return null
} }
val nextId = when (direction) { var nextId = when (direction) {
FocusDirection.Left -> { FocusDirection.Start -> {
view.nextFocusLeftId if (view.isRtl())
view.nextFocusRightId
else
view.nextFocusLeftId
} }
FocusDirection.Up -> { FocusDirection.Up -> {
view.nextFocusUpId view.nextFocusUpId
} }
FocusDirection.Right -> {
view.nextFocusRightId FocusDirection.End -> {
if (view.isRtl())
view.nextFocusLeftId
else
view.nextFocusRightId
} }
FocusDirection.Down -> { FocusDirection.Down -> {
view.nextFocusDownId view.nextFocusDownId
} }
} }
return if (nextId != -1) { if (nextId == NO_ID) {
val next = act.findViewById<View?>(nextId) // if not specified then use forward id
//println("NAME: ${next.accessibilityClassName} | ${next?.isShown}" ) nextId = view.nextFocusForwardId
// if view is still not found to next focus then return and let android decide
if (next?.isShown == false) { if (nextId == NO_ID)
getNextFocus(act, next, direction, depth + 1) return null
} else {
if (depth == 0) {
null
} else {
nextId
}
}
} else {
null
} }
return continueGetNextFocus(root, view, direction, nextId, depth)
} }
enum class FocusDirection {
Left,
Right,
Up,
Down,
}
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) { fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
//println("Keycode: $keyCode") //println("Keycode: $keyCode")
@ -328,30 +480,39 @@ object CommonActivity {
KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
PlayerEventType.SeekForward PlayerEventType.SeekForward
} }
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
PlayerEventType.SeekBack PlayerEventType.SeekBack
} }
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> { KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
PlayerEventType.NextEpisode PlayerEventType.NextEpisode
} }
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> { KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> {
PlayerEventType.PrevEpisode PlayerEventType.PrevEpisode
} }
KeyEvent.KEYCODE_MEDIA_PAUSE -> { KeyEvent.KEYCODE_MEDIA_PAUSE -> {
PlayerEventType.Pause PlayerEventType.Pause
} }
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
PlayerEventType.Play PlayerEventType.Play
} }
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
PlayerEventType.Lock PlayerEventType.Lock
} }
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> { KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
PlayerEventType.ToggleHide PlayerEventType.ToggleHide
} }
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
PlayerEventType.ToggleMute PlayerEventType.ToggleMute
} }
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
PlayerEventType.ShowMirrors PlayerEventType.ShowMirrors
} }
@ -359,21 +520,27 @@ object CommonActivity {
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
PlayerEventType.SearchSubtitlesOnline PlayerEventType.SearchSubtitlesOnline
} }
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
PlayerEventType.ShowSpeed PlayerEventType.ShowSpeed
} }
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
PlayerEventType.Resize PlayerEventType.Resize
} }
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
PlayerEventType.SkipOp PlayerEventType.SkipOp
} }
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
PlayerEventType.SkipCurrentChapter 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 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 PlayerEventType.PlayPauseToggle
} }
else -> null else -> null
}?.let { playerEvent -> }?.let { playerEvent ->
playerEventListener?.invoke(playerEvent) playerEventListener?.invoke(playerEvent)
@ -386,64 +553,64 @@ object CommonActivity {
//} //}
} }
/** overrides focus and custom key events */
fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? { fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? {
if (act == null) return null if (act == null) return null
val currentFocus = act.currentFocus
event?.keyCode?.let { keyCode -> event?.keyCode?.let { keyCode ->
when (event.action) { if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let
KeyEvent.ACTION_DOWN -> { val nextView = when (keyCode) {
if (act.currentFocus != null) { KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
val next = when (keyCode) { act,
KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus( currentFocus,
act, FocusDirection.Start
act.currentFocus, )
FocusDirection.Left
)
KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
act,
act.currentFocus,
FocusDirection.Right
)
KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
act,
act.currentFocus,
FocusDirection.Up
)
KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
act,
act.currentFocus,
FocusDirection.Down
)
else -> null KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
} act,
currentFocus,
FocusDirection.End
)
if (next != null && next != -1) { KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
val nextView = act.findViewById<View?>(next) act,
if (nextView != null) { currentFocus,
nextView.requestFocus() FocusDirection.Up
keyEventListener?.invoke(Pair(event, true)) )
return true
}
}
when (keyCode) { KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
KeyEvent.KEYCODE_DPAD_CENTER -> { act,
if (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) { currentFocus,
UIHelper.showInputMethod(act.currentFocus?.findFocus()) FocusDirection.Down
} )
}
} else -> null
}
//println("Keycode: $keyCode")
//showToast(
// this,
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG
//)
}
} }
// println("NEXT FOCUS : $nextView")
if (nextView != null) {
nextView.requestFocus()
keyEventListener?.invoke(Pair(event, true))
return true
}
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER &&
(act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete)
) {
UIHelper.showInputMethod(act.currentFocus?.findFocus())
}
//println("Keycode: $keyCode")
//showToast(
// this,
// "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}",
// Toast.LENGTH_LONG
//)
} }
// if someone else want to override the focus then don't handle the event as it is already
// consumed. used in video player
if (keyEventListener?.invoke(Pair(event, false)) == true) { if (keyEventListener?.invoke(Pair(event, false)) == true) {
return true return true
} }

View file

@ -50,7 +50,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
companion object { companion object {
private const val USER_AGENT = private const val USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
private var instance: DownloaderTestImpl? = null private var instance: DownloaderTestImpl? = null
/** /**

View file

@ -11,22 +11,27 @@ import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.Coroutines.mainWork
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.nicehttp.RequestBodyTypes
import okhttp3.Interceptor import okhttp3.Interceptor
import org.mozilla.javascript.Scriptable import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
const val USER_AGENT = const val USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
//val baseHeader = mapOf("User-Agent" to USER_AGENT) //val baseHeader = mapOf("User-Agent" to USER_AGENT)
val mapper = JsonMapper.builder().addModule(KotlinModule()) val mapper = JsonMapper.builder().addModule(KotlinModule())
@ -50,8 +55,10 @@ object APIHolder {
val allProviders = threadSafeListOf<MainAPI>() val allProviders = threadSafeListOf<MainAPI>()
fun initAll() { fun initAll() {
for (api in allProviders) { synchronized(allProviders) {
api.init() for (api in allProviders) {
api.init()
}
} }
apiMap = null apiMap = null
} }
@ -64,27 +71,35 @@ object APIHolder {
var apiMap: Map<String, Int>? = null var apiMap: Map<String, Int>? = null
fun addPluginMapping(plugin: MainAPI) { fun addPluginMapping(plugin: MainAPI) {
apis = apis + plugin synchronized(apis) {
apis = apis + plugin
}
initMap(true) initMap(true)
} }
fun removePluginMapping(plugin: MainAPI) { fun removePluginMapping(plugin: MainAPI) {
apis = apis.filter { it != plugin } synchronized(apis) {
apis = apis.filter { it != plugin }
}
initMap(true) initMap(true)
} }
private fun initMap(forcedUpdate: Boolean = false) { private fun initMap(forcedUpdate: Boolean = false) {
if (apiMap == null || forcedUpdate) synchronized(apis) {
apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap() if (apiMap == null || forcedUpdate)
apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap()
}
} }
fun getApiFromNameNull(apiName: String?): MainAPI? { fun getApiFromNameNull(apiName: String?): MainAPI? {
if (apiName == null) return null if (apiName == null) return null
synchronized(allProviders) { synchronized(allProviders) {
initMap() initMap()
return apiMap?.get(apiName)?.let { apis.getOrNull(it) } synchronized(apis) {
// Leave the ?. null check, it can crash regardless return apiMap?.get(apiName)?.let { apis.getOrNull(it) }
?: allProviders.firstOrNull { it.name == apiName } // Leave the ?. null check, it can crash regardless
?: allProviders.firstOrNull { it.name == apiName }
}
} }
} }
@ -164,10 +179,17 @@ object APIHolder {
private var trackerCache: HashMap<String, AniSearch> = hashMapOf() private var trackerCache: HashMap<String, AniSearch> = hashMapOf()
/** backwards compatibility, use getTracker4 instead */
suspend fun getTracker(
titles: List<String>,
types: Set<TrackerType>?,
year: Int?,
): Tracker? = getTracker(titles, types, year, false)
/** /**
* Get anime tracker information based on title, year and type. * Get anime tracker information based on title, year and type.
* Both titles are attempted to be matched with both Romaji and English title. * Both titles are attempted to be matched with both Romaji and English title.
* Uses the consumet api. * Uses the anilist api.
* *
* @param titles uses first index to search, but if you have multiple titles and want extra guarantee to match you can also have that * @param titles uses first index to search, but if you have multiple titles and want extra guarantee to match you can also have that
* @param types Optional parameter to narrow down the scope to Movies, TV, etc. See TrackerType.getTypes() * @param types Optional parameter to narrow down the scope to Movies, TV, etc. See TrackerType.getTypes()
@ -176,7 +198,8 @@ object APIHolder {
suspend fun getTracker( suspend fun getTracker(
titles: List<String>, titles: List<String>,
types: Set<TrackerType>?, types: Set<TrackerType>?,
year: Int? year: Int?,
lessAccurate: Boolean
): Tracker? { ): Tracker? {
return try { return try {
require(titles.isNotEmpty()) { "titles must no be empty when calling getTracker" } require(titles.isNotEmpty()) { "titles must no be empty when calling getTracker" }
@ -184,30 +207,70 @@ object APIHolder {
val mainTitle = titles[0] val mainTitle = titles[0]
val search = val search =
trackerCache[mainTitle] trackerCache[mainTitle]
?: app.get("https://api.consumet.org/meta/anilist/$mainTitle") ?: searchAnilist(mainTitle)?.also {
.parsedSafe<AniSearch>()?.also { trackerCache[mainTitle] = it
trackerCache[mainTitle] = it } ?: return null
} ?: return null
val res = search.results?.find { media -> val res = search.data?.page?.media?.find { media ->
val matchingYears = year == null || media.releaseDate == year val matchingYears = year == null || media.seasonYear == year
val matchingTitles = media.title?.let { title -> val matchingTitles = media.title?.let { title ->
titles.any { userTitle -> titles.any { userTitle ->
title.isMatchingTitles(userTitle) title.isMatchingTitles(userTitle)
} }
} ?: false } ?: false
val matchingTypes = types?.any { it.name.equals(media.type, true) } == true val matchingTypes = types?.any { it.name.equals(media.format, true) } == true
matchingTitles && matchingTypes && matchingYears if(lessAccurate) matchingTitles || matchingTypes && matchingYears else matchingTitles && matchingTypes && matchingYears
} ?: return null } ?: return null
Tracker(res.malId, res.aniId, res.image, res.cover) Tracker(res.idMal, res.id.toString(), res.coverImage?.extraLarge ?: res.coverImage?.large, res.bannerImage)
} catch (t: Throwable) { } catch (t: Throwable) {
logError(t) logError(t)
null null
} }
} }
private suspend fun searchAnilist(
title: String?,
): AniSearch? {
val query = """
query (
${'$'}page: Int = 1
${'$'}search: String
${'$'}sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC]
${'$'}type: MediaType
) {
Page(page: ${'$'}page, perPage: 20) {
media(
search: ${'$'}search
sort: ${'$'}sort
type: ${'$'}type
) {
id
idMal
title { romaji english }
coverImage { extraLarge large }
bannerImage
seasonYear
format
}
}
}
""".trimIndent().trim()
val data = mapOf(
"query" to query,
"variables" to mapOf(
"search" to title,
"sort" to "SEARCH_MATCH",
"type" to "ANIME",
)
).toJson().toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull())
return app.post("https://graphql.anilist.co", requestBody = data)
.parsedSafe()
}
fun Context.getApiSettings(): HashSet<String> { fun Context.getApiSettings(): HashSet<String> {
//val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
@ -215,7 +278,7 @@ object APIHolder {
val hashSet = HashSet<String>() val hashSet = HashSet<String>()
val activeLangs = getApiProviderLangSettings() val activeLangs = getApiProviderLangSettings()
val hasUniversal = activeLangs.contains(AllLanguagesName) val hasUniversal = activeLangs.contains(AllLanguagesName)
hashSet.addAll(apis.filter { hasUniversal || activeLangs.contains(it.lang) } hashSet.addAll(synchronized(apis) { apis.filter { hasUniversal || activeLangs.contains(it.lang) } }
.map { it.name }) .map { it.name })
/*val set = settingsManager.getStringSet( /*val set = settingsManager.getStringSet(
@ -314,8 +377,9 @@ object APIHolder {
} ?: default } ?: default
val langs = this.getApiProviderLangSettings() val langs = this.getApiProviderLangSettings()
val hasUniversal = langs.contains(AllLanguagesName) val hasUniversal = langs.contains(AllLanguagesName)
val allApis = apis.filter { hasUniversal || langs.contains(it.lang) } val allApis = synchronized(apis) {
.filter { api -> api.hasMainPage || !hasHomePageIsRequired } apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) }
}
return if (currentPrefMedia.isEmpty()) { return if (currentPrefMedia.isEmpty()) {
allApis allApis
} else { } else {
@ -736,6 +800,7 @@ fun fixTitle(str: String): String {
.replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it } .replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else it }
} }
} }
/** /**
* Get rhino context in a safe way as it needs to be initialized on the main thread. * Get rhino context in a safe way as it needs to be initialized on the main thread.
* Make sure you get the scope using: val scope: Scriptable = rhino.initSafeStandardObjects() * Make sure you get the scope using: val scope: Scriptable = rhino.initSafeStandardObjects()
@ -801,6 +866,19 @@ enum class TvType(value: Int?) {
Others(12) Others(12)
} }
public enum class AutoDownloadMode(val value: Int) {
Disable(0),
FilterByLang(1),
All(2),
NsfwOnly(3)
;
companion object {
infix fun getEnum(value: Int): AutoDownloadMode? =
AutoDownloadMode.values().firstOrNull { it.value == value }
}
}
// IN CASE OF FUTURE ANIME MOVIE OR SMTH // IN CASE OF FUTURE ANIME MOVIE OR SMTH
fun TvType.isMovieType(): Boolean { fun TvType.isMovieType(): Boolean {
return this == TvType.Movie || this == TvType.AnimeMovie || this == TvType.Torrent || this == TvType.Live return this == TvType.Movie || this == TvType.AnimeMovie || this == TvType.Torrent || this == TvType.Live
@ -1119,10 +1197,11 @@ interface LoadResponse {
companion object { companion object {
private val malIdPrefix = malApi.idPrefix private val malIdPrefix = malApi.idPrefix
private val aniListIdPrefix = aniListApi.idPrefix private val aniListIdPrefix = aniListApi.idPrefix
private val simklIdPrefix = simklApi.idPrefix
var isTrailersEnabled = true var isTrailersEnabled = true
fun LoadResponse.isMovie(): Boolean { fun LoadResponse.isMovie(): Boolean {
return this.type.isMovieType() return this.type.isMovieType() || this is MovieLoadResponse
} }
@JvmName("addActorNames") @JvmName("addActorNames")
@ -1140,6 +1219,20 @@ interface LoadResponse {
this.actors = actors?.map { (actor, role) -> ActorData(actor, role = role) } this.actors = actors?.map { (actor, role) -> ActorData(actor, role = role) }
} }
/**
* Internal helper function to add simkl ids from other databases.
*/
private fun LoadResponse.addSimklId(
database: SimklApi.Companion.SyncServices,
id: String?
) {
normalSafeApiCall {
this.syncData[simklIdPrefix] =
SimklApi.addIdToString(this.syncData[simklIdPrefix], database, id.toString())
?: return@normalSafeApiCall
}
}
@JvmName("addActorsOnly") @JvmName("addActorsOnly")
fun LoadResponse.addActors(actors: List<Actor>?) { fun LoadResponse.addActors(actors: List<Actor>?) {
this.actors = actors?.map { actor -> ActorData(actor) } this.actors = actors?.map { actor -> ActorData(actor) }
@ -1155,10 +1248,16 @@ interface LoadResponse {
fun LoadResponse.addMalId(id: Int?) { fun LoadResponse.addMalId(id: Int?) {
this.syncData[malIdPrefix] = (id ?: return).toString() this.syncData[malIdPrefix] = (id ?: return).toString()
this.addSimklId(SimklApi.Companion.SyncServices.Mal, id.toString())
} }
fun LoadResponse.addAniListId(id: Int?) { fun LoadResponse.addAniListId(id: Int?) {
this.syncData[aniListIdPrefix] = (id ?: return).toString() this.syncData[aniListIdPrefix] = (id ?: return).toString()
this.addSimklId(SimklApi.Companion.SyncServices.AniList, id.toString())
}
fun LoadResponse.addSimklId(id: Int?) {
this.addSimklId(SimklApi.Companion.SyncServices.Simkl, id.toString())
} }
fun LoadResponse.addImdbUrl(url: String?) { fun LoadResponse.addImdbUrl(url: String?) {
@ -1240,6 +1339,7 @@ interface LoadResponse {
fun LoadResponse.addImdbId(id: String?) { fun LoadResponse.addImdbId(id: String?) {
// TODO add imdb sync // TODO add imdb sync
this.addSimklId(SimklApi.Companion.SyncServices.Imdb, id)
} }
fun LoadResponse.addTrackId(id: String?) { fun LoadResponse.addTrackId(id: String?) {
@ -1252,6 +1352,7 @@ interface LoadResponse {
fun LoadResponse.addTMDbId(id: String?) { fun LoadResponse.addTMDbId(id: String?) {
// TODO add TMDb sync // TODO add TMDb sync
this.addSimklId(SimklApi.Companion.SyncServices.Tmdb, id)
} }
fun LoadResponse.addRating(text: String?) { fun LoadResponse.addRating(text: String?) {
@ -1679,30 +1780,42 @@ data class Tracker(
val cover: String? = null, val cover: String? = null,
) )
data class Title( data class AniSearch(
@JsonProperty("romaji") val romaji: String? = null, @JsonProperty("data") var data: Data? = Data()
@JsonProperty("english") val english: String? = null,
) { ) {
fun isMatchingTitles(title: String?): Boolean { data class Data(
if (title == null) return false @JsonProperty("Page") var page: Page? = Page()
return english.equals(title, true) || romaji.equals(title, true) ) {
data class Page(
@JsonProperty("media") var media: ArrayList<Media> = arrayListOf()
) {
data class Media(
@JsonProperty("title") var title: Title? = null,
@JsonProperty("id") var id: Int? = null,
@JsonProperty("idMal") var idMal: Int? = null,
@JsonProperty("seasonYear") var seasonYear: Int? = null,
@JsonProperty("format") var format: String? = null,
@JsonProperty("coverImage") var coverImage: CoverImage? = null,
@JsonProperty("bannerImage") var bannerImage: String? = null,
) {
data class CoverImage(
@JsonProperty("extraLarge") var extraLarge: String? = null,
@JsonProperty("large") var large: String? = null,
)
data class Title(
@JsonProperty("romaji") var romaji: String? = null,
@JsonProperty("english") var english: String? = null,
) {
fun isMatchingTitles(title: String?): Boolean {
if (title == null) return false
return english.equals(title, true) || romaji.equals(title, true)
}
}
}
}
} }
} }
data class Results(
@JsonProperty("id") val aniId: String? = null,
@JsonProperty("malId") val malId: Int? = null,
@JsonProperty("title") val title: Title? = null,
@JsonProperty("releaseDate") val releaseDate: Int? = null,
@JsonProperty("type") val type: String? = null,
@JsonProperty("image") val image: String? = null,
@JsonProperty("cover") val cover: String? = null,
)
data class AniSearch(
@JsonProperty("results") val results: ArrayList<Results>? = arrayListOf()
)
/** /**
* used for the getTracker() method * used for the getTracker() method
**/ **/

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,53 @@
package com.lagradost.cloudstream3
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.plugins.PluginManager.checkSafeModeFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
object NativeCrashHandler {
// external fun triggerNativeCrash()
/*private external fun initNativeCrashHandler()
private external fun getSignalStatus(): Int
private fun initSignalPolling() = CoroutineScope(Dispatchers.IO).launch {
//launch {
// delay(10000)
// triggerNativeCrash()
//}
while (true) {
delay(10_000)
val signal = getSignalStatus()
// Signal is initialized to zero
if (signal == 0) continue
// Do not crash in safe mode!
if (lastError != null) continue
if (checkSafeModeFile()) continue
AcraApplication.exceptionHandler?.uncaughtException(
Thread.currentThread(),
RuntimeException("Native crash with code: $signal. Try uninstalling extensions.\n")
)
}
}
fun initCrashHandler() {
try {
System.loadLibrary("native-lib")
initNativeCrashHandler()
} catch (t: Throwable) {
// Make debug crash.
if (BuildConfig.DEBUG) throw t
logError(t)
return
}
initSignalPolling()
}*/
}

View file

@ -9,7 +9,7 @@ import java.net.URI
open class AsianLoad : ExtractorApi() { open class AsianLoad : ExtractorApi() {
override var name = "AsianLoad" override var name = "AsianLoad"
override var mainUrl = "https://asianembed.io" override var mainUrl = "https://asianhdplay.pro"
override val requiresReferer = true override val requiresReferer = true
private val sourceRegex = Regex("""sources:[\W\w]*?file:\s*?["'](.*?)["']""") private val sourceRegex = Regex("""sources:[\W\w]*?file:\s*?["'](.*?)["']""")

View file

@ -4,7 +4,7 @@ import com.lagradost.cloudstream3.utils.*
open class ByteShare : ExtractorApi() { open class ByteShare : ExtractorApi() {
override val name = "ByteShare" override val name = "ByteShare"
override val mainUrl = "https://byteshare.net" override val mainUrl = "https://byteshare.to"
override val requiresReferer = false override val requiresReferer = false
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> { override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {

View file

@ -1,13 +1,11 @@
package com.lagradost.cloudstream3.extractors package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 import com.lagradost.cloudstream3.utils.Qualities
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import android.util.Log
import java.net.URLDecoder import java.net.URLDecoder
open class Cda: ExtractorApi() { open class Cda: ExtractorApi() {

View file

@ -2,15 +2,17 @@ package com.lagradost.cloudstream3.extractors
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.extractors.helper.*
import com.lagradost.cloudstream3.extractors.helper.AesHelper.cryptoAESHandler
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.Qualities
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory class Moviesapi : Chillx() {
import javax.crypto.spec.IvParameterSpec override val name = "Moviesapi"
import javax.crypto.spec.PBEKeySpec override val mainUrl = "https://w1.moviesapi.club"
import javax.crypto.spec.SecretKeySpec }
class Bestx : Chillx() { class Bestx : Chillx() {
override val name = "Bestx" override val name = "Bestx"
@ -27,7 +29,7 @@ open class Chillx : ExtractorApi() {
override val requiresReferer = true override val requiresReferer = true
companion object { companion object {
private const val KEY = "4VqE3#N7zt&HEP^a" private const val KEY = "m4H6D9%0\$N&F6rQ&"
} }
override suspend fun getUrl( override suspend fun getUrl(
@ -42,10 +44,9 @@ open class Chillx : ExtractorApi() {
referer = referer referer = referer
).text ).text
)?.groupValues?.get(1) )?.groupValues?.get(1)
val encData = AppUtils.tryParseJson<AESData>(base64Decode(master ?: return)) val decrypt = cryptoAESHandler(master ?: return, KEY.toByteArray(), false)?.replace("\\", "") ?: throw ErrorLoadingException("failed to decrypt")
val decrypt = cryptoAESHandler(encData ?: return, KEY, false)
val source = Regex("""sources:\s*\[\{"file":"([^"]+)""").find(decrypt)?.groupValues?.get(1) val source = Regex(""""?file"?:\s*"([^"]+)""").find(decrypt)?.groupValues?.get(1)
val tracks = Regex("""tracks:\s*\[(.+)]""").find(decrypt)?.groupValues?.get(1) val tracks = Regex("""tracks:\s*\[(.+)]""").find(decrypt)?.groupValues?.get(1)
// required // required
@ -81,52 +82,6 @@ open class Chillx : ExtractorApi() {
} }
} }
private fun cryptoAESHandler(
data: AESData,
pass: String,
encrypt: Boolean = true
): String {
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512")
val spec = PBEKeySpec(
pass.toCharArray(),
data.salt?.hexToByteArray(),
data.iterations?.toIntOrNull() ?: 1,
256
)
val key = factory.generateSecret(spec)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
return if (!encrypt) {
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(key.encoded, "AES"),
IvParameterSpec(data.iv?.hexToByteArray())
)
String(cipher.doFinal(base64DecodeArray(data.ciphertext.toString())))
} else {
cipher.init(
Cipher.ENCRYPT_MODE,
SecretKeySpec(key.encoded, "AES"),
IvParameterSpec(data.iv?.hexToByteArray())
)
base64Encode(cipher.doFinal(data.ciphertext?.toByteArray()))
}
}
private fun String.hexToByteArray(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
data class AESData(
@JsonProperty("ciphertext") val ciphertext: String? = null,
@JsonProperty("iv") val iv: String? = null,
@JsonProperty("salt") val salt: String? = null,
@JsonProperty("iterations") val iterations: String? = null,
)
data class Tracks( data class Tracks(
@JsonProperty("file") val file: String? = null, @JsonProperty("file") val file: String? = null,
@JsonProperty("label") val label: String? = null, @JsonProperty("label") val label: String? = null,

View file

@ -7,6 +7,10 @@ import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.getQualityFromName
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
class Dooood : DoodLaExtractor() {
override var mainUrl = "https://dooood.com"
}
class DoodWfExtractor : DoodLaExtractor() { class DoodWfExtractor : DoodLaExtractor() {
override var mainUrl = "https://dood.wf" override var mainUrl = "https://dood.wf"
} }

View file

@ -5,6 +5,16 @@ import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8 import com.lagradost.cloudstream3.utils.M3u8Helper.Companion.generateM3u8
class Guccihide : Filesim() {
override val name = "Guccihide"
override var mainUrl = "https://guccihide.com"
}
class Ahvsh : Filesim() {
override val name = "Ahvsh"
override var mainUrl = "https://ahvsh.com"
}
class Moviesm4u : Filesim() { class Moviesm4u : Filesim() {
override val mainUrl = "https://moviesm4u.com" override val mainUrl = "https://moviesm4u.com"
override val name = "Moviesm4u" override val name = "Moviesm4u"
@ -15,6 +25,11 @@ class FileMoonIn : Filesim() {
override val name = "FileMoon" override val name = "FileMoon"
} }
class StreamhideTo : Filesim() {
override val mainUrl = "https://streamhide.to"
override val name = "Streamhide"
}
class StreamhideCom : Filesim() { class StreamhideCom : Filesim() {
override var name: String = "Streamhide" override var name: String = "Streamhide"
override var mainUrl: String = "https://streamhide.com" override var mainUrl: String = "https://streamhide.com"
@ -42,7 +57,7 @@ class FileMoonSx : Filesim() {
open class Filesim : ExtractorApi() { open class Filesim : ExtractorApi() {
override val name = "Filesim" override val name = "Filesim"
override val mainUrl = "https://files.im" override val mainUrl = "https://files.im"
override val requiresReferer = false override val requiresReferer = true
override suspend fun getUrl( override suspend fun getUrl(
url: String, url: String,
@ -50,27 +65,19 @@ open class Filesim : ExtractorApi() {
subtitleCallback: (SubtitleFile) -> Unit, subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit callback: (ExtractorLink) -> Unit
) { ) {
val response = app.get(url, referer = mainUrl).document val response = app.get(url, referer = referer)
response.select("script[type=text/javascript]").map { script -> val script = if (!getPacked(response.text).isNullOrEmpty()) {
if (script.data().contains(Regex("eval\\(function\\(p,a,c,k,e,[rd]"))) { getAndUnpack(response.text)
val unpackedscript = getAndUnpack(script.data()) } else {
val m3u8Regex = Regex("file.\"(.*?m3u8.*?)\"") response.document.selectFirst("script:containsData(sources:)")?.data()
val m3u8 = m3u8Regex.find(unpackedscript)?.destructured?.component1() ?: ""
if (m3u8.isNotEmpty()) {
generateM3u8(
name,
m3u8,
mainUrl
).forEach(callback)
}
}
} }
val m3u8 =
Regex("file:\\s*\"(.*?m3u8.*?)\"").find(script ?: return)?.groupValues?.getOrNull(1)
generateM3u8(
name,
m3u8 ?: return,
mainUrl
).forEach(callback)
} }
/* private data class ResponseSource(
@JsonProperty("file") val file: String,
@JsonProperty("type") val type: String?,
@JsonProperty("label") val label: String?
) */
} }

View file

@ -2,14 +2,10 @@ package com.lagradost.cloudstream3.extractors
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.extractors.helper.AesHelper.cryptoAESHandler
import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import java.security.DigestException
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class DatabaseGdrive2 : Gdriveplayer() { class DatabaseGdrive2 : Gdriveplayer() {
override var mainUrl = "https://databasegdriveplayer.co" override var mainUrl = "https://databasegdriveplayer.co"
@ -65,78 +61,6 @@ open class Gdriveplayer : ExtractorApi() {
?.data()?.let { getAndUnpack(it) } ?.data()?.let { getAndUnpack(it) }
} }
private fun String.decodeHex(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
// https://stackoverflow.com/a/41434590/8166854
private fun GenerateKeyAndIv(
password: ByteArray,
salt: ByteArray,
hashAlgorithm: String = "MD5",
keyLength: Int = 32,
ivLength: Int = 16,
iterations: Int = 1
): List<ByteArray>? {
val md = MessageDigest.getInstance(hashAlgorithm)
val digestLength = md.digestLength
val targetKeySize = keyLength + ivLength
val requiredLength = (targetKeySize + digestLength - 1) / digestLength * digestLength
val generatedData = ByteArray(requiredLength)
var generatedLength = 0
try {
md.reset()
while (generatedLength < targetKeySize) {
if (generatedLength > 0)
md.update(
generatedData,
generatedLength - digestLength,
digestLength
)
md.update(password)
md.update(salt, 0, 8)
md.digest(generatedData, generatedLength, digestLength)
for (i in 1 until iterations) {
md.update(generatedData, generatedLength, digestLength)
md.digest(generatedData, generatedLength, digestLength)
}
generatedLength += digestLength
}
return listOf(
generatedData.copyOfRange(0, keyLength),
generatedData.copyOfRange(keyLength, targetKeySize)
)
} catch (e: DigestException) {
return null
}
}
private fun cryptoAESHandler(
data: AesData,
pass: ByteArray,
encrypt: Boolean = true
): String? {
val (key, iv) = GenerateKeyAndIv(pass, data.s.decodeHex()) ?: return null
val cipher = Cipher.getInstance("AES/CBC/NoPadding")
return if (!encrypt) {
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
String(cipher.doFinal(base64DecodeArray(data.ct)))
} else {
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
base64Encode(cipher.doFinal(data.ct.toByteArray()))
}
}
private fun Regex.first(str: String): String? { private fun Regex.first(str: String): String? {
return find(str)?.groupValues?.getOrNull(1) return find(str)?.groupValues?.getOrNull(1)
} }
@ -154,14 +78,14 @@ open class Gdriveplayer : ExtractorApi() {
val document = app.get(url).document val document = app.get(url).document
val eval = unpackJs(document)?.replace("\\", "") ?: return val eval = unpackJs(document)?.replace("\\", "") ?: return
val data = tryParseJson<AesData>(Regex("data='(\\S+?)'").first(eval)) ?: return val data = Regex("data='(\\S+?)'").first(eval) ?: return
val password = Regex("null,['|\"](\\w+)['|\"]").first(eval) val password = Regex("null,['|\"](\\w+)['|\"]").first(eval)
?.split(Regex("\\D+")) ?.split(Regex("\\D+"))
?.joinToString("") { ?.joinToString("") {
Char(it.toInt()).toString() Char(it.toInt()).toString()
}.let { Regex("var pass = \"(\\S+?)\"").first(it ?: return)?.toByteArray() } }.let { Regex("var pass = \"(\\S+?)\"").first(it ?: return)?.toByteArray() }
?: throw ErrorLoadingException("can't find password") ?: throw ErrorLoadingException("can't find password")
val decryptedData = cryptoAESHandler(data, password, false)?.let { getAndUnpack(it) }?.replace("\\", "") val decryptedData = cryptoAESHandler(data, password, false, "AES/CBC/NoPadding")?.let { getAndUnpack(it) }?.replace("\\", "")
val sourceData = decryptedData?.substringAfter("sources:[")?.substringBefore("],") val sourceData = decryptedData?.substringAfter("sources:[")?.substringBefore("],")
val subData = decryptedData?.substringAfter("tracks:[")?.substringBefore("],") val subData = decryptedData?.substringAfter("tracks:[")?.substringBefore("],")
@ -194,12 +118,6 @@ open class Gdriveplayer : ExtractorApi() {
} }
data class AesData(
@JsonProperty("ct") val ct: String,
@JsonProperty("iv") val iv: String,
@JsonProperty("s") val s: String
)
data class Tracks( data class Tracks(
@JsonProperty("file") val file: String, @JsonProperty("file") val file: String,
@JsonProperty("kind") val kind: String, @JsonProperty("kind") val kind: String,

View file

@ -19,9 +19,12 @@ open class Gofile : ExtractorApi() {
subtitleCallback: (SubtitleFile) -> Unit, subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit callback: (ExtractorLink) -> Unit
) { ) {
val id = Regex("/(?:\\?c=|d/)([\\da-zA-Z]+)").find(url)?.groupValues?.get(1) val id = Regex("/(?:\\?c=|d/)([\\da-zA-Z-]+)").find(url)?.groupValues?.get(1)
val token = app.get("$mainApi/createAccount").parsedSafe<Account>()?.data?.get("token") val token = app.get("$mainApi/createAccount").parsedSafe<Account>()?.data?.get("token")
app.get("$mainApi/getContent?contentId=$id&token=$token&websiteToken=12345") val websiteToken = app.get("$mainUrl/dist/js/alljs.js").text.let {
Regex("websiteToken\\s*=\\s*\"([^\"]+)").find(it)?.groupValues?.get(1)
}
app.get("$mainApi/getContent?contentId=$id&token=$token&websiteToken=$websiteToken")
.parsedSafe<Source>()?.data?.contents?.forEach { .parsedSafe<Source>()?.data?.contents?.forEach {
callback.invoke( callback.invoke(
ExtractorLink( ExtractorLink(

View file

@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
class Neonime7n : Hxfile() { class Neonime7n : Hxfile() {
override val name = "Neonime7n" override val name = "Neonime7n"
override val mainUrl = "https://7njctn.neonime.watch" override val mainUrl = "https://neonime.fun"
override val redirect = false override val redirect = false
} }
@ -19,7 +19,7 @@ class Neonime8n : Hxfile() {
class KotakAnimeid : Hxfile() { class KotakAnimeid : Hxfile() {
override val name = "KotakAnimeid" override val name = "KotakAnimeid"
override val mainUrl = "https://kotakanimeid.com" override val mainUrl = "https://nontonanimeid.bio"
override val requiresReferer = true override val requiresReferer = true
} }

View file

@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.M3u8Helper import com.lagradost.cloudstream3.utils.M3u8Helper
class MoviehabNet : Moviehab() { class MoviehabNet : Moviehab() {
override var mainUrl = "https://play.moviehab.net" override var mainUrl = "https://play.moviehab.asia"
} }
open class Moviehab : ExtractorApi() { open class Moviehab : ExtractorApi() {

View file

@ -10,24 +10,39 @@ open class Mp4Upload : ExtractorApi() {
override var name = "Mp4Upload" override var name = "Mp4Upload"
override var mainUrl = "https://www.mp4upload.com" override var mainUrl = "https://www.mp4upload.com"
private val srcRegex = Regex("""player\.src\("(.*?)"""") private val srcRegex = Regex("""player\.src\("(.*?)"""")
override val requiresReferer = true private val srcRegex2 = Regex("""player\.src\([\w\W]*src: "(.*?)"""")
override val requiresReferer = true
private val idMatch = Regex("""mp4upload\.com/(embed-|)([A-Za-z0-9]*)""")
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? { override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink>? {
with(app.get(url)) { val realUrl = idMatch.find(url)?.groupValues?.get(2)?.let { id ->
getAndUnpack(this.text).let { unpackedText -> "$mainUrl/embed-$id.html"
val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull() } ?: url
srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link -> val response = app.get(realUrl)
return listOf( val unpackedText = getAndUnpack(response.text)
ExtractorLink( val quality =
name, unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
name, srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link ->
link, return listOf(
url, ExtractorLink(
quality ?: Qualities.Unknown.value, name,
) name,
) link,
} url,
} quality ?: Qualities.Unknown.value,
)
)
}
srcRegex2.find(unpackedText)?.groupValues?.get(1)?.let { link ->
return listOf(
ExtractorLink(
name,
name,
link,
url,
quality ?: Qualities.Unknown.value,
)
)
} }
return null return null
} }

View file

@ -9,7 +9,7 @@ import java.net.URI
open class MultiQuality : ExtractorApi() { open class MultiQuality : ExtractorApi() {
override var name = "MultiQuality" override var name = "MultiQuality"
override var mainUrl = "https://gogo-play.net" override var mainUrl = "https://anihdplay.com"
private val sourceRegex = Regex("""file:\s*['"](.*?)['"],label:\s*['"](.*?)['"]""") private val sourceRegex = Regex("""file:\s*['"](.*?)['"],label:\s*['"](.*?)['"]""")
private val m3u8Regex = Regex(""".*?(\d*).m3u8""") private val m3u8Regex = Regex(""".*?(\d*).m3u8""")
private val urlRegex = Regex("""(.*?)([^/]+$)""") private val urlRegex = Regex("""(.*?)([^/]+$)""")

View file

@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.INFER_TYPE
import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.getQualityFromName
import com.lagradost.cloudstream3.utils.loadExtractor import com.lagradost.cloudstream3.utils.loadExtractor
@ -66,7 +67,7 @@ open class Pelisplus(val mainUrl: String) {
href, href,
page.url, page.url,
getQualityFromName(qual), getQualityFromName(qual),
element.attr("href").contains(".m3u8") type = INFER_TYPE
) )
) )
} }

View file

@ -0,0 +1,30 @@
package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities
open class Pixeldrain : ExtractorApi() {
override val name = "Pixeldrain"
override val mainUrl = "https://pixeldrain.com"
override val requiresReferer = false
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val mId = Regex("/([ul]/[\\da-zA-Z\\-]+)").find(url)?.groupValues?.get(1)?.split("/")
callback.invoke(
ExtractorLink(
this.name,
this.name,
"$mainUrl/api/file/${mId?.last() ?: return}?download",
url,
Qualities.Unknown.value,
)
)
}
}

View file

@ -0,0 +1,170 @@
package com.lagradost.cloudstream3.extractors
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.base64DecodeArray
import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.M3u8Helper
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
// No License found in https://github.com/enimax-anime/key
// special credits to @enimax for providing key
class Megacloud : Rabbitstream() {
override val name = "Megacloud"
override val mainUrl = "https://megacloud.tv"
override val embed = "embed-2/ajax/e-1"
override val key = "https://raw.githubusercontent.com/enimax-anime/key/e6/key.txt"
}
class Dokicloud : Rabbitstream() {
override val name = "Dokicloud"
override val mainUrl = "https://dokicloud.one"
}
open class Rabbitstream : ExtractorApi() {
override val name = "Rabbitstream"
override val mainUrl = "https://rabbitstream.net"
override val requiresReferer = false
open val embed = "ajax/embed-4"
open val key = "https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt"
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val id = url.substringAfterLast("/").substringBefore("?")
val response = app.get(
"$mainUrl/$embed/getSources?id=$id",
referer = mainUrl,
headers = mapOf("X-Requested-With" to "XMLHttpRequest")
)
val encryptedMap = response.parsedSafe<SourcesEncrypted>()
val sources = encryptedMap?.sources
val decryptedSources = if (sources == null || encryptedMap.encrypted == false) {
response.parsedSafe()
} else {
val (key, encData) = extractRealKey(sources, getRawKey())
val decrypted = decryptMapped<List<Sources>>(encData, key)
SourcesResponses(
sources = decrypted,
tracks = encryptedMap.tracks
)
}
decryptedSources?.sources?.map { source ->
M3u8Helper.generateM3u8(
name,
source?.file ?: return@map,
"$mainUrl/",
).forEach(callback)
}
decryptedSources?.tracks?.map { track ->
subtitleCallback.invoke(
SubtitleFile(
track?.label ?: "",
track?.file ?: return@map
)
)
}
}
private suspend fun getRawKey(): String = app.get(key).text
private fun extractRealKey(originalString: String?, stops: String): Pair<String, String> {
val table = parseJson<List<List<Int>>>(stops)
val decryptedKey = StringBuilder()
var offset = 0
var encryptedString = originalString
table.forEach { (start, end) ->
decryptedKey.append(encryptedString?.substring(start - offset, end - offset))
encryptedString = encryptedString?.substring(
0,
start - offset
) + encryptedString?.substring(end - offset)
offset += end - start
}
return decryptedKey.toString() to encryptedString.toString()
}
private inline fun <reified T> decryptMapped(input: String, key: String): T? {
val decrypt = decrypt(input, key)
return AppUtils.tryParseJson(decrypt)
}
private fun decrypt(input: String, key: String): String {
return decryptSourceUrl(
generateKey(
base64DecodeArray(input).copyOfRange(8, 16),
key.toByteArray()
), input
)
}
private fun generateKey(salt: ByteArray, secret: ByteArray): ByteArray {
var key = md5(secret + salt)
var currentKey = key
while (currentKey.size < 48) {
key = md5(key + secret + salt)
currentKey += key
}
return currentKey
}
private fun md5(input: ByteArray): ByteArray {
return MessageDigest.getInstance("MD5").digest(input)
}
private fun decryptSourceUrl(decryptionKey: ByteArray, sourceUrl: String): String {
val cipherData = base64DecodeArray(sourceUrl)
val encrypted = cipherData.copyOfRange(16, cipherData.size)
val aesCBC = Cipher.getInstance("AES/CBC/PKCS5Padding")
aesCBC.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(decryptionKey.copyOfRange(0, 32), "AES"),
IvParameterSpec(decryptionKey.copyOfRange(32, decryptionKey.size))
)
val decryptedData = aesCBC?.doFinal(encrypted) ?: throw ErrorLoadingException("Cipher not found")
return String(decryptedData, StandardCharsets.UTF_8)
}
data class Tracks(
@JsonProperty("file") val file: String? = null,
@JsonProperty("label") val label: String? = null,
@JsonProperty("kind") val kind: String? = null,
)
data class Sources(
@JsonProperty("file") val file: String? = null,
@JsonProperty("type") val type: String? = null,
@JsonProperty("label") val label: String? = null,
)
data class SourcesResponses(
@JsonProperty("sources") val sources: List<Sources?>? = emptyList(),
@JsonProperty("tracks") val tracks: List<Tracks?>? = emptyList(),
)
data class SourcesEncrypted(
@JsonProperty("sources") val sources: String? = null,
@JsonProperty("encrypted") val encrypted: Boolean? = null,
@JsonProperty("tracks") val tracks: List<Tracks?>? = emptyList(),
)
}

View file

@ -7,15 +7,22 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.M3u8Helper import com.lagradost.cloudstream3.utils.M3u8Helper
class SpeedoStream2 : SpeedoStream() {
override val mainUrl = "https://speedostream.mom"
}
class SpeedoStream1 : SpeedoStream() { class SpeedoStream1 : SpeedoStream() {
override val mainUrl = "https://speedostream.nl" override val mainUrl = "https://speedostream.pm"
} }
open class SpeedoStream : ExtractorApi() { open class SpeedoStream : ExtractorApi() {
override val name = "SpeedoStream" override val name = "SpeedoStream"
override val mainUrl = "https://speedostream.com" override val mainUrl = "https://speedostream.bond"
override val requiresReferer = true override val requiresReferer = true
// .bond, .pm, .mom redirect to .bond
private val hostUrl = "https://speedostream.bond"
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> { override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
val sources = mutableListOf<ExtractorLink>() val sources = mutableListOf<ExtractorLink>()
app.get(url, referer = referer).document.select("script").map { script -> app.get(url, referer = referer).document.select("script").map { script ->
@ -26,7 +33,7 @@ open class SpeedoStream : ExtractorApi() {
M3u8Helper.generateM3u8( M3u8Helper.generateM3u8(
name, name,
it.file, it.file,
"$mainUrl/", "$hostUrl/",
).forEach { m3uData -> sources.add(m3uData) } ).forEach { m3uData -> sources.add(m3uData) }
} }
} }
@ -37,6 +44,4 @@ open class SpeedoStream : ExtractorApi() {
private data class File( private data class File(
@JsonProperty("file") val file: String, @JsonProperty("file") val file: String,
) )
} }

View file

@ -8,6 +8,31 @@ import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.M3u8Helper import com.lagradost.cloudstream3.utils.M3u8Helper
import kotlin.random.Random import kotlin.random.Random
class Sblona : StreamSB() {
override var name = "Sblona"
override var mainUrl = "https://sblona.com"
}
class Lvturbo : StreamSB() {
override var name = "Lvturbo"
override var mainUrl = "https://lvturbo.com"
}
class Sbrapid : StreamSB() {
override var name = "Sbrapid"
override var mainUrl = "https://sbrapid.com"
}
class Sbface : StreamSB() {
override var name = "Sbface"
override var mainUrl = "https://sbface.com"
}
class Sbsonic : StreamSB() {
override var name = "Sbsonic"
override var mainUrl = "https://sbsonic.com"
}
class Vidgomunimesb : StreamSB() { class Vidgomunimesb : StreamSB() {
override var mainUrl = "https://vidgomunimesb.xyz" override var mainUrl = "https://vidgomunimesb.xyz"
} }

View file

@ -0,0 +1,42 @@
package com.lagradost.cloudstream3.extractors
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.getAndUnpack
import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.M3u8Helper
open class StreamoUpload : ExtractorApi() {
override val name = "StreamoUpload"
override val mainUrl = "https://streamoupload.xyz"
override val requiresReferer = true
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
val sources = mutableListOf<ExtractorLink>()
val response = app.get(url, referer = referer)
val scriptElements = response.document.select("script").map { script ->
if (script.data().contains("eval(function(p,a,c,k,e,d)")) {
val data = getAndUnpack(script.data())
.substringAfter("sources:[")
.substringBefore("],")
.replace("file", "\"file\"")
.trim()
tryParseJson<File>(data)?.let {
M3u8Helper.generateM3u8(
name,
it.file,
"$mainUrl/",
).forEach { m3uData -> sources.add(m3uData) }
}
}
}
return sources
}
private data class File(
@JsonProperty("file") val file: String,
)
}

View file

@ -0,0 +1,45 @@
package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities
open class Userscloud : ExtractorApi() {
override val name = "Userscloud"
override val mainUrl = "https://userscloud.com"
override val requiresReferer = false
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val res = app.get(url).document
val video = res.selectFirst("video#vjsplayer source")?.attr("src")
val quality = res.selectFirst("div.innerTB h2 b")?.text()
callback.invoke(
ExtractorLink(
this.name,
this.name,
video ?: return,
"$mainUrl/",
getQuality(quality),
headers = mapOf(
"Accept" to "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5",
"Range" to "bytes=0-",
"Sec-Fetch-Dest" to "video",
"Sec-Fetch-Mode" to "no-cors",
)
)
)
}
private fun getQuality(str: String?): Int {
return Regex("(\\d{3,4})[pP]").find(str ?: "")?.groupValues?.getOrNull(1)?.toIntOrNull()
?: Qualities.Unknown.value
}
}

View file

@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.argamap import com.lagradost.cloudstream3.argamap
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.INFER_TYPE
import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.extractorApis
import com.lagradost.cloudstream3.utils.getQualityFromName import com.lagradost.cloudstream3.utils.getQualityFromName
import com.lagradost.cloudstream3.utils.loadExtractor import com.lagradost.cloudstream3.utils.loadExtractor
@ -70,7 +71,7 @@ class Vidstream(val mainUrl: String) {
href, href,
page.url, page.url,
getQualityFromName(qual), getQualityFromName(qual),
element.attr("href").contains(".m3u8") type = INFER_TYPE
) )
) )
} }

View file

@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.encrypt
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.INFER_TYPE
import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.Qualities
class Vidstreamz : WcoStream() { class Vidstreamz : WcoStream() {
@ -126,8 +127,7 @@ open class WcoStream : ExtractorApi() {
if (!response.text.startsWith("{")) throw ErrorLoadingException("Seems like 9Anime kiddies changed stuff again, Go touch some grass for bout an hour Or use a different Server") if (!response.text.startsWith("{")) throw ErrorLoadingException("Seems like 9Anime kiddies changed stuff again, Go touch some grass for bout an hour Or use a different Server")
return response.parsed<Response>().data.media.sources.map { return response.parsed<Response>().data.media.sources.map {
ExtractorLink(name, it.file,it.file,host,Qualities.Unknown.value,it.file.contains(".m3u8")) ExtractorLink(name, it.file, it.file, host, Qualities.Unknown.value, type = INFER_TYPE)
} }
} }
} }

View file

@ -0,0 +1,35 @@
package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.INFER_TYPE
import com.lagradost.cloudstream3.utils.Qualities
open class Wibufile : ExtractorApi() {
override val name: String = "Wibufile"
override val mainUrl: String = "https://wibufile.com"
override val requiresReferer = false
override suspend fun getUrl(
url: String,
referer: String?,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
) {
val res = app.get(url).text
val video = Regex("src: ['\"](.*?)['\"]").find(res)?.groupValues?.get(1)
callback.invoke(
ExtractorLink(
name,
name,
video ?: return,
"$mainUrl/",
Qualities.Unknown.value,
type = INFER_TYPE
)
)
}
}

View file

@ -0,0 +1,95 @@
package com.lagradost.cloudstream3.extractors.helper
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.base64DecodeArray
import com.lagradost.cloudstream3.base64Encode
import com.lagradost.cloudstream3.utils.AppUtils
import java.security.DigestException
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object AesHelper {
private const val HASH = "AES/CBC/PKCS5PADDING"
private const val KDF = "MD5"
fun cryptoAESHandler(
data: String,
pass: ByteArray,
encrypt: Boolean = true,
padding: String = HASH,
): String? {
val parse = AppUtils.tryParseJson<AesData>(data) ?: return null
val (key, iv) = generateKeyAndIv(pass, parse.s.hexToByteArray()) ?: return null
val cipher = Cipher.getInstance(padding)
return if (!encrypt) {
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
String(cipher.doFinal(base64DecodeArray(parse.ct)))
} else {
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
base64Encode(cipher.doFinal(parse.ct.toByteArray()))
}
}
// https://stackoverflow.com/a/41434590/8166854
fun generateKeyAndIv(
password: ByteArray,
salt: ByteArray,
hashAlgorithm: String = KDF,
keyLength: Int = 32,
ivLength: Int = 16,
iterations: Int = 1
): Pair<ByteArray,ByteArray>? {
val md = MessageDigest.getInstance(hashAlgorithm)
val digestLength = md.digestLength
val targetKeySize = keyLength + ivLength
val requiredLength = (targetKeySize + digestLength - 1) / digestLength * digestLength
val generatedData = ByteArray(requiredLength)
var generatedLength = 0
try {
md.reset()
while (generatedLength < targetKeySize) {
if (generatedLength > 0)
md.update(
generatedData,
generatedLength - digestLength,
digestLength
)
md.update(password)
md.update(salt, 0, 8)
md.digest(generatedData, generatedLength, digestLength)
for (i in 1 until iterations) {
md.update(generatedData, generatedLength, digestLength)
md.digest(generatedData, generatedLength, digestLength)
}
generatedLength += digestLength
}
return generatedData.copyOfRange(0, keyLength) to generatedData.copyOfRange(keyLength, targetKeySize)
} catch (e: DigestException) {
return null
}
}
fun String.hexToByteArray(): ByteArray {
check(length % 2 == 0) { "Must have an even length" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
private data class AesData(
@JsonProperty("ct") val ct: String,
@JsonProperty("iv") val iv: String,
@JsonProperty("s") val s: String
)
}

View file

@ -21,10 +21,11 @@ class CrossTmdbProvider : TmdbProvider() {
return Regex("""[^a-zA-Z0-9-]""").replace(name, "") return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
} }
private val validApis by lazy { private val validApis
apis.filter { it.lang == this.lang && it::class.java != this::class.java } get() =
//.distinctBy { it.uniqueId } synchronized(apis) { apis.filter { it.lang == this.lang && it::class.java != this::class.java } }
} //.distinctBy { it.uniqueId }
data class CrossMetaData( data class CrossMetaData(
@JsonProperty("isSuccess") val isSuccess: Boolean, @JsonProperty("isSuccess") val isSuccess: Boolean,
@ -60,7 +61,8 @@ class CrossTmdbProvider : TmdbProvider() {
override suspend fun load(url: String): LoadResponse? { override suspend fun load(url: String): LoadResponse? {
val base = super.load(url)?.apply { val base = super.load(url)?.apply {
this.recommendations = this.recommendations?.filterIsInstance<MovieSearchResponse>() // TODO REMOVE this.recommendations =
this.recommendations?.filterIsInstance<MovieSearchResponse>() // TODO REMOVE
val matchName = filterName(this.name) val matchName = filterName(this.name)
when (this) { when (this) {
is MovieLoadResponse -> { is MovieLoadResponse -> {
@ -98,6 +100,7 @@ class CrossTmdbProvider : TmdbProvider() {
this.dataUrl = this.dataUrl =
CrossMetaData(true, data.map { it.apiName to it.dataUrl }).toJson() CrossMetaData(true, data.map { it.apiName to it.dataUrl }).toJson()
} }
else -> { else -> {
throw ErrorLoadingException("Nothing besides movies are implemented for this provider") throw ErrorLoadingException("Nothing besides movies are implemented for this provider")
} }

View file

@ -25,13 +25,16 @@ class MultiAnimeProvider : MainAPI() {
} }
} }
private val validApis by lazy { private val validApis
APIHolder.apis.filter { get() =
it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains( synchronized(APIHolder.apis) {
TvType.Anime APIHolder.apis.filter {
) it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains(
} TvType.Anime
} )
}
}
private fun filterName(name: String): String { private fun filterName(name: String): String {
return Regex("""[^a-zA-Z0-9-]""").replace(name, "") return Regex("""[^a-zA-Z0-9-]""").replace(name, "")

View file

@ -57,32 +57,6 @@ fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) ->
liveData.observe(this) { action(it) } liveData.observe(this) { action(it) }
} }
inline fun <reified T : Any> some(value: T?): Some<T> {
return if (value == null) {
Some.None
} else {
Some.Success(value)
}
}
sealed class Some<out T> {
data class Success<out T>(val value: T) : Some<T>()
object None : Some<Nothing>()
override fun toString(): String {
return when (this) {
is None -> "None"
is Success -> "Some(${value.toString()})"
}
}
}
sealed class ResourceSome<out T> {
data class Success<out T>(val value: T) : ResourceSome<T>()
object None : ResourceSome<Nothing>()
data class Loading(val data: Any? = null) : ResourceSome<Nothing>()
}
sealed class Resource<out T> { sealed class Resource<out T> {
data class Success<out T>(val value: T) : Resource<T>() data class Success<out T>(val value: T) : Resource<T>()
data class Failure( data class Failure(
@ -155,6 +129,70 @@ fun CoroutineScope.launchSafe(
return this.launch(context, start, obj) return this.launch(context, start, obj)
} }
fun<T> throwAbleToResource(
throwable: Throwable
): Resource<T> {
return when (throwable) {
is NullPointerException -> {
for (line in throwable.stackTrace) {
if (line?.fileName?.endsWith("provider.kt", ignoreCase = true) == true) {
return Resource.Failure(
false,
null,
null,
"NullPointerException at ${line.fileName} ${line.lineNumber}\nSite might have updated or added Cloudflare/DDOS protection"
)
}
}
safeFail(throwable)
}
is SocketTimeoutException, is InterruptedIOException -> {
Resource.Failure(
true,
null,
null,
"Connection Timeout\nPlease try again later."
)
}
is HttpException -> {
Resource.Failure(
false,
throwable.statusCode,
null,
throwable.message ?: "HttpException"
)
}
is UnknownHostException -> {
Resource.Failure(true, null, null, "Cannot connect to server, try again later.\n${throwable.message}")
}
is ErrorLoadingException -> {
Resource.Failure(
true,
null,
null,
throwable.message ?: "Error loading, try again later."
)
}
is NotImplementedError -> {
Resource.Failure(false, null, null, "This operation is not implemented.")
}
is SSLHandshakeException -> {
Resource.Failure(
true,
null,
null,
(throwable.message ?: "SSLHandshakeException") + "\nTry a VPN or DNS."
)
}
is CancellationException -> {
throwable.cause?.let {
throwAbleToResource(it)
} ?: safeFail(throwable)
}
else -> safeFail(throwable)
}
}
suspend fun <T> safeApiCall( suspend fun <T> safeApiCall(
apiCall: suspend () -> T, apiCall: suspend () -> T,
): Resource<T> { ): Resource<T> {
@ -163,60 +201,7 @@ suspend fun <T> safeApiCall(
Resource.Success(apiCall.invoke()) Resource.Success(apiCall.invoke())
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
logError(throwable) logError(throwable)
when (throwable) { throwAbleToResource(throwable)
is NullPointerException -> {
for (line in throwable.stackTrace) {
if (line?.fileName?.endsWith("provider.kt", ignoreCase = true) == true) {
return@withContext Resource.Failure(
false,
null,
null,
"NullPointerException at ${line.fileName} ${line.lineNumber}\nSite might have updated or added Cloudflare/DDOS protection"
)
}
}
safeFail(throwable)
}
is SocketTimeoutException, is InterruptedIOException -> {
Resource.Failure(
true,
null,
null,
"Connection Timeout\nPlease try again later."
)
}
is HttpException -> {
Resource.Failure(
false,
throwable.statusCode,
null,
throwable.message ?: "HttpException"
)
}
is UnknownHostException -> {
Resource.Failure(true, null, null, "Cannot connect to server, try again later.")
}
is ErrorLoadingException -> {
Resource.Failure(
true,
null,
null,
throwable.message ?: "Error loading, try again later."
)
}
is NotImplementedError -> {
Resource.Failure(false, null, null, "This operation is not implemented.")
}
is SSLHandshakeException -> {
Resource.Failure(
true,
null,
null,
(throwable.message ?: "SSLHandshakeException") + "\nTry a VPN or DNS."
)
}
else -> safeFail(throwable)
}
} }
} }
} }

View file

@ -36,7 +36,9 @@ abstract class Plugin {
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI") Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI")
element.sourcePlugin = this.__filename element.sourcePlugin = this.__filename
// Race condition causing which would case duplicates if not for distinctBy // Race condition causing which would case duplicates if not for distinctBy
APIHolder.allProviders.add(element) synchronized(APIHolder.allProviders) {
APIHolder.allProviders.add(element)
}
APIHolder.addPluginMapping(element) APIHolder.addPluginMapping(element)
} }
@ -51,10 +53,14 @@ abstract class Plugin {
} }
class Manifest { class Manifest {
@JsonProperty("name") var name: String? = null @JsonProperty("name")
@JsonProperty("pluginClassName") var pluginClassName: String? = null var name: String? = null
@JsonProperty("version") var version: Int? = null @JsonProperty("pluginClassName")
@JsonProperty("requiresResources") var requiresResources: Boolean = false var pluginClassName: String? = null
@JsonProperty("version")
var version: Int? = null
@JsonProperty("requiresResources")
var requiresResources: Boolean = false
} }
/** /**

View file

@ -137,6 +137,20 @@ object PluginManager {
} }
} }
/**
* Deletes all generated oat files which will force Android to recompile the dex extensions.
* This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update.
*/
fun deleteAllOatFiles(context: Context) {
File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo ->
repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file ->
val success = file.deleteRecursively()
Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success")
}
}
}
fun getPluginsOnline(): Array<PluginData> { fun getPluginsOnline(): Array<PluginData> {
return getKey(PLUGINS_KEY) ?: emptyArray() return getKey(PLUGINS_KEY) ?: emptyArray()
} }
@ -163,7 +177,11 @@ object PluginManager {
private val classLoaders: MutableMap<PathClassLoader, Plugin> = private val classLoaders: MutableMap<PathClassLoader, Plugin> =
HashMap<PathClassLoader, Plugin>() HashMap<PathClassLoader, Plugin>()
private var loadedLocalPlugins = false var loadedLocalPlugins = false
private set
var loadedOnlinePlugins = false
private set
private val gson = Gson() private val gson = Gson()
private suspend fun maybeLoadPlugin(context: Context, file: File) { private suspend fun maybeLoadPlugin(context: Context, file: File) {
@ -277,6 +295,7 @@ object PluginManager {
} }
// ioSafe { // ioSafe {
loadedOnlinePlugins = true
afterPluginsLoadedEvent.invoke(false) afterPluginsLoadedEvent.invoke(false)
// } // }
@ -289,7 +308,7 @@ object PluginManager {
* 2. Fetch all not downloaded plugins * 2. Fetch all not downloaded plugins
* 3. Download them and reload plugins * 3. Download them and reload plugins
**/ **/
fun downloadNotExistingPluginsAndLoad(activity: Activity) { fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) {
val newDownloadPlugins = mutableListOf<String>() val newDownloadPlugins = mutableListOf<String>()
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY) val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
?: emptyArray()) + PREBUILT_REPOSITORIES ?: emptyArray()) + PREBUILT_REPOSITORIES
@ -303,6 +322,8 @@ object PluginManager {
// Iterate online repos and returns not downloaded plugins // Iterate online repos and returns not downloaded plugins
val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData -> val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
val sitePlugin = onlineData.second val sitePlugin = onlineData.second
val tvtypes = sitePlugin.tvTypes ?: listOf()
//Don't include empty urls //Don't include empty urls
if (sitePlugin.url.isBlank()) { if (sitePlugin.url.isBlank()) {
return@mapNotNull null return@mapNotNull null
@ -317,22 +338,29 @@ object PluginManager {
return@mapNotNull null return@mapNotNull null
} }
//Omit lang not selected on language setting //Omit non-NSFW if mode is set to NSFW only
val lang = sitePlugin.language ?: return@mapNotNull null if (mode == AutoDownloadMode.NsfwOnly) {
//If set to 'universal', don't skip any language if (tvtypes.contains(TvType.NSFW.name) == false) {
if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) { return@mapNotNull null
return@mapNotNull null
}
//Log.i(TAG, "sitePlugin lang => $lang")
//Omit NSFW, if disabled
sitePlugin.tvTypes?.let { tvtypes ->
if (!settingsForProvider.enableAdult) {
if (tvtypes.contains(TvType.NSFW.name)) {
return@mapNotNull null
}
} }
} }
//Omit NSFW, if disabled
if (!settingsForProvider.enableAdult) {
if (tvtypes.contains(TvType.NSFW.name)) {
return@mapNotNull null
}
}
//Omit lang not selected on language setting
if (mode == AutoDownloadMode.FilterByLang) {
val lang = sitePlugin.language ?: return@mapNotNull null
//If set to 'universal', don't skip any language
if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
return@mapNotNull null
}
//Log.i(TAG, "sitePlugin lang => $lang")
}
val savedData = PluginData( val savedData = PluginData(
url = sitePlugin.url, url = sitePlugin.url,
internalName = sitePlugin.internalName, internalName = sitePlugin.internalName,
@ -531,10 +559,14 @@ object PluginManager {
} }
// remove all registered apis // remove all registered apis
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach { synchronized(APIHolder.apis) {
removePluginMapping(it) APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach {
removePluginMapping(it)
}
}
synchronized(APIHolder.allProviders) {
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename }
} }
APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename }
extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename } extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename }
classLoaders.values.removeIf { v -> v == plugin } classLoaders.values.removeIf { v -> v == plugin }

View file

@ -8,22 +8,14 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import java.security.MessageDigest import java.security.MessageDigest
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
object VotingApi { // please do not cheat the votes lol object VotingApi { // please do not cheat the votes lol
private const val LOGKEY = "VotingApi" private const val LOGKEY = "VotingApi"
enum class VoteType(val value: Int) { private const val apiDomain = "https://counterapi.com/api"
UPVOTE(1),
DOWNVOTE(-1),
NONE(0)
}
private val apiDomain = "https://api.countapi.xyz"
private fun transformUrl(url: String): String = // dont touch or all votes get reset private fun transformUrl(url: String): String = // dont touch or all votes get reset
MessageDigest MessageDigest
@ -35,12 +27,12 @@ object VotingApi { // please do not cheat the votes lol
return getVotes(url) return getVotes(url)
} }
suspend fun SitePlugin.vote(requestType: VoteType): Int { fun SitePlugin.hasVoted(): Boolean {
return vote(url, requestType) return hasVoted(url)
} }
fun SitePlugin.getVoteType(): VoteType { suspend fun SitePlugin.vote(): Int {
return getVoteType(url) return vote(url)
} }
fun SitePlugin.canVote(): Boolean { fun SitePlugin.canVote(): Boolean {
@ -50,28 +42,31 @@ object VotingApi { // please do not cheat the votes lol
// Plugin url to Int // Plugin url to Int
private val votesCache = mutableMapOf<String, Int>() private val votesCache = mutableMapOf<String, Int>()
suspend fun getVotes(pluginUrl: String): Int { private fun getRepository(pluginUrl: String) = pluginUrl
val url = "${apiDomain}/get/cs3-votes/${transformUrl(pluginUrl)}" .split("/")
.drop(2)
.take(3)
.joinToString("-")
private suspend fun readVote(pluginUrl: String): Int {
var url = "${apiDomain}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
Log.d(LOGKEY, "Requesting: $url") Log.d(LOGKEY, "Requesting: $url")
return votesCache[pluginUrl] ?: app.get(url).parsedSafe<Result>()?.value?.also { return app.get(url).parsedSafe<Result>()?.value ?: 0
votesCache[pluginUrl] = it }
} ?: (0.also {
ioSafe { private suspend fun writeVote(pluginUrl: String): Boolean {
createBucket(pluginUrl) var url = "${apiDomain}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}"
Log.d(LOGKEY, "Requesting: $url")
return app.get(url).parsedSafe<Result>()?.value != null
}
suspend fun getVotes(pluginUrl: String): Int =
votesCache[pluginUrl] ?: readVote(pluginUrl).also {
votesCache[pluginUrl] = it
} }
})
}
fun getVoteType(pluginUrl: String): VoteType { fun hasVoted(pluginUrl: String) =
return getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
}
private suspend fun createBucket(pluginUrl: String) {
val url =
"${apiDomain}/create?namespace=cs3-votes&key=${transformUrl(pluginUrl)}&value=0&update_lowerbound=-2&update_upperbound=2&enable_reset=0"
Log.d(LOGKEY, "Requesting: $url")
app.get(url)
}
fun canVote(pluginUrl: String): Boolean { fun canVote(pluginUrl: String): Boolean {
if (!PluginManager.urlPlugins.contains(pluginUrl)) return false if (!PluginManager.urlPlugins.contains(pluginUrl)) return false
@ -79,7 +74,7 @@ object VotingApi { // please do not cheat the votes lol
} }
private val voteLock = Mutex() private val voteLock = Mutex()
suspend fun vote(pluginUrl: String, requestType: VoteType): Int { suspend fun vote(pluginUrl: String): Int {
// Prevent multiple requests at the same time. // Prevent multiple requests at the same time.
voteLock.withLock { voteLock.withLock {
if (!canVote(pluginUrl)) { if (!canVote(pluginUrl)) {
@ -90,33 +85,21 @@ object VotingApi { // please do not cheat the votes lol
return getVotes(pluginUrl) return getVotes(pluginUrl)
} }
val savedType: VoteType = if (hasVoted(pluginUrl)) {
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE main {
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
val newType = if (requestType == savedType) VoteType.NONE else requestType .show()
val changeValue = if (requestType == savedType) { }
-requestType.value return getVotes(pluginUrl)
} else if (savedType == VoteType.NONE) {
requestType.value
} else if (savedType != requestType) {
-savedType.value + requestType.value
} else 0
// Pre-emptively set vote key
setKey("cs3-votes/${transformUrl(pluginUrl)}", newType)
val url =
"${apiDomain}/update/cs3-votes/${transformUrl(pluginUrl)}?amount=${changeValue}"
Log.d(LOGKEY, "Requesting: $url")
val res = app.get(url).parsedSafe<Result>()?.value
if (res == null) {
// "Refund" key if the response is invalid
setKey("cs3-votes/${transformUrl(pluginUrl)}", savedType)
} else {
votesCache[pluginUrl] = res
} }
return res ?: 0
if (writeVote(pluginUrl)) {
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
}
return getVotes(pluginUrl)
} }
} }

View file

@ -12,6 +12,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
val malApi = MALApi(0) val malApi = MALApi(0)
val aniListApi = AniListApi(0) val aniListApi = AniListApi(0)
val openSubtitlesApi = OpenSubtitlesApi(0) val openSubtitlesApi = OpenSubtitlesApi(0)
val simklApi = SimklApi(0)
val googleDriveApi = GoogleDriveApi(0) val googleDriveApi = GoogleDriveApi(0)
val indexSubtitlesApi = IndexSubtitleApi() val indexSubtitlesApi = IndexSubtitleApi()
val addic7ed = Addic7ed() val addic7ed = Addic7ed()
@ -20,19 +21,19 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
// used to login via app intent // used to login via app intent
val OAuth2Apis val OAuth2Apis
get() = listOf<OAuth2API>( get() = listOf<OAuth2API>(
malApi, aniListApi, googleDriveApi malApi, aniListApi, simklApi, googleDriveApi
) )
// this needs init with context and can be accessed in settings // this needs init with context and can be accessed in settings
val accountManagers val accountManagers
get() = listOf( get() = listOf(
malApi, aniListApi, openSubtitlesApi, googleDriveApi //, nginxApi malApi, aniListApi, openSubtitlesApi, simklApi, googleDriveApi //, nginxApi
) )
// used for active syncing // used for active syncing
val SyncApis val SyncApis
get() = listOf( get() = listOf(
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi) SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
) )
// used for active backup // used for active backup

View file

@ -10,7 +10,8 @@ enum class SyncIdName {
MyAnimeList, MyAnimeList,
Trakt, Trakt,
Imdb, Imdb,
LocalList Simkl,
LocalList,
} }
interface SyncAPI : OAuth2API { interface SyncAPI : OAuth2API {
@ -35,9 +36,9 @@ interface SyncAPI : OAuth2API {
4 -> PlanToWatch 4 -> PlanToWatch
5 -> ReWatching 5 -> ReWatching
*/ */
suspend fun score(id: String, status: SyncStatus): Boolean suspend fun score(id: String, status: AbstractSyncStatus): Boolean
suspend fun getStatus(id: String): SyncStatus? suspend fun getStatus(id: String): AbstractSyncStatus?
suspend fun getResult(id: String): SyncResult? suspend fun getResult(id: String): SyncResult?
@ -59,14 +60,24 @@ interface SyncAPI : OAuth2API {
override var id: Int? = null, override var id: Int? = null,
) : SearchResponse ) : SearchResponse
data class SyncStatus( abstract class AbstractSyncStatus {
val status: Int, abstract var status: Int
/** 1-10 */ /** 1-10 */
val score: Int?, abstract var score: Int?
val watchedEpisodes: Int?, abstract var watchedEpisodes: Int?
var isFavorite: Boolean? = null, abstract var isFavorite: Boolean?
var maxEpisodes: Int? = null, abstract var maxEpisodes: Int?
) }
data class SyncStatus(
override var status: Int,
/** 1-10 */
override var score: Int?,
override var watchedEpisodes: Int?,
override var isFavorite: Boolean? = null,
override var maxEpisodes: Int? = null,
) : AbstractSyncStatus()
data class SyncResult( data class SyncResult(
/**Used to verify*/ /**Used to verify*/

View file

@ -18,11 +18,11 @@ class SyncRepo(private val repo: SyncAPI) {
repo.requireLibraryRefresh = value repo.requireLibraryRefresh = value
} }
suspend fun score(id: String, status: SyncAPI.SyncStatus): Resource<Boolean> { suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Resource<Boolean> {
return safeApiCall { repo.score(id, status) } return safeApiCall { repo.score(id, status) }
} }
suspend fun getStatus(id: String): Resource<SyncAPI.SyncStatus> { suspend fun getStatus(id: String): Resource<SyncAPI.AbstractSyncStatus> {
return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") } return safeApiCall { repo.getStatus(id) ?: throw ErrorLoadingException("No data") }
} }

View file

@ -158,7 +158,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
) )
} }
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null val internalId = id.toIntOrNull() ?: return null
val data = getDataAboutId(internalId) ?: return null val data = getDataAboutId(internalId) ?: return null
@ -171,7 +171,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
) )
} }
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return postDataAboutId( return postDataAboutId(
id.toIntOrNull() ?: return false, id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status), fromIntToAnimeStatus(status.status),

View file

@ -45,11 +45,11 @@ class LocalList : SyncAPI {
override val mainUrl = "" override val mainUrl = ""
override val syncIdName = SyncIdName.LocalList override val syncIdName = SyncIdName.LocalList
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return true return true
} }
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? { override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
return null return null
} }

View file

@ -91,7 +91,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
} }
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean { override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return setScoreRequest( return setScoreRequest(
id.toIntOrNull() ?: return false, id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status), fromIntToAnimeStatus(status.status),

View file

@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Log import android.util.Log
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.google.common.collect.BiMap
import com.google.common.collect.HashBiMap
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.subtitles.AbstractSubApi import com.lagradost.cloudstream3.subtitles.AbstractSubApi
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
@ -15,8 +16,8 @@ import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils
import java.net.URLEncoder import okhttp3.Interceptor
import java.nio.charset.StandardCharsets import okhttp3.Response
class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi { class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
override val idPrefix = "opensubtitles" override val idPrefix = "opensubtitles"
@ -36,6 +37,23 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
var currentSession: SubtitleOAuthEntity? = null var currentSession: SubtitleOAuthEntity? = null
} }
private val headerInterceptor = OpenSubtitleInterceptor()
/** Automatically adds required api headers */
private class OpenSubtitleInterceptor : Interceptor {
/** Required user agent! */
private val userAgent = "Cloudstream3 v0.1"
override fun intercept(chain: Interceptor.Chain): Response {
return chain.proceed(
chain.request().newBuilder()
.removeHeader("user-agent")
.addHeader("user-agent", userAgent)
.addHeader("Api-Key", apiKey)
.build()
)
}
}
private fun canDoRequest(): Boolean { private fun canDoRequest(): Boolean {
return unixTimeMs > currentCoolDown return unixTimeMs > currentCoolDown
} }
@ -98,13 +116,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
val response = app.post( val response = app.post(
url = "$host/login", url = "$host/login",
headers = mapOf( headers = mapOf(
"Api-Key" to apiKey, "Content-Type" to "application/json",
"Content-Type" to "application/json"
), ),
data = mapOf( data = mapOf(
"username" to username, "username" to username,
"password" to password "password" to password
) ),
interceptor = headerInterceptor
) )
//Log.i(TAG, "Responsecode = ${response.code}") //Log.i(TAG, "Responsecode = ${response.code}")
//Log.i(TAG, "Result => ${response.text}") //Log.i(TAG, "Result => ${response.text}")
@ -149,11 +167,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
// "pt" to "pt-PT", // "pt" to "pt-PT",
// "pt" to "pt-BR" // "pt" to "pt-BR"
) )
private fun fixLanguage(language: String?) : String? {
private fun fixLanguage(language: String?): String? {
return languageExceptions[language] ?: language return languageExceptions[language] ?: language
} }
// O(n) but good enough, BiMap did not want to work properly // O(n) but good enough, BiMap did not want to work properly
private fun fixLanguageReverse(language: String?) : String? { private fun fixLanguageReverse(language: String?): String? {
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
} }
@ -183,9 +203,9 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
val req = app.get( val req = app.get(
url = searchQueryUrl, url = searchQueryUrl,
headers = mapOf( headers = mapOf(
Pair("Api-Key", apiKey),
Pair("Content-Type", "application/json") Pair("Content-Type", "application/json")
) ),
interceptor = headerInterceptor
) )
Log.i(TAG, "Search Req => ${req.text}") Log.i(TAG, "Search Req => ${req.text}")
if (!req.isSuccessful) { if (!req.isSuccessful) {
@ -207,7 +227,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
//Use any valid name/title in hierarchy //Use any valid name/title in hierarchy
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
?: featureDetails?.parentTitle ?: attr.release ?: query.query ?: featureDetails?.parentTitle ?: attr.release ?: query.query
val lang = fixLanguageReverse(attr.language)?: "" val lang = fixLanguageReverse(attr.language) ?: ""
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
val year = featureDetails?.year ?: query.year val year = featureDetails?.year ?: query.year
@ -251,13 +271,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
"Authorization", "Authorization",
"Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}" "Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}"
), ),
Pair("Api-Key", apiKey),
Pair("Content-Type", "application/json"), Pair("Content-Type", "application/json"),
Pair("Accept", "*/*") Pair("Accept", "*/*")
), ),
data = mapOf( data = mapOf(
Pair("file_id", data.data) Pair("file_id", data.data)
) ),
interceptor = headerInterceptor
) )
Log.i(TAG, "Request result => (${req.code}) ${req.text}") Log.i(TAG, "Request result => (${req.code}) ${req.text}")
//Log.i(TAG, "Request headers => ${req.headers}") //Log.i(TAG, "Request headers => ${req.headers}")

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,24 @@
package com.lagradost.cloudstream3.ui package com.lagradost.cloudstream3.ui
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.HomePageResponse
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainPageRequest
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.fixUrl
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope.coroutineContext import kotlinx.coroutines.GlobalScope.coroutineContext
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -174,7 +182,7 @@ class APIRepository(val api: MainAPI) {
data: String, data: String,
isCasting: Boolean, isCasting: Boolean,
subtitleCallback: (SubtitleFile) -> Unit, subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit callback: (ExtractorLink) -> Unit,
): Boolean { ): Boolean {
if (isInvalidData(data)) return false // this makes providers cleaner if (isInvalidData(data)) return false // this makes providers cleaner
return try { return try {

View file

@ -25,6 +25,7 @@ import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.sortSubs import com.lagradost.cloudstream3.sortSubs
import com.lagradost.cloudstream3.sortUrls import com.lagradost.cloudstream3.sortUrls
import com.lagradost.cloudstream3.ui.player.LoadType
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultEpisode
@ -294,7 +295,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
val generator = RepoLinkGenerator(listOf(epData)) val generator = RepoLinkGenerator(listOf(epData))
val isSuccessful = safeApiCall { val isSuccessful = safeApiCall {
generator.generateLinks(clearCache = false, isCasting = true, generator.generateLinks(
clearCache = false, type = LoadType.Chromecast,
callback = { callback = {
it.first?.let { link -> it.first?.let { link ->
currentLinks.add(link) currentLinks.add(link)

View file

@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs import kotlin.math.abs
@ -24,7 +25,7 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
} }
} }
override fun onRequestChildFocus( /*override fun onRequestChildFocus(
parent: RecyclerView, parent: RecyclerView,
state: RecyclerView.State, state: RecyclerView.State,
child: View, child: View,
@ -32,13 +33,17 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
): Boolean { ): Boolean {
// android.widget.FrameLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams // android.widget.FrameLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams
return try { return try {
val pos = maxOf(0, getPosition(focused!!) - 2) if(focused != null) {
parent.scrollToPosition(pos) // val pos = maxOf(0, getPosition(focused) - 2) // IDK WHY
val pos = getPosition(focused)
if(pos >= 0) parent.scrollToPosition(pos)
}
super.onRequestChildFocus(parent, state, child, focused) super.onRequestChildFocus(parent, state, child, focused)
} catch (e: Exception) { } catch (e: Exception) {
false false
} }
} }*/
// Allows moving right and left with focus https://gist.github.com/vganin/8930b41f55820ec49e4d // Allows moving right and left with focus https://gist.github.com/vganin/8930b41f55820ec49e4d
override fun onInterceptFocusSearch(focused: View, direction: Int): View? { override fun onInterceptFocusSearch(focused: View, direction: Int): View? {
@ -65,32 +70,47 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
val spanCount = this.spanCount val spanCount = this.spanCount
val orientation = this.orientation val orientation = this.orientation
if (orientation == VERTICAL) { // fixes arabic by inverting left and right layout focus
val correctDirection = if (this.isLayoutRTL) {
when (direction) { when (direction) {
View.FOCUS_RIGHT -> View.FOCUS_LEFT
View.FOCUS_LEFT -> View.FOCUS_RIGHT
else -> direction
}
} else direction
if (orientation == VERTICAL) {
when (correctDirection) {
View.FOCUS_DOWN -> { View.FOCUS_DOWN -> {
return spanCount return spanCount
} }
View.FOCUS_UP -> { View.FOCUS_UP -> {
return -spanCount return -spanCount
} }
View.FOCUS_RIGHT -> { View.FOCUS_RIGHT -> {
return 1 return 1
} }
View.FOCUS_LEFT -> { View.FOCUS_LEFT -> {
return -1 return -1
} }
} }
} else if (orientation == HORIZONTAL) { } else if (orientation == HORIZONTAL) {
when (direction) { when (correctDirection) {
View.FOCUS_DOWN -> { View.FOCUS_DOWN -> {
return 1 return 1
} }
View.FOCUS_UP -> { View.FOCUS_UP -> {
return -1 return -1
} }
View.FOCUS_RIGHT -> { View.FOCUS_RIGHT -> {
return spanCount return spanCount
} }
View.FOCUS_LEFT -> { View.FOCUS_LEFT -> {
return -spanCount return -spanCount
} }
@ -143,3 +163,31 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
layoutManager = manager layoutManager = manager
} }
} }
/**
* Recyclerview wherein the max item width or height is set by the biggest view to prevent inconsistent view sizes.
*/
class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) {
private var biggestObserved: Int = 0
private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation
private val isHorizontal = orientation == HORIZONTAL
private fun View.updateMaxSize() {
if (isHorizontal) {
this.minimumHeight = biggestObserved
} else {
this.minimumWidth = biggestObserved
}
}
override fun onChildAttachedToWindow(child: View) {
child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
val observed = if (isHorizontal) child.measuredHeight else child.measuredWidth
if (observed > biggestObserved) {
biggestObserved = observed
children.forEach { it.updateMaxSize() }
} else {
child.updateMaxSize()
}
super.onChildAttachedToWindow(child)
}
}

View file

@ -16,14 +16,16 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import kotlinx.android.synthetic.main.activity_easter_egg_monke.* import com.lagradost.cloudstream3.databinding.ActivityEasterEggMonkeBinding
import java.util.*
class EasterEggMonke : AppCompatActivity() { class EasterEggMonke : AppCompatActivity() {
lateinit var binding : ActivityEasterEggMonkeBinding
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_easter_egg_monke)
binding = ActivityEasterEggMonkeBinding.inflate(layoutInflater)
setContentView(binding.root)
val handler = Handler(mainLooper) val handler = Handler(mainLooper)
lateinit var runnable: Runnable lateinit var runnable: Runnable
@ -32,15 +34,14 @@ class EasterEggMonke : AppCompatActivity() {
handler.postDelayed(runnable, 300) handler.postDelayed(runnable, 300)
} }
handler.postDelayed(runnable, 1000) handler.postDelayed(runnable, 1000)
} }
private fun shower() { private fun shower() {
val containerW = frame.width val containerW = binding.frame.width
val containerH = frame.height val containerH = binding.frame.height
var starW: Float = monke.width.toFloat() var starW: Float = binding.monke.width.toFloat()
var starH: Float = monke.height.toFloat() var starH: Float = binding.monke.height.toFloat()
val newStar = AppCompatImageView(this) val newStar = AppCompatImageView(this)
val idx = (monkeys.size * Math.random()).toInt() val idx = (monkeys.size * Math.random()).toInt()
@ -48,7 +49,7 @@ class EasterEggMonke : AppCompatActivity() {
newStar.isVisible = true newStar.isVisible = true
newStar.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, newStar.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT) FrameLayout.LayoutParams.WRAP_CONTENT)
frame.addView(newStar) binding.frame.addView(newStar)
newStar.scaleX = Math.random().toFloat() * 1.5f + newStar.scaleX newStar.scaleX = Math.random().toFloat() * 1.5f + newStar.scaleX
newStar.scaleY = newStar.scaleX newStar.scaleY = newStar.scaleX
@ -70,7 +71,7 @@ class EasterEggMonke : AppCompatActivity() {
set.addListener(object : AnimatorListenerAdapter() { set.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) { override fun onAnimationEnd(animation: Animator) {
frame.removeView(newStar) binding.frame.removeView(newStar)
} }
}) })

View file

@ -12,20 +12,23 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.network.WebViewResolver
import com.lagradost.cloudstream3.utils.AppUtils.loadRepository import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
import kotlinx.android.synthetic.main.fragment_webview.*
class WebviewFragment : Fragment() { class WebviewFragment : Fragment() {
var binding: FragmentWebviewBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val url = arguments?.getString(WEBVIEW_URL) ?: "".also { val url = arguments?.getString(WEBVIEW_URL) ?: "".also {
findNavController().popBackStack() findNavController().popBackStack()
} }
web_view.webViewClient = object : WebViewClient() { binding?.webView?.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading( override fun shouldOverrideUrlLoading(
view: WebView?, view: WebView?,
request: WebResourceRequest? request: WebResourceRequest?
@ -40,24 +43,28 @@ class WebviewFragment : Fragment() {
return super.shouldOverrideUrlLoading(view, request) return super.shouldOverrideUrlLoading(view, request)
} }
} }
binding?.webView?.apply {
WebViewResolver.webViewUserAgent = settings.userAgentString
WebViewResolver.webViewUserAgent = web_view.settings.userAgentString addJavascriptInterface(RepoApi(activity), "RepoApi")
settings.javaScriptEnabled = true
web_view.addJavascriptInterface(RepoApi(activity), "RepoApi") settings.userAgentString = USER_AGENT
web_view.settings.javaScriptEnabled = true settings.domStorageEnabled = true
web_view.settings.userAgentString = USER_AGENT
web_view.settings.domStorageEnabled = true
// WebView.setWebContentsDebuggingEnabled(true) // WebView.setWebContentsDebuggingEnabled(true)
web_view.loadUrl(url) loadUrl(url)
}
} }
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View {
val localBinding = FragmentWebviewBinding.inflate(inflater, container, false)
binding = localBinding
// Inflate the layout for this fragment // Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_webview, container, false) return localBinding.root//inflater.inflate(R.layout.fragment_webview, container, false)
} }
companion object { companion object {
@ -70,7 +77,7 @@ class WebviewFragment : Fragment() {
private class RepoApi(val activity: FragmentActivity?) { private class RepoApi(val activity: FragmentActivity?) {
@JavascriptInterface @JavascriptInterface
fun installRepo(repoUrl: String) { fun installRepo(repoUrl: String) {
activity?.loadRepository(repoUrl) activity?.loadRepository(repoUrl)
} }
} }

View file

@ -0,0 +1,106 @@
package com.lagradost.cloudstream3.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.databinding.WhoIsWatchingAccountAddBinding
import com.lagradost.cloudstream3.databinding.WhoIsWatchingAccountBinding
import com.lagradost.cloudstream3.ui.result.setImage
import com.lagradost.cloudstream3.utils.DataStoreHelper
class WhoIsWatchingAdapter(
private val selectCallBack: (DataStoreHelper.Account) -> Unit = { },
private val editCallBack: (DataStoreHelper.Account) -> Unit = { },
private val addAccountCallback: () -> Unit = {}
) :
ListAdapter<DataStoreHelper.Account, WhoIsWatchingAdapter.WhoIsWatchingHolder>(DiffCallback()) {
companion object {
const val FOOTER = 1
const val NORMAL = 0
}
override fun getItemCount(): Int {
return currentList.size + 1
}
override fun getItemViewType(position: Int): Int = when (position) {
currentList.size -> FOOTER
else -> NORMAL
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WhoIsWatchingHolder =
WhoIsWatchingHolder(
binding = when (viewType) {
NORMAL -> WhoIsWatchingAccountBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
FOOTER -> WhoIsWatchingAccountAddBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
else -> throw NotImplementedError()
},
selectCallBack = selectCallBack,
addAccountCallback = addAccountCallback,
editCallBack = editCallBack,
)
override fun onBindViewHolder(holder: WhoIsWatchingHolder, position: Int) =
holder.bind(currentList.getOrNull(position))
class WhoIsWatchingHolder(
val binding: ViewBinding,
val selectCallBack: (DataStoreHelper.Account) -> Unit,
val addAccountCallback: () -> Unit,
val editCallBack: (DataStoreHelper.Account) -> Unit
) :
RecyclerView.ViewHolder(binding.root) {
fun bind(card: DataStoreHelper.Account?) {
when (binding) {
is WhoIsWatchingAccountBinding -> binding.apply {
if(card == null) return@apply
outline.isVisible = card.keyIndex == DataStoreHelper.selectedKeyIndex
profileText.text = card.name
profileImageBackground.setImage(card.image)
root.setOnClickListener {
selectCallBack(card)
}
root.setOnLongClickListener {
editCallBack(card)
return@setOnLongClickListener true
}
}
is WhoIsWatchingAccountAddBinding -> binding.apply {
root.setOnClickListener {
addAccountCallback()
}
}
}
}
}
class DiffCallback : DiffUtil.ItemCallback<DataStoreHelper.Account>() {
override fun areItemsTheSame(
oldItem: DataStoreHelper.Account,
newItem: DataStoreHelper.Account
): Boolean = oldItem.keyIndex == newItem.keyIndex
override fun areContentsTheSame(
oldItem: DataStoreHelper.Account,
newItem: DataStoreHelper.Account
): Boolean = oldItem == newItem
}
}

View file

@ -5,6 +5,7 @@ import android.content.DialogInterface
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
@ -19,7 +20,7 @@ import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
object DownloadButtonSetup { object DownloadButtonSetup {
fun handleDownloadClick(activity: Activity?, click: DownloadClickEvent) { fun handleDownloadClick(click: DownloadClickEvent) {
val id = click.data.id val id = click.data.id
if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return
when (click.action) { when (click.action) {
@ -89,9 +90,9 @@ object DownloadButtonSetup {
)?.fileLength )?.fileLength
?: 0 ?: 0
if (length > 0) { if (length > 0) {
showToast(act, R.string.delete, Toast.LENGTH_LONG) showToast(R.string.delete, Toast.LENGTH_LONG)
} else { } else {
showToast(act, R.string.download, Toast.LENGTH_LONG) showToast(R.string.download, Toast.LENGTH_LONG)
} }
} }
} }

View file

@ -1,6 +0,0 @@
package com.lagradost.cloudstream3.ui.download
interface DownloadButtonViewHolder {
var downloadButton : EasyDownloadButton
fun reattachDownloadButton()
}

View file

@ -3,18 +3,12 @@ package com.lagradost.cloudstream3.ui.download
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.core.widget.ContentLoadingProgressBar
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import kotlinx.android.synthetic.main.download_child_episode.view.*
import java.util.*
const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_PLAY_FILE = 0
const val DOWNLOAD_ACTION_DELETE_FILE = 1 const val DOWNLOAD_ACTION_DELETE_FILE = 1
@ -29,46 +23,16 @@ data class VisualDownloadChildCached(
val data: VideoDownloadHelper.DownloadEpisodeCached, val data: VideoDownloadHelper.DownloadEpisodeCached,
) )
data class DownloadClickEvent(val action: Int, val data: EasyDownloadButton.IMinimumData) data class DownloadClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadEpisodeCached)
class DownloadChildAdapter( class DownloadChildAdapter(
var cardList: List<VisualDownloadChildCached>, var cardList: List<VisualDownloadChildCached>,
private val clickCallback: (DownloadClickEvent) -> Unit, private val clickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val mBoundViewHolders: HashSet<DownloadButtonViewHolder> = HashSet()
private fun getAllBoundViewHolders(): Set<DownloadButtonViewHolder?>? {
return Collections.unmodifiableSet(mBoundViewHolders)
}
fun killAdapter() {
getAllBoundViewHolders()?.forEach { view ->
view?.downloadButton?.dispose()
}
}
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
if (holder is DownloadButtonViewHolder) {
holder.downloadButton.dispose()
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is DownloadButtonViewHolder) {
holder.downloadButton.dispose()
mBoundViewHolders.remove(holder)
}
}
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
if (holder is DownloadButtonViewHolder) {
holder.reattachDownloadButton()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return DownloadChildViewHolder( return DownloadChildViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.download_child_episode, parent, false), DownloadChildEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false),
clickCallback clickCallback
) )
} }
@ -77,7 +41,6 @@ class DownloadChildAdapter(
when (holder) { when (holder) {
is DownloadChildViewHolder -> { is DownloadChildViewHolder -> {
holder.bind(cardList[position]) holder.bind(cardList[position])
mBoundViewHolders.add(holder)
} }
} }
} }
@ -88,66 +51,44 @@ class DownloadChildAdapter(
class DownloadChildViewHolder class DownloadChildViewHolder
constructor( constructor(
itemView: View, val binding: DownloadChildEpisodeBinding,
private val clickCallback: (DownloadClickEvent) -> Unit, private val clickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder { ) : RecyclerView.ViewHolder(binding.root) {
override var downloadButton = EasyDownloadButton()
private val title: TextView = itemView.download_child_episode_text /*private val title: TextView = itemView.download_child_episode_text
private val extraInfo: TextView = itemView.download_child_episode_text_extra private val extraInfo: TextView = itemView.download_child_episode_text_extra
private val holder: CardView = itemView.download_child_episode_holder private val holder: CardView = itemView.download_child_episode_holder
private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress
private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded
private val downloadImage: ImageView = itemView.download_child_episode_download private val downloadImage: ImageView = itemView.download_child_episode_download*/
private var localCard: VisualDownloadChildCached? = null
fun bind(card: VisualDownloadChildCached) { fun bind(card: VisualDownloadChildCached) {
localCard = card
val d = card.data val d = card.data
val posDur = getViewPos(d.id) val posDur = getViewPos(d.id)
if (posDur != null) { binding.downloadChildEpisodeProgress.apply {
val visualPos = posDur.fixVisual() if (posDur != null) {
progressBar.max = (visualPos.duration / 1000).toInt() val visualPos = posDur.fixVisual()
progressBar.progress = (visualPos.position / 1000).toInt() max = (visualPos.duration / 1000).toInt()
progressBar.visibility = View.VISIBLE progress = (visualPos.position / 1000).toInt()
} else { visibility = View.VISIBLE
progressBar.visibility = View.GONE } else {
visibility = View.GONE
}
} }
title.text = title.context.getNameFull(d.name, d.episode, d.season) binding.downloadButton.setDefaultClickListener(card.data, binding.downloadChildEpisodeTextExtra, clickCallback)
title.isSelected = true // is needed for text repeating
downloadButton.setUpButton( binding.downloadChildEpisodeText.apply {
card.currentBytes, text = context.getNameFull(d.name, d.episode, d.season)
card.totalBytes, isSelected = true // is needed for text repeating
progressBarDownload, }
downloadImage,
extraInfo,
card.data,
clickCallback
)
holder.setOnClickListener {
binding.downloadChildEpisodeHolder.setOnClickListener {
clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d)) clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d))
} }
} }
override fun reattachDownloadButton() {
downloadButton.dispose()
val card = localCard
if (card != null) {
downloadButton.setUpButton(
card.currentBytes,
card.totalBytes,
progressBarDownload,
downloadImage,
extraInfo,
card.data,
clickCallback
)
}
}
} }
} }

View file

@ -5,23 +5,24 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.DataStore.getKeys
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.android.synthetic.main.fragment_child_downloads.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class DownloadChildFragment : Fragment() { class DownloadChildFragment : Fragment() {
companion object { companion object {
fun newInstance(headerName: String, folder: String) : Bundle { fun newInstance(headerName: String, folder: String): Bundle {
return Bundle().apply { return Bundle().apply {
putString("folder", folder) putString("folder", folder)
putString("name", headerName) putString("name", headerName)
@ -30,13 +31,20 @@ class DownloadChildFragment : Fragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
(download_child_list?.adapter as DownloadChildAdapter?)?.killAdapter()
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
binding = null
super.onDestroyView() super.onDestroyView()
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { var binding: FragmentChildDownloadsBinding? = null
return inflater.inflate(R.layout.fragment_child_downloads, container, false) override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root//inflater.inflate(R.layout.fragment_child_downloads, container, false)
} }
private fun updateList(folder: String) = main { private fun updateList(folder: String) = main {
@ -50,14 +58,15 @@ class DownloadChildFragment : Fragment() {
?: return@mapNotNull null ?: return@mapNotNull null
VisualDownloadChildCached(info.fileLength, info.totalBytes, it) VisualDownloadChildCached(info.fileLength, info.totalBytes, it)
} }
}.sortedBy { it.data.episode + (it.data.season?: 0)*100000 } }.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 }
if (eps.isEmpty()) { if (eps.isEmpty()) {
activity?.onBackPressed() activity?.onBackPressed()
return@main return@main
} }
(download_child_list?.adapter as DownloadChildAdapter? ?: return@main).cardList = eps (binding?.downloadChildList?.adapter as DownloadChildAdapter? ?: return@main).cardList =
download_child_list?.adapter?.notifyDataSetChanged() eps
binding?.downloadChildList?.adapter?.notifyDataSetChanged()
} }
} }
@ -72,23 +81,26 @@ class DownloadChildFragment : Fragment() {
activity?.onBackPressed() // TODO FIX activity?.onBackPressed() // TODO FIX
return return
} }
context?.fixPaddingStatusbar(download_child_root) fixPaddingStatusbar(binding?.downloadChildRoot)
download_child_toolbar.title = name binding?.downloadChildToolbar?.apply {
download_child_toolbar.setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) title = name
download_child_toolbar.setNavigationOnClickListener { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
activity?.onBackPressed() setNavigationOnClickListener {
activity?.onBackPressed()
}
} }
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> = val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
DownloadChildAdapter( DownloadChildAdapter(
ArrayList(), ArrayList(),
) { click -> ) { click ->
handleDownloadClick(activity, click) handleDownloadClick(click)
} }
downloadDeleteEventListener = { id: Int -> downloadDeleteEventListener = { id: Int ->
val list = (download_child_list?.adapter as DownloadChildAdapter?)?.cardList val list = (binding?.downloadChildList?.adapter as DownloadChildAdapter?)?.cardList
if (list != null) { if (list != null) {
if (list.any { it.data.id == id }) { if (list.any { it.data.id == id }) {
updateList(folder) updateList(folder)
@ -98,8 +110,12 @@ class DownloadChildFragment : Fragment() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
download_child_list.adapter = adapter binding?.downloadChildList?.adapter = adapter
download_child_list.layoutManager = GridLayoutManager(context, 1) binding?.downloadChildList?.setLinearListLayout(
isHorizontal = false,
nextDown = FOCUS_SELF,
nextRight = FOCUS_SELF
)//layoutManager = GridLayoutManager(context, 1)
updateList(folder) updateList(folder)
} }

View file

@ -34,12 +34,14 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import kotlinx.android.synthetic.main.fragment_downloads.*
import kotlinx.android.synthetic.main.stream_input.*
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter.formatShortFileSize
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doOnTextChanged
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
import com.lagradost.cloudstream3.databinding.StreamInputBinding
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.BasicLink
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
import java.net.URI import java.net.URI
@ -60,8 +62,8 @@ class DownloadFragment : Fragment() {
private fun setList(list: List<VisualDownloadHeaderCached>) { private fun setList(list: List<VisualDownloadHeaderCached>) {
main { main {
(download_list?.adapter as DownloadHeaderAdapter?)?.cardList = list (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList = list
download_list?.adapter?.notifyDataSetChanged() binding?.downloadList?.adapter?.notifyDataSetChanged()
} }
} }
@ -70,10 +72,12 @@ class DownloadFragment : Fragment() {
VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!! VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!!
downloadDeleteEventListener = null downloadDeleteEventListener = null
} }
(download_list?.adapter as DownloadHeaderAdapter?)?.killAdapter() binding = null
super.onDestroyView() super.onDestroyView()
} }
var binding: FragmentDownloadsBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -82,7 +86,9 @@ class DownloadFragment : Fragment() {
downloadsViewModel = downloadsViewModel =
ViewModelProvider(this)[DownloadViewModel::class.java] ViewModelProvider(this)[DownloadViewModel::class.java]
return inflater.inflate(R.layout.fragment_downloads, container, false) val localBinding = FragmentDownloadsBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root//inflater.inflate(R.layout.fragment_downloads, container, false)
} }
private var downloadDeleteEventListener: ((Int) -> Unit)? = null private var downloadDeleteEventListener: ((Int) -> Unit)? = null
@ -92,36 +98,40 @@ class DownloadFragment : Fragment() {
hideKeyboard() hideKeyboard()
observe(downloadsViewModel.noDownloadsText) { observe(downloadsViewModel.noDownloadsText) {
text_no_downloads.text = it binding?.textNoDownloads?.text = it
} }
observe(downloadsViewModel.headerCards) { observe(downloadsViewModel.headerCards) {
setList(it) setList(it)
download_loading.isVisible = false binding?.downloadLoading?.isVisible = false
} }
observe(downloadsViewModel.availableBytes) { observe(downloadsViewModel.availableBytes) {
download_free_txt?.text = binding?.downloadFreeTxt?.text =
getString(R.string.storage_size_format).format( getString(R.string.storage_size_format).format(
getString(R.string.free_storage), getString(R.string.free_storage),
formatShortFileSize(view.context, it) formatShortFileSize(view.context, it)
) )
download_free?.setLayoutWidth(it) binding?.downloadFree?.setLayoutWidth(it)
} }
observe(downloadsViewModel.usedBytes) { observe(downloadsViewModel.usedBytes) {
download_used_txt?.text = binding?.apply {
getString(R.string.storage_size_format).format( downloadUsedTxt.text =
getString(R.string.used_storage), getString(R.string.storage_size_format).format(
formatShortFileSize(view.context, it) getString(R.string.used_storage),
) formatShortFileSize(view.context, it)
download_used?.setLayoutWidth(it) )
download_storage_appbar?.isVisible = it > 0 downloadUsed.setLayoutWidth(it)
downloadStorageAppbar.isVisible = it > 0
}
} }
observe(downloadsViewModel.downloadBytes) { observe(downloadsViewModel.downloadBytes) {
download_app_txt?.text = binding?.apply {
getString(R.string.storage_size_format).format( downloadAppTxt.text =
getString(R.string.app_storage), getString(R.string.storage_size_format).format(
formatShortFileSize(view.context, it) getString(R.string.app_storage),
) formatShortFileSize(view.context, it)
download_app?.setLayoutWidth(it) )
downloadApp.setLayoutWidth(it)
}
} }
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> = val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
@ -143,6 +153,7 @@ class DownloadFragment : Fragment() {
) )
} }
} }
1 -> { 1 -> {
(activity as AppCompatActivity?)?.loadResult( (activity as AppCompatActivity?)?.loadResult(
click.data.url, click.data.url,
@ -154,7 +165,7 @@ class DownloadFragment : Fragment() {
}, },
{ downloadClickEvent -> { downloadClickEvent ->
if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter
handleDownloadClick(activity, downloadClickEvent) handleDownloadClick(downloadClickEvent)
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) { if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
context?.let { ctx -> context?.let { ctx ->
downloadsViewModel.updateList(ctx) downloadsViewModel.updateList(ctx)
@ -164,7 +175,7 @@ class DownloadFragment : Fragment() {
) )
downloadDeleteEventListener = { id -> downloadDeleteEventListener = { id ->
val list = (download_list?.adapter as DownloadHeaderAdapter?)?.cardList val list = (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList
if (list != null) { if (list != null) {
if (list.any { it.data.id == id }) { if (list.any { it.data.id == id }) {
context?.let { ctx -> context?.let { ctx ->
@ -177,31 +188,42 @@ class DownloadFragment : Fragment() {
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
download_list?.adapter = adapter binding?.downloadList?.apply {
download_list?.layoutManager = GridLayoutManager(context, 1) this.adapter = adapter
setLinearListLayout(
isHorizontal = false,
nextRight = FOCUS_SELF,
nextUp = FOCUS_SELF,
nextDown = FOCUS_SELF
)
//layoutManager = GridLayoutManager(context, 1)
}
// Should be visible in emulator layout // Should be visible in emulator layout
download_stream_button?.isGone = isTrueTvSettings() binding?.downloadStreamButton?.isGone = isTrueTvSettings()
download_stream_button?.setOnClickListener { binding?.downloadStreamButton?.setOnClickListener {
val dialog = val dialog =
Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom) Dialog(it.context ?: return@setOnClickListener, R.style.AlertDialogCustom)
dialog.setContentView(R.layout.stream_input)
val binding = StreamInputBinding.inflate(dialog.layoutInflater)
dialog.setContentView(binding.root)
dialog.show() dialog.show()
// If user has clicked the switch do not interfere // If user has clicked the switch do not interfere
var preventAutoSwitching = false var preventAutoSwitching = false
dialog.hls_switch?.setOnClickListener { binding.hlsSwitch.setOnClickListener {
preventAutoSwitching = true preventAutoSwitching = true
} }
fun activateSwitchOnHls(text: String?) { fun activateSwitchOnHls(text: String?) {
dialog.hls_switch?.isChecked = normalSafeApiCall { binding.hlsSwitch.isChecked = normalSafeApiCall {
URI(text).path?.substringAfterLast(".")?.contains("m3u") URI(text).path?.substringAfterLast(".")?.contains("m3u")
} == true } == true
} }
dialog.stream_referer?.doOnTextChanged { text, _, _, _ -> binding.streamReferer.doOnTextChanged { text, _, _, _ ->
if (!preventAutoSwitching) if (!preventAutoSwitching)
activateSwitchOnHls(text?.toString()) activateSwitchOnHls(text?.toString())
} }
@ -210,16 +232,16 @@ class DownloadFragment : Fragment() {
0 0
)?.text?.toString()?.let { copy -> )?.text?.toString()?.let { copy ->
val fixedText = copy.trim() val fixedText = copy.trim()
dialog.stream_url?.setText(fixedText) binding.streamUrl.setText(fixedText)
activateSwitchOnHls(fixedText) activateSwitchOnHls(fixedText)
} }
dialog.apply_btt?.setOnClickListener { binding.applyBtt.setOnClickListener {
val url = dialog.stream_url.text?.toString() val url = binding.streamUrl.text?.toString()
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
showToast(activity, R.string.error_invalid_url, Toast.LENGTH_SHORT) showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
} else { } else {
val referer = dialog.stream_referer.text?.toString() val referer = binding.streamReferer.text?.toString()
activity?.navigate( activity?.navigate(
R.id.global_to_navigation_player, R.id.global_to_navigation_player,
@ -228,7 +250,7 @@ class DownloadFragment : Fragment() {
listOf(BasicLink(url)), listOf(BasicLink(url)),
extract = true, extract = true,
referer = referer, referer = referer,
isM3u8 = dialog.hls_switch?.isChecked isM3u8 = binding.hlsSwitch.isChecked
) )
) )
) )
@ -237,22 +259,22 @@ class DownloadFragment : Fragment() {
} }
} }
dialog.cancel_btt?.setOnClickListener { binding.cancelBtt.setOnClickListener {
dialog.dismissSafe(activity) dialog.dismissSafe(activity)
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
download_list?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val dy = scrollY - oldScrollY val dy = scrollY - oldScrollY
if (dy > 0) { //check for scroll down if (dy > 0) { //check for scroll down
download_stream_button?.shrink() // hide binding?.downloadStreamButton?.shrink() // hide
} else if (dy < -5) { } else if (dy < -5) {
download_stream_button?.extend() // show binding?.downloadStreamButton?.extend() // show
} }
} }
} }
downloadsViewModel.updateList(requireContext()) downloadsViewModel.updateList(requireContext())
context?.fixPaddingStatusbar(download_root) fixPaddingStatusbar(binding?.downloadRoot)
} }
} }

View file

@ -5,16 +5,13 @@ import android.text.format.Formatter.formatShortFileSize
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import androidx.core.view.isVisible
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.core.widget.ContentLoadingProgressBar
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.setImage
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import kotlinx.android.synthetic.main.download_header_episode.view.*
import java.util.* import java.util.*
data class VisualDownloadHeaderCached( data class VisualDownloadHeaderCached(
@ -26,7 +23,10 @@ data class VisualDownloadHeaderCached(
val child: VideoDownloadHelper.DownloadEpisodeCached?, val child: VideoDownloadHelper.DownloadEpisodeCached?,
) )
data class DownloadHeaderClickEvent(val action: Int, val data: VideoDownloadHelper.DownloadHeaderCached) data class DownloadHeaderClickEvent(
val action: Int,
val data: VideoDownloadHelper.DownloadHeaderCached
)
class DownloadHeaderAdapter( class DownloadHeaderAdapter(
var cardList: List<VisualDownloadHeaderCached>, var cardList: List<VisualDownloadHeaderCached>,
@ -34,39 +34,13 @@ class DownloadHeaderAdapter(
private val movieClickCallback: (DownloadClickEvent) -> Unit, private val movieClickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val mBoundViewHolders: HashSet<DownloadButtonViewHolder> = HashSet()
private fun getAllBoundViewHolders(): Set<DownloadButtonViewHolder?>? {
return Collections.unmodifiableSet(mBoundViewHolders)
}
fun killAdapter() {
getAllBoundViewHolders()?.forEach { view ->
view?.downloadButton?.dispose()
}
}
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
if (holder is DownloadButtonViewHolder) {
holder.downloadButton.dispose()
}
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is DownloadButtonViewHolder) {
holder.downloadButton.dispose()
mBoundViewHolders.remove(holder)
}
}
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
if (holder is DownloadButtonViewHolder) {
holder.reattachDownloadButton()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return DownloadHeaderViewHolder( return DownloadHeaderViewHolder(
LayoutInflater.from(parent.context).inflate(R.layout.download_header_episode, parent, false), DownloadHeaderEpisodeBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
),
clickCallback, clickCallback,
movieClickCallback movieClickCallback
) )
@ -76,7 +50,6 @@ class DownloadHeaderAdapter(
when (holder) { when (holder) {
is DownloadHeaderViewHolder -> { is DownloadHeaderViewHolder -> {
holder.bind(cardList[position]) holder.bind(cardList[position])
mBoundViewHolders.add(holder)
} }
} }
} }
@ -87,93 +60,89 @@ class DownloadHeaderAdapter(
class DownloadHeaderViewHolder class DownloadHeaderViewHolder
constructor( constructor(
itemView: View, val binding: DownloadHeaderEpisodeBinding,
private val clickCallback: (DownloadHeaderClickEvent) -> Unit, private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
private val movieClickCallback: (DownloadClickEvent) -> Unit, private val movieClickCallback: (DownloadClickEvent) -> Unit,
) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder { ) : RecyclerView.ViewHolder(binding.root) {
override var downloadButton = EasyDownloadButton()
private val poster: ImageView? = itemView.download_header_poster /*private val poster: ImageView? = itemView.download_header_poster
private val title: TextView = itemView.download_header_title private val title: TextView = itemView.download_header_title
private val extraInfo: TextView = itemView.download_header_info private val extraInfo: TextView = itemView.download_header_info
private val holder: CardView = itemView.episode_holder private val holder: CardView = itemView.episode_holder
private val downloadBar: ContentLoadingProgressBar = itemView.download_header_progress_downloaded private val downloadBar: ContentLoadingProgressBar = itemView.download_header_progress_downloaded
private val downloadImage: ImageView = itemView.download_header_episode_download private val downloadImage: ImageView = itemView.download_header_episode_download
private val normalImage: ImageView = itemView.download_header_goto_child private val normalImage: ImageView = itemView.download_header_goto_child*/
private var localCard: VisualDownloadHeaderCached? = null
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
fun bind(card: VisualDownloadHeaderCached) { fun bind(card: VisualDownloadHeaderCached) {
localCard = card
val d = card.data val d = card.data
poster?.setImage(d.poster) binding.downloadHeaderPoster.apply {
poster?.setOnClickListener { setImage(d.poster)
clickCallback.invoke(DownloadHeaderClickEvent(1, d)) setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(1, d))
}
} }
title.text = d.name binding.apply {
val mbString = formatShortFileSize(itemView.context, card.totalBytes)
//val isMovie = d.type.isMovieType() binding.downloadHeaderTitle.text = d.name
if (card.child != null) { val mbString = formatShortFileSize(itemView.context, card.totalBytes)
downloadBar.visibility = View.VISIBLE
downloadImage.visibility = View.VISIBLE
normalImage.visibility = View.GONE
/*setUpButton(
card.currentBytes,
card.totalBytes,
downloadBar,
downloadImage,
extraInfo,
card.child,
movieClickCallback
)*/
holder.setOnClickListener { //val isMovie = d.type.isMovieType()
movieClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, card.child)) if (card.child != null) {
} //downloadHeaderProgressDownloaded.visibility = View.VISIBLE
} else {
downloadBar.visibility = View.GONE
downloadImage.visibility = View.GONE
normalImage.visibility = View.VISIBLE
try { // downloadHeaderEpisodeDownload.visibility = View.VISIBLE
extraInfo.text = binding.downloadHeaderGotoChild.visibility = View.GONE
extraInfo.context.getString(R.string.extra_info_format).format(
card.totalDownloads, downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, movieClickCallback)
if (card.totalDownloads == 1) extraInfo.context.getString(R.string.episode) else extraInfo.context.getString( downloadButton.isVisible = true
R.string.episodes /*setUpButton(
), card.currentBytes,
mbString card.totalBytes,
downloadBar,
downloadImage,
extraInfo,
card.child,
movieClickCallback
)*/
episodeHolder.setOnClickListener {
movieClickCallback.invoke(
DownloadClickEvent(
DOWNLOAD_ACTION_PLAY_FILE,
card.child
)
) )
} catch (t : Throwable) { }
// you probably formatted incorrectly } else {
extraInfo.text = "Error" downloadButton.isVisible = false
logError(t) // downloadHeaderProgressDownloaded.visibility = View.GONE
// downloadHeaderEpisodeDownload.visibility = View.GONE
binding.downloadHeaderGotoChild.visibility = View.VISIBLE
try {
downloadHeaderInfo.text =
downloadHeaderInfo.context.getString(R.string.extra_info_format).format(
card.totalDownloads,
if (card.totalDownloads == 1) downloadHeaderInfo.context.getString(R.string.episode) else downloadHeaderInfo.context.getString(
R.string.episodes
),
mbString
)
} catch (t: Throwable) {
// you probably formatted incorrectly
downloadHeaderInfo.text = "Error"
logError(t)
}
episodeHolder.setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(0, d))
}
} }
holder.setOnClickListener {
clickCallback.invoke(DownloadHeaderClickEvent(0, d))
}
}
}
override fun reattachDownloadButton() {
downloadButton.dispose()
val card = localCard
if (card?.child != null) {
downloadButton.setUpButton(
card.currentBytes,
card.totalBytes,
downloadBar,
downloadImage,
extraInfo,
card.child,
movieClickCallback
)
} }
} }
} }

View file

@ -1,264 +0,0 @@
package com.lagradost.cloudstream3.ui.download
import android.animation.ObjectAnimator
import android.text.format.Formatter.formatShortFileSize
import android.view.View
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.Coroutines
import com.lagradost.cloudstream3.utils.IDisposable
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.VideoDownloadManager
class EasyDownloadButton : IDisposable {
interface IMinimumData {
val id: Int
}
private var _clickCallback: ((DownloadClickEvent) -> Unit)? = null
private var _imageChangeCallback: ((Pair<Int, String>) -> Unit)? = null
override fun dispose() {
try {
_clickCallback = null
_imageChangeCallback = null
downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent -= it }
downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent -= it }
} catch (e: Exception) {
e.printStackTrace()
}
}
private var downloadProgressEventListener: ((Triple<Int, Long, Long>) -> Unit)? = null
private var downloadStatusEventListener: ((Pair<Int, VideoDownloadManager.DownloadType>) -> Unit)? =
null
fun setUpMaterialButton(
setupCurrentBytes: Long?,
setupTotalBytes: Long?,
progressBar: ContentLoadingProgressBar,
downloadButton: MaterialButton,
textView: TextView?,
data: IMinimumData,
clickCallback: (DownloadClickEvent) -> Unit,
) {
setUpDownloadButton(
setupCurrentBytes,
setupTotalBytes,
progressBar,
textView,
data,
downloadButton,
{
downloadButton.setIconResource(it.first)
downloadButton.text = it.second
},
clickCallback
)
}
fun setUpMoreButton(
setupCurrentBytes: Long?,
setupTotalBytes: Long?,
progressBar: ContentLoadingProgressBar,
downloadImage: ImageView,
textView: TextView?,
textViewProgress: TextView?,
clickableView: View,
isTextPercentage: Boolean,
data: IMinimumData,
clickCallback: (DownloadClickEvent) -> Unit,
) {
setUpDownloadButton(
setupCurrentBytes,
setupTotalBytes,
progressBar,
textViewProgress,
data,
clickableView,
{ (image, text) ->
downloadImage.isVisible = textViewProgress?.isGone ?: true
downloadImage.setImageResource(image)
textView?.text = text
},
clickCallback, isTextPercentage
)
}
fun setUpButton(
setupCurrentBytes: Long?,
setupTotalBytes: Long?,
progressBar: ContentLoadingProgressBar,
downloadImage: ImageView,
textView: TextView?,
data: IMinimumData,
clickCallback: (DownloadClickEvent) -> Unit,
) {
setUpDownloadButton(
setupCurrentBytes,
setupTotalBytes,
progressBar,
textView,
data,
downloadImage,
{
downloadImage.setImageResource(it.first)
},
clickCallback
)
}
private fun setUpDownloadButton(
setupCurrentBytes: Long?,
setupTotalBytes: Long?,
progressBar: ContentLoadingProgressBar,
textView: TextView?,
data: IMinimumData,
downloadView: View,
downloadImageChangeCallback: (Pair<Int, String>) -> Unit,
clickCallback: (DownloadClickEvent) -> Unit,
isTextPercentage: Boolean = false
) {
_clickCallback = clickCallback
_imageChangeCallback = downloadImageChangeCallback
var lastState: VideoDownloadManager.DownloadType? = null
var currentBytes = setupCurrentBytes ?: 0
var totalBytes = setupTotalBytes ?: 0
var needImageUpdate = true
fun changeDownloadImage(state: VideoDownloadManager.DownloadType) {
lastState = state
if (currentBytes <= 0) needImageUpdate = true
val img = if (currentBytes > 0) {
when (state) {
VideoDownloadManager.DownloadType.IsPaused -> Pair(
R.drawable.ic_baseline_play_arrow_24,
R.string.download_paused
)
VideoDownloadManager.DownloadType.IsDownloading -> Pair(
R.drawable.netflix_pause,
R.string.downloading
)
else -> Pair(R.drawable.ic_baseline_delete_outline_24, R.string.downloaded)
}
} else {
Pair(R.drawable.netflix_download, R.string.download)
}
_imageChangeCallback?.invoke(
Pair(
img.first,
downloadView.context.getString(img.second)
)
)
}
fun fixDownloadedBytes(setCurrentBytes: Long, setTotalBytes: Long, animate: Boolean) {
currentBytes = setCurrentBytes
totalBytes = setTotalBytes
if (currentBytes == 0L) {
changeDownloadImage(VideoDownloadManager.DownloadType.IsStopped)
textView?.visibility = View.GONE
progressBar.visibility = View.GONE
} else {
if (lastState == VideoDownloadManager.DownloadType.IsStopped) {
changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
}
textView?.visibility = View.VISIBLE
progressBar.visibility = View.VISIBLE
val currentMbString = formatShortFileSize(textView?.context, setCurrentBytes)
val totalMbString = formatShortFileSize(textView?.context, setTotalBytes)
textView?.text =
if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
textView?.context?.getString(R.string.download_size_format)
?.format(currentMbString, totalMbString)
progressBar.let { bar ->
bar.max = (setTotalBytes / 1000).toInt()
if (animate) {
val animation: ObjectAnimator = ObjectAnimator.ofInt(
bar,
"progress",
bar.progress,
(setCurrentBytes / 1000).toInt()
)
animation.duration = 500
animation.setAutoCancel(true)
animation.interpolator = DecelerateInterpolator()
animation.start()
} else {
bar.progress = (setCurrentBytes / 1000).toInt()
}
}
}
}
fixDownloadedBytes(currentBytes, totalBytes, false)
changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
downloadProgressEventListener = { downloadData: Triple<Int, Long, Long> ->
if (data.id == downloadData.first) {
if (downloadData.second != currentBytes || downloadData.third != totalBytes) { // TO PREVENT WASTING UI TIME
Coroutines.runOnMainThread {
fixDownloadedBytes(downloadData.second, downloadData.third, true)
changeDownloadImage(VideoDownloadManager.getDownloadState(data.id))
}
}
}
}
downloadStatusEventListener =
{ downloadData: Pair<Int, VideoDownloadManager.DownloadType> ->
if (data.id == downloadData.first) {
if (lastState != downloadData.second || needImageUpdate) { // TO PREVENT WASTING UI TIME
Coroutines.runOnMainThread {
changeDownloadImage(downloadData.second)
}
}
}
}
downloadProgressEventListener?.let { VideoDownloadManager.downloadProgressEvent += it }
downloadStatusEventListener?.let { VideoDownloadManager.downloadStatusEvent += it }
downloadView.setOnClickListener {
if (currentBytes <= 0 || totalBytes <= 0) {
_clickCallback?.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
} else {
val list = arrayListOf(
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file),
)
// DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone &&
if ((currentBytes * 100 / totalBytes) < 98) {
list.add(
if (lastState == VideoDownloadManager.DownloadType.IsDownloading)
Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download)
else
Pair(DOWNLOAD_ACTION_RESUME_DOWNLOAD, R.string.popup_resume_download)
)
}
it.popupMenuNoIcons(
list
) {
_clickCallback?.invoke(DownloadClickEvent(itemId, data))
}
}
}
downloadView.setOnLongClickListener {
clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data))
return@setOnLongClickListener true
}
}
}

View file

@ -0,0 +1,202 @@
package com.lagradost.cloudstream3.ui.download.button
import android.content.Context
import android.text.format.Formatter
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.VideoDownloadManager
typealias DownloadStatusTell = VideoDownloadManager.DownloadType
data class DownloadMetadata(
var id: Int,
var downloadedLength: Long,
var totalLength: Long,
var status: DownloadStatusTell? = null
) {
val progressPercentage: Long
get() = if (downloadedLength < 1024) 0 else maxOf(
0,
minOf(100, (downloadedLength * 100L) / (totalLength + 1))
)
}
abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) :
FrameLayout(context, attributeSet) {
var persistentId: Int? = null // used to save sessions
lateinit var progressBar: ContentLoadingProgressBar
var progressText: TextView? = null
/*val gid: String? get() = sessionIdToGid[persistentId]
// used for resuming data
var _lastRequestOverride: UriRequest? = null
var lastRequest: UriRequest?
get() = _lastRequestOverride ?: sessionIdToLastRequest[persistentId]
set(value) {
_lastRequestOverride = value
}
var files: List<AbstractClient.JsonFile> = emptyList()*/
protected var isZeroBytes: Boolean = true
fun inflate(@LayoutRes layout: Int) {
inflate(context, layout, this)
}
init {
resetViewData()
}
open fun resetViewData() {
// lastRequest = null
isZeroBytes = true
persistentId = null
}
var currentMetaData: DownloadMetadata =
DownloadMetadata(0, 0, 0, null)
fun setPersistentId(id: Int) {
persistentId = id
currentMetaData.id = id
VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(context, id)?.let { savedData ->
val downloadedBytes = savedData.fileLength
val totalBytes = savedData.totalBytes
/*lastRequest = savedData.uriRequest
files = savedData.files
var totalBytes: Long = 0
var downloadedBytes: Long = 0
for (file in savedData.files) {
downloadedBytes += file.completedLength
totalBytes += file.length
}*/
setProgress(downloadedBytes, totalBytes)
// some extra padding for just in case
val status = VideoDownloadManager.downloadStatus[id]
?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) DownloadStatusTell.IsDone else DownloadStatusTell.IsPaused
currentMetaData.apply {
this.id = id
this.downloadedLength = downloadedBytes
this.totalLength = totalBytes
this.status = status
}
setStatus(status)
} ?: run {
resetView()
}
}
abstract fun setStatus(status: VideoDownloadManager.DownloadType?)
open fun setProgress(downloadedBytes: Long, totalBytes: Long) {
isZeroBytes = downloadedBytes == 0L
progressBar.post {
val steps = 10000L
progressBar.max = steps.toInt()
// div by zero error and 1 byte off is ok impo
val progress = (downloadedBytes * steps / (totalBytes + 1L)).toInt()
val animation = ProgressBarAnimation(
progressBar,
progressBar.progress.toFloat(),
progress.toFloat()
).apply {
fillAfter = true
duration =
if (progress > progressBar.progress) // we don't want to animate backward changes in progress
100
else
0L
}
if (isZeroBytes) {
progressText?.isVisible = false
} else {
progressText?.apply {
val currentMbString = Formatter.formatShortFileSize(context, downloadedBytes)
val totalMbString = Formatter.formatShortFileSize(context, totalBytes)
text =
//if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else
context?.getString(R.string.download_size_format)
?.format(currentMbString, totalMbString)
}
}
progressBar.startAnimation(animation)
}
}
fun downloadStatusEvent(data: Pair<Int, VideoDownloadManager.DownloadType>) {
val (id, status) = data
if (id == persistentId) {
currentMetaData.status = status
setStatus(status)
}
}
/*fun downloadDeleteEvent(data: Int) {
}*/
/*fun downloadEvent(data: Pair<Int, VideoDownloadManager.DownloadActionType>) {
val (id, action) = data
}*/
fun downloadProgressEvent(data: Triple<Int, Long, Long>) {
val (id, bytesDownloaded, bytesTotal) = data
if (id == persistentId) {
currentMetaData.downloadedLength = bytesDownloaded
currentMetaData.totalLength = bytesTotal
setProgress(bytesDownloaded, bytesTotal)
}
}
override fun onAttachedToWindow() {
VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent
//VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent
//VideoDownloadManager.downloadEvent += ::downloadEvent
VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent
val pid = persistentId
if (pid != null) {
// refresh in case of onDetachedFromWindow -> onAttachedToWindow while still being ???????
setPersistentId(pid)
}
super.onAttachedToWindow()
}
override fun onDetachedFromWindow() {
VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent
//VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent
//VideoDownloadManager.downloadEvent -= ::downloadEvent
VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent
super.onDetachedFromWindow()
}
/**
* No checks required. Arg will always include a download with current id
* */
abstract fun updateViewOnDownload(metadata: DownloadMetadata)
/**
* Get a clean slate again, might be useful in recyclerview?
* */
abstract fun resetView()
}

View file

@ -0,0 +1,59 @@
package com.lagradost.cloudstream3.ui.download.button
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.core.view.isVisible
import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
class DownloadButton(context: Context, attributeSet: AttributeSet) :
PieFetchButton(context, attributeSet) {
var mainText: TextView? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
progressText = findViewById(R.id.result_movie_download_text_precentage)
mainText = findViewById(R.id.result_movie_download_text)
}
override fun setStatus(status: DownloadStatusTell?) {
mainText?.post {
val txt = when (status) {
DownloadStatusTell.IsPaused -> R.string.download_paused
DownloadStatusTell.IsDownloading -> R.string.downloading
DownloadStatusTell.IsDone -> R.string.downloaded
else -> R.string.download
}
mainText?.setText(txt)
}
super.setStatus(status)
}
override fun setDefaultClickListener(
card: VideoDownloadHelper.DownloadEpisodeCached,
textView: TextView?,
callback: (DownloadClickEvent) -> Unit
) {
this.setDefaultClickListener(
this.findViewById<MaterialButton>(R.id.download_movie_button),
textView,
card,
callback
)
}
@SuppressLint("SetTextI18n")
override fun updateViewOnDownload(metadata: DownloadMetadata) {
super.updateViewOnDownload(metadata)
val isVis = metadata.progressPercentage > 0
progressText?.isVisible = isVis
if (isVis)
progressText?.text = "${metadata.progressPercentage}%"
}
}

View file

@ -0,0 +1,323 @@
package com.lagradost.cloudstream3.ui.download.button
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.animation.AnimationUtils
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD
import com.lagradost.cloudstream3.ui.download.DownloadClickEvent
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import com.lagradost.cloudstream3.utils.VideoDownloadManager
open class PieFetchButton(context: Context, attributeSet: AttributeSet) :
BaseFetchButton(context, attributeSet) {
private var waitingAnimation: Int = 0
private var animateWaiting: Boolean = false
private var activeOutline: Int = 0
private var nonActiveOutline: Int = 0
private var iconInit: Int = 0
private var iconError: Int = 0
private var iconComplete: Int = 0
private var iconActive: Int = 0
private var iconWaiting: Int = 0
private var iconRemoved: Int = 0
private var iconPaused: Int = 0
private var hideWhenIcon: Boolean = true
var overrideLayout: Int? = null
companion object {
val fillArray = arrayOf(
R.drawable.circular_progress_bar_clockwise,
R.drawable.circular_progress_bar_counter_clockwise,
R.drawable.circular_progress_bar_small_to_large,
R.drawable.circular_progress_bar_top_to_bottom,
)
}
private var progressBarBackground: View
private var statusView: ImageView
open fun onInflate() {}
init {
context.obtainStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0).apply {
try {
inflate(
overrideLayout ?: getResourceId(
R.styleable.PieFetchButton_download_layout,
R.layout.download_button_view
)
)
} catch (e: Exception) {
Log.e(
"PieFetchButton", "Error inflating PieFetchButton, " +
"check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color"
)
throw e
}
progressBar = findViewById(R.id.progress_downloaded)
progressBarBackground = findViewById(R.id.progress_downloaded_background)
statusView = findViewById(R.id.image_download_status)
animateWaiting = getBoolean(
R.styleable.PieFetchButton_download_animate_waiting,
true
)
hideWhenIcon = getBoolean(
R.styleable.PieFetchButton_download_hide_when_icon,
true
)
waitingAnimation = getResourceId(
R.styleable.PieFetchButton_download_waiting_animation,
R.anim.rotate_around_center_point
)
activeOutline = getResourceId(
R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape
)
nonActiveOutline = getResourceId(
R.styleable.PieFetchButton_download_outline_non_active,
R.drawable.circle_shape_dotted
)
iconInit = getResourceId(
R.styleable.PieFetchButton_download_icon_init, R.drawable.netflix_download
)
iconError = getResourceId(
R.styleable.PieFetchButton_download_icon_paused, R.drawable.download_icon_error
)
iconComplete = getResourceId(
R.styleable.PieFetchButton_download_icon_complete, R.drawable.download_icon_done
)
iconPaused = getResourceId(
R.styleable.PieFetchButton_download_icon_paused, 0//R.drawable.download_icon_pause
)
iconActive = getResourceId(
R.styleable.PieFetchButton_download_icon_active, 0 //R.drawable.download_icon_load
)
iconWaiting = getResourceId(
R.styleable.PieFetchButton_download_icon_waiting, 0
)
iconRemoved = getResourceId(
R.styleable.PieFetchButton_download_icon_removed, R.drawable.netflix_download
)
val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0)
val progressDrawable = getResourceId(
R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex]
)
progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable)
recycle()
}
resetView()
onInflate()
}
private var currentStatus: DownloadStatusTell? = null
/*private fun getActivity(): Activity? {
var context = context
while (context is ContextWrapper) {
if (context is Activity) {
return context
}
context = context.baseContext
}
return null
}
fun callback(event : DownloadClickEvent) {
handleDownloadClick(
getActivity(),
event
)
}*/
protected fun setDefaultClickListener(
view: View, textView: TextView?, card: VideoDownloadHelper.DownloadEpisodeCached,
callback: (DownloadClickEvent) -> Unit
) {
this.progressText = textView
this.setPersistentId(card.id)
view.setOnClickListener {
if (isZeroBytes) {
callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card))
//callback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, data))
} else {
val list = arrayListOf(
Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file),
Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file),
)
currentMetaData.apply {
// DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone &&
if (progressPercentage < 98) {
list.add(
if (status == VideoDownloadManager.DownloadType.IsDownloading)
Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download)
else
Pair(
DOWNLOAD_ACTION_RESUME_DOWNLOAD,
R.string.popup_resume_download
)
)
}
}
it.popupMenuNoIcons(
list
) {
callback(DownloadClickEvent(itemId, card))
//callback.invoke(DownloadClickEvent(itemId, data))
}
}
}
view.setOnLongClickListener {
callback(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, card))
//clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data))
return@setOnLongClickListener true
}
}
open fun setDefaultClickListener(
card: VideoDownloadHelper.DownloadEpisodeCached,
textView: TextView?,
callback: (DownloadClickEvent) -> Unit
) {
setDefaultClickListener(this, textView, card, callback)
}
/*open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List<UriRequest>) {
this.setOnClickListener {
when (this.currentStatus) {
null -> {
setStatus(DownloadStatusTell.IsPending)
ioThread {
val request = requestGetter.invoke(this)
if (request.size == 1) {
performDownload(request.first())
} else if (request.isNotEmpty()) {
performFailQueueDownload(request)
}
}
}
DownloadStatusTell.Paused -> {
resumeDownload()
}
DownloadStatusTell.Active -> {
pauseDownload()
}
DownloadStatusTell.Error -> {
redownload()
}
else -> {}
}
}
}*/
/** Also sets currentStatus */
override fun setStatus(status: DownloadStatusTell?) {
currentStatus = status
//progressBar.isVisible =
// status != null && status != DownloadStatusTell.Complete && status != DownloadStatusTell.Error
//progressBarBackground.isVisible = status != null && status != DownloadStatusTell.Complete
progressBarBackground.post {
val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading
if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) {
val animation = AnimationUtils.loadAnimation(context, waitingAnimation)
progressBarBackground.startAnimation(animation)
} else {
progressBarBackground.clearAnimation()
}
val progressDrawable =
if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline
progressBarBackground.background =
ContextCompat.getDrawable(context, progressDrawable)
val drawable = getDrawableFromStatus(status)
statusView.setImageDrawable(drawable)
val isDrawable = drawable != null
statusView.isVisible = isDrawable
val hide = hideWhenIcon && isDrawable
if (hide) {
progressBar.clearAnimation()
progressBarBackground.clearAnimation()
}
progressBarBackground.isGone = hide
progressBar.isGone = hide
}
}
override fun resetView() {
setStatus(null)
currentMetaData = DownloadMetadata(0, 0, 0, null)
isZeroBytes = true
progressBar.progress = 0
}
override fun updateViewOnDownload(metadata: DownloadMetadata) {
val newStatus = metadata.status
if (newStatus == null) {
resetView()
return
}
val isDone =
newStatus == DownloadStatusTell.IsDone || (metadata.downloadedLength > 1024 && metadata.downloadedLength + 1024 >= metadata.totalLength)
if (isDone)
setStatus(DownloadStatusTell.IsDone)
else {
setProgress(metadata.downloadedLength, metadata.totalLength)
setStatus(newStatus)
}
}
open fun getDrawableFromStatus(status: DownloadStatusTell?): Drawable? {
val drawableInt = when (status) {
DownloadStatusTell.IsPaused -> iconPaused
DownloadStatusTell.IsPending -> iconWaiting
DownloadStatusTell.IsDownloading -> iconActive
DownloadStatusTell.IsFailed -> iconError
DownloadStatusTell.IsDone -> iconComplete
DownloadStatusTell.IsStopped -> iconRemoved
null -> iconInit
}
if (drawableInt == 0) {
return null
}
return ContextCompat.getDrawable(this.context, drawableInt)
}
}

View file

@ -0,0 +1,18 @@
package com.lagradost.cloudstream3.ui.download.button
import android.view.animation.Animation
import android.view.animation.Transformation
import android.widget.ProgressBar
class ProgressBarAnimation(
private val progressBar: ProgressBar,
private val from: Float,
private val to: Float
) :
Animation() {
override fun applyTransformation(interpolatedTime: Float, t: Transformation?) {
super.applyTransformation(interpolatedTime, t)
val value = from + (to - from) * interpolatedTime
progressBar.progress = value.toInt()
}
}

View file

@ -1,22 +1,23 @@
package com.lagradost.cloudstream3.ui.home package com.lagradost.cloudstream3.ui.home
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.databinding.HomeResultGridBinding
import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.utils.AppUtils.isRtl
import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import kotlinx.android.synthetic.main.home_result_grid.view.background_card
import kotlinx.android.synthetic.main.home_result_grid_expanded.view.*
class HomeChildItemAdapter( class HomeChildItemAdapter(
val cardList: MutableList<SearchResponse>, val cardList: MutableList<SearchResponse>,
private val overrideLayout: Int? = null,
private val nextFocusUp: Int? = null, private val nextFocusUp: Int? = null,
private val nextFocusDown: Int? = null, private val nextFocusDown: Int? = null,
private val clickCallback: (SearchClickCallback) -> Unit, private val clickCallback: (SearchClickCallback) -> Unit,
@ -26,16 +27,28 @@ class HomeChildItemAdapter(
var hasNext: Boolean = false var hasNext: Boolean = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layout = overrideLayout val expanded = parent.context.IsBottomLayout()
?: if (parent.context.IsBottomLayout()) R.layout.home_result_grid_expanded else R.layout.home_result_grid /* val layout = if (bottom) R.layout.home_result_grid_expanded else R.layout.home_result_grid
val root = LayoutInflater.from(parent.context).inflate(layout, parent, false)
val binding = HomeResultGridBinding.bind(root)*/
val inflater = LayoutInflater.from(parent.context)
val binding = if (expanded) HomeResultGridExpandedBinding.inflate(
inflater,
parent,
false
) else HomeResultGridBinding.inflate(inflater, parent, false)
return CardViewHolder( return CardViewHolder(
LayoutInflater.from(parent.context).inflate(layout, parent, false), binding,
clickCallback, clickCallback,
itemCount, itemCount,
nextFocusUp, nextFocusUp,
nextFocusDown, nextFocusDown,
isHorizontal isHorizontal,
parent.isRtl()
) )
} }
@ -69,58 +82,101 @@ class HomeChildItemAdapter(
class CardViewHolder class CardViewHolder
constructor( constructor(
itemView: View, val binding: ViewBinding,
private val clickCallback: (SearchClickCallback) -> Unit, private val clickCallback: (SearchClickCallback) -> Unit,
var itemCount: Int, var itemCount: Int,
private val nextFocusUp: Int? = null, private val nextFocusUp: Int? = null,
private val nextFocusDown: Int? = null, private val nextFocusDown: Int? = null,
private val isHorizontal: Boolean = false private val isHorizontal: Boolean = false,
private val isRtl: Boolean
) : ) :
RecyclerView.ViewHolder(itemView) { RecyclerView.ViewHolder(binding.root) {
fun bind(card: SearchResponse, position: Int) { fun bind(card: SearchResponse, position: Int) {
// TV focus fixing // TV focus fixing
val nextFocusBehavior = when (position) { /*val nextFocusBehavior = when (position) {
0 -> true 0 -> true
itemCount - 1 -> false itemCount - 1 -> false
else -> null else -> null
} }
(itemView.image_holder ?: itemView.background_card)?.apply { if (position == 0) { // to fix tv
val min = 114.toPx if (isRtl) {
val max = 180.toPx itemView.nextFocusRightId = R.id.nav_rail_view
itemView.nextFocusLeftId = -1
}
else {
itemView.nextFocusLeftId = R.id.nav_rail_view
itemView.nextFocusRightId = -1
}
} else {
itemView.nextFocusRightId = -1
itemView.nextFocusLeftId = -1
}*/
layoutParams =
layoutParams.apply { when (binding) {
width = if (!isHorizontal) { is HomeResultGridBinding -> {
min binding.backgroundCard.apply {
} else { val min = 114.toPx
max val max = 180.toPx
}
height = if (!isHorizontal) { layoutParams =
max layoutParams.apply {
} else { width = if (!isHorizontal) {
min min
} } else {
max
}
height = if (!isHorizontal) {
max
} else {
min
}
}
} }
}
}
is HomeResultGridExpandedBinding -> {
binding.backgroundCard.apply {
val min = 114.toPx
val max = 180.toPx
layoutParams =
layoutParams.apply {
width = if (!isHorizontal) {
min
} else {
max
}
height = if (!isHorizontal) {
max
} else {
min
}
}
}
if (position == 0) { // to fix tv
binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view
}
}
}
SearchResultBuilder.bind( SearchResultBuilder.bind(
clickCallback, clickCallback,
card, card,
position, position,
itemView, itemView,
nextFocusBehavior, null, // nextFocusBehavior,
nextFocusUp, nextFocusUp,
nextFocusDown nextFocusDown
) )
itemView.tag = position itemView.tag = position
if (position == 0) { // to fix tv
itemView.background_card?.nextFocusLeftId = R.id.nav_rail_view
}
//val ani = ScaleAnimation(0.9f, 1.0f, 0.9f, 1f) //val ani = ScaleAnimation(0.9f, 1.0f, 0.9f, 1f)
//ani.fillAfter = true //ani.fillAfter = true
//ani.duration = 200 //ani.duration = 200

View file

@ -31,18 +31,24 @@ import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterBackupRestoreEvent
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent
import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.mainPluginsLoadedEvent
import com.lagradost.cloudstream3.databinding.FragmentHomeBinding
import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding
import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding
import com.lagradost.cloudstream3.databinding.TvtypesChipsBinding
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.mvvm.observeNullable
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.ui.search.* import com.lagradost.cloudstream3.ui.search.*
import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
@ -64,25 +70,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
import kotlinx.android.synthetic.main.activity_main_tv.*
import kotlinx.android.synthetic.main.fragment_home.*
import kotlinx.android.synthetic.main.fragment_home.home_api_fab
import kotlinx.android.synthetic.main.fragment_home.home_change_api_loading
import kotlinx.android.synthetic.main.fragment_home.home_loading
import kotlinx.android.synthetic.main.fragment_home.home_loading_error
import kotlinx.android.synthetic.main.fragment_home.home_loading_shimmer
import kotlinx.android.synthetic.main.fragment_home.home_loading_statusbar
import kotlinx.android.synthetic.main.fragment_home.home_master_recycler
import kotlinx.android.synthetic.main.fragment_home.home_reload_connection_open_in_browser
import kotlinx.android.synthetic.main.fragment_home.home_reload_connectionerror
import kotlinx.android.synthetic.main.fragment_home.result_error_text
import kotlinx.android.synthetic.main.fragment_home_tv.*
import kotlinx.android.synthetic.main.fragment_result.*
import kotlinx.android.synthetic.main.fragment_search.*
import kotlinx.android.synthetic.main.home_episodes_expanded.*
import kotlinx.android.synthetic.main.tvtypes_chips.*
import kotlinx.android.synthetic.main.tvtypes_chips.view.*
import java.util.* import java.util.*
@ -126,22 +114,26 @@ class HomeFragment : Fragment() {
expand: HomeViewModel.ExpandableHomepageList, expand: HomeViewModel.ExpandableHomepageList,
deleteCallback: (() -> Unit)? = null, deleteCallback: (() -> Unit)? = null,
expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null, expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null,
dismissCallback : (() -> Unit), dismissCallback: (() -> Unit),
): BottomSheetDialog { ): BottomSheetDialog {
val context = this val context = this
val bottomSheetDialogBuilder = BottomSheetDialog(context) val bottomSheetDialogBuilder = BottomSheetDialog(context)
val binding: HomeEpisodesExpandedBinding = HomeEpisodesExpandedBinding.inflate(
bottomSheetDialogBuilder.setContentView(R.layout.home_episodes_expanded) bottomSheetDialogBuilder.layoutInflater,
val title = bottomSheetDialogBuilder.findViewById<TextView>(R.id.home_expanded_text)!! null,
false
)
bottomSheetDialogBuilder.setContentView(binding.root)
//val title = bottomSheetDialogBuilder.findViewById<TextView>(R.id.home_expanded_text)!!
//title.findViewTreeLifecycleOwner().lifecycle.addObserver() //title.findViewTreeLifecycleOwner().lifecycle.addObserver()
val item = expand.list val item = expand.list
title.text = item.name binding.homeExpandedText.text = item.name
val recycle = // val recycle =
bottomSheetDialogBuilder.findViewById<AutofitRecyclerView>(R.id.home_expanded_recycler)!! // bottomSheetDialogBuilder.findViewById<AutofitRecyclerView>(R.id.home_expanded_recycler)!!
val titleHolder = //val titleHolder =
bottomSheetDialogBuilder.findViewById<FrameLayout>(R.id.home_expanded_drag_down)!! // bottomSheetDialogBuilder.findViewById<FrameLayout>(R.id.home_expanded_drag_down)!!
// main { // main {
//(bottomSheetDialogBuilder.ownerActivity as androidx.fragment.app.FragmentActivity?)?.supportFragmentManager?.fragments?.lastOrNull()?.viewLifecycleOwner?.apply { //(bottomSheetDialogBuilder.ownerActivity as androidx.fragment.app.FragmentActivity?)?.supportFragmentManager?.fragments?.lastOrNull()?.viewLifecycleOwner?.apply {
@ -160,10 +152,10 @@ class HomeFragment : Fragment() {
// }) // })
//} //}
// } // }
val delete = bottomSheetDialogBuilder.home_expanded_delete //val delete = bottomSheetDialogBuilder.home_expanded_delete
delete.isGone = deleteCallback == null binding.homeExpandedDelete.isGone = deleteCallback == null
if (deleteCallback != null) { if (deleteCallback != null) {
delete.setOnClickListener { binding.homeExpandedDelete.setOnClickListener {
try { try {
val builder: AlertDialog.Builder = AlertDialog.Builder(context) val builder: AlertDialog.Builder = AlertDialog.Builder(context)
val dialogClickListener = val dialogClickListener =
@ -173,6 +165,7 @@ class HomeFragment : Fragment() {
deleteCallback.invoke() deleteCallback.invoke()
bottomSheetDialogBuilder.dismissSafe(this) bottomSheetDialogBuilder.dismissSafe(this)
} }
DialogInterface.BUTTON_NEGATIVE -> {} DialogInterface.BUTTON_NEGATIVE -> {}
} }
} }
@ -192,26 +185,27 @@ class HomeFragment : Fragment() {
} }
} }
} }
binding.homeExpandedDragDown.setOnClickListener {
titleHolder.setOnClickListener {
bottomSheetDialogBuilder.dismissSafe(this) bottomSheetDialogBuilder.dismissSafe(this)
} }
// Span settings // Span settings
recycle.spanCount = currentSpan binding.homeExpandedRecycler.spanCount = currentSpan
recycle.adapter = SearchAdapter(item.list.toMutableList(), recycle) { callback -> binding.homeExpandedRecycler.adapter =
handleSearchClickCallback(this, callback) SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback ->
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) { handleSearchClickCallback(callback)
bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
//bottomSheetDialogBuilder.dismissSafe(this) bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later
//bottomSheetDialogBuilder.dismissSafe(this)
}
}.apply {
hasNext = expand.hasNext
} }
}.apply {
hasNext = expand.hasNext
}
recycle.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.homeExpandedRecycler.addOnScrollListener(object :
RecyclerView.OnScrollListener() {
var expandCount = 0 var expandCount = 0
val name = expand.list.name val name = expand.list.name
@ -239,7 +233,7 @@ class HomeFragment : Fragment() {
}) })
val spanListener = { span: Int -> val spanListener = { span: Int ->
recycle.spanCount = span binding.homeExpandedRecycler.spanCount = span
//(recycle.adapter as SearchAdapter).notifyDataSetChanged() //(recycle.adapter as SearchAdapter).notifyDataSetChanged()
} }
@ -281,19 +275,19 @@ class HomeFragment : Fragment() {
) )
} }
private fun getPairList(header: ChipGroup) = getPairList( private fun getPairList(header: TvtypesChipsBinding) = getPairList(
header.home_select_anime, header.homeSelectAnime,
header.home_select_cartoons, header.homeSelectCartoons,
header.home_select_tv_series, header.homeSelectTvSeries,
header.home_select_documentaries, header.homeSelectDocumentaries,
header.home_select_movies, header.homeSelectMovies,
header.home_select_asian, header.homeSelectAsian,
header.home_select_livestreams, header.homeSelectLivestreams,
header.home_select_nsfw, header.homeSelectNsfw,
header.home_select_others header.homeSelectOthers
) )
fun validateChips(header: ChipGroup?, validTypes: List<TvType>) { fun validateChips(header: TvtypesChipsBinding?, validTypes: List<TvType>) {
if (header == null) return if (header == null) return
val pairList = getPairList(header) val pairList = getPairList(header)
for ((button, types) in pairList) { for ((button, types) in pairList) {
@ -302,7 +296,7 @@ class HomeFragment : Fragment() {
} }
} }
fun updateChips(header: ChipGroup?, selectedTypes: List<TvType>) { fun updateChips(header: TvtypesChipsBinding?, selectedTypes: List<TvType>) {
if (header == null) return if (header == null) return
val pairList = getPairList(header) val pairList = getPairList(header)
for ((button, types) in pairList) { for ((button, types) in pairList) {
@ -312,10 +306,21 @@ class HomeFragment : Fragment() {
} }
fun bindChips( fun bindChips(
header: ChipGroup?, header: TvtypesChipsBinding?,
selectedTypes: List<TvType>, selectedTypes: List<TvType>,
validTypes: List<TvType>, validTypes: List<TvType>,
callback: (List<TvType>) -> Unit callback: (List<TvType>) -> Unit
) {
bindChips(header, selectedTypes, validTypes, callback, null, null)
}
fun bindChips(
header: TvtypesChipsBinding?,
selectedTypes: List<TvType>,
validTypes: List<TvType>,
callback: (List<TvType>) -> Unit,
nextFocusDown: Int?,
nextFocusUp: Int?
) { ) {
if (header == null) return if (header == null) return
val pairList = getPairList(header) val pairList = getPairList(header)
@ -323,6 +328,17 @@ class HomeFragment : Fragment() {
val isValid = validTypes.any { types.contains(it) } val isValid = validTypes.any { types.contains(it) }
button?.isVisible = isValid button?.isVisible = isValid
button?.isChecked = isValid && selectedTypes.any { types.contains(it) } button?.isChecked = isValid && selectedTypes.any { types.contains(it) }
button?.isFocusable = true
if (isTrueTvSettings()) {
button?.isFocusableInTouchMode = true
}
if (nextFocusDown != null)
button?.nextFocusDownId = nextFocusDown
if (nextFocusUp != null)
button?.nextFocusUpId = nextFocusUp
button?.setOnCheckedChangeListener { _, _ -> button?.setOnCheckedChangeListener { _, _ ->
val list = ArrayList<TvType>() val list = ArrayList<TvType>()
for ((sbutton, vvalidTypes) in pairList) { for ((sbutton, vvalidTypes) in pairList) {
@ -345,7 +361,13 @@ class HomeFragment : Fragment() {
BottomSheetDialog(this) BottomSheetDialog(this)
builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED
builder.setContentView(R.layout.home_select_mainpage) val binding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate(
builder.layoutInflater,
null,
false
)
builder.setContentView(binding.root)
builder.show() builder.show()
builder.let { dialog -> builder.let { dialog ->
val isMultiLang = getApiProviderLangSettings().let { set -> val isMultiLang = getApiProviderLangSettings().let { set ->
@ -361,14 +383,11 @@ class HomeFragment : Fragment() {
?.toMutableList() ?.toMutableList()
?: mutableListOf(TvType.Movie, TvType.TvSeries) ?: mutableListOf(TvType.Movie, TvType.TvSeries)
val cancelBtt = dialog.findViewById<MaterialButton>(R.id.cancel_btt) binding.cancelBtt.setOnClickListener {
val applyBtt = dialog.findViewById<MaterialButton>(R.id.apply_btt)
cancelBtt?.setOnClickListener {
dialog.dismissSafe() dialog.dismissSafe()
} }
applyBtt?.setOnClickListener { binding.applyBtt.setOnClickListener {
if (currentApiName != selectedApiName) { if (currentApiName != selectedApiName) {
currentApiName?.let(callback) currentApiName?.let(callback)
} }
@ -409,7 +428,7 @@ class HomeFragment : Fragment() {
} }
bindChips( bindChips(
dialog.home_select_group, binding.tvtypesChipsScroll.tvtypesChips,
preSelectedTypes, preSelectedTypes,
validAPIs.flatMap { it.supportedTypes }.distinct() validAPIs.flatMap { it.supportedTypes }.distinct()
) { list -> ) { list ->
@ -424,6 +443,9 @@ class HomeFragment : Fragment() {
private val homeViewModel: HomeViewModel by activityViewModels() private val homeViewModel: HomeViewModel by activityViewModels()
var binding: FragmentHomeBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@ -431,14 +453,25 @@ class HomeFragment : Fragment() {
): View? { ): View? {
//homeViewModel = //homeViewModel =
// ViewModelProvider(this).get(HomeViewModel::class.java) // ViewModelProvider(this).get(HomeViewModel::class.java)
bottomSheetDialog?.ownShow() bottomSheetDialog?.ownShow()
val layout = val layout =
if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home if (isTvSettings()) R.layout.fragment_home_tv else R.layout.fragment_home
return inflater.inflate(layout, container, false) val root = inflater.inflate(layout, container, false)
binding = try {
FragmentHomeBinding.bind(root)
} catch (t: Throwable) {
showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG)
logError(t)
null
}
return root
} }
override fun onDestroyView() { override fun onDestroyView() {
bottomSheetDialog?.ownHide() bottomSheetDialog?.ownHide()
binding = null
super.onDestroyView() super.onDestroyView()
} }
@ -451,7 +484,7 @@ class HomeFragment : Fragment() {
private val apiChangeClickListener = View.OnClickListener { view -> private val apiChangeClickListener = View.OnClickListener { view ->
view.context.selectHomepage(currentApiName) { api -> view.context.selectHomepage(currentApiName) { api ->
homeViewModel.loadAndCancel(api) homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true)
} }
/*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf() /*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf()
@ -528,138 +561,137 @@ class HomeFragment : Fragment() {
private var bottomSheetDialog: BottomSheetDialog? = null private var bottomSheetDialog: BottomSheetDialog? = null
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
fixGrid() fixGrid()
home_change_api_loading?.setOnClickListener(apiChangeClickListener) binding?.apply {
home_api_fab?.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
home_random?.setOnClickListener { //homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
if (listHomepageItems.isNotEmpty()) { homeApiFab.setOnClickListener(apiChangeClickListener)
activity.loadSearchResult(listHomepageItems.random()) homeChangeApi.setOnClickListener(apiChangeClickListener)
homeSwitchAccount.setOnClickListener { v ->
DataStoreHelper.showWhoIsWatching(v?.context ?: return@setOnClickListener)
} }
homeRandom.setOnClickListener {
if (listHomepageItems.isNotEmpty()) {
activity.loadSearchResult(listHomepageItems.random())
}
}
homeMasterRecycler.adapter =
HomeParentItemAdapterPreview(
mutableListOf(),
homeViewModel
)
//fixPaddingStatusbar(homeLoadingStatusbar)
homeApiFab.isVisible = !isTvSettings()
homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy > 0) { //check for scroll down
homeApiFab.shrink() // hide
homeRandom.shrink()
} else if (dy < -5) {
if (!isTvSettings()) {
homeApiFab.extend() // show
homeRandom.extend()
}
}
super.onScrolled(recyclerView, dx, dy)
}
})
} }
//Load value for toggling Random button. Hide at startup //Load value for toggling Random button. Hide at startup
context?.let { context?.let {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
toggleRandomButton = toggleRandomButton =
settingsManager.getBoolean(getString(R.string.random_button_key), false) settingsManager.getBoolean(
home_random?.visibility = View.GONE getString(R.string.random_button_key),
} false
) && !isTvSettings()
observe(homeViewModel.preview) { preview -> binding?.homeRandom?.visibility = View.GONE
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setPreviewData(
preview
)
} }
observe(homeViewModel.apiName) { apiName -> observe(homeViewModel.apiName) { apiName ->
currentApiName = apiName currentApiName = apiName
home_api_fab?.text = apiName binding?.homeApiFab?.text = apiName
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setApiName( binding?.homeChangeApi?.text = apiName
apiName
)
} }
observe(homeViewModel.page) { data -> observe(homeViewModel.page) { data ->
when (data) { binding?.apply {
is Resource.Success -> { when (data) {
home_loading_shimmer?.stopShimmer() is Resource.Success -> {
homeLoadingShimmer.stopShimmer()
val d = data.value val d = data.value
val mutableListOfResponse = mutableListOf<SearchResponse>() val mutableListOfResponse = mutableListOf<SearchResponse>()
listHomepageItems.clear() listHomepageItems.clear()
(home_master_recycler?.adapter as? ParentItemAdapter)?.updateList( (homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList(
d.values.toMutableList(), d.values.toMutableList(),
home_master_recycler homeMasterRecycler
) )
home_loading?.isVisible = false homeLoading.isVisible = false
home_loading_error?.isVisible = false homeLoadingError.isVisible = false
home_master_recycler?.isVisible = true homeMasterRecycler.isVisible = true
//home_loaded?.isVisible = true //home_loaded?.isVisible = true
if (toggleRandomButton) { if (toggleRandomButton) {
//Flatten list //Flatten list
d.values.forEach { dlist -> d.values.forEach { dlist ->
mutableListOfResponse.addAll(dlist.list.list) mutableListOfResponse.addAll(dlist.list.list)
}
listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url })
homeRandom.isVisible = listHomepageItems.isNotEmpty()
} else {
homeRandom.isGone = true
} }
listHomepageItems.addAll(mutableListOfResponse.distinctBy { it.url })
home_random?.isVisible = listHomepageItems.isNotEmpty()
} else {
home_random?.isGone = true
} }
}
is Resource.Failure -> {
home_loading_shimmer?.stopShimmer()
result_error_text.text = data.errorString is Resource.Failure -> {
homeLoadingShimmer.stopShimmer()
resultErrorText.text = data.errorString
homeReloadConnectionerror.setOnClickListener(apiChangeClickListener)
homeReloadConnectionOpenInBrowser.setOnClickListener { view ->
val validAPIs = apis//.filter { api -> api.hasMainPage }
home_reload_connectionerror.setOnClickListener(apiChangeClickListener) view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api ->
Pair(
home_reload_connection_open_in_browser.setOnClickListener { view -> index,
val validAPIs = apis//.filter { api -> api.hasMainPage } api.name
)
view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api -> }) {
Pair( try {
index, val i = Intent(Intent.ACTION_VIEW)
api.name i.data = Uri.parse(validAPIs[itemId].mainUrl)
) startActivity(i)
}) { } catch (e: Exception) {
try { logError(e)
val i = Intent(Intent.ACTION_VIEW) }
i.data = Uri.parse(validAPIs[itemId].mainUrl)
startActivity(i)
} catch (e: Exception) {
logError(e)
} }
} }
homeLoading.isVisible = false
homeLoadingError.isVisible = true
homeMasterRecycler.isVisible = false
//home_loaded?.isVisible = false
} }
home_loading?.isVisible = false is Resource.Loading -> {
home_loading_error?.isVisible = true (homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList(listOf())
home_master_recycler?.isVisible = false homeLoadingShimmer.startShimmer()
//home_loaded?.isVisible = false homeLoading.isVisible = true
} homeLoadingError.isVisible = false
is Resource.Loading -> { homeMasterRecycler.isVisible = false
(home_master_recycler?.adapter as? ParentItemAdapter)?.updateList(listOf()) //home_loaded?.isVisible = false
home_loading_shimmer?.startShimmer()
home_loading?.isVisible = true
home_loading_error?.isVisible = false
home_master_recycler?.isVisible = false
//home_loaded?.isVisible = false
}
}
}
observe(homeViewModel.availableWatchStatusTypes) { availableWatchStatusTypes ->
context?.setKey(
HOME_BOOKMARK_VALUE_LIST,
availableWatchStatusTypes.first.map { it.internalId }.toIntArray()
)
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setAvailableWatchStatusTypes(
availableWatchStatusTypes
)
}
observe(homeViewModel.bookmarks) { data ->
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setBookmarkData(
data
)
}
observe(homeViewModel.resumeWatching) { resumeWatching ->
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setResumeWatchingData(
resumeWatching
)
if (isTrueTvSettings()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ioSafe {
activity?.addProgramsToContinueWatching(resumeWatching.mapNotNull { it as? DataStoreHelper.ResumeWatchingResult })
} }
} }
} }
@ -668,72 +700,37 @@ class HomeFragment : Fragment() {
//context?.fixPaddingStatusbarView(home_statusbar) //context?.fixPaddingStatusbarView(home_statusbar)
//context?.fixPaddingStatusbar(home_padding) //context?.fixPaddingStatusbar(home_padding)
context?.fixPaddingStatusbar(home_loading_statusbar)
home_master_recycler?.adapter = observeNullable(homeViewModel.popup) { item ->
HomeParentItemAdapterPreview(mutableListOf(), { callback -> if (item == null) {
homeHandleSearch(callback) bottomSheetDialog?.dismissSafe()
}, { item -> bottomSheetDialog = null
bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = { return@observeNullable
homeViewModel.expandAndReturn(it)
}, dismissCallback = {
bottomSheetDialog = null
})
}, { name ->
homeViewModel.expand(name)
}, { load ->
activity?.loadResult(load.response.url, load.response.apiName, load.action)
}, {
homeViewModel.loadMoreHomeScrollResponses()
}, {
apiChangeClickListener.onClick(it)
}, reloadStored = {
reloadStored()
}, loadStoredData = {
homeViewModel.loadStoredData(it)
}, { (isQuickSearch, text) ->
if (!isQuickSearch) {
QuickSearchFragment.pushSearch(
activity,
text,
currentApiName?.let { arrayOf(it) })
}
})
reloadStored()
loadHomePage(false)
home_master_recycler?.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy > 0) { //check for scroll down
home_api_fab?.shrink() // hide
home_random?.shrink()
} else if (dy < -5) {
if (!isTvSettings()) {
home_api_fab?.extend() // show
home_random?.extend()
}
}
super.onScrolled(recyclerView, dx, dy)
} }
})
// don't recreate
if (bottomSheetDialog != null) {
return@observeNullable
}
val (items, delete) = item
bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = {
homeViewModel.expandAndReturn(it)
}, dismissCallback = {
homeViewModel.popup(null)
bottomSheetDialog = null
}, deleteCallback = delete)
}
homeViewModel.reloadStored()
homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false)
//loadHomePage(false)
// nice profile pic on homepage // nice profile pic on homepage
//home_profile_picture_holder?.isVisible = false //home_profile_picture_holder?.isVisible = false
// just in case // just in case
if (isTvSettings()) {
home_api_fab?.isVisible = false
if (isTrueTvSettings()) {
home_change_api_loading?.isVisible = true
home_change_api_loading?.isFocusable = true
home_change_api_loading?.isFocusableInTouchMode = true
}
// home_bookmark_select?.isFocusable = true
// home_bookmark_select?.isFocusableInTouchMode = true
} else {
home_api_fab?.isVisible = true
home_change_api_loading?.isVisible = false
}
//TODO READD THIS //TODO READD THIS
/*for (syncApi in OAuth2Apis) { /*for (syncApi in OAuth2Apis) {
val login = syncApi.loginInfo() val login = syncApi.loginInfo()

View file

@ -3,50 +3,20 @@ package com.lagradost.cloudstream3.ui.home
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.transition.ChangeBounds
import androidx.transition.TransitionManager
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.lagradost.cloudstream3.APIHolder.getId
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.HomepageParentBinding
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.result.LinearListLayout
import com.lagradost.cloudstream3.ui.result.ResultViewModel2
import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST
import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.result.setLinearListLayout
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchFragment.Companion.filterSearchResponse import com.lagradost.cloudstream3.ui.search.SearchFragment.Companion.filterSearchResponse
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable import com.lagradost.cloudstream3.utils.AppUtils.isRecyclerScrollable
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
import kotlinx.android.synthetic.main.activity_main_tv.*
import kotlinx.android.synthetic.main.activity_main_tv.view.*
import kotlinx.android.synthetic.main.fragment_home.*
import kotlinx.android.synthetic.main.fragment_home.view.*
import kotlinx.android.synthetic.main.fragment_home_head_tv.*
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.*
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.home_preview_viewpager
import kotlinx.android.synthetic.main.homepage_parent.view.*
class LoadClickCallback( class LoadClickCallback(
val action: Int = 0, val action: Int = 0,
@ -57,17 +27,23 @@ class LoadClickCallback(
open class ParentItemAdapter( open class ParentItemAdapter(
private var items: MutableList<HomeViewModel.ExpandableHomepageList>, private var items: MutableList<HomeViewModel.ExpandableHomepageList>,
//private val viewModel: HomeViewModel,
private val clickCallback: (SearchClickCallback) -> Unit, private val clickCallback: (SearchClickCallback) -> Unit,
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
private val expandCallback: ((String) -> Unit)? = null, private val expandCallback: ((String) -> Unit)? = null,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val root = LayoutInflater.from(parent.context).inflate(
if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent,
parent,
false
)
val binding = HomepageParentBinding.bind(root)
return ParentViewHolder( return ParentViewHolder(
LayoutInflater.from(parent.context).inflate( binding,
if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent,
parent,
false
),
clickCallback, clickCallback,
moreInfoClickCallback, moreInfoClickCallback,
expandCallback expandCallback
@ -178,15 +154,17 @@ open class ParentItemAdapter(
class ParentViewHolder class ParentViewHolder
constructor( constructor(
itemView: View, val binding: HomepageParentBinding,
// val viewModel: HomeViewModel,
private val clickCallback: (SearchClickCallback) -> Unit, private val clickCallback: (SearchClickCallback) -> Unit,
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
private val expandCallback: ((String) -> Unit)? = null, private val expandCallback: ((String) -> Unit)? = null,
) : ) :
RecyclerView.ViewHolder(itemView) { RecyclerView.ViewHolder(binding.root) {
val title: TextView = itemView.home_child_more_info val title: TextView = binding.homeChildMoreInfo
private val recyclerView: RecyclerView = itemView.home_child_recyclerview private val recyclerView: RecyclerView = binding.homeChildRecyclerview
private val startFocus = R.id.nav_rail_view
private val endFocus = FOCUS_SELF
fun update(expand: HomeViewModel.ExpandableHomepageList) { fun update(expand: HomeViewModel.ExpandableHomepageList) {
val info = expand.list val info = expand.list
(recyclerView.adapter as? HomeChildItemAdapter?)?.apply { (recyclerView.adapter as? HomeChildItemAdapter?)?.apply {
@ -200,8 +178,13 @@ open class ParentItemAdapter(
nextFocusDown = recyclerView.nextFocusDownId, nextFocusDown = recyclerView.nextFocusDownId,
).apply { ).apply {
isHorizontal = info.isHorizontalImages isHorizontal = info.isHorizontalImages
hasNext = expand.hasNext
} }
recyclerView.setLinearListLayout() recyclerView.setLinearListLayout(
isHorizontal = true,
nextLeft = startFocus,
nextRight = endFocus,
)
} }
} }
@ -216,7 +199,11 @@ open class ParentItemAdapter(
isHorizontal = info.isHorizontalImages isHorizontal = info.isHorizontalImages
hasNext = expand.hasNext hasNext = expand.hasNext
} }
recyclerView.setLinearListLayout() recyclerView.setLinearListLayout(
isHorizontal = true,
nextLeft = startFocus,
nextRight = endFocus,
)
title.text = info.name title.text = info.name
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {

View file

@ -2,24 +2,18 @@ package com.lagradost.cloudstream3.ui.home
import android.content.res.Configuration import android.content.res.Configuration
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding
import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.setImage
import kotlinx.android.synthetic.main.fragment_home_head_tv.*
import kotlinx.android.synthetic.main.fragment_home_head_tv.view.*
import kotlinx.android.synthetic.main.home_scroll_view.view.*
class HomeScrollAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
class HomeScrollAdapter(
@LayoutRes val layout: Int = R.layout.home_scroll_view,
private val forceHorizontalPosters: Boolean? = null
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items: MutableList<LoadResponse> = mutableListOf() private var items: MutableList<LoadResponse> = mutableListOf()
var hasMoreItems: Boolean = false var hasMoreItems: Boolean = false
@ -45,9 +39,16 @@ class HomeScrollAdapter(
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = if (isTvSettings()) {
HomeScrollViewTvBinding.inflate(inflater, parent, false)
} else {
HomeScrollViewBinding.inflate(inflater, parent, false)
}
return CardViewHolder( return CardViewHolder(
LayoutInflater.from(parent.context).inflate(layout, parent, false), binding,
forceHorizontalPosters //forceHorizontalPosters
) )
} }
@ -61,22 +62,32 @@ class HomeScrollAdapter(
class CardViewHolder class CardViewHolder
constructor( constructor(
itemView: View, val binding: ViewBinding,
private val forceHorizontalPosters: Boolean? = null //private val forceHorizontalPosters: Boolean? = null
) : ) :
RecyclerView.ViewHolder(itemView) { RecyclerView.ViewHolder(binding.root) {
fun bind(card: LoadResponse) { fun bind(card: LoadResponse) {
card.apply { val isHorizontal =
val isHorizontal = binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
(forceHorizontalPosters == true) || ((forceHorizontalPosters != false) && itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
val posterUrl = if (isHorizontal) backgroundPosterUrl ?: posterUrl else posterUrl val posterUrl =
?: backgroundPosterUrl if (isHorizontal) card.backgroundPosterUrl ?: card.posterUrl else card.posterUrl
itemView.home_scroll_preview_tags?.text = tags?.joinToString("") ?: "" ?: card.backgroundPosterUrl
itemView.home_scroll_preview_tags?.isGone = tags.isNullOrEmpty()
itemView.home_scroll_preview?.setImage(posterUrl, posterHeaders) when (binding) {
itemView.home_scroll_preview_title?.text = name is HomeScrollViewBinding -> {
binding.homeScrollPreview.setImage(posterUrl)
binding.homeScrollPreviewTags.apply {
text = card.tags?.joinToString("") ?: ""
isGone = card.tags.isNullOrEmpty()
}
binding.homeScrollPreviewTitle.text = card.name
}
is HomeScrollViewTvBinding -> {
binding.homeScrollPreview.setImage(posterUrl)
}
} }
} }
} }

View file

@ -1,10 +1,10 @@
package com.lagradost.cloudstream3.ui.home package com.lagradost.cloudstream3.ui.home
import android.os.Build
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.filterHomePageListByFilmQuality import com.lagradost.cloudstream3.APIHolder.filterHomePageListByFilmQuality
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
@ -13,26 +13,47 @@ import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.context
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.HomePageList
import com.lagradost.cloudstream3.LoadResponse
import com.lagradost.cloudstream3.MainAPI
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.MainActivity.Companion.lastError
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.debugWarning
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.plugins.PluginManager
import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.APIRepository
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.WatchType
import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment
import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchHelper
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
import com.lagradost.cloudstream3.utils.AppUtils.addProgramsToContinueWatching
import com.lagradost.cloudstream3.utils.AppUtils.loadResult
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE
import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllBookmarkedData
import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.* import java.util.EnumSet
import kotlin.collections.set import kotlin.collections.set
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
@ -72,7 +93,22 @@ class HomeViewModel : ViewModel() {
} }
} }
private var repo: APIRepository? = null fun deleteResumeWatching() {
deleteAllResumeStateIds()
loadResumeWatching()
}
fun deleteBookmarks(list: List<SearchResponse>) {
list.forEach { DataStoreHelper.deleteBookmarkedData(it.id) }
loadStoredData()
}
fun deleteBookmarks() {
deleteAllBookmarkedData()
loadStoredData()
}
var repo: APIRepository? = null
private val _apiName = MutableLiveData<String>() private val _apiName = MutableLiveData<String>()
val apiName: LiveData<String> = _apiName val apiName: LiveData<String> = _apiName
@ -83,7 +119,7 @@ class HomeViewModel : ViewModel() {
private var currentShuffledList: List<SearchResponse> = listOf() private var currentShuffledList: List<SearchResponse> = listOf()
private fun autoloadRepo(): APIRepository { private fun autoloadRepo(): APIRepository {
return APIRepository(apis.first { it.hasMainPage }) return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } })
} }
private val _availableWatchStatusTypes = private val _availableWatchStatusTypes =
@ -101,8 +137,14 @@ class HomeViewModel : ViewModel() {
val resumeWatching: LiveData<List<SearchResponse>> = _resumeWatching val resumeWatching: LiveData<List<SearchResponse>> = _resumeWatching
val preview: LiveData<Resource<Pair<Boolean, List<LoadResponse>>>> = _preview val preview: LiveData<Resource<Pair<Boolean, List<LoadResponse>>>> = _preview
fun loadResumeWatching() = viewModelScope.launchSafe { private fun loadResumeWatching() = viewModelScope.launchSafe {
val resumeWatchingResult = getResumeWatching() val resumeWatchingResult = getResumeWatching()
if (isTrueTvSettings() && resumeWatchingResult != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ioSafe {
// this WILL crash on non tvs, so keep this inside a try catch
activity?.addProgramsToContinueWatching(resumeWatchingResult)
}
}
resumeWatchingResult?.let { resumeWatchingResult?.let {
_resumeWatching.postValue(it) _resumeWatching.postValue(it)
} }
@ -128,6 +170,10 @@ class HomeViewModel : ViewModel() {
currentWatchTypes.remove(WatchType.NONE) currentWatchTypes.remove(WatchType.NONE)
if (currentWatchTypes.size <= 0) { if (currentWatchTypes.size <= 0) {
setKey(
HOME_BOOKMARK_VALUE_LIST,
intArrayOf()
)
_availableWatchStatusTypes.postValue(setOf<WatchType>() to setOf()) _availableWatchStatusTypes.postValue(setOf<WatchType>() to setOf())
_bookmarks.postValue(Pair(false, ArrayList())) _bookmarks.postValue(Pair(false, ArrayList()))
return@launchSafe return@launchSafe
@ -135,7 +181,10 @@ class HomeViewModel : ViewModel() {
val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first()) val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first())
//if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first()) //if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first())
setKey(
HOME_BOOKMARK_VALUE_LIST,
watchPrefNotNull.map { it.internalId }.toIntArray()
)
_availableWatchStatusTypes.postValue( _availableWatchStatusTypes.postValue(
Pair( Pair(
watchPrefNotNull, watchPrefNotNull,
@ -152,8 +201,11 @@ class HomeViewModel : ViewModel() {
} }
private var onGoingLoad: Job? = null private var onGoingLoad: Job? = null
private fun loadAndCancel(api: MainAPI?) { private var isCurrentlyLoadingName: String? = null
private fun loadAndCancel(api: MainAPI) {
//println("loaded ${api.name}")
onGoingLoad?.cancel() onGoingLoad?.cancel()
isCurrentlyLoadingName = api.name
onGoingLoad = load(api) onGoingLoad = load(api)
} }
@ -255,12 +307,12 @@ class HomeViewModel : ViewModel() {
} }
} }
private fun load(api: MainAPI?) = ioSafe { private fun load(api: MainAPI): Job = ioSafe {
repo = if (api != null) { repo = //if (api != null) {
APIRepository(api) APIRepository(api)
} else { //} else {
autoloadRepo() // autoloadRepo()
} //}
_apiName.postValue(repo?.name) _apiName.postValue(repo?.name)
_randomItems.postValue(listOf()) _randomItems.postValue(listOf())
@ -274,6 +326,7 @@ class HomeViewModel : ViewModel() {
_page.postValue(Resource.Loading()) _page.postValue(Resource.Loading())
_preview.postValue(Resource.Loading()) _preview.postValue(Resource.Loading())
// cancel the current preview expand as that is no longer relevant
addJob?.cancel() addJob?.cancel()
when (val data = repo?.getMainPage(1, null)) { when (val data = repo?.getMainPage(1, null)) {
@ -337,41 +390,142 @@ class HomeViewModel : ViewModel() {
logError(e) logError(e)
} }
} }
is Resource.Failure -> { is Resource.Failure -> {
_page.postValue(data!!) _page.postValue(data!!)
_preview.postValue(data!!) _preview.postValue(data!!)
} }
else -> Unit else -> Unit
} }
isCurrentlyLoadingName = null
}
fun click(callback: SearchClickCallback) {
if (callback.action == SEARCH_ACTION_FOCUSED) {
//focusCallback(callback.card)
} else {
SearchHelper.handleSearchClickCallback(callback)
}
} }
fun loadAndCancel(preferredApiName: String?, forceReload: Boolean = true) =
viewModelScope.launchSafe { private val _popup = MutableLiveData<Pair<ExpandableHomepageList, (() -> Unit)?>?>(null)
val popup: LiveData<Pair<ExpandableHomepageList, (() -> Unit)?>?> = _popup
fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) {
if (list == null)
_popup.postValue(null)
else
_popup.postValue(list to deleteCallback)
}
private fun bookmarksUpdated(unused: Boolean) {
reloadStored()
}
private fun afterPluginsLoaded(forceReload: Boolean) {
loadAndCancel(DataStoreHelper.currentHomePage, forceReload)
}
private fun afterMainPluginsLoaded(unused: Boolean = false) {
loadAndCancel(DataStoreHelper.currentHomePage, false)
}
private fun reloadHome(unused: Boolean = false) {
loadAndCancel(DataStoreHelper.currentHomePage, true)
}
init {
MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated
MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded
MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded
MainActivity.reloadHomeEvent += ::reloadHome
}
override fun onCleared() {
MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated
MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded
MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded
MainActivity.reloadHomeEvent -= ::reloadHome
super.onCleared()
}
fun queryTextSubmit(query: String) {
QuickSearchFragment.pushSearch(
query,
repo?.name?.let { arrayOf(it) })
}
fun queryTextChange(newText: String) {
// do nothing
}
fun loadStoredData() {
val list = EnumSet.noneOf(WatchType::class.java)
getKey<IntArray>(HOME_BOOKMARK_VALUE_LIST)?.map { WatchType.fromInternalId(it) }?.let {
list.addAll(it)
}
loadStoredData(list)
}
fun reloadStored() {
loadResumeWatching()
loadStoredData()
}
fun click(load: LoadClickCallback) {
loadResult(load.response.url, load.response.apiName, load.action)
}
// only save the key if it is from UI, as we don't want internal functions changing the setting
fun loadAndCancel(
preferredApiName: String?,
forceReload: Boolean = true,
fromUI: Boolean = false
) =
ioSafe {
//println("trying to load $preferredApiName")
// Since plugins are loaded in stages this function can get called multiple times. // Since plugins are loaded in stages this function can get called multiple times.
// The issue with this is that the homepage may be fetched multiple times while the first request is loading // The issue with this is that the homepage may be fetched multiple times while the first request is loading
val api = getApiFromNameNull(preferredApiName) // api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true
if (!forceReload && api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true) { val currentPage = page.value
return@launchSafe
// if we don't need to reload and we have a valid homepage or currently loading the same thing then return
val currentLoading = isCurrentlyLoadingName
if (!forceReload && (currentPage is Resource.Success && currentPage.value.isNotEmpty() || (currentLoading != null && currentLoading == preferredApiName))) {
return@ioSafe
} }
val api = getApiFromNameNull(preferredApiName)
if (preferredApiName == noneApi.name) { if (preferredApiName == noneApi.name) {
setKey(USER_SELECTED_HOMEPAGE_API, noneApi.name) // just set to random
if (fromUI) DataStoreHelper.currentHomePage = noneApi.name
loadAndCancel(noneApi) loadAndCancel(noneApi)
} else if (preferredApiName == randomApi.name) { } else if (preferredApiName == randomApi.name) {
// randomize the api, if none exist like if not loaded or not installed
// then use nothing
val validAPIs = context?.filterProviderByPreferredMedia() val validAPIs = context?.filterProviderByPreferredMedia()
if (validAPIs.isNullOrEmpty()) { if (validAPIs.isNullOrEmpty()) {
// Do not set USER_SELECTED_HOMEPAGE_API when there is no plugins loaded
loadAndCancel(noneApi) loadAndCancel(noneApi)
} else { } else {
val apiRandom = validAPIs.random() val apiRandom = validAPIs.random()
loadAndCancel(apiRandom) loadAndCancel(apiRandom)
setKey(USER_SELECTED_HOMEPAGE_API, apiRandom.name) if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name
} }
// If the plugin isn't loaded yet. (Does not set the key)
} else if (api == null) { } else if (api == null) {
loadAndCancel(noneApi) // API is not found aka not loaded or removed, post the loading
// progress if waiting for plugins, otherwise nothing
if (PluginManager.loadedOnlinePlugins || PluginManager.checkSafeModeFile() || lastError != null) {
loadAndCancel(noneApi)
} else {
_page.postValue(Resource.Loading())
if (preferredApiName != null)
_apiName.postValue(preferredApiName)
}
} else { } else {
setKey(USER_SELECTED_HOMEPAGE_API, api.name) // if the api is found, then set it to it and save key
if (fromUI) DataStoreHelper.currentHomePage = api.name
loadAndCancel(api) loadAndCancel(api)
} }
} }

View file

@ -15,6 +15,7 @@ import android.view.animation.AlphaAnimation
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder
@ -24,6 +25,7 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
@ -40,7 +42,6 @@ import com.lagradost.cloudstream3.utils.AppUtils.reduceDragSensitivity
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import kotlinx.android.synthetic.main.fragment_library.*
import org.checkerframework.framework.qual.Unused import org.checkerframework.framework.qual.Unused
import kotlin.math.abs import kotlin.math.abs
@ -77,11 +78,22 @@ class LibraryFragment : Fragment() {
private val libraryViewModel: LibraryViewModel by activityViewModels() private val libraryViewModel: LibraryViewModel by activityViewModels()
var binding: FragmentLibraryBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View? { ): View {
MainActivity.afterBackupRestoreEvent += ::onNewSyncData MainActivity.afterBackupRestoreEvent += ::onNewSyncData
return inflater.inflate(R.layout.fragment_library, container, false) val localBinding = FragmentLibraryBinding.inflate(inflater, container, false)
binding = localBinding
return localBinding.root
//return inflater.inflate(R.layout.fragment_library, container, false)
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
} }
override fun onDestroyView() { override fun onDestroyView() {
@ -90,7 +102,7 @@ class LibraryFragment : Fragment() {
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
viewpager?.currentItem?.let { currentItem -> binding?.viewpager?.currentItem?.let { currentItem ->
outState.putInt(VIEWPAGER_ITEM_KEY, currentItem) outState.putInt(VIEWPAGER_ITEM_KEY, currentItem)
} }
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
@ -98,9 +110,9 @@ class LibraryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
context?.fixPaddingStatusbar(search_status_bar_padding) fixPaddingStatusbar(binding?.searchStatusBarPadding)
sort_fab?.setOnClickListener { binding?.sortFab?.setOnClickListener {
val methods = libraryViewModel.sortingMethods.map { val methods = libraryViewModel.sortingMethods.map {
txt(it.stringRes).asString(view.context) txt(it.stringRes).asString(view.context)
} }
@ -116,7 +128,7 @@ class LibraryFragment : Fragment() {
}) })
} }
main_search?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { binding?.mainSearch?.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
libraryViewModel.sort(ListSorting.Query, query) libraryViewModel.sort(ListSorting.Query, query)
return true return true
@ -139,7 +151,7 @@ class LibraryFragment : Fragment() {
libraryViewModel.reloadPages(false) libraryViewModel.reloadPages(false)
list_selector?.setOnClickListener { binding?.listSelector?.setOnClickListener {
val items = libraryViewModel.availableApiNames val items = libraryViewModel.availableApiNames
val currentItem = libraryViewModel.currentApiName.value val currentItem = libraryViewModel.currentApiName.value
@ -162,12 +174,14 @@ class LibraryFragment : Fragment() {
syncId: SyncIdName, syncId: SyncIdName,
apiName: String? = null, apiName: String? = null,
) { ) {
val availableProviders = allProviders.filter { val availableProviders = synchronized(allProviders) {
it.supportedSyncNames.contains(syncId) allProviders.filter {
}.map { it.name } + it.supportedSyncNames.contains(syncId)
// Add the api if it exists }.map { it.name } +
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } ?: emptyList()) // Add the api if it exists
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
?: emptyList())
}
val baseOptions = listOf( val baseOptions = listOf(
LibraryOpenerType.Default, LibraryOpenerType.Default,
LibraryOpenerType.None, LibraryOpenerType.None,
@ -219,20 +233,22 @@ class LibraryFragment : Fragment() {
} }
} }
provider_selector?.setOnClickListener { binding?.providerSelector?.setOnClickListener {
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
activity?.showPluginSelectionDialog(syncName.name, syncName) activity?.showPluginSelectionDialog(syncName.name, syncName)
} }
viewpager?.setPageTransformer(LibraryScrollTransformer()) binding?.viewpager?.setPageTransformer(LibraryScrollTransformer())
viewpager?.adapter = binding?.viewpager?.adapter =
viewpager.adapter ?: ViewpagerAdapter(mutableListOf(), { isScrollingDown: Boolean -> binding?.viewpager?.adapter ?: ViewpagerAdapter(
if (isScrollingDown) { mutableListOf(),
sort_fab?.shrink() { isScrollingDown: Boolean ->
} else { if (isScrollingDown) {
sort_fab?.extend() binding?.sortFab?.shrink()
} } else {
}) callback@{ searchClickCallback -> binding?.sortFab?.extend()
}
}) callback@{ searchClickCallback ->
// To prevent future accidents // To prevent future accidents
debugAssert({ debugAssert({
searchClickCallback.card !is SyncAPI.LibraryItem searchClickCallback.card !is SyncAPI.LibraryItem
@ -277,6 +293,7 @@ class LibraryFragment : Fragment() {
) )
} }
} }
LibraryOpenerType.None -> {} LibraryOpenerType.None -> {}
LibraryOpenerType.Provider -> LibraryOpenerType.Provider ->
savedSelection.providerData?.apiName?.let { apiName -> savedSelection.providerData?.apiName?.let { apiName ->
@ -285,8 +302,10 @@ class LibraryFragment : Fragment() {
apiName, apiName,
) )
} }
LibraryOpenerType.Browser -> LibraryOpenerType.Browser ->
openBrowser(searchClickCallback.card.url) openBrowser(searchClickCallback.card.url)
LibraryOpenerType.Search -> { LibraryOpenerType.Search -> {
QuickSearchFragment.pushSearch( QuickSearchFragment.pushSearch(
activity, activity,
@ -298,22 +317,28 @@ class LibraryFragment : Fragment() {
} }
} }
viewpager?.offscreenPageLimit = 2 binding?.apply {
viewpager?.reduceDragSensitivity() viewpager.offscreenPageLimit = 2
viewpager.reduceDragSensitivity()
}
val startLoading = Runnable { val startLoading = Runnable {
gridview?.numColumns = context?.getSpanCount() ?: 3 binding?.apply {
gridview?.adapter = gridview.numColumns = context?.getSpanCount() ?: 3
context?.let { LoadingPosterAdapter(it, 6 * 3) } gridview.adapter =
library_loading_overlay?.isVisible = true context?.let { LoadingPosterAdapter(it, 6 * 3) }
library_loading_shimmer?.startShimmer() libraryLoadingOverlay.isVisible = true
empty_list_textview?.isVisible = false libraryLoadingShimmer.startShimmer()
emptyListTextview.isVisible = false
}
} }
val stopLoading = Runnable { val stopLoading = Runnable {
gridview?.adapter = null binding?.apply {
library_loading_overlay?.isVisible = false gridview.adapter = null
library_loading_shimmer?.stopShimmer() libraryLoadingOverlay.isVisible = false
libraryLoadingShimmer.stopShimmer()
}
} }
val handler = Handler(Looper.getMainLooper()) val handler = Handler(Looper.getMainLooper())
@ -324,65 +349,75 @@ class LibraryFragment : Fragment() {
handler.removeCallbacks(startLoading) handler.removeCallbacks(startLoading)
val pages = resource.value val pages = resource.value
val showNotice = pages.all { it.items.isEmpty() } val showNotice = pages.all { it.items.isEmpty() }
empty_list_textview?.isVisible = showNotice
if (showNotice) {
if (libraryViewModel.availableApiNames.size > 1) { binding?.apply {
empty_list_textview?.setText(R.string.empty_library_logged_in_message) emptyListTextview.isVisible = showNotice
} else { if (showNotice) {
empty_list_textview?.setText(R.string.empty_library_no_accounts_message) if (libraryViewModel.availableApiNames.size > 1) {
emptyListTextview.setText(R.string.empty_library_logged_in_message)
} else {
emptyListTextview.setText(R.string.empty_library_no_accounts_message)
}
} }
(viewpager.adapter as? ViewpagerAdapter)?.pages = pages
// Using notifyItemRangeChanged keeps the animations when sorting
viewpager.adapter?.notifyItemRangeChanged(
0,
viewpager.adapter?.itemCount ?: 0
)
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
// Without this there would be a flashing effect:
// loading -> show old viewpager -> black screen -> show new viewpager
handler.postDelayed(stopLoading, 300)
savedInstanceState?.getInt(VIEWPAGER_ITEM_KEY)?.let { currentPos ->
if (currentPos < 0) return@let
viewpager.setCurrentItem(currentPos, false)
// Using remove() sets the key to 0 instead of removing it
savedInstanceState.putInt(VIEWPAGER_ITEM_KEY, -1)
}
// Since the animation to scroll multiple items is so much its better to just hide
// the viewpager a bit while the fastest animation is running
fun hideViewpager(distance: Int) {
if (distance < 3) return
val hideAnimation = AlphaAnimation(1f, 0f).apply {
duration = distance * 50L
fillAfter = true
}
val showAnimation = AlphaAnimation(0f, 1f).apply {
duration = distance * 50L
startOffset = distance * 100L
fillAfter = true
}
viewpager.startAnimation(hideAnimation)
viewpager.startAnimation(showAnimation)
}
TabLayoutMediator(
libraryTabLayout,
viewpager,
) { tab, position ->
tab.text = pages.getOrNull(position)?.title?.asStringNull(context)
tab.view.setOnClickListener {
val currentItem =
binding?.viewpager?.currentItem ?: return@setOnClickListener
val distance = abs(position - currentItem)
hideViewpager(distance)
}
}.attach()
} }
(viewpager.adapter as? ViewpagerAdapter)?.pages = pages
// Using notifyItemRangeChanged keeps the animations when sorting
viewpager.adapter?.notifyItemRangeChanged(0, viewpager.adapter?.itemCount ?: 0)
// Only stop loading after 300ms to hide the fade effect the viewpager produces when updating
// Without this there would be a flashing effect:
// loading -> show old viewpager -> black screen -> show new viewpager
handler.postDelayed(stopLoading, 300)
savedInstanceState?.getInt(VIEWPAGER_ITEM_KEY)?.let { currentPos ->
if (currentPos < 0) return@let
viewpager?.setCurrentItem(currentPos, false)
// Using remove() sets the key to 0 instead of removing it
savedInstanceState.putInt(VIEWPAGER_ITEM_KEY, -1)
}
// Since the animation to scroll multiple items is so much its better to just hide
// the viewpager a bit while the fastest animation is running
fun hideViewpager(distance: Int) {
if (distance < 3) return
val hideAnimation = AlphaAnimation(1f, 0f).apply {
duration = distance * 50L
fillAfter = true
}
val showAnimation = AlphaAnimation(0f, 1f).apply {
duration = distance * 50L
startOffset = distance * 100L
fillAfter = true
}
viewpager?.startAnimation(hideAnimation)
viewpager?.startAnimation(showAnimation)
}
TabLayoutMediator(
library_tab_layout,
viewpager,
) { tab, position ->
tab.text = pages.getOrNull(position)?.title?.asStringNull(context)
tab.view.setOnClickListener {
val currentItem = viewpager?.currentItem ?: return@setOnClickListener
val distance = abs(position - currentItem)
hideViewpager(distance)
}
}.attach()
} }
is Resource.Loading -> { is Resource.Loading -> {
// Only start loading after 200ms to prevent loading cached lists // Only start loading after 200ms to prevent loading cached lists
handler.postDelayed(startLoading, 200) handler.postDelayed(startLoading, 200)
} }
is Resource.Failure -> { is Resource.Failure -> {
stopLoading.run() stopLoading.run()
// No user indication it failed :( // No user indication it failed :(
@ -393,7 +428,7 @@ class LibraryFragment : Fragment() {
} }
override fun onConfigurationChanged(newConfig: Configuration) { override fun onConfigurationChanged(newConfig: Configuration) {
(viewpager.adapter as? ViewpagerAdapter)?.rebind() (binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind()
super.onConfigurationChanged(newConfig) super.onConfigurationChanged(newConfig)
} }

View file

@ -2,13 +2,13 @@ package com.lagradost.cloudstream3.ui.library
import android.view.View import android.view.View
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import kotlinx.android.synthetic.main.library_viewpager_page.view.* import com.lagradost.cloudstream3.R
import kotlin.math.roundToInt import kotlin.math.roundToInt
class LibraryScrollTransformer : ViewPager2.PageTransformer { class LibraryScrollTransformer : ViewPager2.PageTransformer {
override fun transformPage(page: View, position: Float) { override fun transformPage(page: View, position: Float) {
val padding = (-position * page.width).roundToInt() val padding = (-position * page.width).roundToInt()
page.page_recyclerview.setPadding( page.findViewById<View>(R.id.page_recyclerview).setPadding(
padding, 0, padding, 0,
-padding, 0 -padding, 0
) )

View file

@ -11,7 +11,6 @@ import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import kotlinx.coroutines.delay
enum class ListSorting(@StringRes val stringRes: Int) { enum class ListSorting(@StringRes val stringRes: Int) {
Query(R.string.none), Query(R.string.none),

View file

@ -5,15 +5,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ListPopupWindow.MATCH_PARENT
import android.widget.RelativeLayout
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import kotlinx.android.synthetic.main.loading_poster_dynamic.view.*
import kotlin.math.roundToInt
import kotlin.math.sqrt
class LoadingPosterAdapter(context: Context, private val itemCount: Int) : class LoadingPosterAdapter(context: Context, private val itemCount: Int) :
BaseAdapter() { BaseAdapter() {

View file

@ -3,23 +3,21 @@ package com.lagradost.cloudstream3.ui.library
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.AutofitRecyclerView
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import kotlinx.android.synthetic.main.search_result_grid_expanded.view.*
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -32,8 +30,11 @@ class PageAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return LibraryItemViewHolder( return LibraryItemViewHolder(
LayoutInflater.from(parent.context) SearchResultGridExpandedBinding.inflate(
.inflate(R.layout.search_result_grid_expanded, parent, false) LayoutInflater.from(parent.context),
parent,
false
)
) )
} }
@ -57,8 +58,8 @@ class PageAdapter(
} }
} }
inner class LibraryItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) :
val cardView: ImageView = itemView.imageView RecyclerView.ViewHolder(binding.root) {
private val compactView = false//itemView.context.getGridIsCompact() private val compactView = false//itemView.context.getGridIsCompact()
private val coverHeight: Int = private val coverHeight: Int =
@ -85,11 +86,12 @@ class PageAdapter(
val fg = val fg =
getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor)) getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor))
itemView.text_rating.apply { binding.textRating.apply {
setTextColor(ColorStateList.valueOf(fg)) setTextColor(ColorStateList.valueOf(fg))
} }
itemView.text_rating_holder?.backgroundTintList = ColorStateList.valueOf(bg) binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg)
itemView.watchProgress?.apply { binding.textRating.backgroundTintList = ColorStateList.valueOf(bg)
binding.watchProgress.apply {
progressTintList = ColorStateList.valueOf(fg) progressTintList = ColorStateList.valueOf(fg)
progressBackgroundTintList = ColorStateList.valueOf(bg) progressBackgroundTintList = ColorStateList.valueOf(bg)
} }
@ -99,7 +101,7 @@ class PageAdapter(
// See searchAdaptor for this, it basically fixes the height // See searchAdaptor for this, it basically fixes the height
if (!compactView) { if (!compactView) {
cardView.apply { binding.imageView.apply {
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
coverHeight coverHeight
@ -108,23 +110,13 @@ class PageAdapter(
} }
val showProgress = item.episodesCompleted != null && item.episodesTotal != null val showProgress = item.episodesCompleted != null && item.episodesTotal != null
itemView.watchProgress.isVisible = showProgress binding.watchProgress.isVisible = showProgress
if (showProgress) { if (showProgress) {
itemView.watchProgress.max = item.episodesTotal!! binding.watchProgress.max = item.episodesTotal!!
itemView.watchProgress.progress = item.episodesCompleted!! binding.watchProgress.progress = item.episodesCompleted!!
} }
itemView.imageText.text = item.name binding.imageText.text = item.name
val showRating = (item.personalRating ?: 0) != 0
itemView.text_rating_holder.isVisible = showRating
if (showRating) {
// We want to show 8.5 but not 8.0 hence the replace
val rating = ((item.personalRating ?: 0).toDouble() / 10).toString()
.replace(".0", "")
itemView.text_rating.text = "$rating"
}
} }
} }
} }

View file

@ -2,16 +2,14 @@ package com.lagradost.cloudstream3.ui.library
import android.os.Build import android.os.Build
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.doOnAttach import androidx.core.view.doOnAttach
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.OnFlingListener import androidx.recyclerview.widget.RecyclerView.OnFlingListener
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.LibraryViewpagerPageBinding
import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchClickCallback
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
import kotlinx.android.synthetic.main.library_viewpager_page.view.*
class ViewpagerAdapter( class ViewpagerAdapter(
var pages: List<SyncAPI.Page>, var pages: List<SyncAPI.Page>,
@ -20,8 +18,7 @@ class ViewpagerAdapter(
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return PageViewHolder( return PageViewHolder(
LayoutInflater.from(parent.context) LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
.inflate(R.layout.library_viewpager_page, parent, false)
) )
} }
@ -34,6 +31,7 @@ class ViewpagerAdapter(
} }
private val unbound = mutableSetOf<Int>() private val unbound = mutableSetOf<Int>()
/** /**
* Used to mark all pages for re-binding and forces all items to be refreshed * Used to mark all pages for re-binding and forces all items to be refreshed
* Without this the pages will still use the same adapters * Without this the pages will still use the same adapters
@ -43,44 +41,46 @@ class ViewpagerAdapter(
this.notifyItemRangeChanged(0, pages.size) this.notifyItemRangeChanged(0, pages.size)
} }
inner class PageViewHolder(private val itemViewTest: View) : inner class PageViewHolder(private val binding: LibraryViewpagerPageBinding) :
RecyclerView.ViewHolder(itemViewTest) { RecyclerView.ViewHolder(binding.root) {
fun bind(page: SyncAPI.Page, rebind: Boolean) { fun bind(page: SyncAPI.Page, rebind: Boolean) {
itemView.page_recyclerview?.spanCount = binding.pageRecyclerview.apply {
this@PageViewHolder.itemView.context.getSpanCount() ?: 3 spanCount =
this@PageViewHolder.itemView.context.getSpanCount() ?: 3
if (itemViewTest.page_recyclerview?.adapter == null || rebind) { if (adapter == null || rebind) {
// Only add the items after it has been attached since the items rely on ItemWidth // Only add the items after it has been attached since the items rely on ItemWidth
// Which is only determined after the recyclerview is attached. // Which is only determined after the recyclerview is attached.
// If this fails then item height becomes 0 when there is only one item // If this fails then item height becomes 0 when there is only one item
itemViewTest.page_recyclerview?.doOnAttach { doOnAttach {
itemViewTest.page_recyclerview?.adapter = PageAdapter( adapter = PageAdapter(
page.items.toMutableList(), page.items.toMutableList(),
itemViewTest.page_recyclerview, this,
clickCallback clickCallback
) )
}
} else {
(adapter as? PageAdapter)?.updateList(page.items)
scrollToPosition(0)
} }
} else {
(itemViewTest.page_recyclerview?.adapter as? PageAdapter)?.updateList(page.items)
itemViewTest.page_recyclerview?.scrollToPosition(0)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
itemViewTest.page_recyclerview.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY -> setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
val diff = scrollY - oldScrollY val diff = scrollY - oldScrollY
if (diff == 0) return@setOnScrollChangeListener if (diff == 0) return@setOnScrollChangeListener
scrollCallback.invoke(diff > 0) scrollCallback.invoke(diff > 0)
} }
} else { } else {
itemViewTest.page_recyclerview.onFlingListener = object : OnFlingListener() { onFlingListener = object : OnFlingListener() {
override fun onFling(velocityX: Int, velocityY: Int): Boolean { override fun onFling(velocityX: Int, velocityY: Int): Boolean {
scrollCallback.invoke(velocityY > 0) scrollCallback.invoke(velocityY > 0)
return false return false
}
} }
} }
} }
} }
} }

View file

@ -7,24 +7,29 @@ import android.graphics.drawable.AnimatedVectorDrawable
import android.media.metrics.PlaybackErrorEvent import android.media.metrics.PlaybackErrorEvent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.Toast import android.widget.Toast
import androidx.annotation.LayoutRes import androidx.annotation.LayoutRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.media.session.MediaButtonReceiver
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.google.android.exoplayer2.ExoPlayer import androidx.media3.common.PlaybackException
import com.google.android.exoplayer2.PlaybackException import androidx.media3.exoplayer.ExoPlayer
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import androidx.media3.session.MediaSession
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.SubtitleView import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.TimeBar
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.CommonActivity.canEnterPipMode import com.lagradost.cloudstream3.CommonActivity.canEnterPipMode
@ -34,6 +39,8 @@ import com.lagradost.cloudstream3.CommonActivity.playerEventListener
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.unixTimeMs
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils
@ -42,8 +49,6 @@ import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import kotlinx.android.synthetic.main.fragment_player.*
import kotlinx.android.synthetic.main.player_custom_layout.*
enum class PlayerResize(@StringRes val nameRes: Int) { enum class PlayerResize(@StringRes val nameRes: Int) {
Fit(R.string.resize_fit), Fit(R.string.resize_fit),
@ -72,9 +77,15 @@ abstract class AbstractPlayerFragment(
var isBuffering = true var isBuffering = true
protected open var hasPipModeSupport = true protected open var hasPipModeSupport = true
var playerPausePlayHolderHolder : FrameLayout? = null
var playerPausePlay : ImageView? = null
var playerBuffering : ProgressBar? = null
var playerView : PlayerView? = null
var piphide : FrameLayout? = null
var subtitleHolder : FrameLayout? = null
@LayoutRes @LayoutRes
protected var layout: Int = R.layout.fragment_player protected open var layout: Int = R.layout.fragment_player
open fun nextEpisode() { open fun nextEpisode() {
throw NotImplementedError() throw NotImplementedError()
@ -84,11 +95,11 @@ abstract class AbstractPlayerFragment(
throw NotImplementedError() throw NotImplementedError()
} }
open fun playerPositionChanged(posDur: Pair<Long, Long>) { open fun playerPositionChanged(position: Long, duration : Long) {
throw NotImplementedError() throw NotImplementedError()
} }
open fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) { open fun playerDimensionsLoaded(width: Int, height : Int) {
throw NotImplementedError() throw NotImplementedError()
} }
@ -124,8 +135,8 @@ abstract class AbstractPlayerFragment(
} }
} }
private fun updateIsPlaying(playing: Pair<CSPlayerLoading, CSPlayerLoading>) { private fun updateIsPlaying(wasPlaying : CSPlayerLoading,
val (wasPlaying, isPlaying) = playing isPlaying : CSPlayerLoading) {
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
@ -133,15 +144,15 @@ abstract class AbstractPlayerFragment(
isBuffering = CSPlayerLoading.IsBuffering == isPlaying isBuffering = CSPlayerLoading.IsBuffering == isPlaying
if (isBuffering) { if (isBuffering) {
player_pause_play_holder_holder?.isVisible = false playerPausePlayHolderHolder?.isVisible = false
player_buffering?.isVisible = true playerBuffering?.isVisible = true
} else { } else {
player_pause_play_holder_holder?.isVisible = true playerPausePlayHolderHolder?.isVisible = true
player_buffering?.isVisible = false playerBuffering?.isVisible = false
if (wasPlaying != isPlaying) { if (wasPlaying != isPlaying) {
player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play)
val drawable = player_pause_play?.drawable val drawable = playerPausePlay?.drawable
var startedAnimation = false var startedAnimation = false
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
@ -163,23 +174,24 @@ abstract class AbstractPlayerFragment(
// somehow the phone is wacked // somehow the phone is wacked
if (!startedAnimation) { if (!startedAnimation) {
player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
} }
} else { } else {
player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play)
} }
} }
canEnterPipMode = isPlayingRightNow && hasPipModeSupport canEnterPipMode = isPlayingRightNow && hasPipModeSupport
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInPIPMode) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity?.let { act -> activity?.let { act ->
PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow) PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow, player.getAspectRatio())
} }
} }
} }
private var pipReceiver: BroadcastReceiver? = null private var pipReceiver: BroadcastReceiver? = null
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
try { try {
isInPIPMode = isInPictureInPictureMode isInPIPMode = isInPictureInPictureMode
if (isInPictureInPictureMode) { if (isInPictureInPictureMode) {
@ -197,25 +209,26 @@ abstract class AbstractPlayerFragment(
CSPlayerEvent.values()[intent.getIntExtra( CSPlayerEvent.values()[intent.getIntExtra(
EXTRA_CONTROL_TYPE, EXTRA_CONTROL_TYPE,
0 0
)] )], source = PlayerEventSource.UI
) )
} }
} }
val filter = IntentFilter() val filter = IntentFilter()
filter.addAction( filter.addAction(ACTION_MEDIA_CONTROL)
ACTION_MEDIA_CONTROL
)
activity?.registerReceiver(pipReceiver, filter) activity?.registerReceiver(pipReceiver, filter)
val isPlaying = player.getIsPlaying() val isPlaying = player.getIsPlaying()
val isPlayingValue = val isPlayingValue =
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
updateIsPlaying(Pair(isPlayingValue, isPlayingValue)) updateIsPlaying(isPlayingValue, isPlayingValue)
} else { } else {
// Restore the full-screen UI. // Restore the full-screen UI.
piphide?.isVisible = true piphide?.isVisible = true
exitedPipMode() exitedPipMode()
pipReceiver?.let { pipReceiver?.let {
activity?.unregisterReceiver(it) // Prevents java.lang.IllegalArgumentException: Receiver not registered
normalSafeApiCall {
activity?.unregisterReceiver(it)
}
} }
activity?.hideSystemUI() activity?.hideSystemUI()
this.view?.let { UIHelper.hideKeyboard(it) } this.view?.let { UIHelper.hideKeyboard(it) }
@ -239,18 +252,16 @@ abstract class AbstractPlayerFragment(
} }
} }
open fun playerError(exception: Exception) { open fun playerError(exception: Throwable) {
fun showToast(message: String, gotoNext: Boolean = false) { fun showToast(message: String, gotoNext: Boolean = false) {
if (gotoNext && hasNextMirror()) { if (gotoNext && hasNextMirror()) {
showToast( showToast(
activity,
message, message,
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
) )
nextMirror() nextMirror()
} else { } else {
showToast( showToast(
activity,
context?.getString(R.string.no_links_found_toast) + "\n" + message, context?.getString(R.string.no_links_found_toast) + "\n" + message,
Toast.LENGTH_LONG Toast.LENGTH_LONG
) )
@ -270,18 +281,21 @@ abstract class AbstractPlayerFragment(
gotoNext = true 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_INVALID_HTTP_CONTENT_TYPE -> { 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_INVALID_HTTP_CONTENT_TYPE -> {
showToast( showToast(
"${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", "${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg",
gotoNext = true gotoNext = true
) )
} }
PlaybackException.ERROR_CODE_DECODING_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> { PlaybackException.ERROR_CODE_DECODING_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> {
showToast( showToast(
"${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg", "${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg",
gotoNext = true gotoNext = true
) )
} }
else -> { else -> {
showToast( showToast(
"${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", "${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg",
@ -290,12 +304,14 @@ abstract class AbstractPlayerFragment(
} }
} }
} }
is InvalidFileException -> { is InvalidFileException -> {
showToast( showToast(
"${ctx.getString(R.string.source_error)}\n${exception.message}", "${ctx.getString(R.string.source_error)}\n${exception.message}",
gotoNext = true gotoNext = true
) )
} }
else -> { else -> {
exception.message?.let { exception.message?.let {
showToast( showToast(
@ -313,29 +329,25 @@ abstract class AbstractPlayerFragment(
} }
} }
@SuppressLint("UnsafeOptInUsageError")
private fun playerUpdated(player: Any?) { private fun playerUpdated(player: Any?) {
if (player is ExoPlayer) { if (player is ExoPlayer) {
context?.let { ctx -> context?.let { ctx ->
val mediaButtonReceiver = ComponentName(ctx, MediaButtonReceiver::class.java) mMediaSession?.release()
MediaSessionCompat(ctx, "Player", mediaButtonReceiver, null).let { media -> mMediaSession = MediaSession.Builder(ctx, player)
//media.setCallback(mMediaSessionCallback) // Ensure unique ID for concurrent players
//media.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS) .setId(unixTimeMs.toString())
val mediaSessionConnector = MediaSessionConnector(media) .build()
mediaSessionConnector.setPlayer(player)
media.isActive = true
mMediaSessionCompat = media
}
} }
// Necessary for multiple combined videos // Necessary for multiple combined videos
player_view?.setShowMultiWindowTimeBar(true) playerView?.setShowMultiWindowTimeBar(true)
player_view?.player = player playerView?.player = player
player_view?.performClick() playerView?.performClick()
} }
} }
private var mediaSessionConnector: MediaSessionConnector? = null private var mMediaSession: MediaSession? = null
private var mMediaSessionCompat: MediaSessionCompat? = null
// this can be used in the future for players other than exoplayer // this can be used in the future for players other than exoplayer
//private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() { //private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() {
@ -358,39 +370,106 @@ abstract class AbstractPlayerFragment(
// } // }
//} //}
/** 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) {
Log.i(TAG, "Handle event: $event")
when(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)
}
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") @SuppressLint("SetTextI18n", "UnsafeOptInUsageError")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
resizeMode = getKey(RESIZE_MODE_KEY) ?: 0 resizeMode = getKey(RESIZE_MODE_KEY) ?: 0
resize(resizeMode, false) resize(resizeMode, false)
player.releaseCallbacks() player.releaseCallbacks()
player.initCallbacks( player.initCallbacks(
playerUpdated = ::playerUpdated, eventHandler = ::mainCallback,
updateIsPlaying = ::updateIsPlaying,
playerError = ::playerError,
requestAutoFocus = ::requestAudioFocus,
nextEpisode = ::nextEpisode,
prevEpisode = ::prevEpisode,
playerPositionChanged = ::playerPositionChanged,
playerDimensionsLoaded = ::playerDimensionsLoaded,
requestedListeningPercentages = listOf( requestedListeningPercentages = listOf(
SKIP_OP_VIDEO_PERCENTAGE, SKIP_OP_VIDEO_PERCENTAGE,
PRELOAD_NEXT_EPISODE_PERCENTAGE, PRELOAD_NEXT_EPISODE_PERCENTAGE,
NEXT_WATCH_EPISODE_PERCENTAGE, NEXT_WATCH_EPISODE_PERCENTAGE,
UPDATE_SYNC_PROGRESS_PERCENTAGE, UPDATE_SYNC_PROGRESS_PERCENTAGE,
), ),
subtitlesUpdates = ::subtitlesChanged,
embeddedSubtitlesFetched = ::embeddedSubtitlesFetched,
onTracksInfoChanged = ::onTracksInfoChanged,
onTimestampInvoked = ::onTimestamp,
onTimestampSkipped = ::onTimestampSkipped
) )
if (player is CS3IPlayer) { if (player is CS3IPlayer) {
subView = player_view?.findViewById(R.id.exo_subtitles) subView = playerView?.findViewById(R.id.exo_subtitles)
subStyle = SubtitlesFragment.getCurrentSavedStyle() subStyle = SubtitlesFragment.getCurrentSavedStyle()
player.initSubtitles(subView, subtitle_holder, subStyle) player.initSubtitles(subView, subtitleHolder, subStyle)
/** 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 SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged
@ -436,6 +515,9 @@ abstract class AbstractPlayerFragment(
playerEventListener = null playerEventListener = null
keyEventListener = null keyEventListener = null
canEnterPipMode = false canEnterPipMode = false
mMediaSession?.release()
mMediaSession = null
playerView?.player = null
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
keepScreenOn(false) keepScreenOn(false)
@ -451,6 +533,7 @@ abstract class AbstractPlayerFragment(
resize(PlayerResize.values()[resize], showToast) resize(PlayerResize.values()[resize], showToast)
} }
@SuppressLint("UnsafeOptInUsageError")
fun resize(resize: PlayerResize, showToast: Boolean) { fun resize(resize: PlayerResize, showToast: Boolean) {
setKey(RESIZE_MODE_KEY, resize.ordinal) setKey(RESIZE_MODE_KEY, resize.ordinal)
val type = when (resize) { val type = when (resize) {
@ -458,10 +541,10 @@ abstract class AbstractPlayerFragment(
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
} }
player_view?.resizeMode = type playerView?.resizeMode = type
if (showToast) if (showToast)
showToast(activity, resize.nameRes, Toast.LENGTH_SHORT) showToast(resize.nameRes, Toast.LENGTH_SHORT)
} }
override fun onStop() { override fun onStop() {
@ -482,6 +565,13 @@ abstract class AbstractPlayerFragment(
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
return inflater.inflate(layout, container, false) val root = inflater.inflate(layout, container, false)
playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder)
playerPausePlay = root.findViewById(R.id.player_pause_play)
playerBuffering = root.findViewById(R.id.player_buffering)
playerView = root.findViewById(R.id.player_view)
piphide = root.findViewById(R.id.piphide)
subtitleHolder = root.findViewById(R.id.subtitle_holder)
return root
} }
} }

View file

@ -1,50 +1,71 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.util.Rational
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.media3.common.C.*
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSourceFactory
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.dash.DashMediaSource
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
import androidx.media3.exoplayer.source.ClippingMediaSource
import androidx.media3.exoplayer.source.ConcatenatingMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.exoplayer.text.TextRenderer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.ui.SubtitleView
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.C.*
import com.google.android.exoplayer2.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
import com.google.android.exoplayer2.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
import com.google.android.exoplayer2.source.*
import com.google.android.exoplayer2.text.TextRenderer
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.trackselection.TrackSelectionOverride
import com.google.android.exoplayer2.trackselection.TrackSelector
import com.google.android.exoplayer2.ui.SubtitleView
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.HttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.google.android.exoplayer2.util.MimeTypes
import com.google.android.exoplayer2.video.VideoSize
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.USER_AGENT
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppUtils.isUsingMobileData
import com.lagradost.cloudstream3.utils.DrmExtractorLink
import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
import java.io.File import java.io.File
import java.time.Duration import java.lang.IllegalArgumentException
import java.util.UUID
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSession import javax.net.ssl.SSLSession
@ -52,11 +73,25 @@ import javax.net.ssl.SSLSession
const val TAG = "CS3ExoPlayer" const val TAG = "CS3ExoPlayer"
const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language"
/** Cache */ /** toleranceBeforeUs The maximum time that the actual position seeked to may precede the
* requested seek position, in microseconds. Must be non-negative. */
const val toleranceBeforeUs = 300_000L
/**
* toleranceAfterUs The maximum time that the actual position seeked to may exceed the requested
* seek position, in microseconds. Must be non-negative.
*/
const val toleranceAfterUs = 300_000L
class CS3IPlayer : IPlayer { class CS3IPlayer : IPlayer {
private var isPlaying = false private var isPlaying = false
private var exoPlayer: ExoPlayer? = null private var exoPlayer: ExoPlayer? = null
set(value) {
// If the old value is not null then the player has not been properly released.
debugAssert({ field != null && value != null }, { "Previous player instance should be released!" })
field = value
}
var cacheSize = 0L var cacheSize = 0L
var simpleCacheSize = 0L var simpleCacheSize = 0L
var videoBufferMs = 0L var videoBufferMs = 0L
@ -83,7 +118,16 @@ class CS3IPlayer : IPlayer {
* */ * */
data class MediaItemSlice( data class MediaItemSlice(
val mediaItem: MediaItem, val mediaItem: MediaItem,
val durationUs: Long val durationUs: Long,
val drm: DrmMetadata? = null
)
data class DrmMetadata(
val kid: String,
val key: String,
val uuid: UUID,
val kty: String,
val keyRequestParameters: HashMap<String, String>,
) )
override fun getDuration(): Long? = exoPlayer?.duration override fun getDuration(): Long? = exoPlayer?.duration
@ -97,80 +141,24 @@ class CS3IPlayer : IPlayer {
* Boolean = if it's active * Boolean = if it's active
* */ * */
private var playerSelectedSubtitleTracks = listOf<Pair<String, Boolean>>() private var playerSelectedSubtitleTracks = listOf<Pair<String, Boolean>>()
/** isPlaying */
private var updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null
private var requestAutoFocus: (() -> Unit)? = null
private var playerError: ((Exception) -> Unit)? = null
private var subtitlesUpdates: (() -> Unit)? = null
/** width x height */
private var playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)? = null
/** used for playerPositionChanged */
private var requestedListeningPercentages: List<Int>? = null private var requestedListeningPercentages: List<Int>? = null
/** Fired when seeking the player or on requestedListeningPercentages, private var eventHandler: ((PlayerEvent) -> Unit)? = null
* used to make things appear on que
* position, duration */
private var playerPositionChanged: ((Pair<Long, Long>) -> Unit)? = null
private var nextEpisode: (() -> Unit)? = null fun event(event: PlayerEvent) {
private var prevEpisode: (() -> Unit)? = null eventHandler?.invoke(event)
}
private var playerUpdated: ((Any?) -> Unit)? = null
private var embeddedSubtitlesFetched: ((List<SubtitleData>) -> Unit)? = null
private var onTracksInfoChanged: (() -> Unit)? = null
private var onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null
private var onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null
override fun releaseCallbacks() { override fun releaseCallbacks() {
playerUpdated = null eventHandler = null
updateIsPlaying = null
requestAutoFocus = null
playerError = null
playerDimensionsLoaded = null
requestedListeningPercentages = null
playerPositionChanged = null
nextEpisode = null
prevEpisode = null
subtitlesUpdates = null
onTracksInfoChanged = null
onTimestampInvoked = null
requestSubtitleUpdate = null
onTimestampSkipped = null
} }
override fun initCallbacks( override fun initCallbacks(
playerUpdated: (Any?) -> Unit, eventHandler: ((PlayerEvent) -> Unit),
updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)?,
requestAutoFocus: (() -> Unit)?,
playerError: ((Exception) -> Unit)?,
playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)?,
requestedListeningPercentages: List<Int>?, requestedListeningPercentages: List<Int>?,
playerPositionChanged: ((Pair<Long, Long>) -> Unit)?,
nextEpisode: (() -> Unit)?,
prevEpisode: (() -> Unit)?,
subtitlesUpdates: (() -> Unit)?,
embeddedSubtitlesFetched: ((List<SubtitleData>) -> Unit)?,
onTracksInfoChanged: (() -> Unit)?,
onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)?,
onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)?,
) { ) {
this.playerUpdated = playerUpdated
this.updateIsPlaying = updateIsPlaying
this.requestAutoFocus = requestAutoFocus
this.playerError = playerError
this.playerDimensionsLoaded = playerDimensionsLoaded
this.requestedListeningPercentages = requestedListeningPercentages this.requestedListeningPercentages = requestedListeningPercentages
this.playerPositionChanged = playerPositionChanged this.eventHandler = eventHandler
this.nextEpisode = nextEpisode
this.prevEpisode = prevEpisode
this.subtitlesUpdates = subtitlesUpdates
this.embeddedSubtitlesFetched = embeddedSubtitlesFetched
this.onTracksInfoChanged = onTracksInfoChanged
this.onTimestampInvoked = onTimestampInvoked
this.onTimestampSkipped = onTimestampSkipped
} }
// I know, this is not a perfect solution, however it works for fixing subs // I know, this is not a perfect solution, however it works for fixing subs
@ -179,7 +167,7 @@ class CS3IPlayer : IPlayer {
try { try {
Handler(it).post { Handler(it).post {
try { try {
seekTime(1L) seekTime(1L, source = PlayerEventSource.Player)
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }
@ -233,8 +221,9 @@ class CS3IPlayer : IPlayer {
subtitleHelper.setAllSubtitles(subtitles) subtitleHelper.setAllSubtitles(subtitles)
} }
var currentSubtitles: SubtitleData? = null private var currentSubtitles: SubtitleData? = null
@SuppressLint("UnsafeOptInUsageError")
private fun List<Tracks.Group>.getTrack(id: String?): Pair<TrackGroup, Int>? { private fun List<Tracks.Group>.getTrack(id: String?): Pair<TrackGroup, Int>? {
if (id == null) return null if (id == null) return null
// This beast of an expression does: // This beast of an expression does:
@ -319,6 +308,7 @@ class CS3IPlayer : IPlayer {
}.flatten() }.flatten()
} }
@SuppressLint("UnsafeOptInUsageError")
private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> { private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> {
return (0 until this.mediaTrackGroup.length).mapNotNull { i -> return (0 until this.mediaTrackGroup.length).mapNotNull { i ->
if (this.isSupported) if (this.isSupported)
@ -347,6 +337,7 @@ class CS3IPlayer : IPlayer {
) )
} }
@SuppressLint("UnsafeOptInUsageError")
override fun getVideoTracks(): CurrentTracks { override fun getVideoTracks(): CurrentTracks {
val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList()
val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO }
@ -366,6 +357,7 @@ class CS3IPlayer : IPlayer {
/** /**
* @return True if the player should be reloaded * @return True if the player should be reloaded
* */ * */
@SuppressLint("UnsafeOptInUsageError")
override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
Log.i(TAG, "setPreferredSubtitles init $subtitle") Log.i(TAG, "setPreferredSubtitles init $subtitle")
currentSubtitles = subtitle currentSubtitles = subtitle
@ -378,7 +370,7 @@ class CS3IPlayer : IPlayer {
if (subtitle == null) { if (subtitle == null) {
trackSelector.setParameters( trackSelector.setParameters(
trackSelector.buildUponParameters() trackSelector.buildUponParameters()
.setPreferredTextLanguage(null) .setTrackTypeDisabled(TRACK_TYPE_TEXT, true)
.clearOverridesOfType(TRACK_TYPE_TEXT) .clearOverridesOfType(TRACK_TYPE_TEXT)
) )
} else { } else {
@ -387,6 +379,7 @@ class CS3IPlayer : IPlayer {
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
return@let true return@let true
} }
SubtitleStatus.IS_ACTIVE -> { SubtitleStatus.IS_ACTIVE -> {
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
@ -395,6 +388,7 @@ class CS3IPlayer : IPlayer {
.apply { .apply {
val track = getTextTrack(subtitle.getId()) val track = getTextTrack(subtitle.getId())
if (track != null) { if (track != null) {
setTrackTypeDisabled(TRACK_TYPE_TEXT, false)
setOverrideForType( setOverrideForType(
TrackSelectionOverride( TrackSelectionOverride(
track.first, track.first,
@ -412,6 +406,7 @@ class CS3IPlayer : IPlayer {
// }, 1) // }, 1)
//} //}
} }
SubtitleStatus.NOT_FOUND -> { SubtitleStatus.NOT_FOUND -> {
Log.i(TAG, "setPreferredSubtitles NOT_FOUND") Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
return@let true return@let true
@ -441,10 +436,18 @@ class CS3IPlayer : IPlayer {
} }
} }
@SuppressLint("UnsafeOptInUsageError")
override fun getAspectRatio(): Rational? {
return exoPlayer?.videoFormat?.let { format ->
Rational(format.width, format.height)
}
}
override fun updateSubtitleStyle(style: SaveCaptionStyle) { override fun updateSubtitleStyle(style: SaveCaptionStyle) {
subtitleHelper.setSubStyle(style) subtitleHelper.setSubStyle(style)
} }
@SuppressLint("UnsafeOptInUsageError")
override fun saveData() { override fun saveData() {
Log.i(TAG, "saveData") Log.i(TAG, "saveData")
updatedTime() updatedTime()
@ -474,14 +477,14 @@ class CS3IPlayer : IPlayer {
Log.i(TAG, "onStop") Log.i(TAG, "onStop")
saveData() saveData()
exoPlayer?.pause() handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
//releasePlayer() //releasePlayer()
} }
override fun onPause() { override fun onPause() {
Log.i(TAG, "onPause") Log.i(TAG, "onPause")
saveData() saveData()
exoPlayer?.pause() handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
//releasePlayer() //releasePlayer()
} }
@ -518,6 +521,7 @@ class CS3IPlayer : IPlayer {
var requestSubtitleUpdate: (() -> Unit)? = null var requestSubtitleUpdate: (() -> Unit)? = null
@SuppressLint("UnsafeOptInUsageError")
private fun createOnlineSource(headers: Map<String, String>): HttpDataSource.Factory { private fun createOnlineSource(headers: Map<String, String>): HttpDataSource.Factory {
val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT)
return source.apply { return source.apply {
@ -525,6 +529,7 @@ class CS3IPlayer : IPlayer {
} }
} }
@SuppressLint("UnsafeOptInUsageError")
private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory {
val provider = getApiFromNameNull(link.source) val provider = getApiFromNameNull(link.source)
val interceptor = provider?.getVideoInterceptor(link) val interceptor = provider?.getVideoInterceptor(link)
@ -557,6 +562,7 @@ class CS3IPlayer : IPlayer {
} }
} }
@SuppressLint("UnsafeOptInUsageError")
private fun Context.createOfflineSource(): DataSource.Factory { private fun Context.createOfflineSource(): DataSource.Factory {
return DefaultDataSourceFactory(this, USER_AGENT) return DefaultDataSourceFactory(this, USER_AGENT)
} }
@ -602,6 +608,7 @@ class CS3IPlayer : IPlayer {
return Pair(subSources, activeSubtitles) return Pair(subSources, activeSubtitles)
}*/ }*/
@SuppressLint("UnsafeOptInUsageError")
private fun getCache(context: Context, cacheSize: Long): SimpleCache? { private fun getCache(context: Context, cacheSize: Long): SimpleCache? {
return try { return try {
val databaseProvider = StandaloneDatabaseProvider(context) val databaseProvider = StandaloneDatabaseProvider(context)
@ -633,14 +640,10 @@ class CS3IPlayer : IPlayer {
return getMediaItemBuilder(mimeType).setUri(url).build() return getMediaItemBuilder(mimeType).setUri(url).build()
} }
@SuppressLint("UnsafeOptInUsageError")
private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector { private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector {
val trackSelector = DefaultTrackSelector(context) val trackSelector = DefaultTrackSelector(context)
trackSelector.parameters = DefaultTrackSelector.ParametersBuilder(context) trackSelector.parameters = trackSelector.buildUponParameters()
// .setRendererDisabled(C.TRACK_TYPE_VIDEO, true)
.setRendererDisabled(C.TRACK_TYPE_TEXT, true)
// Experimental, I think this causes issues with audio track init 5001
// .setTunnelingEnabled(true)
.setDisabledTextTrackSelectionFlags(C.TRACK_TYPE_TEXT)
// This will not force higher quality videos to fail // This will not force higher quality videos to fail
// but will make the m3u8 pick the correct preferred // but will make the m3u8 pick the correct preferred
.setMaxVideoSize(Int.MAX_VALUE, maxVideoHeight ?: Int.MAX_VALUE) .setMaxVideoSize(Int.MAX_VALUE, maxVideoHeight ?: Int.MAX_VALUE)
@ -651,6 +654,7 @@ class CS3IPlayer : IPlayer {
var currentTextRenderer: CustomTextRenderer? = null var currentTextRenderer: CustomTextRenderer? = null
@SuppressLint("UnsafeOptInUsageError")
private fun buildExoPlayer( private fun buildExoPlayer(
context: Context, context: Context,
mediaItemSlices: List<MediaItemSlice>, mediaItemSlices: List<MediaItemSlice>,
@ -674,26 +678,26 @@ class CS3IPlayer : IPlayer {
ExoPlayer.Builder(context) ExoPlayer.Builder(context)
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
DefaultRenderersFactory(context).apply { DefaultRenderersFactory(context).apply {
// setEnableDecoderFallback(true) setEnableDecoderFallback(true)
// Enable Ffmpeg extension // Enable Ffmpeg extension
// setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON) setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
}.createRenderers( }.createRenderers(
eventHandler, eventHandler,
videoRendererEventListener, videoRendererEventListener,
audioRendererEventListener, audioRendererEventListener,
textRendererOutput, textRendererOutput,
metadataRendererOutput metadataRendererOutput
).map { ).map {
if (it is TextRenderer) { if (it is TextRenderer) {
currentTextRenderer = CustomTextRenderer( val currentTextRenderer = CustomTextRenderer(
subtitleOffset, subtitleOffset,
textRendererOutput, textRendererOutput,
eventHandler.looper, eventHandler.looper,
CustomSubtitleDecoderFactory() CustomSubtitleDecoderFactory()
) ).also { this.currentTextRenderer = it }
currentTextRenderer!! currentTextRenderer
} else it } else it
}.toTypedArray() }.toTypedArray()
} }
.setTrackSelector( .setTrackSelector(
trackSelector ?: getTrackSelector( trackSelector ?: getTrackSelector(
@ -702,7 +706,7 @@ class CS3IPlayer : IPlayer {
) )
) )
// Allows any seeking to be +- 0.3s to allow for faster seeking // Allows any seeking to be +- 0.3s to allow for faster seeking
.setSeekParameters(SeekParameters(300_000, 300_000)) .setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs))
.setLoadControl( .setLoadControl(
DefaultLoadControl.Builder() DefaultLoadControl.Builder()
.setTargetBufferBytes( .setTargetBufferBytes(
@ -735,15 +739,33 @@ class CS3IPlayer : IPlayer {
// If there is only one item then treat it as normal, if multiple: concatenate the items. // If there is only one item then treat it as normal, if multiple: concatenate the items.
val videoMediaSource = if (mediaItemSlices.size == 1) { val videoMediaSource = if (mediaItemSlices.size == 1) {
factory.createMediaSource(mediaItemSlices.first().mediaItem) val item = mediaItemSlices.first()
item.drm?.let { drm ->
val drmCallback =
LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray())
val manager = DefaultDrmSessionManager.Builder()
.setPlayClearSamplesWithoutKeys(true)
.setMultiSession(false)
.setKeyRequestParameters(drm.keyRequestParameters)
.setUuidAndExoMediaDrmProvider(drm.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER)
.build(drmCallback)
val manifestDataSourceFactory = DefaultHttpDataSource.Factory()
DashMediaSource.Factory(manifestDataSourceFactory)
.setDrmSessionManagerProvider { manager }
.createMediaSource(item.mediaItem)
} ?: run {
factory.createMediaSource(item.mediaItem)
}
} else { } else {
val source = ConcatenatingMediaSource() val source = ConcatenatingMediaSource()
mediaItemSlices.map { mediaItemSlices.map { item ->
source.addMediaSource( source.addMediaSource(
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
ClippingMediaSource( ClippingMediaSource(
factory.createMediaSource(it.mediaItem), factory.createMediaSource(item.mediaItem),
it.durationUs item.durationUs
) )
) )
} }
@ -769,50 +791,65 @@ class CS3IPlayer : IPlayer {
private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? {
val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null
for (lastTimeStamp in lastTimeStamps) { for (lastTimeStamp in lastTimeStamps) {
if (lastTimeStamp.startMs <= position && position < lastTimeStamp.endMs) { if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) {
return lastTimeStamp return lastTimeStamp
} }
} }
return null return null
} }
fun updatedTime(writePosition: Long? = null) { fun updatedTime(
getCurrentTimestamp(writePosition)?.let { timestamp -> writePosition: Long? = null,
onTimestampInvoked?.invoke(timestamp) source: PlayerEventSource = PlayerEventSource.Player
) {
val position = writePosition ?: exoPlayer?.currentPosition
getCurrentTimestamp(position)?.let { timestamp ->
event(TimestampInvokedEvent(timestamp, source))
} }
val position = writePosition ?: exoPlayer?.currentPosition
val duration = exoPlayer?.contentDuration val duration = exoPlayer?.contentDuration
if (duration != null && position != null) { if (duration != null && position != null) {
playerPositionChanged?.invoke(Pair(position, duration)) event(
PositionEvent(
source,
fromMs = exoPlayer?.currentPosition ?: 0,
position,
duration
)
)
} }
} }
override fun seekTime(time: Long) { override fun seekTime(time: Long, source: PlayerEventSource) {
exoPlayer?.seekTime(time) exoPlayer?.seekTime(time, source)
} }
override fun seekTo(time: Long) { override fun seekTo(time: Long, source: PlayerEventSource) {
updatedTime(time) updatedTime(time, source)
exoPlayer?.seekTo(time) exoPlayer?.seekTo(time)
} }
private fun ExoPlayer.seekTime(time: Long) { private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) {
updatedTime(currentPosition + time) updatedTime(currentPosition + time, source)
seekTo(currentPosition + time) seekTo(currentPosition + time)
} }
override fun handleEvent(event: CSPlayerEvent) { override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) {
Log.i(TAG, "handleEvent ${event.name}") Log.i(TAG, "handleEvent ${event.name}")
try { try {
exoPlayer?.apply { exoPlayer?.apply {
when (event) { when (event) {
CSPlayerEvent.Play -> { CSPlayerEvent.Play -> {
event(PlayEvent(source))
play() play()
} }
CSPlayerEvent.Pause -> { CSPlayerEvent.Pause -> {
event(PauseEvent(source))
pause() pause()
} }
CSPlayerEvent.ToggleMute -> { CSPlayerEvent.ToggleMute -> {
if (volume <= 0) { if (volume <= 0) {
//is muted //is muted
@ -823,33 +860,35 @@ class CS3IPlayer : IPlayer {
volume = 0f volume = 0f
} }
} }
CSPlayerEvent.PlayPauseToggle -> { CSPlayerEvent.PlayPauseToggle -> {
if (isPlaying) { if (isPlaying) {
pause() handleEvent(CSPlayerEvent.Pause, source)
} else { } else {
play() handleEvent(CSPlayerEvent.Play, source)
} }
} }
CSPlayerEvent.SeekForward -> seekTime(seekActionTime)
CSPlayerEvent.SeekBack -> seekTime(-seekActionTime) CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source)
CSPlayerEvent.NextEpisode -> nextEpisode?.invoke() CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source)
CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke() CSPlayerEvent.NextEpisode -> event(EpisodeSeekEvent(offset = 1, source = source))
CSPlayerEvent.PrevEpisode -> event(EpisodeSeekEvent(offset = -1, source = source))
CSPlayerEvent.SkipCurrentChapter -> { CSPlayerEvent.SkipCurrentChapter -> {
//val dur = this@CS3IPlayer.getDuration() ?: return@apply //val dur = this@CS3IPlayer.getDuration() ?: return@apply
getCurrentTimestamp()?.let { lastTimeStamp -> getCurrentTimestamp()?.let { lastTimeStamp ->
if (lastTimeStamp.skipToNextEpisode) { if (lastTimeStamp.skipToNextEpisode) {
handleEvent(CSPlayerEvent.NextEpisode) handleEvent(CSPlayerEvent.NextEpisode, source)
} else { } else {
seekTo(lastTimeStamp.endMs + 1L) seekTo(lastTimeStamp.endMs + 1L)
} }
onTimestampSkipped?.invoke(lastTimeStamp) event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source))
} }
} }
} }
} }
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "handleEvent error", e) Log.e(TAG, "handleEvent error", t)
playerError?.invoke(e) event(ErrorEvent(t))
} }
} }
@ -888,18 +927,14 @@ class CS3IPlayer : IPlayer {
requestSubtitleUpdate = ::reloadSubs requestSubtitleUpdate = ::reloadSubs
playerUpdated?.invoke(exoPlayer) event(PlayerAttachedEvent(exoPlayer))
exoPlayer?.prepare() exoPlayer?.prepare()
exoPlayer?.let { exo -> exoPlayer?.let { exo ->
updateIsPlaying?.invoke( event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering))
Pair(
CSPlayerLoading.IsBuffering,
CSPlayerLoading.IsBuffering
)
)
isPlaying = exo.isPlaying isPlaying = exo.isPlaying
} }
exoPlayer?.addListener(object : Player.Listener { exoPlayer?.addListener(object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
normalSafeApiCall { normalSafeApiCall {
@ -933,18 +968,19 @@ class CS3IPlayer : IPlayer {
) )
} }
embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks) event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks))
onTracksInfoChanged?.invoke() event(TracksChangedEvent())
subtitlesUpdates?.invoke() event(SubtitlesUpdatedEvent())
} }
} }
@SuppressLint("UnsafeOptInUsageError")
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
exoPlayer?.let { exo -> exoPlayer?.let { exo ->
updateIsPlaying?.invoke( event(
Pair( StatusEvent(
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused,
if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused isPlaying = if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
) )
) )
isPlaying = exo.isPlaying isPlaying = exo.isPlaying
@ -954,6 +990,7 @@ class CS3IPlayer : IPlayer {
Player.STATE_READY -> { Player.STATE_READY -> {
onRenderFirst() onRenderFirst()
} }
else -> {} else -> {}
} }
@ -963,23 +1000,19 @@ class CS3IPlayer : IPlayer {
Player.STATE_READY -> { Player.STATE_READY -> {
} }
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
// Only play next episode if autoplay is on (default) event(VideoEndedEvent())
if (PreferenceManager.getDefaultSharedPreferences(context)
?.getBoolean(
context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key),
true
) == true
) {
handleEvent(CSPlayerEvent.NextEpisode)
}
} }
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
updatedTime() updatedTime(source = PlayerEventSource.Player)
} }
Player.STATE_IDLE -> { Player.STATE_IDLE -> {
// IDLE
} }
else -> Unit else -> Unit
} }
} }
@ -994,13 +1027,15 @@ class CS3IPlayer : IPlayer {
&& exoPlayer?.duration != TIME_UNSET -> { && exoPlayer?.duration != TIME_UNSET -> {
exoPlayer?.prepare() exoPlayer?.prepare()
} }
error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> { error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> {
// Re-initialize player at the current live window default position. // Re-initialize player at the current live window default position.
exoPlayer?.seekToDefaultPosition() exoPlayer?.seekToDefaultPosition()
exoPlayer?.prepare() exoPlayer?.prepare()
} }
else -> { else -> {
playerError?.invoke(error) event(ErrorEvent(error))
} }
} }
@ -1014,7 +1049,7 @@ class CS3IPlayer : IPlayer {
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying) super.onIsPlayingChanged(isPlaying)
if (isPlaying) { if (isPlaying) {
requestAutoFocus?.invoke() event(RequestAudioFocusEvent())
onRenderFirst() onRenderFirst()
} }
} }
@ -1025,6 +1060,7 @@ class CS3IPlayer : IPlayer {
Player.STATE_READY -> { Player.STATE_READY -> {
} }
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
// Only play next episode if autoplay is on (default) // Only play next episode if autoplay is on (default)
if (PreferenceManager.getDefaultSharedPreferences(context) if (PreferenceManager.getDefaultSharedPreferences(context)
@ -1033,42 +1069,50 @@ class CS3IPlayer : IPlayer {
true true
) == true ) == true
) { ) {
handleEvent(CSPlayerEvent.NextEpisode) handleEvent(
CSPlayerEvent.NextEpisode,
source = PlayerEventSource.Player
)
} }
} }
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
updatedTime() updatedTime(source = PlayerEventSource.Player)
} }
Player.STATE_IDLE -> { Player.STATE_IDLE -> {
// IDLE // IDLE
} }
else -> Unit else -> Unit
} }
} }
override fun onVideoSizeChanged(videoSize: VideoSize) { override fun onVideoSizeChanged(videoSize: VideoSize) {
super.onVideoSizeChanged(videoSize) super.onVideoSizeChanged(videoSize)
playerDimensionsLoaded?.invoke(Pair(videoSize.width, videoSize.height)) event(ResizedEvent(height = videoSize.height, width = videoSize.width))
} }
override fun onRenderedFirstFrame() { override fun onRenderedFirstFrame() {
updatedTime()
super.onRenderedFirstFrame() super.onRenderedFirstFrame()
onRenderFirst() onRenderFirst()
updatedTime(source = PlayerEventSource.Player)
} }
}) })
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "loadExo error", e) Log.e(TAG, "loadExo error", t)
playerError?.invoke(e) event(ErrorEvent(t))
} }
} }
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList() private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList()
@SuppressLint("UnsafeOptInUsageError")
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) { override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
lastTimeStamps = timeStamps lastTimeStamps = timeStamps
timeStamps.forEach { timestamp -> timeStamps.forEach { timestamp ->
exoPlayer?.createMessage { _, _ -> exoPlayer?.createMessage { _, _ ->
updatedTime() updatedTime(source = PlayerEventSource.Player)
//if (payload is EpisodeSkip.SkipStamp) // this should always be true //if (payload is EpisodeSkip.SkipStamp) // this should always be true
// onTimestampInvoked?.invoke(payload) // onTimestampInvoked?.invoke(payload)
} }
@ -1078,46 +1122,48 @@ class CS3IPlayer : IPlayer {
?.setDeleteAfterDelivery(false) ?.setDeleteAfterDelivery(false)
?.send() ?.send()
} }
updatedTime() updatedTime(source = PlayerEventSource.Player)
} }
@SuppressLint("UnsafeOptInUsageError")
fun onRenderFirst() { fun onRenderFirst() {
if (!hasUsedFirstRender) { // this insures that we only call this once per player load if (hasUsedFirstRender) { // this insures that we only call this once per player load
Log.i(TAG, "Rendered first frame") return
val invalid = exoPlayer?.duration?.let { duration -> }
// Only errors short playback when not playing downloaded files Log.i(TAG, "Rendered first frame")
duration < 20_000L && currentDownloadedFile == null hasUsedFirstRender = true
// Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period val invalid = exoPlayer?.duration?.let { duration ->
// If you can get the total time that'd be better, but this is already niche. // Only errors short playback when not playing downloaded files
&& exoPlayer?.currentTimeline?.periodCount == 1 duration < 20_000L && currentDownloadedFile == null
&& exoPlayer?.isCurrentMediaItemLive != true // Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period
} ?: false // If you can get the total time that'd be better, but this is already niche.
&& exoPlayer?.currentTimeline?.periodCount == 1
&& exoPlayer?.isCurrentMediaItemLive != true
} ?: false
if (invalid) { if (invalid) {
releasePlayer(saveTime = false) releasePlayer(saveTime = false)
playerError?.invoke(InvalidFileException("Too short playback")) event(ErrorEvent(InvalidFileException("Too short playback")))
return return
} }
setPreferredSubtitles(currentSubtitles) setPreferredSubtitles(currentSubtitles)
hasUsedFirstRender = true val format = exoPlayer?.videoFormat
val format = exoPlayer?.videoFormat val width = format?.width
val width = format?.width val height = format?.height
val height = format?.height if (height != null && width != null) {
if (height != null && width != null) { event(ResizedEvent(width = width, height = height))
playerDimensionsLoaded?.invoke(Pair(width, height)) updatedTime()
updatedTime() exoPlayer?.apply {
exoPlayer?.apply { requestedListeningPercentages?.forEach { percentage ->
requestedListeningPercentages?.forEach { percentage -> createMessage { _, _ ->
createMessage { _, _ -> updatedTime()
updatedTime()
}
.setLooper(Looper.getMainLooper())
.setPosition( /* positionMs= */contentDuration * percentage / 100)
// .setPayload(customPayloadData)
.setDeleteAfterDelivery(false)
.send()
} }
.setLooper(Looper.getMainLooper())
.setPosition(contentDuration * percentage / 100)
// .setPayload(customPayloadData)
.setDeleteAfterDelivery(false)
.send()
} }
} }
} }
@ -1140,12 +1186,13 @@ class CS3IPlayer : IPlayer {
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources) loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources)
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "loadOfflinePlayer error", e) Log.e(TAG, "loadOfflinePlayer error", t)
playerError?.invoke(e) event(ErrorEvent(t))
} }
} }
@SuppressLint("UnsafeOptInUsageError")
private fun getSubSources( private fun getSubSources(
onlineSourceFactory: HttpDataSource.Factory?, onlineSourceFactory: HttpDataSource.Factory?,
offlineSourceFactory: DataSource.Factory?, offlineSourceFactory: DataSource.Factory?,
@ -1169,6 +1216,7 @@ class CS3IPlayer : IPlayer {
null null
} }
} }
SubtitleOrigin.URL -> { SubtitleOrigin.URL -> {
if (onlineSourceFactory != null) { if (onlineSourceFactory != null) {
activeSubtitles.add(sub) activeSubtitles.add(sub)
@ -1181,6 +1229,7 @@ class CS3IPlayer : IPlayer {
null null
} }
} }
SubtitleOrigin.EMBEDDED_IN_VIDEO -> { SubtitleOrigin.EMBEDDED_IN_VIDEO -> {
if (offlineSourceFactory != null) { if (offlineSourceFactory != null) {
activeSubtitles.add(sub) activeSubtitles.add(sub)
@ -1199,6 +1248,7 @@ class CS3IPlayer : IPlayer {
return exoPlayer != null return exoPlayer != null
} }
@SuppressLint("UnsafeOptInUsageError")
private fun loadOnlinePlayer(context: Context, link: ExtractorLink) { private fun loadOnlinePlayer(context: Context, link: ExtractorLink) {
Log.i(TAG, "loadOnlinePlayer $link") Log.i(TAG, "loadOnlinePlayer $link")
try { try {
@ -1215,18 +1265,37 @@ class CS3IPlayer : IPlayer {
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
} }
val mime = when { val mime = when (link.type) {
link.isM3u8 -> MimeTypes.APPLICATION_M3U8 ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8
link.isDash -> MimeTypes.APPLICATION_MPD ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD
else -> MimeTypes.VIDEO_MP4 ExtractorLinkType.VIDEO -> MimeTypes.VIDEO_MP4
ExtractorLinkType.TORRENT -> throw IllegalArgumentException("No torrent support")
ExtractorLinkType.MAGNET -> throw IllegalArgumentException("No magnet support")
} }
val mediaItems = if (link is ExtractorLinkPlayList) {
link.playlist.map { val mediaItems = when (link) {
is ExtractorLinkPlayList -> link.playlist.map {
MediaItemSlice(getMediaItem(mime, it.url), it.durationUs) MediaItemSlice(getMediaItem(mime, it.url), it.durationUs)
} }
} else {
listOf( is DrmExtractorLink -> {
listOf(
// Single sliced list with unset length
MediaItemSlice(
getMediaItem(mime, link.url), Long.MIN_VALUE,
drm = DrmMetadata(
kid = link.kid,
key = link.key,
uuid = link.uuid,
kty = link.kty,
keyRequestParameters = link.keyRequestParameters
)
)
)
}
else -> listOf(
// Single sliced list with unset length // Single sliced list with unset length
MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE) MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE)
) )
@ -1252,16 +1321,16 @@ class CS3IPlayer : IPlayer {
} }
loadExo(context, mediaItems, subSources, cacheFactory) loadExo(context, mediaItems, subSources, cacheFactory)
} catch (e: Exception) { } catch (t: Throwable) {
Log.e(TAG, "loadOnlinePlayer error", e) Log.e(TAG, "loadOnlinePlayer error", t)
playerError?.invoke(e) event(ErrorEvent(t))
} }
} }
override fun reloadPlayer(context: Context) { override fun reloadPlayer(context: Context) {
Log.i(TAG, "reloadPlayer") Log.i(TAG, "reloadPlayer")
exoPlayer?.release() releasePlayer(false)
currentLink?.let { currentLink?.let {
loadOnlinePlayer(context, it) loadOnlinePlayer(context, it)
} ?: currentDownloadedFile?.let { } ?: currentDownloadedFile?.let {

View file

@ -3,19 +3,23 @@ package com.lagradost.cloudstream3.ui.player
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.exoplayer2.Format import androidx.media3.common.Format
import com.google.android.exoplayer2.text.* import androidx.media3.common.MimeTypes
import com.google.android.exoplayer2.text.cea.Cea608Decoder import androidx.media3.exoplayer.text.ExoplayerCuesDecoder
import com.google.android.exoplayer2.text.cea.Cea708Decoder import androidx.media3.exoplayer.text.SubtitleDecoderFactory
import com.google.android.exoplayer2.text.dvb.DvbDecoder import androidx.media3.extractor.text.SubtitleDecoder
import com.google.android.exoplayer2.text.pgs.PgsDecoder import androidx.media3.extractor.text.SubtitleInputBuffer
import com.google.android.exoplayer2.text.ssa.SsaDecoder import androidx.media3.extractor.text.SubtitleOutputBuffer
import com.google.android.exoplayer2.text.subrip.SubripDecoder import androidx.media3.extractor.text.cea.Cea608Decoder
import com.google.android.exoplayer2.text.ttml.TtmlDecoder import androidx.media3.extractor.text.cea.Cea708Decoder
import com.google.android.exoplayer2.text.tx3g.Tx3gDecoder import androidx.media3.extractor.text.dvb.DvbDecoder
import com.google.android.exoplayer2.text.webvtt.Mp4WebvttDecoder import androidx.media3.extractor.text.pgs.PgsDecoder
import com.google.android.exoplayer2.text.webvtt.WebvttDecoder import androidx.media3.extractor.text.ssa.SsaDecoder
import com.google.android.exoplayer2.util.MimeTypes import androidx.media3.extractor.text.subrip.SubripDecoder
import androidx.media3.extractor.text.ttml.TtmlDecoder
import androidx.media3.extractor.text.tx3g.Tx3gDecoder
import androidx.media3.extractor.text.webvtt.Mp4WebvttDecoder
import androidx.media3.extractor.text.webvtt.WebvttDecoder
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import org.mozilla.universalchardet.UniversalDetector import org.mozilla.universalchardet.UniversalDetector

View file

@ -1,8 +1,8 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.os.Looper import android.os.Looper
import com.google.android.exoplayer2.text.SubtitleDecoderFactory import androidx.media3.exoplayer.text.SubtitleDecoderFactory
import com.google.android.exoplayer2.text.TextOutput import androidx.media3.exoplayer.text.TextOutput
class CustomTextRenderer( class CustomTextRenderer(
offset: Long, offset: Long,

View file

@ -50,47 +50,60 @@ class DownloadFileGenerator(
return null return null
} }
fun cleanDisplayName(name: String): String {
return name.substringBeforeLast('.').trim()
}
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
isCasting: Boolean, type: LoadType,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit, callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit, subtitleCallback: (SubtitleData) -> Unit,
offset: Int, offset: Int
): Boolean { ): Boolean {
val meta = episodes[currentIndex + offset] val meta = episodes[currentIndex + offset]
callback(Pair(null, meta)) callback(null to meta)
context?.let { ctx -> val ctx = context ?: return true
val relative = meta.relativePath val relative = meta.relativePath ?: return true
val display = meta.displayName val display = meta.displayName ?: return true
if (display == null || relative == null) { val cleanDisplay = cleanDisplayName(display)
return@let
VideoDownloadManager.getFolder(ctx, relative, meta.basePath)
?.forEach { (name, uri) ->
// only these files are allowed, so no videos as subtitles
if (listOf(
".vtt",
".srt",
".txt",
".ass",
".ttml",
".sbv",
".dfxp"
).none { name.contains(it, true) }
) return@forEach
// cant have the exact same file as a subtitle
if (name.equals(display, true)) return@forEach
val cleanName = cleanDisplayName(name)
// we only want files with the approx same name
if (!cleanName.startsWith(cleanDisplay, true)) return@forEach
val realName = cleanName.removePrefix(cleanDisplay)
subtitleCallback(
SubtitleData(
realName.ifBlank { ctx.getString(R.string.default_subtitles) },
uri.toString(),
SubtitleOrigin.DOWNLOADED_FILE,
name.toSubtitleMimeType(),
emptyMap()
)
)
} }
VideoDownloadManager.getFolder(ctx, relative, meta.basePath)
?.forEach { file ->
val name = display.removeSuffix(".mp4")
if (file.first != meta.displayName && file.first.startsWith(name)) {
val realName = file.first.removePrefix(name)
.removeSuffix(".vtt")
.removeSuffix(".srt")
.removeSuffix(".txt")
.trim()
.removePrefix("(")
.removeSuffix(")")
subtitleCallback(
SubtitleData(
realName.ifBlank { ctx.getString(R.string.default_subtitles) },
file.second.toString(),
SubtitleOrigin.DOWNLOADED_FILE,
name.toSubtitleMimeType(),
emptyMap()
)
)
}
}
}
return true return true
} }

View file

@ -1,16 +1,17 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.content.ContentUris
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.safefile.SafeFile
const val DTAG = "PlayerActivity" const val DTAG = "PlayerActivity"
@ -50,14 +51,17 @@ class DownloadedPlayerActivity : AppCompatActivity() {
} }
private fun playUri(uri: Uri) { private fun playUri(uri: Uri) {
val name = UniFile.fromUri(this, uri).name val name = SafeFile.fromUri(this, uri)?.name()
this.navigate( this.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance( R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator( DownloadFileGenerator(
listOf( listOf(
ExtractorUri( ExtractorUri(
uri = uri, uri = uri,
name = name ?: getString(R.string.downloaded_file) name = name ?: getString(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 = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull()?.hashCode()
) )
) )
) )

View file

@ -37,14 +37,17 @@ class ExtractorLinkGenerator(
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
isCasting: Boolean, type: LoadType,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit, callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit, subtitleCallback: (SubtitleData) -> Unit,
offset: Int offset: Int
): Boolean { ): Boolean {
subtitles.forEach(subtitleCallback) subtitles.forEach(subtitleCallback)
val allowedTypes = type.toSet()
links.forEach { links.forEach {
callback.invoke(it to null) if(allowedTypes.contains(it.type)) {
callback.invoke(it to null)
}
} }
return true return true

View file

@ -19,12 +19,15 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.exoplayer2.Format.NO_VALUE import androidx.media3.common.Format.NO_VALUE
import com.google.android.exoplayer2.util.MimeTypes import androidx.media3.common.MimeTypes
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.showToast
import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding
import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding
import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding
import com.lagradost.cloudstream3.mvvm.* import com.lagradost.cloudstream3.mvvm.*
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders
@ -34,8 +37,6 @@ import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForced
import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType
import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper
import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog
import com.lagradost.cloudstream3.ui.player.source_priority.SourcePriority
import com.lagradost.cloudstream3.ui.player.source_priority.SourcePriorityDialog
import com.lagradost.cloudstream3.ui.result.* import com.lagradost.cloudstream3.ui.result.*
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1 import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageISO639_1
@ -51,18 +52,9 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import kotlinx.android.synthetic.main.dialog_online_subtitles.* import com.lagradost.safefile.SafeFile
import kotlinx.android.synthetic.main.dialog_online_subtitles.apply_btt
import kotlinx.android.synthetic.main.dialog_online_subtitles.cancel_btt
import kotlinx.android.synthetic.main.fragment_player.*
import kotlinx.android.synthetic.main.player_custom_layout.*
import kotlinx.android.synthetic.main.player_select_source_and_subs.*
import kotlinx.android.synthetic.main.player_select_source_and_subs.subtitles_click_settings
import kotlinx.android.synthetic.main.player_select_tracks.*
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import java.util.* import java.util.*
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
import kotlin.math.abs import kotlin.math.abs
class GeneratorPlayer : FullScreenPlayer() { class GeneratorPlayer : FullScreenPlayer() {
@ -100,12 +92,14 @@ class GeneratorPlayer : FullScreenPlayer() {
private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none
private var binding: FragmentPlayerBinding? = null
private fun startLoading() { private fun startLoading() {
player.release() player.release()
currentSelectedSubtitles = null currentSelectedSubtitles = null
isActive = false isActive = false
overlay_loading_skip_button?.isVisible = false binding?.overlayLoadingSkipButton?.isVisible = false
player_loading_overlay?.isVisible = true binding?.playerLoadingOverlay?.isVisible = true
} }
private fun setSubtitles(sub: SubtitleData?): Boolean { private fun setSubtitles(sub: SubtitleData?): Boolean {
@ -120,7 +114,7 @@ class GeneratorPlayer : FullScreenPlayer() {
override fun onTracksInfoChanged() { override fun onTracksInfoChanged() {
val tracks = player.getVideoTracks() val tracks = player.getVideoTracks()
player_tracks_btt?.isVisible = playerBinding?.playerTracksBtt?.isVisible =
tracks.allVideoTracks.size > 1 || tracks.allAudioTracks.size > 1 tracks.allVideoTracks.size > 1 || tracks.allAudioTracks.size > 1
// Only set the preferred language if it is available. // Only set the preferred language if it is available.
// Otherwise it may give some users audio track init failed! // Otherwise it may give some users audio track init failed!
@ -142,7 +136,7 @@ class GeneratorPlayer : FullScreenPlayer() {
return durPos.position return durPos.position
} }
var currentVerifyLink: Job? = null private var currentVerifyLink: Job? = null
private fun loadExtractorJob(extractorLink: ExtractorLink?) { private fun loadExtractorJob(extractorLink: ExtractorLink?) {
currentVerifyLink?.cancel() currentVerifyLink?.cancel()
@ -160,12 +154,12 @@ class GeneratorPlayer : FullScreenPlayer() {
if (link == null) return if (link == null) return
// manage UI // manage UI
player_loading_overlay?.isVisible = false binding?.playerLoadingOverlay?.isVisible = false
uiReset() uiReset()
currentSelectedLink = link currentSelectedLink = link
currentMeta = viewModel.getMeta() currentMeta = viewModel.getMeta()
nextMeta = viewModel.getNextMeta() nextMeta = viewModel.getNextMeta()
setEpisodes(viewModel.getAllMeta() ?: emptyList()) // setEpisodes(viewModel.getAllMeta() ?: emptyList())
isActive = true isActive = true
setPlayerDimen(null) setPlayerDimen(null)
setTitle() setTitle()
@ -211,7 +205,7 @@ class GeneratorPlayer : FullScreenPlayer() {
closestQuality(linkData?.quality) closestQuality(linkData?.quality)
) )
val sourcePriority = val sourcePriority =
QualityDataHelper.getSourcePriority(qualityProfile, linkData?.name) QualityDataHelper.getSourcePriority(qualityProfile, linkData?.source)
// negative because we want to sort highest quality first // negative because we want to sort highest quality first
return qualityPriority + sourcePriority return qualityPriority + sourcePriority
@ -259,7 +253,9 @@ class GeneratorPlayer : FullScreenPlayer() {
val isSingleProvider = subsProviders.size == 1 val isSingleProvider = subsProviders.size == 1
val dialog = Dialog(context, R.style.AlertDialogCustomBlack) val dialog = Dialog(context, R.style.AlertDialogCustomBlack)
dialog.setContentView(R.layout.dialog_online_subtitles) val binding =
DialogOnlineSubtitlesBinding.inflate(LayoutInflater.from(context), null, false)
dialog.setContentView(binding.root)
var currentSubtitles: List<AbstractSubtitleEntities.SubtitleEntity> = emptyList() var currentSubtitles: List<AbstractSubtitleEntities.SubtitleEntity> = emptyList()
var currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null var currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null
@ -297,6 +293,7 @@ class GeneratorPlayer : FullScreenPlayer() {
imageViewEnd.setImageDrawable(drawableEnd) imageViewEnd.setImageDrawable(drawableEnd)
} }
@SuppressLint("SetTextI18n")
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(context).inflate(layout, null) val view = convertView ?: LayoutInflater.from(context).inflate(layout, null)
@ -320,16 +317,16 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
dialog.show() dialog.show()
dialog.cancel_btt.setOnClickListener { binding.cancelBtt.setOnClickListener {
dialog.dismissSafe() dialog.dismissSafe()
} }
dialog.subtitle_adapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE
dialog.subtitle_adapter.adapter = arrayAdapter binding.subtitleAdapter.adapter = arrayAdapter
val adapter = val adapter =
dialog.subtitle_adapter.adapter as? ArrayAdapter<AbstractSubtitleEntities.SubtitleEntity> binding.subtitleAdapter.adapter as? ArrayAdapter<AbstractSubtitleEntities.SubtitleEntity>
dialog.subtitle_adapter.setOnItemClickListener { _, _, position, _ -> binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ ->
currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener
} }
@ -345,16 +342,16 @@ class GeneratorPlayer : FullScreenPlayer() {
val currentTempMeta = getMetaData() val currentTempMeta = getMetaData()
// bruh idk why it is not correct // bruh idk why it is not correct
val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent)) val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent))
dialog.search_loading_bar.progressTintList = color binding.searchLoadingBar.progressTintList = color
dialog.search_loading_bar.indeterminateTintList = color binding.searchLoadingBar.indeterminateTintList = color
observeNullable(viewModel.currentSubtitleYear) { observeNullable(viewModel.currentSubtitleYear) {
// When year is changed search again // When year is changed search again
dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true) binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true)
dialog.year_btt.text = it?.toString() ?: txt(R.string.none).asString(context) binding.yearBtt.text = it?.toString() ?: txt(R.string.none).asString(context)
} }
dialog.year_btt?.setOnClickListener { binding.yearBtt.setOnClickListener {
val none = txt(R.string.none).asString(context) val none = txt(R.string.none).asString(context)
val currentYear = Calendar.getInstance().get(Calendar.YEAR) val currentYear = Calendar.getInstance().get(Calendar.YEAR)
val earliestYear = 1900 val earliestYear = 1900
@ -382,10 +379,10 @@ class GeneratorPlayer : FullScreenPlayer() {
) )
} }
dialog.subtitles_search.setOnQueryTextListener(object : binding.subtitlesSearch.setOnQueryTextListener(object :
androidx.appcompat.widget.SearchView.OnQueryTextListener { androidx.appcompat.widget.SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean { override fun onQueryTextSubmit(query: String?): Boolean {
dialog.search_loading_bar?.show() binding.searchLoadingBar.show()
ioSafe { ioSafe {
val search = val search =
AbstractSubtitleEntities.SubtitleSearch( AbstractSubtitleEntities.SubtitleSearch(
@ -417,7 +414,7 @@ class GeneratorPlayer : FullScreenPlayer() {
// ugly ik // ugly ik
activity?.runOnUiThread { activity?.runOnUiThread {
setSubtitlesList(items) setSubtitlesList(items)
dialog.search_loading_bar?.hide() binding.searchLoadingBar.hide()
} }
} }
@ -429,7 +426,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
}) })
dialog.search_filter.setOnClickListener { view -> binding.searchFilter.setOnClickListener { view ->
val lang639_1 = languages.map { it.ISO_639_1 } val lang639_1 = languages.map { it.ISO_639_1 }
activity?.showDialog(languages.map { it.languageName }, activity?.showDialog(languages.map { it.languageName },
lang639_1.indexOf(currentLanguageTwoLetters), lang639_1.indexOf(currentLanguageTwoLetters),
@ -438,11 +435,11 @@ class GeneratorPlayer : FullScreenPlayer() {
true, true,
{ }) { index -> { }) { index ->
currentLanguageTwoLetters = lang639_1[index] currentLanguageTwoLetters = lang639_1[index]
dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true) binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true)
} }
} }
dialog.apply_btt.setOnClickListener { binding.applyBtt.setOnClickListener {
currentSubtitle?.let { currentSubtitle -> currentSubtitle?.let { currentSubtitle ->
providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api ->
ioSafe { ioSafe {
@ -468,7 +465,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
dialog.show() dialog.show()
dialog.subtitles_search.setQuery(currentTempMeta.name, true) binding.subtitlesSearch.setQuery(currentTempMeta.name, true)
//TODO: Set year text from currently loaded movie on Player //TODO: Set year text from currently loaded movie on Player
//dialog.subtitles_search_year?.setText(currentTempMeta.year) //dialog.subtitles_search_year?.setText(currentTempMeta.year)
} }
@ -511,7 +508,6 @@ class GeneratorPlayer : FullScreenPlayer() {
selectSourceDialog?.dismissSafe() selectSourceDialog?.dismissSafe()
showToast( showToast(
activity,
String.format(ctx.getString(R.string.player_loaded_subtitles), subtitleData.name), String.format(ctx.getString(R.string.player_loaded_subtitles), subtitleData.name),
Toast.LENGTH_LONG Toast.LENGTH_LONG
) )
@ -525,15 +521,16 @@ class GeneratorPlayer : FullScreenPlayer() {
if (uri == null) return@normalSafeApiCall if (uri == null) return@normalSafeApiCall
val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall
// RW perms for the path // RW perms for the path
val flags = ctx.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
ctx.contentResolver.takePersistableUriPermission(uri, flags) val file = SafeFile.fromUri(ctx, uri)
val fileName = file?.name()
val file = UniFile.fromUri(ctx, uri) println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName")
println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}")
// DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES // DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES
val name = file.name ?: uri.toString() val name = fileName ?: uri.toString()
val subtitleData = SubtitleData( val subtitleData = SubtitleData(
name, name,
@ -556,17 +553,19 @@ class GeneratorPlayer : FullScreenPlayer() {
//println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs")
context?.let { ctx -> context?.let { ctx ->
val isPlaying = player.getIsPlaying() val isPlaying = player.getIsPlaying()
player.handleEvent(CSPlayerEvent.Pause) player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI)
val currentSubtitles = sortSubs(currentSubs) val currentSubtitles = sortSubs(currentSubs)
val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) val sourceDialog = Dialog(ctx, R.style.AlertDialogCustomBlack)
sourceDialog.setContentView(R.layout.player_select_source_and_subs) val binding =
PlayerSelectSourceAndSubsBinding.inflate(LayoutInflater.from(ctx), null, false)
sourceDialog.setContentView(binding.root)
selectSourceDialog = sourceDialog selectSourceDialog = sourceDialog
sourceDialog.show() sourceDialog.show()
val providerList = sourceDialog.sort_providers val providerList = binding.sortProviders
val subtitleList = sourceDialog.sort_subtitles val subtitleList = binding.sortSubtitles
val loadFromFileFooter: TextView = val loadFromFileFooter: TextView =
layoutInflater.inflate(R.layout.sort_bottom_footer_add_choice, null) as TextView layoutInflater.inflate(R.layout.sort_bottom_footer_add_choice, null) as TextView
@ -674,12 +673,12 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
} }
sourceDialog.cancel_btt?.setOnClickListener { binding.cancelBtt.setOnClickListener {
sourceDialog.dismissSafe(activity) sourceDialog.dismissSafe(activity)
} }
fun setProfileName(profile: Int) { fun setProfileName(profile: Int) {
sourceDialog.source_settings_btt.setText( binding.sourceSettingsBtt.setText(
QualityDataHelper.getProfileName( QualityDataHelper.getProfileName(
profile profile
) )
@ -687,7 +686,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
setProfileName(currentQualityProfile) setProfileName(currentQualityProfile)
sourceDialog.profiles_click_settings.setOnClickListener { binding.profilesClickSettings.setOnClickListener {
val activity = activity ?: return@setOnClickListener val activity = activity ?: return@setOnClickListener
QualityProfileDialog( QualityProfileDialog(
activity, activity,
@ -701,7 +700,7 @@ class GeneratorPlayer : FullScreenPlayer() {
}.show() }.show()
} }
sourceDialog.subtitles_encoding_format?.apply { binding.subtitlesEncodingFormat.apply {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list)
@ -714,7 +713,7 @@ class GeneratorPlayer : FullScreenPlayer() {
text = prefNames[if (index == -1) 0 else index] text = prefNames[if (index == -1) 0 else index]
} }
sourceDialog.subtitles_click_settings?.setOnClickListener { binding.subtitlesClickSettings.setOnClickListener {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
.attachBackupListener(ctx.getSyncPrefs()).self .attachBackupListener(ctx.getSyncPrefs()).self
@ -744,7 +743,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
} }
sourceDialog.apply_btt?.setOnClickListener { binding.applyBtt.setOnClickListener {
var init = false var init = false
if (sourceIndex != startSource) { if (sourceIndex != startSource) {
init = true init = true
@ -784,18 +783,19 @@ class GeneratorPlayer : FullScreenPlayer() {
it.height?.times(-1) it.height?.times(-1)
} }
val currentAudioTracks = tracks.allAudioTracks val currentAudioTracks = tracks.allAudioTracks
val binding: PlayerSelectTracksBinding =
PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false)
val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack) val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack)
trackDialog.setContentView(R.layout.player_select_tracks) trackDialog.setContentView(binding.root)
trackDialog.show() trackDialog.show()
// selectTracksDialog = tracksDialog // selectTracksDialog = tracksDialog
val videosList = trackDialog.video_tracks_list val videosList = binding.videoTracksList
val audioList = trackDialog.auto_tracks_list val audioList = binding.autoTracksList
trackDialog.video_tracks_holder.isVisible = currentVideoTracks.size > 1 binding.videoTracksHolder.isVisible = currentVideoTracks.size > 1
trackDialog.audio_tracks_holder.isVisible = currentAudioTracks.size > 1 binding.audioTracksHolder.isVisible = currentAudioTracks.size > 1
fun dismiss() { fun dismiss() {
if (isPlaying) { if (isPlaying) {
@ -860,11 +860,11 @@ class GeneratorPlayer : FullScreenPlayer() {
audioList.setItemChecked(which, true) audioList.setItemChecked(which, true)
} }
trackDialog.cancel_btt?.setOnClickListener { binding.cancelBtt.setOnClickListener {
trackDialog.dismissSafe(activity) trackDialog.dismissSafe(activity)
} }
trackDialog.apply_btt?.setOnClickListener { binding.applyBtt.setOnClickListener {
val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) val currentTrack = currentAudioTracks.getOrNull(audioIndexStart)
player.setPreferredAudioTrack( player.setPreferredAudioTrack(
currentTrack?.language, currentTrack?.id currentTrack?.language, currentTrack?.id
@ -886,13 +886,13 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
override fun playerError(exception: Exception) { override fun playerError(exception: Throwable) {
Log.i(TAG, "playerError = $currentSelectedLink") Log.i(TAG, "playerError = $currentSelectedLink")
super.playerError(exception) super.playerError(exception)
} }
private fun noLinksFound() { private fun noLinksFound() {
showToast(activity, R.string.no_links_found_toast, Toast.LENGTH_SHORT) showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT)
activity?.popCurrentPage() activity?.popCurrentPage()
} }
@ -948,14 +948,13 @@ class GeneratorPlayer : FullScreenPlayer() {
var maxEpisodeSet: Int? = null var maxEpisodeSet: Int? = null
var hasRequestedStamps: Boolean = false var hasRequestedStamps: Boolean = false
override fun playerPositionChanged(posDur: Pair<Long, Long>) { override fun playerPositionChanged(position: Long, duration : Long) {
// Don't save livestream data // Don't save livestream data
if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return
// Don't save NSFW data // Don't save NSFW data
if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return
val (position, duration) = posDur
if (duration <= 0L) return // idk how you achieved this, but div by zero crash if (duration <= 0L) return // idk how you achieved this, but div by zero crash
if (!hasRequestedStamps) { if (!hasRequestedStamps) {
hasRequestedStamps = true hasRequestedStamps = true
@ -1033,8 +1032,10 @@ class GeneratorPlayer : FullScreenPlayer() {
if (meta.tvType.isAnimeOp()) isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE if (meta.tvType.isAnimeOp()) isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE
} }
} }
player_skip_op?.isVisible = isOpVisible
player_skip_episode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true playerBinding?.playerSkipOp?.isVisible = isOpVisible
playerBinding?.playerSkipEpisode?.isVisible =
!isOpVisible && viewModel.hasNextEpisode() == true
if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) {
viewModel.preLoadNextLinks() viewModel.preLoadNextLinks()
@ -1171,7 +1172,7 @@ class GeneratorPlayer : FullScreenPlayer() {
//Hide title, if set in setting //Hide title, if set in setting
if (limitTitle < 0) { if (limitTitle < 0) {
player_video_title?.visibility = View.GONE playerBinding?.playerVideoTitle?.visibility = View.GONE
} else { } else {
//Truncate video title if it exceeds limit //Truncate video title if it exceeds limit
val differenceInLength = playerVideoTitle.length - limitTitle val differenceInLength = playerVideoTitle.length - limitTitle
@ -1182,8 +1183,8 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
val isFiller: Boolean? = (currentMeta as? ResultEpisode)?.isFiller val isFiller: Boolean? = (currentMeta as? ResultEpisode)?.isFiller
player_episode_filler_holder?.isVisible = isFiller ?: false playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false
player_video_title?.text = playerVideoTitle playerBinding?.playerVideoTitle?.text = playerVideoTitle
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
@ -1204,12 +1205,14 @@ class GeneratorPlayer : FullScreenPlayer() {
3 -> "$source - $extra" 3 -> "$source - $extra"
else -> "" else -> ""
} }
player_video_title_rez?.text = title playerBinding?.playerVideoTitleRez?.apply {
player_video_title_rez?.isVisible = title.isNotBlank() text = title
isVisible = title.isNotBlank()
}
} }
override fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) { override fun playerDimensionsLoaded(width: Int, height : Int) {
setPlayerDimen(widthHeight) setPlayerDimen(width to height)
} }
private fun unwrapBundle(savedInstanceState: Bundle?) { private fun unwrapBundle(savedInstanceState: Bundle?) {
@ -1233,7 +1236,14 @@ class GeneratorPlayer : FullScreenPlayer() {
unwrapBundle(savedInstanceState) unwrapBundle(savedInstanceState)
unwrapBundle(arguments) unwrapBundle(arguments)
return super.onCreateView(inflater, container, savedInstanceState) val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null
binding = FragmentPlayerBinding.bind(root)
return root
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
} }
var timestampShowState = false var timestampShowState = false
@ -1244,9 +1254,8 @@ class GeneratorPlayer : FullScreenPlayer() {
private fun displayTimeStamp(show: Boolean) { private fun displayTimeStamp(show: Boolean) {
if (timestampShowState == show) return if (timestampShowState == show) return
skipIndex++ skipIndex++
println("displayTimeStamp = $show")
timestampShowState = show timestampShowState = show
skip_chapter_button?.apply { playerBinding?.skipChapterButton?.apply {
val showWidth = 170.toPx val showWidth = 170.toPx
val noShowWidth = 10.toPx val noShowWidth = 10.toPx
//if((show && width == showWidth) || (!show && width == noShowWidth)) { //if((show && width == showWidth) || (!show && width == noShowWidth)) {
@ -1266,7 +1275,18 @@ class GeneratorPlayer : FullScreenPlayer() {
from, to from, to
).apply { ).apply {
addListener(onEnd = { addListener(onEnd = {
if (!show) skip_chapter_button?.isVisible = false if (show) {
if (!isShowing) {
// Automatically request focus if the menu is not opened
playerBinding?.skipChapterButton?.requestFocus()
}
} else {
playerBinding?.skipChapterButton?.isVisible = false
if (!isShowing) {
// Automatically return focus to play pause
playerBinding?.playerPausePlay?.requestFocus()
}
}
}) })
addUpdateListener { valueAnimator -> addUpdateListener { valueAnimator ->
val value = valueAnimator.animatedValue as Int val value = valueAnimator.animatedValue as Int
@ -1286,10 +1306,10 @@ class GeneratorPlayer : FullScreenPlayer() {
override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
if (timestamp != null) { if (timestamp != null) {
skip_chapter_button.setText(timestamp.uiText) playerBinding?.skipChapterButton?.setText(timestamp.uiText)
displayTimeStamp(true) displayTimeStamp(true)
val currentIndex = skipIndex val currentIndex = skipIndex
skip_chapter_button?.handler?.postDelayed({ playerBinding?.skipChapterButton?.handler?.postDelayed({
if (skipIndex == currentIndex) if (skipIndex == currentIndex)
displayTimeStamp(false) displayTimeStamp(false)
}, 6000) }, 6000)
@ -1332,11 +1352,11 @@ class GeneratorPlayer : FullScreenPlayer() {
viewModel.loadLinks() viewModel.loadLinks()
} }
overlay_loading_skip_button?.setOnClickListener { binding?.overlayLoadingSkipButton?.setOnClickListener {
startPlayer() startPlayer()
} }
player_loading_go_back?.setOnClickListener { binding?.playerLoadingGoBack?.setOnClickListener {
player.release() player.release()
activity?.popCurrentPage() activity?.popCurrentPage()
} }
@ -1360,7 +1380,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
is Resource.Failure -> { is Resource.Failure -> {
showToast(activity, it.errorString, Toast.LENGTH_LONG) showToast(it.errorString, Toast.LENGTH_LONG)
startPlayer() startPlayer()
} }
} }
@ -1369,8 +1389,8 @@ class GeneratorPlayer : FullScreenPlayer() {
observe(viewModel.currentLinks) { observe(viewModel.currentLinks) {
currentLinks = it currentLinks = it
val turnVisible = it.isNotEmpty() val turnVisible = it.isNotEmpty()
val wasGone = overlay_loading_skip_button?.isGone == true val wasGone = binding?.overlayLoadingSkipButton?.isGone == true
overlay_loading_skip_button?.isVisible = turnVisible binding?.overlayLoadingSkipButton?.isVisible = turnVisible
normalSafeApiCall { normalSafeApiCall {
if (currentLinks.any { link -> if (currentLinks.any { link ->
@ -1383,7 +1403,7 @@ class GeneratorPlayer : FullScreenPlayer() {
} }
if (turnVisible && wasGone) { if (turnVisible && wasGone) {
overlay_loading_skip_button?.requestFocus() binding?.overlayLoadingSkipButton?.requestFocus()
} }
} }

View file

@ -1,8 +1,43 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.ExtractorUri
enum class LoadType {
Unknown,
InApp,
InAppDownload,
ExternalApp,
Browser,
Chromecast
}
fun LoadType.toSet() : Set<ExtractorLinkType> {
return when(this) {
LoadType.InApp -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.DASH,
ExtractorLinkType.M3U8
)
LoadType.Browser -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.DASH,
ExtractorLinkType.M3U8
)
LoadType.InAppDownload -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.M3U8
)
LoadType.ExternalApp, LoadType.Unknown -> ExtractorLinkType.values().toSet()
LoadType.Chromecast -> setOf(
ExtractorLinkType.VIDEO,
ExtractorLinkType.DASH,
ExtractorLinkType.M3U8
)
}
}
interface IGenerator { interface IGenerator {
val hasCache: Boolean val hasCache: Boolean
@ -13,15 +48,15 @@ interface IGenerator {
fun goto(index: Int) fun goto(index: Int)
fun getCurrentId(): Int? // this is used to save data or read data about this id 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 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 fun getAll(): List<Any>? // this us used to get the metadata about all entries, not needed
/* not safe, must use try catch */ /* not safe, must use try catch */
suspend fun generateLinks( suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
isCasting: Boolean, type: LoadType,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit, callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit, subtitleCallback: (SubtitleData) -> Unit,
offset : Int = 0, offset: Int = 0,
): Boolean ): Boolean
} }

View file

@ -1,6 +1,7 @@
package com.lagradost.cloudstream3.ui.player package com.lagradost.cloudstream3.ui.player
import android.content.Context import android.content.Context
import android.util.Rational
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
@ -44,9 +45,120 @@ enum class CSPlayerLoading {
IsPaused, IsPaused,
IsPlaying, IsPlaying,
IsBuffering, IsBuffering,
//IsDone,
} }
enum class PlayerEventSource {
/** This event was invoked from the user pressing some button or selecting something */
UI,
/** This event was invoked automatically */
Player,
/** This event was invoked from a external sync tool like WatchTogether */
Sync,
}
abstract class PlayerEvent {
abstract val source: PlayerEventSource
}
/** this is used to update UI based of the current time,
* using requestedListeningPercentages as well as saving time */
data class PositionEvent(
override val source: PlayerEventSource,
val fromMs: Long,
val toMs: Long,
/** duration of the entire video */
val durationMs: Long,
) : PlayerEvent() {
/** how many ms (+-) we have skipped */
val seekMs : Long get() = toMs - fromMs
}
/** player error when rendering or misc, used to display toast or log */
data class ErrorEvent(
val error: Throwable,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent()
/** Event when timestamps appear, null when it should disappear */
data class TimestampInvokedEvent(
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: EpisodeSkip.SkipStamp,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent()
/** this is used by the player to load the next or prev episode */
data class EpisodeSeekEvent(
/** -1 = prev, 1 = next, will never be 0, atm the user cant seek more than +-1 */
val offset: Int,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent() {
init {
assert(offset != 0)
}
}
/** Event when the video is resized aka changed resolution or mirror */
data class ResizedEvent(
val height: Int,
val width: Int,
override val source: PlayerEventSource = PlayerEventSource.Player,
) : PlayerEvent()
/** Event when the player status update, along with the previous status (for animation)*/
data class StatusEvent(
val wasPlaying: CSPlayerLoading,
val isPlaying: CSPlayerLoading,
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Event when tracks are changed, used for UI changes */
data class TracksChangedEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Event from player to give all embedded subtitles */
data class EmbeddedSubtitlesFetchedEvent(
val tracks: List<SubtitleData>,
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** on attach player to view */
data class PlayerAttachedEvent(
val player: Any?,
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Event from player to inform that subtitles have updated in some way */
data class SubtitlesUpdatedEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** current player starts, asking for all other programs to shut the fuck up */
data class RequestAudioFocusEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Pause event, separate from StatusEvent */
data class PauseEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Play event, separate from StatusEvent */
data class PlayEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
/** Event when the player video has ended, up to the settings on what to do when that happens */
data class VideoEndedEvent(
override val source: PlayerEventSource = PlayerEventSource.Player
) : PlayerEvent()
interface Track { interface Track {
/** /**
@ -107,27 +219,16 @@ interface IPlayer {
fun getDuration(): Long? fun getDuration(): Long?
fun getPosition(): Long? fun getPosition(): Long?
fun seekTime(time: Long) fun seekTime(time: Long, source: PlayerEventSource = PlayerEventSource.UI)
fun seekTo(time: Long) fun seekTo(time: Long, source: PlayerEventSource = PlayerEventSource.UI)
fun getSubtitleOffset(): Long // in ms fun getSubtitleOffset(): Long // in ms
fun setSubtitleOffset(offset: Long) // in ms fun setSubtitleOffset(offset: Long) // in ms
fun initCallbacks( fun initCallbacks(
playerUpdated: (Any?) -> Unit, // attach player to view eventHandler: ((PlayerEvent) -> Unit),
updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null, // (wasPlaying, isPlaying) /** this is used to request when the player should report back view percentage */
requestAutoFocus: (() -> Unit)? = null, // current player starts, asking for all other programs to shut the fuck up requestedListeningPercentages: List<Int>? = null,
playerError: ((Exception) -> Unit)? = null, // player error when rendering or misc, used to display toast or log
playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)? = null, // (with, height), for UI
requestedListeningPercentages: List<Int>? = null, // this is used to request when the player should report back view percentage
playerPositionChanged: ((Pair<Long, Long>) -> Unit)? = null,// (position, duration) this is used to update UI based of the current time
nextEpisode: (() -> Unit)? = null, // this is used by the player to load the next episode
prevEpisode: (() -> Unit)? = null, // this is used by the player to load the previous episode
subtitlesUpdates: (() -> Unit)? = null, // callback from player to inform that subtitles have updated in some way
embeddedSubtitlesFetched: ((List<SubtitleData>) -> Unit)? = null, // callback from player to give all embedded subtitles
onTracksInfoChanged: (() -> Unit)? = null, // Callback when tracks are changed, used for UI changes
onTimestampInvoked: ((EpisodeSkip.SkipStamp?) -> Unit)? = null, // Callback when timestamps appear, null when it should disappear
onTimestampSkipped: ((EpisodeSkip.SkipStamp) -> Unit)? = null, // callback for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor)
) )
fun releaseCallbacks() fun releaseCallbacks()
@ -154,7 +255,7 @@ interface IPlayer {
fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing
fun getCurrentPreferredSubtitle(): SubtitleData? fun getCurrentPreferredSubtitle(): SubtitleData?
fun handleEvent(event: CSPlayerEvent) fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI)
fun onStop() fun onStop()
fun onPause() fun onPause()
@ -167,6 +268,19 @@ interface IPlayer {
fun getVideoTracks(): CurrentTracks fun getVideoTracks(): CurrentTracks
/**
* Original video aspect ratio used for PiP mode
*
* Set using: Width, Height.
* Example: Rational(16, 9)
*
* If null will default to set no aspect ratio.
*
* PiP functions calling this needs to coerce this value between 0.418410 and 2.390000
* to prevent crashes.
*/
fun getAspectRatio(): Rational?
/** If no parameters are set it'll default to no set size, Specifying the id allows for track overrides to force the player to pick the quality. */ /** If no parameters are set it'll default to no set size, Specifying the id allows for track overrides to force the player to pick the quality. */
fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null) fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null)

View file

@ -48,7 +48,7 @@ class LinkGenerator(
override suspend fun generateLinks( override suspend fun generateLinks(
clearCache: Boolean, clearCache: Boolean,
isCasting: Boolean, type: LoadType,
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit, callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
subtitleCallback: (SubtitleData) -> Unit, subtitleCallback: (SubtitleData) -> Unit,
offset: Int offset: Int
@ -67,9 +67,8 @@ class LinkGenerator(
link.name ?: link.url, link.name ?: link.url,
unshortenLinkSafe(link.url), // unshorten because it might be a raw link unshortenLinkSafe(link.url), // unshorten because it might be a raw link
referer ?: "", referer ?: "",
Qualities.Unknown.value, isM3u8 ?: normalSafeApiCall { Qualities.Unknown.value,
URI(link.url).path?.substringAfterLast(".")?.contains("m3u") type = INFER_TYPE,
} ?: false
) to null ) to null
) )
} }

View file

@ -15,10 +15,10 @@
*/ */
package com.lagradost.cloudstream3.ui.player; package com.lagradost.cloudstream3.ui.player;
import static com.google.android.exoplayer2.text.Cue.DIMEN_UNSET; import static androidx.media3.common.text.Cue.DIMEN_UNSET;
import static com.google.android.exoplayer2.text.Cue.LINE_TYPE_NUMBER; import static androidx.media3.common.text.Cue.LINE_TYPE_NUMBER;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkNotNull;
import static com.google.android.exoplayer2.util.Assertions.checkState; import static androidx.media3.common.util.Assertions.checkState;
import static java.lang.annotation.ElementType.TYPE_USE; import static java.lang.annotation.ElementType.TYPE_USE;
import android.os.Handler; import android.os.Handler;
@ -28,25 +28,24 @@ import android.os.Message;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.media3.common.C;
import com.google.android.exoplayer2.BaseRenderer; import androidx.media3.common.Format;
import com.google.android.exoplayer2.C; import androidx.media3.common.text.Cue;
import com.google.android.exoplayer2.Format; import androidx.media3.common.text.CueGroup;
import com.google.android.exoplayer2.FormatHolder; import androidx.media3.common.MimeTypes;
import com.google.android.exoplayer2.RendererCapabilities; import androidx.media3.common.util.Log;
import com.google.android.exoplayer2.source.SampleStream.ReadDataResult; import androidx.media3.common.util.Util;
import com.google.android.exoplayer2.text.Cue; import androidx.media3.exoplayer.BaseRenderer;
import com.google.android.exoplayer2.text.CueGroup; import androidx.media3.exoplayer.FormatHolder;
import com.google.android.exoplayer2.text.Subtitle; import androidx.media3.exoplayer.RendererCapabilities;
import com.google.android.exoplayer2.text.SubtitleDecoder; import androidx.media3.exoplayer.source.SampleStream;
import com.google.android.exoplayer2.text.SubtitleDecoderException; import androidx.media3.exoplayer.text.SubtitleDecoderFactory;
import com.google.android.exoplayer2.text.SubtitleDecoderFactory; import androidx.media3.exoplayer.text.TextOutput;
import com.google.android.exoplayer2.text.SubtitleInputBuffer; import androidx.media3.extractor.text.Subtitle;
import com.google.android.exoplayer2.text.SubtitleOutputBuffer; import androidx.media3.extractor.text.SubtitleDecoder;
import com.google.android.exoplayer2.text.TextOutput; import androidx.media3.extractor.text.SubtitleDecoderException;
import com.google.android.exoplayer2.util.Log; import androidx.media3.extractor.text.SubtitleInputBuffer;
import com.google.android.exoplayer2.util.MimeTypes; import androidx.media3.extractor.text.SubtitleOutputBuffer;
import com.google.android.exoplayer2.util.Util;
import java.lang.annotation.Documented; import java.lang.annotation.Documented;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
@ -310,7 +309,7 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback {
return; return;
} }
// Try and read the next subtitle from the source. // Try and read the next subtitle from the source.
@ReadDataResult int result = readSource(formatHolder, nextInputBuffer, /* readFlags= */ 0); @SampleStream.ReadDataResult int result = readSource(formatHolder, nextInputBuffer, /* readFlags= */ 0);
if (result == C.RESULT_BUFFER_READ) { if (result == C.RESULT_BUFFER_READ) {
if (nextInputBuffer.isEndOfStream()) { if (nextInputBuffer.isEndOfStream()) {
inputStreamEnded = true; inputStreamEnded = true;

View file

@ -1,158 +0,0 @@
package com.lagradost.cloudstream3.ui.player
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.ContentLoadingProgressBar
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.ui.result.getDisplayPosition
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
import com.lagradost.cloudstream3.utils.AppUtils.html
import com.lagradost.cloudstream3.utils.UIHelper.setImage
import kotlinx.android.synthetic.main.player_episodes_large.view.episode_holder_large
import kotlinx.android.synthetic.main.player_episodes_large.view.episode_progress
import kotlinx.android.synthetic.main.player_episodes_small.view.episode_holder
import kotlinx.android.synthetic.main.result_episode_large.view.*
data class PlayerEpisodeClickEvent(val action: Int, val data: Any)
class PlayerEpisodeAdapter(
private val items: MutableList<Any> = mutableListOf(),
private val clickCallback: (PlayerEpisodeClickEvent) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return PlayerEpisodeCardViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.player_episodes, parent, false),
clickCallback,
)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
println("HOLDER $holder $position")
when (holder) {
is PlayerEpisodeCardViewHolder -> {
holder.bind(items[position])
}
}
}
override fun getItemCount(): Int {
return items.size
}
fun updateList(newList: List<Any>) {
println("Updated list $newList")
val diffResult = DiffUtil.calculateDiff(EpisodeDiffCallback(this.items, newList))
items.clear()
items.addAll(newList)
diffResult.dispatchUpdatesTo(this)
}
class PlayerEpisodeCardViewHolder
constructor(
itemView: View,
private val clickCallback: (PlayerEpisodeClickEvent) -> Unit,
) : RecyclerView.ViewHolder(itemView) {
@SuppressLint("SetTextI18n")
fun bind(card: Any) {
if (card is ResultEpisode) {
val (parentView, otherView) = if (card.poster == null) {
itemView.episode_holder to itemView.episode_holder_large
} else {
itemView.episode_holder_large to itemView.episode_holder
}
val episodeText: TextView? = parentView.episode_text
val episodeFiller: MaterialButton? = parentView.episode_filler
val episodeRating: TextView? = parentView.episode_rating
val episodeDescript: TextView? = parentView.episode_descript
val episodeProgress: ContentLoadingProgressBar? = parentView.episode_progress
val episodePoster: ImageView? = parentView.episode_poster
parentView.isVisible = true
otherView.isVisible = false
episodeText?.apply {
val name =
if (card.name == null) "${context.getString(R.string.episode)} ${card.episode}" else "${card.episode}. ${card.name}"
text = name
isSelected = true
}
episodeFiller?.isVisible = card.isFiller == true
val displayPos = card.getDisplayPosition()
episodeProgress?.max = (card.duration / 1000).toInt()
episodeProgress?.progress = (displayPos / 1000).toInt()
episodeProgress?.isVisible = displayPos > 0L
episodePoster?.isVisible = episodePoster?.setImage(card.poster) == true
if (card.rating != null) {
episodeRating?.text = episodeRating?.context?.getString(R.string.rated_format)
?.format(card.rating.toFloat() / 10f)
} else {
episodeRating?.text = ""
}
episodeRating?.isGone = episodeRating?.text.isNullOrBlank()
episodeDescript?.apply {
text = card.description.html()
isGone = text.isNullOrBlank()
//setOnClickListener {
// clickCallback.invoke(PlayerEpisodeClickEvent(ACTION_SHOW_DESCRIPTION, card))
//}
}
parentView.setOnClickListener {
clickCallback.invoke(PlayerEpisodeClickEvent(0, card))
}
if (isTrueTvSettings()) {
parentView.isFocusable = true
parentView.isFocusableInTouchMode = true
parentView.touchscreenBlocksFocus = false
}
}
}
}
}
class EpisodeDiffCallback(
private val oldList: List<Any>,
private val newList: List<Any>
) :
DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val a = oldList[oldItemPosition]
val b = newList[newItemPosition]
return if (a is ResultEpisode && b is ResultEpisode) {
a.id == b.id
} else {
a == b
}
}
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition] == newList[newItemPosition]
}

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.Resource
import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.mvvm.safeApiCall
import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultEpisode
@ -15,6 +16,7 @@ import com.lagradost.cloudstream3.utils.EpisodeSkip
import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.ExtractorUri
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class PlayerGeneratorViewModel : ViewModel() { class PlayerGeneratorViewModel : ViewModel() {
companion object { companion object {
@ -38,6 +40,11 @@ class PlayerGeneratorViewModel : ViewModel() {
private val _currentSubtitleYear = MutableLiveData<Int?>(null) private val _currentSubtitleYear = MutableLiveData<Int?>(null)
val currentSubtitleYear: LiveData<Int?> = _currentSubtitleYear val currentSubtitleYear: LiveData<Int?> = _currentSubtitleYear
/**
* Save the Episode ID to prevent starting multiple link loading Jobs when preloading links.
*/
private var currentLoadingEpisodeId: Int? = null
fun setSubtitleYear(year: Int?) { fun setSubtitleYear(year: Int?) {
_currentSubtitleYear.postValue(year) _currentSubtitleYear.postValue(year)
} }
@ -72,18 +79,32 @@ class PlayerGeneratorViewModel : ViewModel() {
} }
fun preLoadNextLinks() { fun preLoadNextLinks() {
val id = getId()
// Do not preload if already loading
if (id == currentLoadingEpisodeId) return
Log.i(TAG, "preLoadNextLinks") Log.i(TAG, "preLoadNextLinks")
currentJob?.cancel() currentJob?.cancel()
currentJob = viewModelScope.launchSafe { currentLoadingEpisodeId = id
if (generator?.hasCache == true && generator?.hasNext() == true) {
safeApiCall { currentJob = viewModelScope.launch {
generator?.generateLinks( try {
clearCache = false, if (generator?.hasCache == true && generator?.hasNext() == true) {
isCasting = false, safeApiCall {
{}, generator?.generateLinks(
{}, type = LoadType.InApp,
offset = 1 clearCache = false,
) callback = {},
subtitleCallback = {},
offset = 1
)
}
}
} catch (t: Throwable) {
logError(t)
} finally {
if (currentLoadingEpisodeId == id) {
currentLoadingEpisodeId = null
} }
} }
} }
@ -147,7 +168,7 @@ class PlayerGeneratorViewModel : ViewModel() {
} }
} }
fun loadLinks(clearCache: Boolean = false, isCasting: Boolean = false) { fun loadLinks(clearCache: Boolean = false, type: LoadType = LoadType.InApp) {
Log.i(TAG, "loadLinks") Log.i(TAG, "loadLinks")
currentJob?.cancel() currentJob?.cancel()
@ -162,14 +183,14 @@ class PlayerGeneratorViewModel : ViewModel() {
// load more data // load more data
_loadingLinks.postValue(Resource.Loading()) _loadingLinks.postValue(Resource.Loading())
val loadingState = safeApiCall { val loadingState = safeApiCall {
generator?.generateLinks(clearCache = clearCache, isCasting = isCasting, { generator?.generateLinks(type = type, clearCache = clearCache, callback = {
currentLinks.add(it) currentLinks.add(it)
// Clone to prevent ConcurrentModificationException // Clone to prevent ConcurrentModificationException
normalSafeApiCall { normalSafeApiCall {
// Extra normalSafeApiCall since .toSet() iterates. // Extra normalSafeApiCall since .toSet() iterates.
_currentLinks.postValue(currentLinks.toSet()) _currentLinks.postValue(currentLinks.toSet())
} }
}, { }, subtitleCallback = {
currentSubs.add(it) currentSubs.add(it)
normalSafeApiCall { normalSafeApiCall {
_currentSubs.postValue(currentSubs.toSet()) _currentSubs.postValue(currentSubs.toSet())

View file

@ -7,28 +7,23 @@ import android.app.RemoteAction
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.os.Build import android.os.Build
import android.util.Rational
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import kotlin.math.roundToInt
class PlayerPipHelper { class PlayerPipHelper {
companion object { companion object {
@RequiresApi(Build.VERSION_CODES.O)
private fun getPen(activity: Activity, code: Int): PendingIntent { private fun getPen(activity: Activity, code: Int): PendingIntent {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return PendingIntent.getBroadcast(
PendingIntent.getBroadcast( activity,
activity, code,
code, Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code),
Intent("media_control").putExtra("control_type", code), PendingIntent.FLAG_IMMUTABLE
PendingIntent.FLAG_IMMUTABLE )
)
} else {
PendingIntent.getBroadcast(
activity,
code,
Intent("media_control").putExtra("control_type", code),
0
)
}
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
@ -48,7 +43,7 @@ class PlayerPipHelper {
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun updatePIPModeActions(activity: Activity, isPlaying: Boolean) { fun updatePIPModeActions(activity: Activity, isPlaying: Boolean, aspectRatio: Rational?) {
val actions: ArrayList<RemoteAction> = ArrayList() val actions: ArrayList<RemoteAction> = ArrayList()
actions.add( actions.add(
getRemoteAction( getRemoteAction(
@ -87,9 +82,32 @@ class PlayerPipHelper {
CSPlayerEvent.SeekForward CSPlayerEvent.SeekForward
) )
) )
activity.setPictureInPictureParams(
PictureInPictureParams.Builder().setActions(actions).build() // Nessecary to prevent crashing.
) val mixAspectRatio = 0.41841f // ~1/2.39
val maxAspectRatio = 2.39f // widescreen standard
val ratioAccuracy = 100000 // To convert the float to int
// java.lang.IllegalArgumentException: setPictureInPictureParams: Aspect ratio is too extreme (must be between 0.418410 and 2.390000)
val fixedRational =
aspectRatio?.toFloat()?.coerceIn(mixAspectRatio, maxAspectRatio)?.let {
Rational((it * ratioAccuracy).roundToInt(), ratioAccuracy)
}
normalSafeApiCall {
activity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setSeamlessResizeEnabled(true)
setAutoEnterEnabled(isPlaying)
}
}
.setAspectRatio(fixedRational)
.setActions(actions)
.build()
)
}
} }
} }
} }

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