mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
commit
f8603a7874
352 changed files with 18863 additions and 9766 deletions
6
.github/workflows/build_to_archive.yml
vendored
6
.github/workflows/build_to_archive.yml
vendored
|
@ -32,10 +32,10 @@ jobs:
|
|||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
repository: "recloudstream/cloudstream-archive"
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: '11'
|
||||
java-version: '17'
|
||||
distribution: 'adopt'
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
@ -56,6 +56,8 @@ jobs:
|
|||
SIGNING_KEY_ALIAS: "key0"
|
||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: "recloudstream/cloudstream-archive"
|
||||
|
|
4
.github/workflows/generate_dokka.yml
vendored
4
.github/workflows/generate_dokka.yml
vendored
|
@ -42,10 +42,10 @@ jobs:
|
|||
cd $GITHUB_WORKSPACE/dokka/
|
||||
rm -rf "./-cloudstream"
|
||||
|
||||
- name: Setup JDK 11
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v2
|
||||
|
|
6
.github/workflows/prerelease.yml
vendored
6
.github/workflows/prerelease.yml
vendored
|
@ -24,10 +24,10 @@ jobs:
|
|||
private_key: ${{ secrets.GH_APP_KEY }}
|
||||
repository: "recloudstream/secrets"
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: '11'
|
||||
java-version: '17'
|
||||
distribution: 'adopt'
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
@ -48,6 +48,8 @@ jobs:
|
|||
SIGNING_KEY_ALIAS: "key0"
|
||||
SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||
SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }}
|
||||
SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }}
|
||||
SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }}
|
||||
- name: Create pre-release
|
||||
uses: "marvinpinto/action-automatic-releases@latest"
|
||||
with:
|
||||
|
|
4
.github/workflows/pull_request.yml
vendored
4
.github/workflows/pull_request.yml
vendored
|
@ -7,10 +7,10 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
java-version: '11'
|
||||
java-version: '17'
|
||||
distribution: 'adopt'
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
|
4
.idea/compiler.xml
generated
4
.idea/compiler.xml
generated
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
|
@ -8,7 +8,6 @@
|
|||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="11" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
|
|
@ -9,8 +9,9 @@
|
|||
+ **AdFree**, No ads whatsoever
|
||||
+ No tracking/analytics
|
||||
+ Bookmarks
|
||||
+ Download and stream movies, tv-shows and anime
|
||||
+ Phone and TV support
|
||||
+ Chromecast
|
||||
+ Extension system for personal customization
|
||||
|
||||
### Supported languages:
|
||||
<a href="https://hosted.weblate.org/engage/cloudstream/">
|
||||
|
|
6
app/CMakeLists.txt
Normal file
6
app/CMakeLists.txt
Normal 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})
|
|
@ -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 java.io.ByteArrayOutputStream
|
||||
import java.net.URL
|
||||
|
@ -9,7 +9,6 @@ plugins {
|
|||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("kotlin-kapt")
|
||||
id("kotlin-android-extensions")
|
||||
id("org.jetbrains.dokka")
|
||||
}
|
||||
|
||||
|
@ -39,6 +38,18 @@ android {
|
|||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
|
||||
viewBinding {
|
||||
enable = true
|
||||
}
|
||||
|
||||
// disable this for now
|
||||
//externalNativeBuild {
|
||||
// cmake {
|
||||
// path("CMakeLists.txt")
|
||||
// }
|
||||
//}
|
||||
|
||||
signingConfigs {
|
||||
create("prerelease") {
|
||||
if (prereleaseStoreFile != null) {
|
||||
|
@ -51,28 +62,38 @@ android {
|
|||
}
|
||||
|
||||
compileSdk = 33
|
||||
buildToolsVersion = "30.0.3"
|
||||
buildToolsVersion = "34.0.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.lagradost.cloudstream3"
|
||||
minSdk = 21
|
||||
targetSdk = 33
|
||||
|
||||
versionCode = 59
|
||||
versionName = "4.0.1"
|
||||
versionCode = 62
|
||||
versionName = "4.2.0"
|
||||
|
||||
resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}")
|
||||
|
||||
resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "")
|
||||
|
||||
resValue("bool", "is_prerelease", "false")
|
||||
|
||||
// Reads local.properties
|
||||
val localProperties = gradleLocalProperties(rootDir)
|
||||
|
||||
buildConfigField(
|
||||
"String",
|
||||
"BUILDDATE",
|
||||
"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"
|
||||
|
||||
kapt {
|
||||
|
@ -85,7 +106,10 @@ android {
|
|||
isDebuggable = false
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
isDebuggable = true
|
||||
|
@ -94,7 +118,6 @@ android {
|
|||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
|
||||
resValue(
|
||||
"string",
|
||||
"debug_gdrive_secret",
|
||||
|
@ -123,6 +146,11 @@ android {
|
|||
versionCode = (System.currentTimeMillis() / 60000).toInt()
|
||||
}
|
||||
}
|
||||
//toolchain {
|
||||
// languageVersion.set(JavaLanguageVersion.of(17))
|
||||
// }
|
||||
// jvmToolchain(17)
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
|
||||
|
@ -146,25 +174,26 @@ repositories {
|
|||
|
||||
dependencies {
|
||||
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")
|
||||
|
||||
implementation("androidx.core:core-ktx:1.8.0")
|
||||
implementation("androidx.appcompat:appcompat:1.4.2") // need target 32 for 1.5.0
|
||||
implementation("androidx.core:core-ktx:1.10.1")
|
||||
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
|
||||
implementation("com.google.android.material:material:1.5.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:2.5.1")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:2.5.1")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.5.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1")
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:2.6.0")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:2.6.0")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.3")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation("androidx.test:core")
|
||||
|
||||
//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("androidx.preference:preference-ktx:1.2.0")
|
||||
|
@ -179,19 +208,23 @@ dependencies {
|
|||
|
||||
// implementation("androidx.leanback:leanback-paging:1.1.0-alpha09")
|
||||
|
||||
// Exoplayer
|
||||
implementation("com.google.android.exoplayer:exoplayer:2.18.2")
|
||||
implementation("com.google.android.exoplayer:extension-cast:2.18.2")
|
||||
implementation("com.google.android.exoplayer:extension-mediasession:2.18.2")
|
||||
implementation("com.google.android.exoplayer:extension-okhttp:2.18.2")
|
||||
// Use the Jellyfin ffmpeg extension for easy ffmpeg audio decoding in exoplayer. Thank you Jellyfin <3
|
||||
// implementation("org.jellyfin.exoplayer:exoplayer-ffmpeg-extension:2.18.2+1")
|
||||
// Media 3
|
||||
implementation("androidx.media3:media3-common:1.1.1")
|
||||
implementation("androidx.media3:media3-exoplayer:1.1.1")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
|
||||
implementation("androidx.media3:media3-ui:1.1.1")
|
||||
implementation("androidx.media3:media3-session:1.1.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")
|
||||
|
||||
// Bug reports
|
||||
implementation("ch.acra:acra-core:5.8.4")
|
||||
implementation("ch.acra:acra-toast:5.8.4")
|
||||
implementation("ch.acra:acra-core:5.11.0")
|
||||
implementation("ch.acra:acra-toast:5.11.0")
|
||||
|
||||
compileOnly("com.google.auto.service:auto-service-annotations:1.0")
|
||||
//either for java sources:
|
||||
|
@ -211,24 +244,24 @@ dependencies {
|
|||
//implementation("com.github.TorrentStream:TorrentStream-Android:2.7.0")
|
||||
|
||||
// Downloading
|
||||
implementation("androidx.work:work-runtime:2.8.0")
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.0")
|
||||
implementation("androidx.work:work-runtime:2.8.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
|
||||
// Networking
|
||||
// implementation("com.squareup.okhttp3:okhttp:4.9.2")
|
||||
// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
|
||||
implementation("com.github.Blatzar:NiceHttp:0.4.2")
|
||||
// implementation("com.squareup.okhttp3:okhttp:4.9.2")
|
||||
// implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1")
|
||||
implementation("com.github.Blatzar:NiceHttp:0.4.3")
|
||||
// To fix SSL fuckery on android 9
|
||||
implementation("org.conscrypt:conscrypt-android:2.2.1")
|
||||
// Util to skip the URI file fuckery 🙏
|
||||
implementation("com.github.tachiyomiorg:unifile:17bec43")
|
||||
implementation("com.github.LagradOst:SafeFile:0.0.5")
|
||||
|
||||
// API because cba maintaining it myself
|
||||
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 'com.squareup.leakcanary:leakcanary-android:2.7'
|
||||
//debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
|
||||
|
||||
// for shimmer when loading
|
||||
implementation("com.facebook.shimmer:shimmer:0.5.0")
|
||||
|
@ -238,17 +271,15 @@ dependencies {
|
|||
// used for subtitle decoding https://github.com/albfernandez/juniversalchardet
|
||||
implementation("com.github.albfernandez:juniversalchardet:2.4.0")
|
||||
|
||||
// slow af yt
|
||||
//implementation("com.github.HaarigerHarald:android-youtubeExtractor:master-SNAPSHOT")
|
||||
|
||||
// newpipe yt taken from https://github.com/TeamNewPipe/NewPipe/blob/dev/app/build.gradle#L204
|
||||
implementation("com.github.TeamNewPipe:NewPipeExtractor:master-SNAPSHOT")
|
||||
// newpipe yt taken from https://github.com/TeamNewPipe/NewPipeExtractor/commits/dev
|
||||
// this should be updated frequently to avoid trailer fu*kery
|
||||
implementation("com.github.teamnewpipe:NewPipeExtractor:1f08d28")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6")
|
||||
|
||||
// Library/extensions searching with Levenshtein distance
|
||||
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("org.skyscreamer:jsonassert:1.2.3")
|
||||
|
|
|
@ -1,6 +1,30 @@
|
|||
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.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.TestingUtils
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
@ -8,16 +32,23 @@ import org.junit.Assert
|
|||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* 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)
|
||||
class ExampleInstrumentedTest {
|
||||
private fun getAllProviders(): List<MainAPI> {
|
||||
private fun getAllProviders(): Array<MainAPI> {
|
||||
println("Providers: ${APIHolder.allProviders.size}")
|
||||
return APIHolder.allProviders //.filter { !it.usesWebView }
|
||||
return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -26,6 +57,73 @@ class ExampleInstrumentedTest {
|
|||
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
|
||||
@Throws(AssertionError::class)
|
||||
fun providerCorrectData() {
|
||||
|
@ -49,7 +147,7 @@ class ExampleInstrumentedTest {
|
|||
@Test
|
||||
fun providerCorrectHomepage() {
|
||||
runBlocking {
|
||||
getAllProviders().amap { api ->
|
||||
getAllProviders().toList().amap { api ->
|
||||
TestingUtils.testHomepage(api, ::println)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.FOREGROUND_SERVICE" /> <!-- Used for update service -->
|
||||
|
||||
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <!– Used for getting if vlc is installed –> -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <!-- Required for getting arbitrary Aniyomi packages -->
|
||||
<!-- Fixes android tv fuckery -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
|
|
28
app/src/main/cpp/native-lib.cpp
Normal file
28
app/src/main/cpp/native-lib.cpp
Normal 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;
|
||||
}
|
|
@ -43,16 +43,16 @@ class CustomReportSender : ReportSender {
|
|||
override fun send(context: Context, errorContent: CrashReportData) {
|
||||
println("Sending report")
|
||||
val url =
|
||||
"https://docs.google.com/forms/d/e/1FAIpQLSdOlbgCx7NeaxjvEGyEQlqdh2nCvwjm2vwpP1VwW7REj9Ri3Q/formResponse"
|
||||
"https://docs.google.com/forms/d/e/1FAIpQLSfO4r353BJ79TTY_-t5KWSIJT2xfqcQWY81xjAA1-1N0U2eSg/formResponse"
|
||||
val data = mapOf(
|
||||
"entry.753293084" to errorContent.toJSON()
|
||||
"entry.1993829403" to errorContent.toJSON()
|
||||
)
|
||||
|
||||
thread { // to not run it on main thread
|
||||
runBlocking {
|
||||
suspendSafeApiCall {
|
||||
val post = app.post(url, data = data)
|
||||
println("Report response: $post")
|
||||
app.post(url, data = data)
|
||||
//println("Report response: $post")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -104,12 +104,17 @@ class ExceptionHandler(val errorFile: File, val onError: (() -> Unit)) :
|
|||
}
|
||||
|
||||
class AcraApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Thread.setDefaultUncaughtExceptionHandler(ExceptionHandler(filesDir.resolve("last_error")) {
|
||||
//NativeCrashHandler.initCrashHandler()
|
||||
ExceptionHandler(filesDir.resolve("last_error")) {
|
||||
val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName)
|
||||
startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
||||
})
|
||||
}.also {
|
||||
exceptionHandler = it
|
||||
Thread.setDefaultUncaughtExceptionHandler(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
|
@ -121,10 +126,10 @@ class AcraApplication : Application() {
|
|||
buildConfigClass = BuildConfig::class.java
|
||||
reportFormat = StringFormat.JSON
|
||||
|
||||
reportContent = arrayOf(
|
||||
reportContent = listOf(
|
||||
ReportField.BUILD_CONFIG, ReportField.USER_CRASH_DATE,
|
||||
ReportField.ANDROID_VERSION, ReportField.PHONE_MODEL,
|
||||
ReportField.STACK_TRACE
|
||||
ReportField.STACK_TRACE,
|
||||
)
|
||||
|
||||
// removed this due to bug when starting the app, moved it to when it actually crashes
|
||||
|
@ -137,6 +142,8 @@ class AcraApplication : Application() {
|
|||
}
|
||||
|
||||
companion object {
|
||||
var exceptionHandler: ExceptionHandler? = null
|
||||
|
||||
/** Use to get activity from Context */
|
||||
tailrec fun Context.getActivity(): Activity? = this as? Activity
|
||||
?: (this as? ContextWrapper)?.baseContext?.getActivity()
|
||||
|
@ -148,6 +155,14 @@ class AcraApplication : Application() {
|
|||
_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? {
|
||||
return context?.removeKeys(folder)
|
||||
}
|
||||
|
@ -203,6 +218,5 @@ class AcraApplication : Application() {
|
|||
activity?.supportFragmentManager?.fragments?.lastOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,8 +7,14 @@ import android.content.Context
|
|||
import android.content.pm.PackageManager
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.NO_ID
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
|
@ -18,8 +24,11 @@ import androidx.annotation.StringRes
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.gms.cast.framework.CastSession
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import com.google.android.material.navigationrail.NavigationRailView
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.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.UiText
|
||||
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.Event
|
||||
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.toPx
|
||||
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 {
|
||||
|
||||
private var _activity: WeakReference<Activity>? = null
|
||||
var activity
|
||||
get() = _activity?.get()
|
||||
private set(value) {
|
||||
_activity = WeakReference(value)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun Activity?.getCastSession(): CastSession? {
|
||||
return (this as MainActivity?)?.mSessionManager?.currentCastSession
|
||||
}
|
||||
|
||||
val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics
|
||||
|
||||
// screenWidth and screenHeight does always
|
||||
// refer to the screen while in landscape mode
|
||||
val screenWidth: Int
|
||||
get() {
|
||||
return max(displayMetrics.widthPixels, displayMetrics.heightPixels)
|
||||
}
|
||||
val screenHeight: Int
|
||||
get() {
|
||||
return min(displayMetrics.widthPixels, displayMetrics.heightPixels)
|
||||
}
|
||||
|
||||
|
||||
var canEnterPipMode: Boolean = false
|
||||
var canShowPipMode: Boolean = false
|
||||
|
@ -56,6 +97,30 @@ object CommonActivity {
|
|||
|
||||
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) {
|
||||
if (act == null) return
|
||||
text.asStringNull(act)?.let {
|
||||
|
@ -140,6 +205,7 @@ object CommonActivity {
|
|||
|
||||
fun init(act: ComponentActivity?) {
|
||||
if (act == null) return
|
||||
activity = act
|
||||
//https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission
|
||||
//https://developer.android.com/guide/topics/ui/picture-in-picture
|
||||
canShowPipMode =
|
||||
|
@ -222,6 +288,7 @@ object CommonActivity {
|
|||
"AmoledLight" -> R.style.AmoledModeLight
|
||||
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
R.style.MonetMode else R.style.AppTheme
|
||||
|
||||
else -> R.style.AppTheme
|
||||
}
|
||||
|
||||
|
@ -244,8 +311,10 @@ object CommonActivity {
|
|||
"Pink" -> R.style.OverlayPrimaryColorPink
|
||||
"Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal
|
||||
|
||||
"Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal
|
||||
|
||||
else -> R.style.OverlayPrimaryColorNormal
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
private fun getNextFocus(
|
||||
act: Activity?,
|
||||
/** because we want closes find, aka when multiple have the same id, we go to parent
|
||||
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?,
|
||||
direction: FocusDirection,
|
||||
depth: Int = 0
|
||||
): Int? {
|
||||
if (view == null || depth >= 10 || act == null) {
|
||||
): View? {
|
||||
// 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
|
||||
}
|
||||
|
||||
val nextId = when (direction) {
|
||||
FocusDirection.Left -> {
|
||||
view.nextFocusLeftId
|
||||
var nextId = when (direction) {
|
||||
FocusDirection.Start -> {
|
||||
if (view.isRtl())
|
||||
view.nextFocusRightId
|
||||
else
|
||||
view.nextFocusLeftId
|
||||
}
|
||||
|
||||
FocusDirection.Up -> {
|
||||
view.nextFocusUpId
|
||||
}
|
||||
FocusDirection.Right -> {
|
||||
view.nextFocusRightId
|
||||
|
||||
FocusDirection.End -> {
|
||||
if (view.isRtl())
|
||||
view.nextFocusLeftId
|
||||
else
|
||||
view.nextFocusRightId
|
||||
}
|
||||
|
||||
FocusDirection.Down -> {
|
||||
view.nextFocusDownId
|
||||
}
|
||||
}
|
||||
|
||||
return if (nextId != -1) {
|
||||
val next = act.findViewById<View?>(nextId)
|
||||
//println("NAME: ${next.accessibilityClassName} | ${next?.isShown}" )
|
||||
|
||||
if (next?.isShown == false) {
|
||||
getNextFocus(act, next, direction, depth + 1)
|
||||
} else {
|
||||
if (depth == 0) {
|
||||
null
|
||||
} else {
|
||||
nextId
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
if (nextId == NO_ID) {
|
||||
// if not specified then use forward id
|
||||
nextId = view.nextFocusForwardId
|
||||
// if view is still not found to next focus then return and let android decide
|
||||
if (nextId == NO_ID)
|
||||
return null
|
||||
}
|
||||
return continueGetNextFocus(root, view, direction, nextId, depth)
|
||||
}
|
||||
|
||||
enum class FocusDirection {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) {
|
||||
//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 -> {
|
||||
PlayerEventType.SeekForward
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
PlayerEventType.SeekBack
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N -> {
|
||||
PlayerEventType.NextEpisode
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B -> {
|
||||
PlayerEventType.PrevEpisode
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||
PlayerEventType.Pause
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> {
|
||||
PlayerEventType.Play
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> {
|
||||
PlayerEventType.Lock
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> {
|
||||
PlayerEventType.ToggleHide
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> {
|
||||
PlayerEventType.ToggleMute
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> {
|
||||
PlayerEventType.ShowMirrors
|
||||
}
|
||||
|
@ -359,21 +520,27 @@ object CommonActivity {
|
|||
KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> {
|
||||
PlayerEventType.SearchSubtitlesOnline
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> {
|
||||
PlayerEventType.ShowSpeed
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> {
|
||||
PlayerEventType.Resize
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> {
|
||||
PlayerEventType.SkipOp
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> {
|
||||
PlayerEventType.SkipCurrentChapter
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation
|
||||
PlayerEventType.PlayPauseToggle
|
||||
}
|
||||
|
||||
else -> null
|
||||
}?.let { playerEvent ->
|
||||
playerEventListener?.invoke(playerEvent)
|
||||
|
@ -386,64 +553,64 @@ object CommonActivity {
|
|||
//}
|
||||
}
|
||||
|
||||
/** overrides focus and custom key events */
|
||||
fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? {
|
||||
if (act == null) return null
|
||||
val currentFocus = act.currentFocus
|
||||
|
||||
event?.keyCode?.let { keyCode ->
|
||||
when (event.action) {
|
||||
KeyEvent.ACTION_DOWN -> {
|
||||
if (act.currentFocus != null) {
|
||||
val next = when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
|
||||
act,
|
||||
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
|
||||
)
|
||||
if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let
|
||||
val nextView = when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus(
|
||||
act,
|
||||
currentFocus,
|
||||
FocusDirection.Start
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus(
|
||||
act,
|
||||
currentFocus,
|
||||
FocusDirection.End
|
||||
)
|
||||
|
||||
if (next != null && next != -1) {
|
||||
val nextView = act.findViewById<View?>(next)
|
||||
if (nextView != null) {
|
||||
nextView.requestFocus()
|
||||
keyEventListener?.invoke(Pair(event, true))
|
||||
return true
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP -> getNextFocus(
|
||||
act,
|
||||
currentFocus,
|
||||
FocusDirection.Up
|
||||
)
|
||||
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_CENTER -> {
|
||||
if (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
|
||||
//)
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus(
|
||||
act,
|
||||
currentFocus,
|
||||
FocusDirection.Down
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
// 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) {
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do
|
|||
|
||||
companion object {
|
||||
private const val USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
|
||||
private var instance: DownloaderTestImpl? = null
|
||||
|
||||
/**
|
||||
|
|
|
@ -11,22 +11,27 @@ import com.fasterxml.jackson.databind.DeserializationFeature
|
|||
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
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.malApi
|
||||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncIdName
|
||||
import com.lagradost.cloudstream3.syncproviders.providers.SimklApi
|
||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.mainWork
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.nicehttp.RequestBodyTypes
|
||||
import okhttp3.Interceptor
|
||||
import org.mozilla.javascript.Scriptable
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
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 mapper = JsonMapper.builder().addModule(KotlinModule())
|
||||
|
@ -50,8 +55,10 @@ object APIHolder {
|
|||
val allProviders = threadSafeListOf<MainAPI>()
|
||||
|
||||
fun initAll() {
|
||||
for (api in allProviders) {
|
||||
api.init()
|
||||
synchronized(allProviders) {
|
||||
for (api in allProviders) {
|
||||
api.init()
|
||||
}
|
||||
}
|
||||
apiMap = null
|
||||
}
|
||||
|
@ -64,27 +71,35 @@ object APIHolder {
|
|||
var apiMap: Map<String, Int>? = null
|
||||
|
||||
fun addPluginMapping(plugin: MainAPI) {
|
||||
apis = apis + plugin
|
||||
synchronized(apis) {
|
||||
apis = apis + plugin
|
||||
}
|
||||
initMap(true)
|
||||
}
|
||||
|
||||
fun removePluginMapping(plugin: MainAPI) {
|
||||
apis = apis.filter { it != plugin }
|
||||
synchronized(apis) {
|
||||
apis = apis.filter { it != plugin }
|
||||
}
|
||||
initMap(true)
|
||||
}
|
||||
|
||||
private fun initMap(forcedUpdate: Boolean = false) {
|
||||
if (apiMap == null || forcedUpdate)
|
||||
apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap()
|
||||
synchronized(apis) {
|
||||
if (apiMap == null || forcedUpdate)
|
||||
apiMap = apis.mapIndexed { index, api -> api.name to index }.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
fun getApiFromNameNull(apiName: String?): MainAPI? {
|
||||
if (apiName == null) return null
|
||||
synchronized(allProviders) {
|
||||
initMap()
|
||||
return apiMap?.get(apiName)?.let { apis.getOrNull(it) }
|
||||
// Leave the ?. null check, it can crash regardless
|
||||
?: allProviders.firstOrNull { it.name == apiName }
|
||||
synchronized(apis) {
|
||||
return apiMap?.get(apiName)?.let { apis.getOrNull(it) }
|
||||
// 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()
|
||||
|
||||
/** 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.
|
||||
* 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 types Optional parameter to narrow down the scope to Movies, TV, etc. See TrackerType.getTypes()
|
||||
|
@ -176,7 +198,8 @@ object APIHolder {
|
|||
suspend fun getTracker(
|
||||
titles: List<String>,
|
||||
types: Set<TrackerType>?,
|
||||
year: Int?
|
||||
year: Int?,
|
||||
lessAccurate: Boolean
|
||||
): Tracker? {
|
||||
return try {
|
||||
require(titles.isNotEmpty()) { "titles must no be empty when calling getTracker" }
|
||||
|
@ -184,30 +207,70 @@ object APIHolder {
|
|||
val mainTitle = titles[0]
|
||||
val search =
|
||||
trackerCache[mainTitle]
|
||||
?: app.get("https://api.consumet.org/meta/anilist/$mainTitle")
|
||||
.parsedSafe<AniSearch>()?.also {
|
||||
trackerCache[mainTitle] = it
|
||||
} ?: return null
|
||||
?: searchAnilist(mainTitle)?.also {
|
||||
trackerCache[mainTitle] = it
|
||||
} ?: return null
|
||||
|
||||
val res = search.results?.find { media ->
|
||||
val matchingYears = year == null || media.releaseDate == year
|
||||
val res = search.data?.page?.media?.find { media ->
|
||||
val matchingYears = year == null || media.seasonYear == year
|
||||
val matchingTitles = media.title?.let { title ->
|
||||
titles.any { userTitle ->
|
||||
title.isMatchingTitles(userTitle)
|
||||
}
|
||||
} ?: false
|
||||
|
||||
val matchingTypes = types?.any { it.name.equals(media.type, true) } == true
|
||||
matchingTitles && matchingTypes && matchingYears
|
||||
val matchingTypes = types?.any { it.name.equals(media.format, true) } == true
|
||||
if(lessAccurate) matchingTitles || matchingTypes && matchingYears else matchingTitles && matchingTypes && matchingYears
|
||||
} ?: 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) {
|
||||
logError(t)
|
||||
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> {
|
||||
//val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
@ -215,7 +278,7 @@ object APIHolder {
|
|||
val hashSet = HashSet<String>()
|
||||
val activeLangs = getApiProviderLangSettings()
|
||||
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 })
|
||||
|
||||
/*val set = settingsManager.getStringSet(
|
||||
|
@ -314,8 +377,9 @@ object APIHolder {
|
|||
} ?: default
|
||||
val langs = this.getApiProviderLangSettings()
|
||||
val hasUniversal = langs.contains(AllLanguagesName)
|
||||
val allApis = apis.filter { hasUniversal || langs.contains(it.lang) }
|
||||
.filter { api -> api.hasMainPage || !hasHomePageIsRequired }
|
||||
val allApis = synchronized(apis) {
|
||||
apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) }
|
||||
}
|
||||
return if (currentPrefMedia.isEmpty()) {
|
||||
allApis
|
||||
} else {
|
||||
|
@ -736,6 +800,7 @@ fun fixTitle(str: String): String {
|
|||
.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.
|
||||
* Make sure you get the scope using: val scope: Scriptable = rhino.initSafeStandardObjects()
|
||||
|
@ -801,6 +866,19 @@ enum class TvType(value: Int?) {
|
|||
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
|
||||
fun TvType.isMovieType(): Boolean {
|
||||
return this == TvType.Movie || this == TvType.AnimeMovie || this == TvType.Torrent || this == TvType.Live
|
||||
|
@ -1119,10 +1197,11 @@ interface LoadResponse {
|
|||
companion object {
|
||||
private val malIdPrefix = malApi.idPrefix
|
||||
private val aniListIdPrefix = aniListApi.idPrefix
|
||||
private val simklIdPrefix = simklApi.idPrefix
|
||||
var isTrailersEnabled = true
|
||||
|
||||
fun LoadResponse.isMovie(): Boolean {
|
||||
return this.type.isMovieType()
|
||||
return this.type.isMovieType() || this is MovieLoadResponse
|
||||
}
|
||||
|
||||
@JvmName("addActorNames")
|
||||
|
@ -1140,6 +1219,20 @@ interface LoadResponse {
|
|||
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")
|
||||
fun LoadResponse.addActors(actors: List<Actor>?) {
|
||||
this.actors = actors?.map { actor -> ActorData(actor) }
|
||||
|
@ -1155,10 +1248,16 @@ interface LoadResponse {
|
|||
|
||||
fun LoadResponse.addMalId(id: Int?) {
|
||||
this.syncData[malIdPrefix] = (id ?: return).toString()
|
||||
this.addSimklId(SimklApi.Companion.SyncServices.Mal, id.toString())
|
||||
}
|
||||
|
||||
fun LoadResponse.addAniListId(id: Int?) {
|
||||
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?) {
|
||||
|
@ -1240,6 +1339,7 @@ interface LoadResponse {
|
|||
|
||||
fun LoadResponse.addImdbId(id: String?) {
|
||||
// TODO add imdb sync
|
||||
this.addSimklId(SimklApi.Companion.SyncServices.Imdb, id)
|
||||
}
|
||||
|
||||
fun LoadResponse.addTrackId(id: String?) {
|
||||
|
@ -1252,6 +1352,7 @@ interface LoadResponse {
|
|||
|
||||
fun LoadResponse.addTMDbId(id: String?) {
|
||||
// TODO add TMDb sync
|
||||
this.addSimklId(SimklApi.Companion.SyncServices.Tmdb, id)
|
||||
}
|
||||
|
||||
fun LoadResponse.addRating(text: String?) {
|
||||
|
@ -1679,30 +1780,42 @@ data class Tracker(
|
|||
val cover: String? = null,
|
||||
)
|
||||
|
||||
data class Title(
|
||||
@JsonProperty("romaji") val romaji: String? = null,
|
||||
@JsonProperty("english") val english: String? = null,
|
||||
data class AniSearch(
|
||||
@JsonProperty("data") var data: Data? = Data()
|
||||
) {
|
||||
fun isMatchingTitles(title: String?): Boolean {
|
||||
if (title == null) return false
|
||||
return english.equals(title, true) || romaji.equals(title, true)
|
||||
data class Data(
|
||||
@JsonProperty("Page") var page: Page? = Page()
|
||||
) {
|
||||
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
|
||||
**/
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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()
|
||||
}*/
|
||||
}
|
|
@ -9,7 +9,7 @@ import java.net.URI
|
|||
|
||||
open class AsianLoad : ExtractorApi() {
|
||||
override var name = "AsianLoad"
|
||||
override var mainUrl = "https://asianembed.io"
|
||||
override var mainUrl = "https://asianhdplay.pro"
|
||||
override val requiresReferer = true
|
||||
|
||||
private val sourceRegex = Regex("""sources:[\W\w]*?file:\s*?["'](.*?)["']""")
|
||||
|
@ -43,4 +43,4 @@ open class AsianLoad : ExtractorApi() {
|
|||
return extractedLinksList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import com.lagradost.cloudstream3.utils.*
|
|||
|
||||
open class ByteShare : ExtractorApi() {
|
||||
override val name = "ByteShare"
|
||||
override val mainUrl = "https://byteshare.net"
|
||||
override val mainUrl = "https://byteshare.to"
|
||||
override val requiresReferer = false
|
||||
|
||||
override suspend fun getUrl(url: String, referer: String?): List<ExtractorLink> {
|
||||
|
@ -20,4 +20,4 @@ open class ByteShare : ExtractorApi() {
|
|||
)
|
||||
return sources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
package com.lagradost.cloudstream3.extractors
|
||||
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
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.M3u8Helper.Companion.generateM3u8
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
import android.util.Log
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import java.net.URLDecoder
|
||||
|
||||
open class Cda: ExtractorApi() {
|
||||
|
|
|
@ -2,15 +2,17 @@ package com.lagradost.cloudstream3.extractors
|
|||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
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.ExtractorApi
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.Qualities
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class Moviesapi : Chillx() {
|
||||
override val name = "Moviesapi"
|
||||
override val mainUrl = "https://w1.moviesapi.club"
|
||||
}
|
||||
|
||||
class Bestx : Chillx() {
|
||||
override val name = "Bestx"
|
||||
|
@ -27,7 +29,7 @@ open class Chillx : ExtractorApi() {
|
|||
override val requiresReferer = true
|
||||
|
||||
companion object {
|
||||
private const val KEY = "4VqE3#N7zt&HEP^a"
|
||||
private const val KEY = "m4H6D9%0\$N&F6rQ&"
|
||||
}
|
||||
|
||||
override suspend fun getUrl(
|
||||
|
@ -42,10 +44,9 @@ open class Chillx : ExtractorApi() {
|
|||
referer = referer
|
||||
).text
|
||||
)?.groupValues?.get(1)
|
||||
val encData = AppUtils.tryParseJson<AESData>(base64Decode(master ?: return))
|
||||
val decrypt = cryptoAESHandler(encData ?: return, KEY, false)
|
||||
val decrypt = cryptoAESHandler(master ?: return, KEY.toByteArray(), false)?.replace("\\", "") ?: throw ErrorLoadingException("failed to decrypt")
|
||||
|
||||
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)
|
||||
|
||||
// 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(
|
||||
@JsonProperty("file") val file: String? = null,
|
||||
@JsonProperty("label") val label: String? = null,
|
||||
|
|
|
@ -7,6 +7,10 @@ import com.lagradost.cloudstream3.utils.Qualities
|
|||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class Dooood : DoodLaExtractor() {
|
||||
override var mainUrl = "https://dooood.com"
|
||||
}
|
||||
|
||||
class DoodWfExtractor : DoodLaExtractor() {
|
||||
override var mainUrl = "https://dood.wf"
|
||||
}
|
||||
|
|
|
@ -5,6 +5,16 @@ import com.lagradost.cloudstream3.app
|
|||
import com.lagradost.cloudstream3.utils.*
|
||||
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() {
|
||||
override val mainUrl = "https://moviesm4u.com"
|
||||
override val name = "Moviesm4u"
|
||||
|
@ -15,6 +25,11 @@ class FileMoonIn : Filesim() {
|
|||
override val name = "FileMoon"
|
||||
}
|
||||
|
||||
class StreamhideTo : Filesim() {
|
||||
override val mainUrl = "https://streamhide.to"
|
||||
override val name = "Streamhide"
|
||||
}
|
||||
|
||||
class StreamhideCom : Filesim() {
|
||||
override var name: String = "Streamhide"
|
||||
override var mainUrl: String = "https://streamhide.com"
|
||||
|
@ -42,7 +57,7 @@ class FileMoonSx : Filesim() {
|
|||
open class Filesim : ExtractorApi() {
|
||||
override val name = "Filesim"
|
||||
override val mainUrl = "https://files.im"
|
||||
override val requiresReferer = false
|
||||
override val requiresReferer = true
|
||||
|
||||
override suspend fun getUrl(
|
||||
url: String,
|
||||
|
@ -50,27 +65,19 @@ open class Filesim : ExtractorApi() {
|
|||
subtitleCallback: (SubtitleFile) -> Unit,
|
||||
callback: (ExtractorLink) -> Unit
|
||||
) {
|
||||
val response = app.get(url, referer = mainUrl).document
|
||||
response.select("script[type=text/javascript]").map { script ->
|
||||
if (script.data().contains(Regex("eval\\(function\\(p,a,c,k,e,[rd]"))) {
|
||||
val unpackedscript = getAndUnpack(script.data())
|
||||
val m3u8Regex = Regex("file.\"(.*?m3u8.*?)\"")
|
||||
val m3u8 = m3u8Regex.find(unpackedscript)?.destructured?.component1() ?: ""
|
||||
if (m3u8.isNotEmpty()) {
|
||||
generateM3u8(
|
||||
name,
|
||||
m3u8,
|
||||
mainUrl
|
||||
).forEach(callback)
|
||||
}
|
||||
}
|
||||
val response = app.get(url, referer = referer)
|
||||
val script = if (!getPacked(response.text).isNullOrEmpty()) {
|
||||
getAndUnpack(response.text)
|
||||
} else {
|
||||
response.document.selectFirst("script:containsData(sources:)")?.data()
|
||||
}
|
||||
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?
|
||||
) */
|
||||
|
||||
}
|
|
@ -2,14 +2,10 @@ package com.lagradost.cloudstream3.extractors
|
|||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.extractors.helper.AesHelper.cryptoAESHandler
|
||||
import com.lagradost.cloudstream3.utils.*
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||
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() {
|
||||
override var mainUrl = "https://databasegdriveplayer.co"
|
||||
|
@ -65,78 +61,6 @@ open class Gdriveplayer : ExtractorApi() {
|
|||
?.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? {
|
||||
return find(str)?.groupValues?.getOrNull(1)
|
||||
}
|
||||
|
@ -154,14 +78,14 @@ open class Gdriveplayer : ExtractorApi() {
|
|||
val document = app.get(url).document
|
||||
|
||||
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)
|
||||
?.split(Regex("\\D+"))
|
||||
?.joinToString("") {
|
||||
Char(it.toInt()).toString()
|
||||
}.let { Regex("var pass = \"(\\S+?)\"").first(it ?: return)?.toByteArray() }
|
||||
?: 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 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(
|
||||
@JsonProperty("file") val file: String,
|
||||
@JsonProperty("kind") val kind: String,
|
||||
|
|
|
@ -19,9 +19,12 @@ open class Gofile : ExtractorApi() {
|
|||
subtitleCallback: (SubtitleFile) -> 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")
|
||||
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 {
|
||||
callback.invoke(
|
||||
ExtractorLink(
|
||||
|
@ -56,4 +59,4 @@ open class Gofile : ExtractorApi() {
|
|||
@JsonProperty("data") val data: Data? = null,
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
|||
|
||||
class Neonime7n : Hxfile() {
|
||||
override val name = "Neonime7n"
|
||||
override val mainUrl = "https://7njctn.neonime.watch"
|
||||
override val mainUrl = "https://neonime.fun"
|
||||
override val redirect = false
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,7 @@ class Neonime8n : Hxfile() {
|
|||
|
||||
class KotakAnimeid : Hxfile() {
|
||||
override val name = "KotakAnimeid"
|
||||
override val mainUrl = "https://kotakanimeid.com"
|
||||
override val mainUrl = "https://nontonanimeid.bio"
|
||||
override val requiresReferer = true
|
||||
}
|
||||
|
||||
|
@ -97,4 +97,4 @@ open class Hxfile : ExtractorApi() {
|
|||
@JsonProperty("label") val label: String?
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import com.lagradost.cloudstream3.utils.ExtractorLink
|
|||
import com.lagradost.cloudstream3.utils.M3u8Helper
|
||||
|
||||
class MoviehabNet : Moviehab() {
|
||||
override var mainUrl = "https://play.moviehab.net"
|
||||
override var mainUrl = "https://play.moviehab.asia"
|
||||
}
|
||||
|
||||
open class Moviehab : ExtractorApi() {
|
||||
|
@ -41,4 +41,4 @@ open class Moviehab : ExtractorApi() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,24 +10,39 @@ open class Mp4Upload : ExtractorApi() {
|
|||
override var name = "Mp4Upload"
|
||||
override var mainUrl = "https://www.mp4upload.com"
|
||||
private val srcRegex = Regex("""player\.src\("(.*?)"""")
|
||||
override val requiresReferer = true
|
||||
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>? {
|
||||
with(app.get(url)) {
|
||||
getAndUnpack(this.text).let { unpackedText ->
|
||||
val quality = unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
|
||||
srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link ->
|
||||
return listOf(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
link,
|
||||
url,
|
||||
quality ?: Qualities.Unknown.value,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val realUrl = idMatch.find(url)?.groupValues?.get(2)?.let { id ->
|
||||
"$mainUrl/embed-$id.html"
|
||||
} ?: url
|
||||
val response = app.get(realUrl)
|
||||
val unpackedText = getAndUnpack(response.text)
|
||||
val quality =
|
||||
unpackedText.lowercase().substringAfter(" height=").substringBefore(" ").toIntOrNull()
|
||||
srcRegex.find(unpackedText)?.groupValues?.get(1)?.let { link ->
|
||||
return listOf(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
link,
|
||||
url,
|
||||
quality ?: Qualities.Unknown.value,
|
||||
)
|
||||
)
|
||||
}
|
||||
srcRegex2.find(unpackedText)?.groupValues?.get(1)?.let { link ->
|
||||
return listOf(
|
||||
ExtractorLink(
|
||||
name,
|
||||
name,
|
||||
link,
|
||||
url,
|
||||
quality ?: Qualities.Unknown.value,
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import java.net.URI
|
|||
|
||||
open class MultiQuality : ExtractorApi() {
|
||||
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 m3u8Regex = Regex(""".*?(\d*).m3u8""")
|
||||
private val urlRegex = Regex("""(.*?)([^/]+$)""")
|
||||
|
@ -56,4 +56,4 @@ open class MultiQuality : ExtractorApi() {
|
|||
return extractedLinksList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.amap
|
|||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.INFER_TYPE
|
||||
import com.lagradost.cloudstream3.utils.extractorApis
|
||||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
||||
import com.lagradost.cloudstream3.utils.loadExtractor
|
||||
|
@ -66,7 +67,7 @@ open class Pelisplus(val mainUrl: String) {
|
|||
href,
|
||||
page.url,
|
||||
getQualityFromName(qual),
|
||||
element.attr("href").contains(".m3u8")
|
||||
type = INFER_TYPE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
|
||||
}
|
|
@ -7,15 +7,22 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
|
|||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.M3u8Helper
|
||||
|
||||
class SpeedoStream2 : SpeedoStream() {
|
||||
override val mainUrl = "https://speedostream.mom"
|
||||
}
|
||||
|
||||
class SpeedoStream1 : SpeedoStream() {
|
||||
override val mainUrl = "https://speedostream.nl"
|
||||
override val mainUrl = "https://speedostream.pm"
|
||||
}
|
||||
|
||||
open class SpeedoStream : ExtractorApi() {
|
||||
override val name = "SpeedoStream"
|
||||
override val mainUrl = "https://speedostream.com"
|
||||
override val mainUrl = "https://speedostream.bond"
|
||||
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> {
|
||||
val sources = mutableListOf<ExtractorLink>()
|
||||
app.get(url, referer = referer).document.select("script").map { script ->
|
||||
|
@ -26,7 +33,7 @@ open class SpeedoStream : ExtractorApi() {
|
|||
M3u8Helper.generateM3u8(
|
||||
name,
|
||||
it.file,
|
||||
"$mainUrl/",
|
||||
"$hostUrl/",
|
||||
).forEach { m3uData -> sources.add(m3uData) }
|
||||
}
|
||||
}
|
||||
|
@ -37,6 +44,4 @@ open class SpeedoStream : ExtractorApi() {
|
|||
private data class File(
|
||||
@JsonProperty("file") val file: String,
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,31 @@ import com.lagradost.cloudstream3.utils.ExtractorLink
|
|||
import com.lagradost.cloudstream3.utils.M3u8Helper
|
||||
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() {
|
||||
override var mainUrl = "https://vidgomunimesb.xyz"
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.amap
|
|||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.argamap
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.INFER_TYPE
|
||||
import com.lagradost.cloudstream3.utils.extractorApis
|
||||
import com.lagradost.cloudstream3.utils.getQualityFromName
|
||||
import com.lagradost.cloudstream3.utils.loadExtractor
|
||||
|
@ -70,7 +71,7 @@ class Vidstream(val mainUrl: String) {
|
|||
href,
|
||||
page.url,
|
||||
getQualityFromName(qual),
|
||||
element.attr("href").contains(".m3u8")
|
||||
type = INFER_TYPE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import com.lagradost.cloudstream3.extractors.helper.NineAnimeHelper.encrypt
|
|||
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
|
||||
|
||||
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")
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
||||
}
|
|
@ -21,10 +21,11 @@ class CrossTmdbProvider : TmdbProvider() {
|
|||
return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
|
||||
}
|
||||
|
||||
private val validApis by lazy {
|
||||
apis.filter { it.lang == this.lang && it::class.java != this::class.java }
|
||||
//.distinctBy { it.uniqueId }
|
||||
}
|
||||
private val validApis
|
||||
get() =
|
||||
synchronized(apis) { apis.filter { it.lang == this.lang && it::class.java != this::class.java } }
|
||||
//.distinctBy { it.uniqueId }
|
||||
|
||||
|
||||
data class CrossMetaData(
|
||||
@JsonProperty("isSuccess") val isSuccess: Boolean,
|
||||
|
@ -60,7 +61,8 @@ class CrossTmdbProvider : TmdbProvider() {
|
|||
|
||||
override suspend fun load(url: String): LoadResponse? {
|
||||
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)
|
||||
when (this) {
|
||||
is MovieLoadResponse -> {
|
||||
|
@ -98,6 +100,7 @@ class CrossTmdbProvider : TmdbProvider() {
|
|||
this.dataUrl =
|
||||
CrossMetaData(true, data.map { it.apiName to it.dataUrl }).toJson()
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw ErrorLoadingException("Nothing besides movies are implemented for this provider")
|
||||
}
|
||||
|
|
|
@ -25,13 +25,16 @@ class MultiAnimeProvider : MainAPI() {
|
|||
}
|
||||
}
|
||||
|
||||
private val validApis by lazy {
|
||||
APIHolder.apis.filter {
|
||||
it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains(
|
||||
TvType.Anime
|
||||
)
|
||||
}
|
||||
}
|
||||
private val validApis
|
||||
get() =
|
||||
synchronized(APIHolder.apis) {
|
||||
APIHolder.apis.filter {
|
||||
it.lang == this.lang && it::class.java != this::class.java && it.supportedTypes.contains(
|
||||
TvType.Anime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun filterName(name: String): String {
|
||||
return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
|
||||
|
|
|
@ -57,32 +57,6 @@ fun <T> LifecycleOwner.observeNullable(liveData: LiveData<T>, action: (t: T) ->
|
|||
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> {
|
||||
data class Success<out T>(val value: T) : Resource<T>()
|
||||
data class Failure(
|
||||
|
@ -155,6 +129,70 @@ fun CoroutineScope.launchSafe(
|
|||
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(
|
||||
apiCall: suspend () -> T,
|
||||
): Resource<T> {
|
||||
|
@ -163,60 +201,7 @@ suspend fun <T> safeApiCall(
|
|||
Resource.Success(apiCall.invoke())
|
||||
} catch (throwable: Throwable) {
|
||||
logError(throwable)
|
||||
when (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)
|
||||
}
|
||||
throwAbleToResource(throwable)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -36,7 +36,9 @@ abstract class Plugin {
|
|||
Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI")
|
||||
element.sourcePlugin = this.__filename
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
@ -51,10 +53,14 @@ abstract class Plugin {
|
|||
}
|
||||
|
||||
class Manifest {
|
||||
@JsonProperty("name") var name: String? = null
|
||||
@JsonProperty("pluginClassName") var pluginClassName: String? = null
|
||||
@JsonProperty("version") var version: Int? = null
|
||||
@JsonProperty("requiresResources") var requiresResources: Boolean = false
|
||||
@JsonProperty("name")
|
||||
var name: String? = null
|
||||
@JsonProperty("pluginClassName")
|
||||
var pluginClassName: String? = null
|
||||
@JsonProperty("version")
|
||||
var version: Int? = null
|
||||
@JsonProperty("requiresResources")
|
||||
var requiresResources: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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> {
|
||||
return getKey(PLUGINS_KEY) ?: emptyArray()
|
||||
}
|
||||
|
@ -163,7 +177,11 @@ object PluginManager {
|
|||
private val classLoaders: MutableMap<PathClassLoader, Plugin> =
|
||||
HashMap<PathClassLoader, Plugin>()
|
||||
|
||||
private var loadedLocalPlugins = false
|
||||
var loadedLocalPlugins = false
|
||||
private set
|
||||
|
||||
var loadedOnlinePlugins = false
|
||||
private set
|
||||
private val gson = Gson()
|
||||
|
||||
private suspend fun maybeLoadPlugin(context: Context, file: File) {
|
||||
|
@ -277,6 +295,7 @@ object PluginManager {
|
|||
}
|
||||
|
||||
// ioSafe {
|
||||
loadedOnlinePlugins = true
|
||||
afterPluginsLoadedEvent.invoke(false)
|
||||
// }
|
||||
|
||||
|
@ -289,7 +308,7 @@ object PluginManager {
|
|||
* 2. Fetch all not downloaded plugins
|
||||
* 3. Download them and reload plugins
|
||||
**/
|
||||
fun downloadNotExistingPluginsAndLoad(activity: Activity) {
|
||||
fun downloadNotExistingPluginsAndLoad(activity: Activity, mode: AutoDownloadMode) {
|
||||
val newDownloadPlugins = mutableListOf<String>()
|
||||
val urls = (getKey<Array<RepositoryData>>(REPOSITORIES_KEY)
|
||||
?: emptyArray()) + PREBUILT_REPOSITORIES
|
||||
|
@ -303,6 +322,8 @@ object PluginManager {
|
|||
// Iterate online repos and returns not downloaded plugins
|
||||
val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData ->
|
||||
val sitePlugin = onlineData.second
|
||||
val tvtypes = sitePlugin.tvTypes ?: listOf()
|
||||
|
||||
//Don't include empty urls
|
||||
if (sitePlugin.url.isBlank()) {
|
||||
return@mapNotNull null
|
||||
|
@ -317,22 +338,29 @@ object PluginManager {
|
|||
return@mapNotNull null
|
||||
}
|
||||
|
||||
//Omit lang not selected on language setting
|
||||
val lang = sitePlugin.language ?: return@mapNotNull null
|
||||
//If set to 'universal', don't skip any language
|
||||
if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
//Log.i(TAG, "sitePlugin lang => $lang")
|
||||
|
||||
//Omit NSFW, if disabled
|
||||
sitePlugin.tvTypes?.let { tvtypes ->
|
||||
if (!settingsForProvider.enableAdult) {
|
||||
if (tvtypes.contains(TvType.NSFW.name)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
//Omit non-NSFW if mode is set to NSFW only
|
||||
if (mode == AutoDownloadMode.NsfwOnly) {
|
||||
if (tvtypes.contains(TvType.NSFW.name) == false) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
}
|
||||
//Omit NSFW, if disabled
|
||||
if (!settingsForProvider.enableAdult) {
|
||||
if (tvtypes.contains(TvType.NSFW.name)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
}
|
||||
|
||||
//Omit lang not selected on language setting
|
||||
if (mode == AutoDownloadMode.FilterByLang) {
|
||||
val lang = sitePlugin.language ?: return@mapNotNull null
|
||||
//If set to 'universal', don't skip any language
|
||||
if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
//Log.i(TAG, "sitePlugin lang => $lang")
|
||||
}
|
||||
|
||||
val savedData = PluginData(
|
||||
url = sitePlugin.url,
|
||||
internalName = sitePlugin.internalName,
|
||||
|
@ -531,10 +559,14 @@ object PluginManager {
|
|||
}
|
||||
|
||||
// remove all registered apis
|
||||
APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach {
|
||||
removePluginMapping(it)
|
||||
synchronized(APIHolder.apis) {
|
||||
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 }
|
||||
|
||||
classLoaders.values.removeIf { v -> v == plugin }
|
||||
|
@ -692,4 +724,4 @@ object PluginManager {
|
|||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,22 +8,14 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
|||
import com.lagradost.cloudstream3.R
|
||||
import java.security.MessageDigest
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
object VotingApi { // please do not cheat the votes lol
|
||||
private const val LOGKEY = "VotingApi"
|
||||
|
||||
enum class VoteType(val value: Int) {
|
||||
UPVOTE(1),
|
||||
DOWNVOTE(-1),
|
||||
NONE(0)
|
||||
}
|
||||
|
||||
private val apiDomain = "https://api.countapi.xyz"
|
||||
private const val apiDomain = "https://counterapi.com/api"
|
||||
|
||||
private fun transformUrl(url: String): String = // dont touch or all votes get reset
|
||||
MessageDigest
|
||||
|
@ -35,12 +27,12 @@ object VotingApi { // please do not cheat the votes lol
|
|||
return getVotes(url)
|
||||
}
|
||||
|
||||
suspend fun SitePlugin.vote(requestType: VoteType): Int {
|
||||
return vote(url, requestType)
|
||||
fun SitePlugin.hasVoted(): Boolean {
|
||||
return hasVoted(url)
|
||||
}
|
||||
|
||||
fun SitePlugin.getVoteType(): VoteType {
|
||||
return getVoteType(url)
|
||||
suspend fun SitePlugin.vote(): Int {
|
||||
return vote(url)
|
||||
}
|
||||
|
||||
fun SitePlugin.canVote(): Boolean {
|
||||
|
@ -50,28 +42,31 @@ object VotingApi { // please do not cheat the votes lol
|
|||
// Plugin url to Int
|
||||
private val votesCache = mutableMapOf<String, Int>()
|
||||
|
||||
suspend fun getVotes(pluginUrl: String): Int {
|
||||
val url = "${apiDomain}/get/cs3-votes/${transformUrl(pluginUrl)}"
|
||||
private fun getRepository(pluginUrl: String) = pluginUrl
|
||||
.split("/")
|
||||
.drop(2)
|
||||
.take(3)
|
||||
.joinToString("-")
|
||||
|
||||
private suspend fun readVote(pluginUrl: String): Int {
|
||||
var url = "${apiDomain}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true"
|
||||
Log.d(LOGKEY, "Requesting: $url")
|
||||
return votesCache[pluginUrl] ?: app.get(url).parsedSafe<Result>()?.value?.also {
|
||||
votesCache[pluginUrl] = it
|
||||
} ?: (0.also {
|
||||
ioSafe {
|
||||
createBucket(pluginUrl)
|
||||
return app.get(url).parsedSafe<Result>()?.value ?: 0
|
||||
}
|
||||
|
||||
private suspend fun writeVote(pluginUrl: String): Boolean {
|
||||
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 {
|
||||
return getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE
|
||||
}
|
||||
|
||||
private suspend fun createBucket(pluginUrl: String) {
|
||||
val url =
|
||||
"${apiDomain}/create?namespace=cs3-votes&key=${transformUrl(pluginUrl)}&value=0&update_lowerbound=-2&update_upperbound=2&enable_reset=0"
|
||||
Log.d(LOGKEY, "Requesting: $url")
|
||||
app.get(url)
|
||||
}
|
||||
fun hasVoted(pluginUrl: String) =
|
||||
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false
|
||||
|
||||
fun canVote(pluginUrl: String): Boolean {
|
||||
if (!PluginManager.urlPlugins.contains(pluginUrl)) return false
|
||||
|
@ -79,7 +74,7 @@ object VotingApi { // please do not cheat the votes lol
|
|||
}
|
||||
|
||||
private val voteLock = Mutex()
|
||||
suspend fun vote(pluginUrl: String, requestType: VoteType): Int {
|
||||
suspend fun vote(pluginUrl: String): Int {
|
||||
// Prevent multiple requests at the same time.
|
||||
voteLock.withLock {
|
||||
if (!canVote(pluginUrl)) {
|
||||
|
@ -90,33 +85,21 @@ object VotingApi { // please do not cheat the votes lol
|
|||
return getVotes(pluginUrl)
|
||||
}
|
||||
|
||||
val savedType: VoteType =
|
||||
getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: VoteType.NONE
|
||||
|
||||
val newType = if (requestType == savedType) VoteType.NONE else requestType
|
||||
val changeValue = if (requestType == savedType) {
|
||||
-requestType.value
|
||||
} else if (savedType == VoteType.NONE) {
|
||||
requestType.value
|
||||
} else if (savedType != requestType) {
|
||||
-savedType.value + requestType.value
|
||||
} else 0
|
||||
|
||||
// Pre-emptively set vote key
|
||||
setKey("cs3-votes/${transformUrl(pluginUrl)}", newType)
|
||||
|
||||
val url =
|
||||
"${apiDomain}/update/cs3-votes/${transformUrl(pluginUrl)}?amount=${changeValue}"
|
||||
Log.d(LOGKEY, "Requesting: $url")
|
||||
val res = app.get(url).parsedSafe<Result>()?.value
|
||||
|
||||
if (res == null) {
|
||||
// "Refund" key if the response is invalid
|
||||
setKey("cs3-votes/${transformUrl(pluginUrl)}", savedType)
|
||||
} else {
|
||||
votesCache[pluginUrl] = res
|
||||
if (hasVoted(pluginUrl)) {
|
||||
main {
|
||||
Toast.makeText(context, R.string.already_voted, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
return getVotes(pluginUrl)
|
||||
}
|
||||
return res ?: 0
|
||||
|
||||
|
||||
if (writeVote(pluginUrl)) {
|
||||
setKey("cs3-votes/${transformUrl(pluginUrl)}", true)
|
||||
votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1
|
||||
}
|
||||
|
||||
return getVotes(pluginUrl)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
val malApi = MALApi(0)
|
||||
val aniListApi = AniListApi(0)
|
||||
val openSubtitlesApi = OpenSubtitlesApi(0)
|
||||
val simklApi = SimklApi(0)
|
||||
val googleDriveApi = GoogleDriveApi(0)
|
||||
val indexSubtitlesApi = IndexSubtitleApi()
|
||||
val addic7ed = Addic7ed()
|
||||
|
@ -20,19 +21,19 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI {
|
|||
// used to login via app intent
|
||||
val OAuth2Apis
|
||||
get() = listOf<OAuth2API>(
|
||||
malApi, aniListApi, googleDriveApi
|
||||
malApi, aniListApi, simklApi, googleDriveApi
|
||||
)
|
||||
|
||||
// this needs init with context and can be accessed in settings
|
||||
val accountManagers
|
||||
get() = listOf(
|
||||
malApi, aniListApi, openSubtitlesApi, googleDriveApi //, nginxApi
|
||||
malApi, aniListApi, openSubtitlesApi, simklApi, googleDriveApi //, nginxApi
|
||||
)
|
||||
|
||||
// used for active syncing
|
||||
val SyncApis
|
||||
get() = listOf(
|
||||
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi)
|
||||
SyncRepo(malApi), SyncRepo(aniListApi), SyncRepo(localListApi), SyncRepo(simklApi)
|
||||
)
|
||||
|
||||
// used for active backup
|
||||
|
|
|
@ -10,7 +10,8 @@ enum class SyncIdName {
|
|||
MyAnimeList,
|
||||
Trakt,
|
||||
Imdb,
|
||||
LocalList
|
||||
Simkl,
|
||||
LocalList,
|
||||
}
|
||||
|
||||
interface SyncAPI : OAuth2API {
|
||||
|
@ -35,9 +36,9 @@ interface SyncAPI : OAuth2API {
|
|||
4 -> PlanToWatch
|
||||
5 -> ReWatching
|
||||
*/
|
||||
suspend fun score(id: String, status: SyncStatus): Boolean
|
||||
suspend fun score(id: String, status: AbstractSyncStatus): Boolean
|
||||
|
||||
suspend fun getStatus(id: String): SyncStatus?
|
||||
suspend fun getStatus(id: String): AbstractSyncStatus?
|
||||
|
||||
suspend fun getResult(id: String): SyncResult?
|
||||
|
||||
|
@ -59,14 +60,24 @@ interface SyncAPI : OAuth2API {
|
|||
override var id: Int? = null,
|
||||
) : SearchResponse
|
||||
|
||||
data class SyncStatus(
|
||||
val status: Int,
|
||||
abstract class AbstractSyncStatus {
|
||||
abstract var status: Int
|
||||
|
||||
/** 1-10 */
|
||||
val score: Int?,
|
||||
val watchedEpisodes: Int?,
|
||||
var isFavorite: Boolean? = null,
|
||||
var maxEpisodes: Int? = null,
|
||||
)
|
||||
abstract var score: Int?
|
||||
abstract var watchedEpisodes: Int?
|
||||
abstract var isFavorite: Boolean?
|
||||
abstract var maxEpisodes: Int?
|
||||
}
|
||||
|
||||
data class SyncStatus(
|
||||
override var status: 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(
|
||||
/**Used to verify*/
|
||||
|
|
|
@ -18,11 +18,11 @@ class SyncRepo(private val repo: SyncAPI) {
|
|||
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) }
|
||||
}
|
||||
|
||||
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") }
|
||||
}
|
||||
|
||||
|
|
|
@ -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 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(
|
||||
id.toIntOrNull() ?: return false,
|
||||
fromIntToAnimeStatus(status.status),
|
||||
|
|
|
@ -45,11 +45,11 @@ class LocalList : SyncAPI {
|
|||
|
||||
override val mainUrl = ""
|
||||
override val syncIdName = SyncIdName.LocalList
|
||||
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
|
||||
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
|
||||
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
@ -91,7 +91,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI {
|
|||
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
|
||||
}
|
||||
|
||||
override suspend fun score(id: String, status: SyncAPI.SyncStatus): Boolean {
|
||||
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
|
||||
return setScoreRequest(
|
||||
id.toIntOrNull() ?: return false,
|
||||
fromIntToAnimeStatus(status.status),
|
||||
|
|
|
@ -2,12 +2,13 @@ package com.lagradost.cloudstream3.syncproviders.providers
|
|||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.google.common.collect.BiMap
|
||||
import com.google.common.collect.HashBiMap
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubApi
|
||||
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
|
||||
|
@ -15,8 +16,8 @@ import com.lagradost.cloudstream3.syncproviders.AuthAPI
|
|||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI
|
||||
import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager
|
||||
import com.lagradost.cloudstream3.utils.AppUtils
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi {
|
||||
override val idPrefix = "opensubtitles"
|
||||
|
@ -36,6 +37,23 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
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 {
|
||||
return unixTimeMs > currentCoolDown
|
||||
}
|
||||
|
@ -98,13 +116,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
val response = app.post(
|
||||
url = "$host/login",
|
||||
headers = mapOf(
|
||||
"Api-Key" to apiKey,
|
||||
"Content-Type" to "application/json"
|
||||
"Content-Type" to "application/json",
|
||||
),
|
||||
data = mapOf(
|
||||
"username" to username,
|
||||
"password" to password
|
||||
)
|
||||
),
|
||||
interceptor = headerInterceptor
|
||||
)
|
||||
//Log.i(TAG, "Responsecode = ${response.code}")
|
||||
//Log.i(TAG, "Result => ${response.text}")
|
||||
|
@ -149,11 +167,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
// "pt" to "pt-PT",
|
||||
// "pt" to "pt-BR"
|
||||
)
|
||||
private fun fixLanguage(language: String?) : String? {
|
||||
|
||||
private fun fixLanguage(language: String?): String? {
|
||||
return languageExceptions[language] ?: language
|
||||
}
|
||||
|
||||
// O(n) but good enough, BiMap did not want to work properly
|
||||
private fun fixLanguageReverse(language: String?) : String? {
|
||||
private fun fixLanguageReverse(language: String?): String? {
|
||||
return languageExceptions.entries.firstOrNull { it.value == language }?.key ?: language
|
||||
}
|
||||
|
||||
|
@ -183,9 +203,9 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
val req = app.get(
|
||||
url = searchQueryUrl,
|
||||
headers = mapOf(
|
||||
Pair("Api-Key", apiKey),
|
||||
Pair("Content-Type", "application/json")
|
||||
)
|
||||
),
|
||||
interceptor = headerInterceptor
|
||||
)
|
||||
Log.i(TAG, "Search Req => ${req.text}")
|
||||
if (!req.isSuccessful) {
|
||||
|
@ -207,7 +227,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
//Use any valid name/title in hierarchy
|
||||
val name = filename ?: featureDetails?.movieName ?: featureDetails?.title
|
||||
?: featureDetails?.parentTitle ?: attr.release ?: query.query
|
||||
val lang = fixLanguageReverse(attr.language)?: ""
|
||||
val lang = fixLanguageReverse(attr.language) ?: ""
|
||||
val resEpNum = featureDetails?.episodeNumber ?: query.epNumber
|
||||
val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber
|
||||
val year = featureDetails?.year ?: query.year
|
||||
|
@ -251,13 +271,13 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi
|
|||
"Authorization",
|
||||
"Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}"
|
||||
),
|
||||
Pair("Api-Key", apiKey),
|
||||
Pair("Content-Type", "application/json"),
|
||||
Pair("Accept", "*/*")
|
||||
),
|
||||
data = mapOf(
|
||||
Pair("file_id", data.data)
|
||||
)
|
||||
),
|
||||
interceptor = headerInterceptor
|
||||
)
|
||||
Log.i(TAG, "Request result => (${req.code}) ${req.text}")
|
||||
//Log.i(TAG, "Request headers => ${req.headers}")
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,16 +1,24 @@
|
|||
package com.lagradost.cloudstream3.ui
|
||||
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTime
|
||||
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
|
||||
import com.lagradost.cloudstream3.DubStatus
|
||||
import com.lagradost.cloudstream3.ErrorLoadingException
|
||||
import com.lagradost.cloudstream3.HomePageResponse
|
||||
import com.lagradost.cloudstream3.LoadResponse
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent
|
||||
import com.lagradost.cloudstream3.MainPageRequest
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.SubtitleFile
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.fixUrl
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope.coroutineContext
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
|
@ -174,7 +182,7 @@ class APIRepository(val api: MainAPI) {
|
|||
data: String,
|
||||
isCasting: Boolean,
|
||||
subtitleCallback: (SubtitleFile) -> Unit,
|
||||
callback: (ExtractorLink) -> Unit
|
||||
callback: (ExtractorLink) -> Unit,
|
||||
): Boolean {
|
||||
if (isInvalidData(data)) return false // this makes providers cleaner
|
||||
return try {
|
||||
|
|
|
@ -25,6 +25,7 @@ import com.lagradost.cloudstream3.mvvm.logError
|
|||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||
import com.lagradost.cloudstream3.sortSubs
|
||||
import com.lagradost.cloudstream3.sortUrls
|
||||
import com.lagradost.cloudstream3.ui.player.LoadType
|
||||
import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator
|
||||
import com.lagradost.cloudstream3.ui.player.SubtitleData
|
||||
import com.lagradost.cloudstream3.ui.result.ResultEpisode
|
||||
|
@ -294,7 +295,8 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi
|
|||
val generator = RepoLinkGenerator(listOf(epData))
|
||||
|
||||
val isSuccessful = safeApiCall {
|
||||
generator.generateLinks(clearCache = false, isCasting = true,
|
||||
generator.generateLinks(
|
||||
clearCache = false, type = LoadType.Chromecast,
|
||||
callback = {
|
||||
it.first?.let { link ->
|
||||
currentLinks.add(link)
|
||||
|
|
|
@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui
|
|||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.abs
|
||||
|
@ -24,7 +25,7 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onRequestChildFocus(
|
||||
/*override fun onRequestChildFocus(
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State,
|
||||
child: View,
|
||||
|
@ -32,13 +33,17 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
|
|||
): Boolean {
|
||||
// android.widget.FrameLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams
|
||||
return try {
|
||||
val pos = maxOf(0, getPosition(focused!!) - 2)
|
||||
parent.scrollToPosition(pos)
|
||||
if(focused != null) {
|
||||
// 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)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
// Allows moving right and left with focus https://gist.github.com/vganin/8930b41f55820ec49e4d
|
||||
override fun onInterceptFocusSearch(focused: View, direction: Int): View? {
|
||||
|
@ -65,32 +70,47 @@ class GrdLayoutManager(val context: Context, _spanCount: Int) :
|
|||
val spanCount = this.spanCount
|
||||
val orientation = this.orientation
|
||||
|
||||
if (orientation == VERTICAL) {
|
||||
// fixes arabic by inverting left and right layout focus
|
||||
val correctDirection = if (this.isLayoutRTL) {
|
||||
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 -> {
|
||||
return spanCount
|
||||
}
|
||||
|
||||
View.FOCUS_UP -> {
|
||||
return -spanCount
|
||||
}
|
||||
|
||||
View.FOCUS_RIGHT -> {
|
||||
return 1
|
||||
}
|
||||
|
||||
View.FOCUS_LEFT -> {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
} else if (orientation == HORIZONTAL) {
|
||||
when (direction) {
|
||||
when (correctDirection) {
|
||||
View.FOCUS_DOWN -> {
|
||||
return 1
|
||||
}
|
||||
|
||||
View.FOCUS_UP -> {
|
||||
return -1
|
||||
}
|
||||
|
||||
View.FOCUS_RIGHT -> {
|
||||
return spanCount
|
||||
}
|
||||
|
||||
View.FOCUS_LEFT -> {
|
||||
return -spanCount
|
||||
}
|
||||
|
@ -142,4 +162,32 @@ class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: Att
|
|||
|
||||
layoutManager = manager
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recyclerview wherein the max item width or height is set by the biggest view to prevent inconsistent view sizes.
|
||||
*/
|
||||
class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) {
|
||||
private var biggestObserved: Int = 0
|
||||
private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation
|
||||
private val isHorizontal = orientation == HORIZONTAL
|
||||
private fun View.updateMaxSize() {
|
||||
if (isHorizontal) {
|
||||
this.minimumHeight = biggestObserved
|
||||
} else {
|
||||
this.minimumWidth = biggestObserved
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChildAttachedToWindow(child: View) {
|
||||
child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED)
|
||||
val observed = if (isHorizontal) child.measuredHeight else child.measuredWidth
|
||||
if (observed > biggestObserved) {
|
||||
biggestObserved = observed
|
||||
children.forEach { it.updateMaxSize() }
|
||||
} else {
|
||||
child.updateMaxSize()
|
||||
}
|
||||
super.onChildAttachedToWindow(child)
|
||||
}
|
||||
}
|
|
@ -16,14 +16,16 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.view.isVisible
|
||||
import com.lagradost.cloudstream3.R
|
||||
import kotlinx.android.synthetic.main.activity_easter_egg_monke.*
|
||||
import java.util.*
|
||||
import com.lagradost.cloudstream3.databinding.ActivityEasterEggMonkeBinding
|
||||
|
||||
class EasterEggMonke : AppCompatActivity() {
|
||||
|
||||
lateinit var binding : ActivityEasterEggMonkeBinding
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_easter_egg_monke)
|
||||
|
||||
binding = ActivityEasterEggMonkeBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val handler = Handler(mainLooper)
|
||||
lateinit var runnable: Runnable
|
||||
|
@ -32,15 +34,14 @@ class EasterEggMonke : AppCompatActivity() {
|
|||
handler.postDelayed(runnable, 300)
|
||||
}
|
||||
handler.postDelayed(runnable, 1000)
|
||||
|
||||
}
|
||||
|
||||
private fun shower() {
|
||||
|
||||
val containerW = frame.width
|
||||
val containerH = frame.height
|
||||
var starW: Float = monke.width.toFloat()
|
||||
var starH: Float = monke.height.toFloat()
|
||||
val containerW = binding.frame.width
|
||||
val containerH = binding.frame.height
|
||||
var starW: Float = binding.monke.width.toFloat()
|
||||
var starH: Float = binding.monke.height.toFloat()
|
||||
|
||||
val newStar = AppCompatImageView(this)
|
||||
val idx = (monkeys.size * Math.random()).toInt()
|
||||
|
@ -48,7 +49,7 @@ class EasterEggMonke : AppCompatActivity() {
|
|||
newStar.isVisible = true
|
||||
newStar.layoutParams = FrameLayout.LayoutParams(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.scaleY = newStar.scaleX
|
||||
|
@ -70,7 +71,7 @@ class EasterEggMonke : AppCompatActivity() {
|
|||
|
||||
set.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
frame.removeView(newStar)
|
||||
binding.frame.removeView(newStar)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -12,20 +12,23 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.lagradost.cloudstream3.MainActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding
|
||||
import com.lagradost.cloudstream3.network.WebViewResolver
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.loadRepository
|
||||
import kotlinx.android.synthetic.main.fragment_webview.*
|
||||
|
||||
|
||||
class WebviewFragment : Fragment() {
|
||||
|
||||
var binding: FragmentWebviewBinding? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val url = arguments?.getString(WEBVIEW_URL) ?: "".also {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
web_view.webViewClient = object : WebViewClient() {
|
||||
binding?.webView?.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
|
@ -40,24 +43,28 @@ class WebviewFragment : Fragment() {
|
|||
return super.shouldOverrideUrlLoading(view, request)
|
||||
}
|
||||
}
|
||||
binding?.webView?.apply {
|
||||
WebViewResolver.webViewUserAgent = settings.userAgentString
|
||||
|
||||
WebViewResolver.webViewUserAgent = web_view.settings.userAgentString
|
||||
|
||||
web_view.addJavascriptInterface(RepoApi(activity), "RepoApi")
|
||||
web_view.settings.javaScriptEnabled = true
|
||||
web_view.settings.userAgentString = USER_AGENT
|
||||
web_view.settings.domStorageEnabled = true
|
||||
addJavascriptInterface(RepoApi(activity), "RepoApi")
|
||||
settings.javaScriptEnabled = true
|
||||
settings.userAgentString = USER_AGENT
|
||||
settings.domStorageEnabled = true
|
||||
// WebView.setWebContentsDebuggingEnabled(true)
|
||||
|
||||
web_view.loadUrl(url)
|
||||
loadUrl(url)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
): View {
|
||||
val localBinding = FragmentWebviewBinding.inflate(inflater, container, false)
|
||||
binding = localBinding
|
||||
// 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 {
|
||||
|
@ -70,7 +77,7 @@ class WebviewFragment : Fragment() {
|
|||
|
||||
private class RepoApi(val activity: FragmentActivity?) {
|
||||
@JavascriptInterface
|
||||
fun installRepo(repoUrl: String) {
|
||||
fun installRepo(repoUrl: String) {
|
||||
activity?.loadRepository(repoUrl)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import android.content.DialogInterface
|
|||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.CommonActivity.activity
|
||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
|
@ -19,7 +20,7 @@ import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
|||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
|
||||
object DownloadButtonSetup {
|
||||
fun handleDownloadClick(activity: Activity?, click: DownloadClickEvent) {
|
||||
fun handleDownloadClick(click: DownloadClickEvent) {
|
||||
val id = click.data.id
|
||||
if (click.data !is VideoDownloadHelper.DownloadEpisodeCached) return
|
||||
when (click.action) {
|
||||
|
@ -89,9 +90,9 @@ object DownloadButtonSetup {
|
|||
)?.fileLength
|
||||
?: 0
|
||||
if (length > 0) {
|
||||
showToast(act, R.string.delete, Toast.LENGTH_LONG)
|
||||
showToast(R.string.delete, Toast.LENGTH_LONG)
|
||||
} else {
|
||||
showToast(act, R.string.download, Toast.LENGTH_LONG)
|
||||
showToast(R.string.download, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
package com.lagradost.cloudstream3.ui.download
|
||||
|
||||
interface DownloadButtonViewHolder {
|
||||
var downloadButton : EasyDownloadButton
|
||||
fun reattachDownloadButton()
|
||||
}
|
|
@ -3,18 +3,12 @@ package com.lagradost.cloudstream3.ui.download
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
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 com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.getNameFull
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import kotlinx.android.synthetic.main.download_child_episode.view.*
|
||||
import java.util.*
|
||||
|
||||
const val DOWNLOAD_ACTION_PLAY_FILE = 0
|
||||
const val DOWNLOAD_ACTION_DELETE_FILE = 1
|
||||
|
@ -29,46 +23,16 @@ data class VisualDownloadChildCached(
|
|||
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(
|
||||
var cardList: List<VisualDownloadChildCached>,
|
||||
private val clickCallback: (DownloadClickEvent) -> Unit,
|
||||
) : 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 {
|
||||
return DownloadChildViewHolder(
|
||||
LayoutInflater.from(parent.context).inflate(R.layout.download_child_episode, parent, false),
|
||||
DownloadChildEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false),
|
||||
clickCallback
|
||||
)
|
||||
}
|
||||
|
@ -77,7 +41,6 @@ class DownloadChildAdapter(
|
|||
when (holder) {
|
||||
is DownloadChildViewHolder -> {
|
||||
holder.bind(cardList[position])
|
||||
mBoundViewHolders.add(holder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -88,66 +51,44 @@ class DownloadChildAdapter(
|
|||
|
||||
class DownloadChildViewHolder
|
||||
constructor(
|
||||
itemView: View,
|
||||
val binding: DownloadChildEpisodeBinding,
|
||||
private val clickCallback: (DownloadClickEvent) -> Unit,
|
||||
) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder {
|
||||
override var downloadButton = EasyDownloadButton()
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
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 holder: CardView = itemView.download_child_episode_holder
|
||||
private val progressBar: ContentLoadingProgressBar = itemView.download_child_episode_progress
|
||||
private val progressBarDownload: ContentLoadingProgressBar = itemView.download_child_episode_progress_downloaded
|
||||
private val downloadImage: ImageView = itemView.download_child_episode_download
|
||||
private val downloadImage: ImageView = itemView.download_child_episode_download*/
|
||||
|
||||
private var localCard: VisualDownloadChildCached? = null
|
||||
|
||||
fun bind(card: VisualDownloadChildCached) {
|
||||
localCard = card
|
||||
val d = card.data
|
||||
|
||||
val posDur = getViewPos(d.id)
|
||||
if (posDur != null) {
|
||||
val visualPos = posDur.fixVisual()
|
||||
progressBar.max = (visualPos.duration / 1000).toInt()
|
||||
progressBar.progress = (visualPos.position / 1000).toInt()
|
||||
progressBar.visibility = View.VISIBLE
|
||||
} else {
|
||||
progressBar.visibility = View.GONE
|
||||
binding.downloadChildEpisodeProgress.apply {
|
||||
if (posDur != null) {
|
||||
val visualPos = posDur.fixVisual()
|
||||
max = (visualPos.duration / 1000).toInt()
|
||||
progress = (visualPos.position / 1000).toInt()
|
||||
visibility = View.VISIBLE
|
||||
} else {
|
||||
visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
title.text = title.context.getNameFull(d.name, d.episode, d.season)
|
||||
title.isSelected = true // is needed for text repeating
|
||||
binding.downloadButton.setDefaultClickListener(card.data, binding.downloadChildEpisodeTextExtra, clickCallback)
|
||||
|
||||
downloadButton.setUpButton(
|
||||
card.currentBytes,
|
||||
card.totalBytes,
|
||||
progressBarDownload,
|
||||
downloadImage,
|
||||
extraInfo,
|
||||
card.data,
|
||||
clickCallback
|
||||
)
|
||||
binding.downloadChildEpisodeText.apply {
|
||||
text = context.getNameFull(d.name, d.episode, d.season)
|
||||
isSelected = true // is needed for text repeating
|
||||
}
|
||||
|
||||
holder.setOnClickListener {
|
||||
|
||||
binding.downloadChildEpisodeHolder.setOnClickListener {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,23 +5,24 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding
|
||||
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.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import kotlinx.android.synthetic.main.fragment_child_downloads.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class DownloadChildFragment : Fragment() {
|
||||
companion object {
|
||||
fun newInstance(headerName: String, folder: String) : Bundle {
|
||||
fun newInstance(headerName: String, folder: String): Bundle {
|
||||
return Bundle().apply {
|
||||
putString("folder", folder)
|
||||
putString("name", headerName)
|
||||
|
@ -30,13 +31,20 @@ class DownloadChildFragment : Fragment() {
|
|||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
(download_child_list?.adapter as DownloadChildAdapter?)?.killAdapter()
|
||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it }
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment_child_downloads, container, false)
|
||||
var binding: FragmentChildDownloadsBinding? = null
|
||||
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 {
|
||||
|
@ -50,14 +58,15 @@ class DownloadChildFragment : Fragment() {
|
|||
?: return@mapNotNull null
|
||||
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()) {
|
||||
activity?.onBackPressed()
|
||||
return@main
|
||||
}
|
||||
|
||||
(download_child_list?.adapter as DownloadChildAdapter? ?: return@main).cardList = eps
|
||||
download_child_list?.adapter?.notifyDataSetChanged()
|
||||
(binding?.downloadChildList?.adapter as DownloadChildAdapter? ?: return@main).cardList =
|
||||
eps
|
||||
binding?.downloadChildList?.adapter?.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,23 +81,26 @@ class DownloadChildFragment : Fragment() {
|
|||
activity?.onBackPressed() // TODO FIX
|
||||
return
|
||||
}
|
||||
context?.fixPaddingStatusbar(download_child_root)
|
||||
fixPaddingStatusbar(binding?.downloadChildRoot)
|
||||
|
||||
download_child_toolbar.title = name
|
||||
download_child_toolbar.setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
|
||||
download_child_toolbar.setNavigationOnClickListener {
|
||||
activity?.onBackPressed()
|
||||
binding?.downloadChildToolbar?.apply {
|
||||
title = name
|
||||
setNavigationIcon(R.drawable.ic_baseline_arrow_back_24)
|
||||
setNavigationOnClickListener {
|
||||
activity?.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
|
||||
DownloadChildAdapter(
|
||||
ArrayList(),
|
||||
) { click ->
|
||||
handleDownloadClick(activity, click)
|
||||
handleDownloadClick(click)
|
||||
}
|
||||
|
||||
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.any { it.data.id == id }) {
|
||||
updateList(folder)
|
||||
|
@ -98,8 +110,12 @@ class DownloadChildFragment : Fragment() {
|
|||
|
||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
|
||||
|
||||
download_child_list.adapter = adapter
|
||||
download_child_list.layoutManager = GridLayoutManager(context, 1)
|
||||
binding?.downloadChildList?.adapter = adapter
|
||||
binding?.downloadChildList?.setLinearListLayout(
|
||||
isHorizontal = false,
|
||||
nextDown = FOCUS_SELF,
|
||||
nextRight = FOCUS_SELF
|
||||
)//layoutManager = GridLayoutManager(context, 1)
|
||||
|
||||
updateList(folder)
|
||||
}
|
||||
|
|
|
@ -34,12 +34,14 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
|||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||
import kotlinx.android.synthetic.main.fragment_downloads.*
|
||||
import kotlinx.android.synthetic.main.stream_input.*
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding
|
||||
import com.lagradost.cloudstream3.databinding.StreamInputBinding
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.ui.player.BasicLink
|
||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||
import com.lagradost.cloudstream3.ui.result.setLinearListLayout
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTrueTvSettings
|
||||
import java.net.URI
|
||||
|
||||
|
@ -60,8 +62,8 @@ class DownloadFragment : Fragment() {
|
|||
|
||||
private fun setList(list: List<VisualDownloadHeaderCached>) {
|
||||
main {
|
||||
(download_list?.adapter as DownloadHeaderAdapter?)?.cardList = list
|
||||
download_list?.adapter?.notifyDataSetChanged()
|
||||
(binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList = list
|
||||
binding?.downloadList?.adapter?.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,10 +72,12 @@ class DownloadFragment : Fragment() {
|
|||
VideoDownloadManager.downloadDeleteEvent -= downloadDeleteEventListener!!
|
||||
downloadDeleteEventListener = null
|
||||
}
|
||||
(download_list?.adapter as DownloadHeaderAdapter?)?.killAdapter()
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
var binding: FragmentDownloadsBinding? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
@ -82,7 +86,9 @@ class DownloadFragment : Fragment() {
|
|||
downloadsViewModel =
|
||||
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
|
||||
|
@ -92,36 +98,40 @@ class DownloadFragment : Fragment() {
|
|||
hideKeyboard()
|
||||
|
||||
observe(downloadsViewModel.noDownloadsText) {
|
||||
text_no_downloads.text = it
|
||||
binding?.textNoDownloads?.text = it
|
||||
}
|
||||
observe(downloadsViewModel.headerCards) {
|
||||
setList(it)
|
||||
download_loading.isVisible = false
|
||||
binding?.downloadLoading?.isVisible = false
|
||||
}
|
||||
observe(downloadsViewModel.availableBytes) {
|
||||
download_free_txt?.text =
|
||||
binding?.downloadFreeTxt?.text =
|
||||
getString(R.string.storage_size_format).format(
|
||||
getString(R.string.free_storage),
|
||||
formatShortFileSize(view.context, it)
|
||||
)
|
||||
download_free?.setLayoutWidth(it)
|
||||
binding?.downloadFree?.setLayoutWidth(it)
|
||||
}
|
||||
observe(downloadsViewModel.usedBytes) {
|
||||
download_used_txt?.text =
|
||||
getString(R.string.storage_size_format).format(
|
||||
getString(R.string.used_storage),
|
||||
formatShortFileSize(view.context, it)
|
||||
)
|
||||
download_used?.setLayoutWidth(it)
|
||||
download_storage_appbar?.isVisible = it > 0
|
||||
binding?.apply {
|
||||
downloadUsedTxt.text =
|
||||
getString(R.string.storage_size_format).format(
|
||||
getString(R.string.used_storage),
|
||||
formatShortFileSize(view.context, it)
|
||||
)
|
||||
downloadUsed.setLayoutWidth(it)
|
||||
downloadStorageAppbar.isVisible = it > 0
|
||||
}
|
||||
}
|
||||
observe(downloadsViewModel.downloadBytes) {
|
||||
download_app_txt?.text =
|
||||
getString(R.string.storage_size_format).format(
|
||||
getString(R.string.app_storage),
|
||||
formatShortFileSize(view.context, it)
|
||||
)
|
||||
download_app?.setLayoutWidth(it)
|
||||
binding?.apply {
|
||||
downloadAppTxt.text =
|
||||
getString(R.string.storage_size_format).format(
|
||||
getString(R.string.app_storage),
|
||||
formatShortFileSize(view.context, it)
|
||||
)
|
||||
downloadApp.setLayoutWidth(it)
|
||||
}
|
||||
}
|
||||
|
||||
val adapter: RecyclerView.Adapter<RecyclerView.ViewHolder> =
|
||||
|
@ -143,6 +153,7 @@ class DownloadFragment : Fragment() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
1 -> {
|
||||
(activity as AppCompatActivity?)?.loadResult(
|
||||
click.data.url,
|
||||
|
@ -154,7 +165,7 @@ class DownloadFragment : Fragment() {
|
|||
},
|
||||
{ downloadClickEvent ->
|
||||
if (downloadClickEvent.data !is VideoDownloadHelper.DownloadEpisodeCached) return@DownloadHeaderAdapter
|
||||
handleDownloadClick(activity, downloadClickEvent)
|
||||
handleDownloadClick(downloadClickEvent)
|
||||
if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) {
|
||||
context?.let { ctx ->
|
||||
downloadsViewModel.updateList(ctx)
|
||||
|
@ -164,7 +175,7 @@ class DownloadFragment : Fragment() {
|
|||
)
|
||||
|
||||
downloadDeleteEventListener = { id ->
|
||||
val list = (download_list?.adapter as DownloadHeaderAdapter?)?.cardList
|
||||
val list = (binding?.downloadList?.adapter as DownloadHeaderAdapter?)?.cardList
|
||||
if (list != null) {
|
||||
if (list.any { it.data.id == id }) {
|
||||
context?.let { ctx ->
|
||||
|
@ -177,31 +188,42 @@ class DownloadFragment : Fragment() {
|
|||
|
||||
downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it }
|
||||
|
||||
download_list?.adapter = adapter
|
||||
download_list?.layoutManager = GridLayoutManager(context, 1)
|
||||
binding?.downloadList?.apply {
|
||||
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
|
||||
download_stream_button?.isGone = isTrueTvSettings()
|
||||
download_stream_button?.setOnClickListener {
|
||||
binding?.downloadStreamButton?.isGone = isTrueTvSettings()
|
||||
binding?.downloadStreamButton?.setOnClickListener {
|
||||
val dialog =
|
||||
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()
|
||||
|
||||
// If user has clicked the switch do not interfere
|
||||
var preventAutoSwitching = false
|
||||
dialog.hls_switch?.setOnClickListener {
|
||||
binding.hlsSwitch.setOnClickListener {
|
||||
preventAutoSwitching = true
|
||||
}
|
||||
|
||||
fun activateSwitchOnHls(text: String?) {
|
||||
dialog.hls_switch?.isChecked = normalSafeApiCall {
|
||||
binding.hlsSwitch.isChecked = normalSafeApiCall {
|
||||
URI(text).path?.substringAfterLast(".")?.contains("m3u")
|
||||
} == true
|
||||
}
|
||||
|
||||
dialog.stream_referer?.doOnTextChanged { text, _, _, _ ->
|
||||
binding.streamReferer.doOnTextChanged { text, _, _, _ ->
|
||||
if (!preventAutoSwitching)
|
||||
activateSwitchOnHls(text?.toString())
|
||||
}
|
||||
|
@ -210,16 +232,16 @@ class DownloadFragment : Fragment() {
|
|||
0
|
||||
)?.text?.toString()?.let { copy ->
|
||||
val fixedText = copy.trim()
|
||||
dialog.stream_url?.setText(fixedText)
|
||||
binding.streamUrl.setText(fixedText)
|
||||
activateSwitchOnHls(fixedText)
|
||||
}
|
||||
|
||||
dialog.apply_btt?.setOnClickListener {
|
||||
val url = dialog.stream_url.text?.toString()
|
||||
binding.applyBtt.setOnClickListener {
|
||||
val url = binding.streamUrl.text?.toString()
|
||||
if (url.isNullOrEmpty()) {
|
||||
showToast(activity, R.string.error_invalid_url, Toast.LENGTH_SHORT)
|
||||
showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
|
||||
} else {
|
||||
val referer = dialog.stream_referer.text?.toString()
|
||||
val referer = binding.streamReferer.text?.toString()
|
||||
|
||||
activity?.navigate(
|
||||
R.id.global_to_navigation_player,
|
||||
|
@ -228,7 +250,7 @@ class DownloadFragment : Fragment() {
|
|||
listOf(BasicLink(url)),
|
||||
extract = true,
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
download_list?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||
binding?.downloadList?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||
val dy = scrollY - oldScrollY
|
||||
if (dy > 0) { //check for scroll down
|
||||
download_stream_button?.shrink() // hide
|
||||
binding?.downloadStreamButton?.shrink() // hide
|
||||
} else if (dy < -5) {
|
||||
download_stream_button?.extend() // show
|
||||
binding?.downloadStreamButton?.extend() // show
|
||||
}
|
||||
}
|
||||
}
|
||||
downloadsViewModel.updateList(requireContext())
|
||||
|
||||
context?.fixPaddingStatusbar(download_root)
|
||||
fixPaddingStatusbar(binding?.downloadRoot)
|
||||
}
|
||||
}
|
|
@ -5,16 +5,13 @@ import android.text.format.Formatter.formatShortFileSize
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.widget.ContentLoadingProgressBar
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import kotlinx.android.synthetic.main.download_header_episode.view.*
|
||||
import java.util.*
|
||||
|
||||
data class VisualDownloadHeaderCached(
|
||||
|
@ -26,7 +23,10 @@ data class VisualDownloadHeaderCached(
|
|||
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(
|
||||
var cardList: List<VisualDownloadHeaderCached>,
|
||||
|
@ -34,39 +34,13 @@ class DownloadHeaderAdapter(
|
|||
private val movieClickCallback: (DownloadClickEvent) -> Unit,
|
||||
) : 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 {
|
||||
return DownloadHeaderViewHolder(
|
||||
LayoutInflater.from(parent.context).inflate(R.layout.download_header_episode, parent, false),
|
||||
DownloadHeaderEpisodeBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
),
|
||||
clickCallback,
|
||||
movieClickCallback
|
||||
)
|
||||
|
@ -76,7 +50,6 @@ class DownloadHeaderAdapter(
|
|||
when (holder) {
|
||||
is DownloadHeaderViewHolder -> {
|
||||
holder.bind(cardList[position])
|
||||
mBoundViewHolders.add(holder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -87,93 +60,89 @@ class DownloadHeaderAdapter(
|
|||
|
||||
class DownloadHeaderViewHolder
|
||||
constructor(
|
||||
itemView: View,
|
||||
val binding: DownloadHeaderEpisodeBinding,
|
||||
private val clickCallback: (DownloadHeaderClickEvent) -> Unit,
|
||||
private val movieClickCallback: (DownloadClickEvent) -> Unit,
|
||||
) : RecyclerView.ViewHolder(itemView), DownloadButtonViewHolder {
|
||||
override var downloadButton = EasyDownloadButton()
|
||||
) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
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 extraInfo: TextView = itemView.download_header_info
|
||||
private val holder: CardView = itemView.episode_holder
|
||||
|
||||
private val downloadBar: ContentLoadingProgressBar = itemView.download_header_progress_downloaded
|
||||
private val downloadImage: ImageView = itemView.download_header_episode_download
|
||||
private val normalImage: ImageView = itemView.download_header_goto_child
|
||||
private var localCard: VisualDownloadHeaderCached? = null
|
||||
private val normalImage: ImageView = itemView.download_header_goto_child*/
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun bind(card: VisualDownloadHeaderCached) {
|
||||
localCard = card
|
||||
val d = card.data
|
||||
|
||||
poster?.setImage(d.poster)
|
||||
poster?.setOnClickListener {
|
||||
clickCallback.invoke(DownloadHeaderClickEvent(1, d))
|
||||
binding.downloadHeaderPoster.apply {
|
||||
setImage(d.poster)
|
||||
setOnClickListener {
|
||||
clickCallback.invoke(DownloadHeaderClickEvent(1, d))
|
||||
}
|
||||
}
|
||||
|
||||
title.text = d.name
|
||||
val mbString = formatShortFileSize(itemView.context, card.totalBytes)
|
||||
binding.apply {
|
||||
|
||||
//val isMovie = d.type.isMovieType()
|
||||
if (card.child != null) {
|
||||
downloadBar.visibility = View.VISIBLE
|
||||
downloadImage.visibility = View.VISIBLE
|
||||
normalImage.visibility = View.GONE
|
||||
/*setUpButton(
|
||||
card.currentBytes,
|
||||
card.totalBytes,
|
||||
downloadBar,
|
||||
downloadImage,
|
||||
extraInfo,
|
||||
card.child,
|
||||
movieClickCallback
|
||||
)*/
|
||||
binding.downloadHeaderTitle.text = d.name
|
||||
val mbString = formatShortFileSize(itemView.context, card.totalBytes)
|
||||
|
||||
holder.setOnClickListener {
|
||||
movieClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, card.child))
|
||||
}
|
||||
} else {
|
||||
downloadBar.visibility = View.GONE
|
||||
downloadImage.visibility = View.GONE
|
||||
normalImage.visibility = View.VISIBLE
|
||||
//val isMovie = d.type.isMovieType()
|
||||
if (card.child != null) {
|
||||
//downloadHeaderProgressDownloaded.visibility = View.VISIBLE
|
||||
|
||||
try {
|
||||
extraInfo.text =
|
||||
extraInfo.context.getString(R.string.extra_info_format).format(
|
||||
card.totalDownloads,
|
||||
if (card.totalDownloads == 1) extraInfo.context.getString(R.string.episode) else extraInfo.context.getString(
|
||||
R.string.episodes
|
||||
),
|
||||
mbString
|
||||
// downloadHeaderEpisodeDownload.visibility = View.VISIBLE
|
||||
binding.downloadHeaderGotoChild.visibility = View.GONE
|
||||
|
||||
downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, movieClickCallback)
|
||||
downloadButton.isVisible = true
|
||||
/*setUpButton(
|
||||
card.currentBytes,
|
||||
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
|
||||
extraInfo.text = "Error"
|
||||
logError(t)
|
||||
}
|
||||
} else {
|
||||
downloadButton.isVisible = false
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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}%"
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -1,22 +1,23 @@
|
|||
package com.lagradost.cloudstream3.ui.home
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.lagradost.cloudstream3.R
|
||||
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.SearchResultBuilder
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.isRtl
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout
|
||||
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(
|
||||
val cardList: MutableList<SearchResponse>,
|
||||
private val overrideLayout: Int? = null,
|
||||
|
||||
private val nextFocusUp: Int? = null,
|
||||
private val nextFocusDown: Int? = null,
|
||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||
|
@ -26,16 +27,28 @@ class HomeChildItemAdapter(
|
|||
var hasNext: Boolean = false
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val layout = overrideLayout
|
||||
?: if (parent.context.IsBottomLayout()) R.layout.home_result_grid_expanded else R.layout.home_result_grid
|
||||
val expanded = parent.context.IsBottomLayout()
|
||||
/* 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(
|
||||
LayoutInflater.from(parent.context).inflate(layout, parent, false),
|
||||
binding,
|
||||
clickCallback,
|
||||
itemCount,
|
||||
nextFocusUp,
|
||||
nextFocusDown,
|
||||
isHorizontal
|
||||
isHorizontal,
|
||||
parent.isRtl()
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -69,58 +82,101 @@ class HomeChildItemAdapter(
|
|||
|
||||
class CardViewHolder
|
||||
constructor(
|
||||
itemView: View,
|
||||
val binding: ViewBinding,
|
||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||
var itemCount: Int,
|
||||
private val nextFocusUp: 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) {
|
||||
|
||||
// TV focus fixing
|
||||
val nextFocusBehavior = when (position) {
|
||||
/*val nextFocusBehavior = when (position) {
|
||||
0 -> true
|
||||
itemCount - 1 -> false
|
||||
else -> null
|
||||
}
|
||||
|
||||
(itemView.image_holder ?: itemView.background_card)?.apply {
|
||||
val min = 114.toPx
|
||||
val max = 180.toPx
|
||||
if (position == 0) { // to fix tv
|
||||
if (isRtl) {
|
||||
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 {
|
||||
width = if (!isHorizontal) {
|
||||
min
|
||||
} else {
|
||||
max
|
||||
}
|
||||
height = if (!isHorizontal) {
|
||||
max
|
||||
} else {
|
||||
min
|
||||
}
|
||||
|
||||
when (binding) {
|
||||
is HomeResultGridBinding -> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
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(
|
||||
clickCallback,
|
||||
card,
|
||||
position,
|
||||
itemView,
|
||||
nextFocusBehavior,
|
||||
null, // nextFocusBehavior,
|
||||
nextFocusUp,
|
||||
nextFocusDown
|
||||
)
|
||||
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)
|
||||
//ani.fillAfter = true
|
||||
//ani.duration = 200
|
||||
|
|
|
@ -31,18 +31,24 @@ import com.lagradost.cloudstream3.APIHolder.apis
|
|||
import com.lagradost.cloudstream3.APIHolder.filterProviderByPreferredMedia
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
|
||||
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.afterPluginsLoadedEvent
|
||||
import com.lagradost.cloudstream3.MainActivity.Companion.bookmarksUpdatedEvent
|
||||
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.logError
|
||||
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.randomApi
|
||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||
import com.lagradost.cloudstream3.ui.WatchType
|
||||
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.SearchHelper.handleSearchClickCallback
|
||||
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.getSpanCount
|
||||
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.*
|
||||
|
||||
|
||||
|
@ -126,22 +114,26 @@ class HomeFragment : Fragment() {
|
|||
expand: HomeViewModel.ExpandableHomepageList,
|
||||
deleteCallback: (() -> Unit)? = null,
|
||||
expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null,
|
||||
dismissCallback : (() -> Unit),
|
||||
dismissCallback: (() -> Unit),
|
||||
): BottomSheetDialog {
|
||||
val context = this
|
||||
val bottomSheetDialogBuilder = BottomSheetDialog(context)
|
||||
|
||||
bottomSheetDialogBuilder.setContentView(R.layout.home_episodes_expanded)
|
||||
val title = bottomSheetDialogBuilder.findViewById<TextView>(R.id.home_expanded_text)!!
|
||||
val binding: HomeEpisodesExpandedBinding = HomeEpisodesExpandedBinding.inflate(
|
||||
bottomSheetDialogBuilder.layoutInflater,
|
||||
null,
|
||||
false
|
||||
)
|
||||
bottomSheetDialogBuilder.setContentView(binding.root)
|
||||
//val title = bottomSheetDialogBuilder.findViewById<TextView>(R.id.home_expanded_text)!!
|
||||
|
||||
//title.findViewTreeLifecycleOwner().lifecycle.addObserver()
|
||||
|
||||
val item = expand.list
|
||||
title.text = item.name
|
||||
val recycle =
|
||||
bottomSheetDialogBuilder.findViewById<AutofitRecyclerView>(R.id.home_expanded_recycler)!!
|
||||
val titleHolder =
|
||||
bottomSheetDialogBuilder.findViewById<FrameLayout>(R.id.home_expanded_drag_down)!!
|
||||
binding.homeExpandedText.text = item.name
|
||||
// val recycle =
|
||||
// bottomSheetDialogBuilder.findViewById<AutofitRecyclerView>(R.id.home_expanded_recycler)!!
|
||||
//val titleHolder =
|
||||
// bottomSheetDialogBuilder.findViewById<FrameLayout>(R.id.home_expanded_drag_down)!!
|
||||
|
||||
// main {
|
||||
//(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
|
||||
delete.isGone = deleteCallback == null
|
||||
//val delete = bottomSheetDialogBuilder.home_expanded_delete
|
||||
binding.homeExpandedDelete.isGone = deleteCallback == null
|
||||
if (deleteCallback != null) {
|
||||
delete.setOnClickListener {
|
||||
binding.homeExpandedDelete.setOnClickListener {
|
||||
try {
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(context)
|
||||
val dialogClickListener =
|
||||
|
@ -173,6 +165,7 @@ class HomeFragment : Fragment() {
|
|||
deleteCallback.invoke()
|
||||
bottomSheetDialogBuilder.dismissSafe(this)
|
||||
}
|
||||
|
||||
DialogInterface.BUTTON_NEGATIVE -> {}
|
||||
}
|
||||
}
|
||||
|
@ -192,26 +185,27 @@ class HomeFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
titleHolder.setOnClickListener {
|
||||
binding.homeExpandedDragDown.setOnClickListener {
|
||||
bottomSheetDialogBuilder.dismissSafe(this)
|
||||
}
|
||||
|
||||
|
||||
// Span settings
|
||||
recycle.spanCount = currentSpan
|
||||
binding.homeExpandedRecycler.spanCount = currentSpan
|
||||
|
||||
recycle.adapter = SearchAdapter(item.list.toMutableList(), recycle) { callback ->
|
||||
handleSearchClickCallback(this, callback)
|
||||
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
|
||||
bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later
|
||||
//bottomSheetDialogBuilder.dismissSafe(this)
|
||||
binding.homeExpandedRecycler.adapter =
|
||||
SearchAdapter(item.list.toMutableList(), binding.homeExpandedRecycler) { callback ->
|
||||
handleSearchClickCallback(callback)
|
||||
if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) {
|
||||
bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later
|
||||
//bottomSheetDialogBuilder.dismissSafe(this)
|
||||
}
|
||||
}.apply {
|
||||
hasNext = expand.hasNext
|
||||
}
|
||||
}.apply {
|
||||
hasNext = expand.hasNext
|
||||
}
|
||||
|
||||
recycle.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
binding.homeExpandedRecycler.addOnScrollListener(object :
|
||||
RecyclerView.OnScrollListener() {
|
||||
var expandCount = 0
|
||||
val name = expand.list.name
|
||||
|
||||
|
@ -239,7 +233,7 @@ class HomeFragment : Fragment() {
|
|||
})
|
||||
|
||||
val spanListener = { span: Int ->
|
||||
recycle.spanCount = span
|
||||
binding.homeExpandedRecycler.spanCount = span
|
||||
//(recycle.adapter as SearchAdapter).notifyDataSetChanged()
|
||||
}
|
||||
|
||||
|
@ -281,19 +275,19 @@ class HomeFragment : Fragment() {
|
|||
)
|
||||
}
|
||||
|
||||
private fun getPairList(header: ChipGroup) = getPairList(
|
||||
header.home_select_anime,
|
||||
header.home_select_cartoons,
|
||||
header.home_select_tv_series,
|
||||
header.home_select_documentaries,
|
||||
header.home_select_movies,
|
||||
header.home_select_asian,
|
||||
header.home_select_livestreams,
|
||||
header.home_select_nsfw,
|
||||
header.home_select_others
|
||||
private fun getPairList(header: TvtypesChipsBinding) = getPairList(
|
||||
header.homeSelectAnime,
|
||||
header.homeSelectCartoons,
|
||||
header.homeSelectTvSeries,
|
||||
header.homeSelectDocumentaries,
|
||||
header.homeSelectMovies,
|
||||
header.homeSelectAsian,
|
||||
header.homeSelectLivestreams,
|
||||
header.homeSelectNsfw,
|
||||
header.homeSelectOthers
|
||||
)
|
||||
|
||||
fun validateChips(header: ChipGroup?, validTypes: List<TvType>) {
|
||||
fun validateChips(header: TvtypesChipsBinding?, validTypes: List<TvType>) {
|
||||
if (header == null) return
|
||||
val pairList = getPairList(header)
|
||||
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
|
||||
val pairList = getPairList(header)
|
||||
for ((button, types) in pairList) {
|
||||
|
@ -312,10 +306,21 @@ class HomeFragment : Fragment() {
|
|||
}
|
||||
|
||||
fun bindChips(
|
||||
header: ChipGroup?,
|
||||
header: TvtypesChipsBinding?,
|
||||
selectedTypes: List<TvType>,
|
||||
validTypes: List<TvType>,
|
||||
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
|
||||
val pairList = getPairList(header)
|
||||
|
@ -323,6 +328,17 @@ class HomeFragment : Fragment() {
|
|||
val isValid = validTypes.any { types.contains(it) }
|
||||
button?.isVisible = isValid
|
||||
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 { _, _ ->
|
||||
val list = ArrayList<TvType>()
|
||||
for ((sbutton, vvalidTypes) in pairList) {
|
||||
|
@ -345,7 +361,13 @@ class HomeFragment : Fragment() {
|
|||
BottomSheetDialog(this)
|
||||
|
||||
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.let { dialog ->
|
||||
val isMultiLang = getApiProviderLangSettings().let { set ->
|
||||
|
@ -361,14 +383,11 @@ class HomeFragment : Fragment() {
|
|||
?.toMutableList()
|
||||
?: mutableListOf(TvType.Movie, TvType.TvSeries)
|
||||
|
||||
val cancelBtt = dialog.findViewById<MaterialButton>(R.id.cancel_btt)
|
||||
val applyBtt = dialog.findViewById<MaterialButton>(R.id.apply_btt)
|
||||
|
||||
cancelBtt?.setOnClickListener {
|
||||
binding.cancelBtt.setOnClickListener {
|
||||
dialog.dismissSafe()
|
||||
}
|
||||
|
||||
applyBtt?.setOnClickListener {
|
||||
binding.applyBtt.setOnClickListener {
|
||||
if (currentApiName != selectedApiName) {
|
||||
currentApiName?.let(callback)
|
||||
}
|
||||
|
@ -409,7 +428,7 @@ class HomeFragment : Fragment() {
|
|||
}
|
||||
|
||||
bindChips(
|
||||
dialog.home_select_group,
|
||||
binding.tvtypesChipsScroll.tvtypesChips,
|
||||
preSelectedTypes,
|
||||
validAPIs.flatMap { it.supportedTypes }.distinct()
|
||||
) { list ->
|
||||
|
@ -424,6 +443,9 @@ class HomeFragment : Fragment() {
|
|||
|
||||
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
var binding: FragmentHomeBinding? = null
|
||||
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
@ -431,14 +453,25 @@ class HomeFragment : Fragment() {
|
|||
): View? {
|
||||
//homeViewModel =
|
||||
// ViewModelProvider(this).get(HomeViewModel::class.java)
|
||||
|
||||
bottomSheetDialog?.ownShow()
|
||||
val layout =
|
||||
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() {
|
||||
bottomSheetDialog?.ownHide()
|
||||
binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
|
@ -451,7 +484,7 @@ class HomeFragment : Fragment() {
|
|||
|
||||
private val apiChangeClickListener = View.OnClickListener { view ->
|
||||
view.context.selectHomepage(currentApiName) { api ->
|
||||
homeViewModel.loadAndCancel(api)
|
||||
homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true)
|
||||
}
|
||||
/*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf()
|
||||
|
||||
|
@ -528,138 +561,137 @@ class HomeFragment : Fragment() {
|
|||
|
||||
private var bottomSheetDialog: BottomSheetDialog? = null
|
||||
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
fixGrid()
|
||||
|
||||
home_change_api_loading?.setOnClickListener(apiChangeClickListener)
|
||||
home_api_fab?.setOnClickListener(apiChangeClickListener)
|
||||
home_random?.setOnClickListener {
|
||||
if (listHomepageItems.isNotEmpty()) {
|
||||
activity.loadSearchResult(listHomepageItems.random())
|
||||
binding?.apply {
|
||||
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||
//homeChangeApiLoading.setOnClickListener(apiChangeClickListener)
|
||||
homeApiFab.setOnClickListener(apiChangeClickListener)
|
||||
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
|
||||
context?.let {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it)
|
||||
toggleRandomButton =
|
||||
settingsManager.getBoolean(getString(R.string.random_button_key), false)
|
||||
home_random?.visibility = View.GONE
|
||||
}
|
||||
|
||||
observe(homeViewModel.preview) { preview ->
|
||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setPreviewData(
|
||||
preview
|
||||
)
|
||||
settingsManager.getBoolean(
|
||||
getString(R.string.random_button_key),
|
||||
false
|
||||
) && !isTvSettings()
|
||||
binding?.homeRandom?.visibility = View.GONE
|
||||
}
|
||||
|
||||
observe(homeViewModel.apiName) { apiName ->
|
||||
currentApiName = apiName
|
||||
home_api_fab?.text = apiName
|
||||
(home_master_recycler?.adapter as? HomeParentItemAdapterPreview?)?.setApiName(
|
||||
apiName
|
||||
)
|
||||
binding?.homeApiFab?.text = apiName
|
||||
binding?.homeChangeApi?.text = apiName
|
||||
}
|
||||
|
||||
observe(homeViewModel.page) { data ->
|
||||
when (data) {
|
||||
is Resource.Success -> {
|
||||
home_loading_shimmer?.stopShimmer()
|
||||
binding?.apply {
|
||||
when (data) {
|
||||
is Resource.Success -> {
|
||||
homeLoadingShimmer.stopShimmer()
|
||||
|
||||
val d = data.value
|
||||
val mutableListOfResponse = mutableListOf<SearchResponse>()
|
||||
listHomepageItems.clear()
|
||||
val d = data.value
|
||||
val mutableListOfResponse = mutableListOf<SearchResponse>()
|
||||
listHomepageItems.clear()
|
||||
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter)?.updateList(
|
||||
d.values.toMutableList(),
|
||||
home_master_recycler
|
||||
)
|
||||
(homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList(
|
||||
d.values.toMutableList(),
|
||||
homeMasterRecycler
|
||||
)
|
||||
|
||||
home_loading?.isVisible = false
|
||||
home_loading_error?.isVisible = false
|
||||
home_master_recycler?.isVisible = true
|
||||
//home_loaded?.isVisible = true
|
||||
if (toggleRandomButton) {
|
||||
//Flatten list
|
||||
d.values.forEach { dlist ->
|
||||
mutableListOfResponse.addAll(dlist.list.list)
|
||||
homeLoading.isVisible = false
|
||||
homeLoadingError.isVisible = false
|
||||
homeMasterRecycler.isVisible = true
|
||||
//home_loaded?.isVisible = true
|
||||
if (toggleRandomButton) {
|
||||
//Flatten list
|
||||
d.values.forEach { dlist ->
|
||||
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)
|
||||
|
||||
home_reload_connection_open_in_browser.setOnClickListener { view ->
|
||||
val validAPIs = apis//.filter { api -> api.hasMainPage }
|
||||
|
||||
view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api ->
|
||||
Pair(
|
||||
index,
|
||||
api.name
|
||||
)
|
||||
}) {
|
||||
try {
|
||||
val i = Intent(Intent.ACTION_VIEW)
|
||||
i.data = Uri.parse(validAPIs[itemId].mainUrl)
|
||||
startActivity(i)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api ->
|
||||
Pair(
|
||||
index,
|
||||
api.name
|
||||
)
|
||||
}) {
|
||||
try {
|
||||
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
|
||||
home_loading_error?.isVisible = true
|
||||
home_master_recycler?.isVisible = false
|
||||
//home_loaded?.isVisible = false
|
||||
}
|
||||
is Resource.Loading -> {
|
||||
(home_master_recycler?.adapter as? ParentItemAdapter)?.updateList(listOf())
|
||||
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 })
|
||||
is Resource.Loading -> {
|
||||
(homeMasterRecycler.adapter as? ParentItemAdapter)?.updateList(listOf())
|
||||
homeLoadingShimmer.startShimmer()
|
||||
homeLoading.isVisible = true
|
||||
homeLoadingError.isVisible = false
|
||||
homeMasterRecycler.isVisible = false
|
||||
//home_loaded?.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -668,72 +700,37 @@ class HomeFragment : Fragment() {
|
|||
|
||||
//context?.fixPaddingStatusbarView(home_statusbar)
|
||||
//context?.fixPaddingStatusbar(home_padding)
|
||||
context?.fixPaddingStatusbar(home_loading_statusbar)
|
||||
|
||||
home_master_recycler?.adapter =
|
||||
HomeParentItemAdapterPreview(mutableListOf(), { callback ->
|
||||
homeHandleSearch(callback)
|
||||
}, { item ->
|
||||
bottomSheetDialog = activity?.loadHomepageList(item, expandCallback = {
|
||||
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)
|
||||
observeNullable(homeViewModel.popup) { item ->
|
||||
if (item == null) {
|
||||
bottomSheetDialog?.dismissSafe()
|
||||
bottomSheetDialog = null
|
||||
return@observeNullable
|
||||
}
|
||||
})
|
||||
|
||||
// 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
|
||||
//home_profile_picture_holder?.isVisible = false
|
||||
// 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
|
||||
/*for (syncApi in OAuth2Apis) {
|
||||
val login = syncApi.loginInfo()
|
||||
|
|
|
@ -3,50 +3,20 @@ package com.lagradost.cloudstream3.ui.home
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
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.ListUpdateCallback
|
||||
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.LoadResponse
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
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.databinding.HomepageParentBinding
|
||||
import com.lagradost.cloudstream3.ui.result.FOCUS_SELF
|
||||
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.SearchFragment.Companion.filterSearchResponse
|
||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.isTvSettings
|
||||
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(
|
||||
val action: Int = 0,
|
||||
|
@ -57,17 +27,23 @@ class LoadClickCallback(
|
|||
|
||||
open class ParentItemAdapter(
|
||||
private var items: MutableList<HomeViewModel.ExpandableHomepageList>,
|
||||
//private val viewModel: HomeViewModel,
|
||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
||||
private val expandCallback: ((String) -> Unit)? = null,
|
||||
) : RecyclerView.Adapter<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(
|
||||
LayoutInflater.from(parent.context).inflate(
|
||||
if (isTvSettings()) R.layout.homepage_parent_tv else R.layout.homepage_parent,
|
||||
parent,
|
||||
false
|
||||
),
|
||||
binding,
|
||||
clickCallback,
|
||||
moreInfoClickCallback,
|
||||
expandCallback
|
||||
|
@ -178,15 +154,17 @@ open class ParentItemAdapter(
|
|||
|
||||
class ParentViewHolder
|
||||
constructor(
|
||||
itemView: View,
|
||||
val binding: HomepageParentBinding,
|
||||
// val viewModel: HomeViewModel,
|
||||
private val clickCallback: (SearchClickCallback) -> Unit,
|
||||
private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit,
|
||||
private val expandCallback: ((String) -> Unit)? = null,
|
||||
) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
val title: TextView = itemView.home_child_more_info
|
||||
private val recyclerView: RecyclerView = itemView.home_child_recyclerview
|
||||
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
val title: TextView = binding.homeChildMoreInfo
|
||||
private val recyclerView: RecyclerView = binding.homeChildRecyclerview
|
||||
private val startFocus = R.id.nav_rail_view
|
||||
private val endFocus = FOCUS_SELF
|
||||
fun update(expand: HomeViewModel.ExpandableHomepageList) {
|
||||
val info = expand.list
|
||||
(recyclerView.adapter as? HomeChildItemAdapter?)?.apply {
|
||||
|
@ -200,8 +178,13 @@ open class ParentItemAdapter(
|
|||
nextFocusDown = recyclerView.nextFocusDownId,
|
||||
).apply {
|
||||
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
|
||||
hasNext = expand.hasNext
|
||||
}
|
||||
recyclerView.setLinearListLayout()
|
||||
recyclerView.setLinearListLayout(
|
||||
isHorizontal = true,
|
||||
nextLeft = startFocus,
|
||||
nextRight = endFocus,
|
||||
)
|
||||
title.text = info.name
|
||||
|
||||
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,24 +2,18 @@ package com.lagradost.cloudstream3.ui.home
|
|||
|
||||
import android.content.res.Configuration
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.core.view.isGone
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
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 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(
|
||||
@LayoutRes val layout: Int = R.layout.home_scroll_view,
|
||||
private val forceHorizontalPosters: Boolean? = null
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
class HomeScrollAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private var items: MutableList<LoadResponse> = mutableListOf()
|
||||
var hasMoreItems: Boolean = false
|
||||
|
||||
|
@ -45,9 +39,16 @@ class HomeScrollAdapter(
|
|||
}
|
||||
|
||||
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(
|
||||
LayoutInflater.from(parent.context).inflate(layout, parent, false),
|
||||
forceHorizontalPosters
|
||||
binding,
|
||||
//forceHorizontalPosters
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -61,22 +62,32 @@ class HomeScrollAdapter(
|
|||
|
||||
class CardViewHolder
|
||||
constructor(
|
||||
itemView: View,
|
||||
private val forceHorizontalPosters: Boolean? = null
|
||||
val binding: ViewBinding,
|
||||
//private val forceHorizontalPosters: Boolean? = null
|
||||
) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(card: LoadResponse) {
|
||||
card.apply {
|
||||
val isHorizontal =
|
||||
(forceHorizontalPosters == true) || ((forceHorizontalPosters != false) && itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
|
||||
val isHorizontal =
|
||||
binding is HomeScrollViewTvBinding || itemView.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val posterUrl = if (isHorizontal) backgroundPosterUrl ?: posterUrl else posterUrl
|
||||
?: backgroundPosterUrl
|
||||
itemView.home_scroll_preview_tags?.text = tags?.joinToString(" • ") ?: ""
|
||||
itemView.home_scroll_preview_tags?.isGone = tags.isNullOrEmpty()
|
||||
itemView.home_scroll_preview?.setImage(posterUrl, posterHeaders)
|
||||
itemView.home_scroll_preview_title?.text = name
|
||||
val posterUrl =
|
||||
if (isHorizontal) card.backgroundPosterUrl ?: card.posterUrl else card.posterUrl
|
||||
?: card.backgroundPosterUrl
|
||||
|
||||
when (binding) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
package com.lagradost.cloudstream3.ui.home
|
||||
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.apis
|
||||
import com.lagradost.cloudstream3.APIHolder.filterHomePageListByFilmQuality
|
||||
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.getKey
|
||||
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.Companion.noneApi
|
||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
|
||||
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.DOWNLOAD_HEADER_CACHE
|
||||
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.getAllWatchStateIds
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState
|
||||
import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
|
||||
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.*
|
||||
import java.util.EnumSet
|
||||
import kotlin.collections.set
|
||||
|
||||
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>()
|
||||
val apiName: LiveData<String> = _apiName
|
||||
|
@ -83,7 +119,7 @@ class HomeViewModel : ViewModel() {
|
|||
private var currentShuffledList: List<SearchResponse> = listOf()
|
||||
|
||||
private fun autoloadRepo(): APIRepository {
|
||||
return APIRepository(apis.first { it.hasMainPage })
|
||||
return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } })
|
||||
}
|
||||
|
||||
private val _availableWatchStatusTypes =
|
||||
|
@ -101,8 +137,14 @@ class HomeViewModel : ViewModel() {
|
|||
val resumeWatching: LiveData<List<SearchResponse>> = _resumeWatching
|
||||
val preview: LiveData<Resource<Pair<Boolean, List<LoadResponse>>>> = _preview
|
||||
|
||||
fun loadResumeWatching() = viewModelScope.launchSafe {
|
||||
private fun loadResumeWatching() = viewModelScope.launchSafe {
|
||||
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 {
|
||||
_resumeWatching.postValue(it)
|
||||
}
|
||||
|
@ -128,6 +170,10 @@ class HomeViewModel : ViewModel() {
|
|||
currentWatchTypes.remove(WatchType.NONE)
|
||||
|
||||
if (currentWatchTypes.size <= 0) {
|
||||
setKey(
|
||||
HOME_BOOKMARK_VALUE_LIST,
|
||||
intArrayOf()
|
||||
)
|
||||
_availableWatchStatusTypes.postValue(setOf<WatchType>() to setOf())
|
||||
_bookmarks.postValue(Pair(false, ArrayList()))
|
||||
return@launchSafe
|
||||
|
@ -135,7 +181,10 @@ class HomeViewModel : ViewModel() {
|
|||
|
||||
val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(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(
|
||||
Pair(
|
||||
watchPrefNotNull,
|
||||
|
@ -152,8 +201,11 @@ class HomeViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
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()
|
||||
isCurrentlyLoadingName = api.name
|
||||
onGoingLoad = load(api)
|
||||
}
|
||||
|
||||
|
@ -255,12 +307,12 @@ class HomeViewModel : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun load(api: MainAPI?) = ioSafe {
|
||||
repo = if (api != null) {
|
||||
private fun load(api: MainAPI): Job = ioSafe {
|
||||
repo = //if (api != null) {
|
||||
APIRepository(api)
|
||||
} else {
|
||||
autoloadRepo()
|
||||
}
|
||||
//} else {
|
||||
// autoloadRepo()
|
||||
//}
|
||||
|
||||
_apiName.postValue(repo?.name)
|
||||
_randomItems.postValue(listOf())
|
||||
|
@ -274,6 +326,7 @@ class HomeViewModel : ViewModel() {
|
|||
|
||||
_page.postValue(Resource.Loading())
|
||||
_preview.postValue(Resource.Loading())
|
||||
// cancel the current preview expand as that is no longer relevant
|
||||
addJob?.cancel()
|
||||
|
||||
when (val data = repo?.getMainPage(1, null)) {
|
||||
|
@ -337,41 +390,142 @@ class HomeViewModel : ViewModel() {
|
|||
logError(e)
|
||||
}
|
||||
}
|
||||
|
||||
is Resource.Failure -> {
|
||||
_page.postValue(data!!)
|
||||
_preview.postValue(data!!)
|
||||
}
|
||||
|
||||
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.
|
||||
// The issue with this is that the homepage may be fetched multiple times while the first request is loading
|
||||
val api = getApiFromNameNull(preferredApiName)
|
||||
if (!forceReload && api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true) {
|
||||
return@launchSafe
|
||||
// api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true
|
||||
val currentPage = page.value
|
||||
|
||||
// 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) {
|
||||
setKey(USER_SELECTED_HOMEPAGE_API, noneApi.name)
|
||||
// just set to random
|
||||
if (fromUI) DataStoreHelper.currentHomePage = noneApi.name
|
||||
loadAndCancel(noneApi)
|
||||
} 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()
|
||||
if (validAPIs.isNullOrEmpty()) {
|
||||
// Do not set USER_SELECTED_HOMEPAGE_API when there is no plugins loaded
|
||||
loadAndCancel(noneApi)
|
||||
} else {
|
||||
val apiRandom = validAPIs.random()
|
||||
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) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import android.view.animation.AlphaAnimation
|
|||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
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.MainActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||
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.UIHelper.fixPaddingStatusbar
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||
import kotlinx.android.synthetic.main.fragment_library.*
|
||||
import org.checkerframework.framework.qual.Unused
|
||||
import kotlin.math.abs
|
||||
|
||||
|
@ -77,11 +78,22 @@ class LibraryFragment : Fragment() {
|
|||
|
||||
private val libraryViewModel: LibraryViewModel by activityViewModels()
|
||||
|
||||
var binding: FragmentLibraryBinding? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View? {
|
||||
): View {
|
||||
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() {
|
||||
|
@ -90,7 +102,7 @@ class LibraryFragment : Fragment() {
|
|||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
viewpager?.currentItem?.let { currentItem ->
|
||||
binding?.viewpager?.currentItem?.let { currentItem ->
|
||||
outState.putInt(VIEWPAGER_ITEM_KEY, currentItem)
|
||||
}
|
||||
super.onSaveInstanceState(outState)
|
||||
|
@ -98,9 +110,9 @@ class LibraryFragment : Fragment() {
|
|||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
context?.fixPaddingStatusbar(search_status_bar_padding)
|
||||
fixPaddingStatusbar(binding?.searchStatusBarPadding)
|
||||
|
||||
sort_fab?.setOnClickListener {
|
||||
binding?.sortFab?.setOnClickListener {
|
||||
val methods = libraryViewModel.sortingMethods.map {
|
||||
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 {
|
||||
libraryViewModel.sort(ListSorting.Query, query)
|
||||
return true
|
||||
|
@ -139,7 +151,7 @@ class LibraryFragment : Fragment() {
|
|||
|
||||
libraryViewModel.reloadPages(false)
|
||||
|
||||
list_selector?.setOnClickListener {
|
||||
binding?.listSelector?.setOnClickListener {
|
||||
val items = libraryViewModel.availableApiNames
|
||||
val currentItem = libraryViewModel.currentApiName.value
|
||||
|
||||
|
@ -162,12 +174,14 @@ class LibraryFragment : Fragment() {
|
|||
syncId: SyncIdName,
|
||||
apiName: String? = null,
|
||||
) {
|
||||
val availableProviders = allProviders.filter {
|
||||
it.supportedSyncNames.contains(syncId)
|
||||
}.map { it.name } +
|
||||
// Add the api if it exists
|
||||
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } ?: emptyList())
|
||||
|
||||
val availableProviders = synchronized(allProviders) {
|
||||
allProviders.filter {
|
||||
it.supportedSyncNames.contains(syncId)
|
||||
}.map { it.name } +
|
||||
// Add the api if it exists
|
||||
(APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) }
|
||||
?: emptyList())
|
||||
}
|
||||
val baseOptions = listOf(
|
||||
LibraryOpenerType.Default,
|
||||
LibraryOpenerType.None,
|
||||
|
@ -219,20 +233,22 @@ class LibraryFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
provider_selector?.setOnClickListener {
|
||||
binding?.providerSelector?.setOnClickListener {
|
||||
val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener
|
||||
activity?.showPluginSelectionDialog(syncName.name, syncName)
|
||||
}
|
||||
|
||||
viewpager?.setPageTransformer(LibraryScrollTransformer())
|
||||
viewpager?.adapter =
|
||||
viewpager.adapter ?: ViewpagerAdapter(mutableListOf(), { isScrollingDown: Boolean ->
|
||||
if (isScrollingDown) {
|
||||
sort_fab?.shrink()
|
||||
} else {
|
||||
sort_fab?.extend()
|
||||
}
|
||||
}) callback@{ searchClickCallback ->
|
||||
binding?.viewpager?.setPageTransformer(LibraryScrollTransformer())
|
||||
binding?.viewpager?.adapter =
|
||||
binding?.viewpager?.adapter ?: ViewpagerAdapter(
|
||||
mutableListOf(),
|
||||
{ isScrollingDown: Boolean ->
|
||||
if (isScrollingDown) {
|
||||
binding?.sortFab?.shrink()
|
||||
} else {
|
||||
binding?.sortFab?.extend()
|
||||
}
|
||||
}) callback@{ searchClickCallback ->
|
||||
// To prevent future accidents
|
||||
debugAssert({
|
||||
searchClickCallback.card !is SyncAPI.LibraryItem
|
||||
|
@ -277,6 +293,7 @@ class LibraryFragment : Fragment() {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
LibraryOpenerType.None -> {}
|
||||
LibraryOpenerType.Provider ->
|
||||
savedSelection.providerData?.apiName?.let { apiName ->
|
||||
|
@ -285,8 +302,10 @@ class LibraryFragment : Fragment() {
|
|||
apiName,
|
||||
)
|
||||
}
|
||||
|
||||
LibraryOpenerType.Browser ->
|
||||
openBrowser(searchClickCallback.card.url)
|
||||
|
||||
LibraryOpenerType.Search -> {
|
||||
QuickSearchFragment.pushSearch(
|
||||
activity,
|
||||
|
@ -298,22 +317,28 @@ class LibraryFragment : Fragment() {
|
|||
}
|
||||
}
|
||||
|
||||
viewpager?.offscreenPageLimit = 2
|
||||
viewpager?.reduceDragSensitivity()
|
||||
binding?.apply {
|
||||
viewpager.offscreenPageLimit = 2
|
||||
viewpager.reduceDragSensitivity()
|
||||
}
|
||||
|
||||
val startLoading = Runnable {
|
||||
gridview?.numColumns = context?.getSpanCount() ?: 3
|
||||
gridview?.adapter =
|
||||
context?.let { LoadingPosterAdapter(it, 6 * 3) }
|
||||
library_loading_overlay?.isVisible = true
|
||||
library_loading_shimmer?.startShimmer()
|
||||
empty_list_textview?.isVisible = false
|
||||
binding?.apply {
|
||||
gridview.numColumns = context?.getSpanCount() ?: 3
|
||||
gridview.adapter =
|
||||
context?.let { LoadingPosterAdapter(it, 6 * 3) }
|
||||
libraryLoadingOverlay.isVisible = true
|
||||
libraryLoadingShimmer.startShimmer()
|
||||
emptyListTextview.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
val stopLoading = Runnable {
|
||||
gridview?.adapter = null
|
||||
library_loading_overlay?.isVisible = false
|
||||
library_loading_shimmer?.stopShimmer()
|
||||
binding?.apply {
|
||||
gridview.adapter = null
|
||||
libraryLoadingOverlay.isVisible = false
|
||||
libraryLoadingShimmer.stopShimmer()
|
||||
}
|
||||
}
|
||||
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
|
@ -324,65 +349,75 @@ class LibraryFragment : Fragment() {
|
|||
handler.removeCallbacks(startLoading)
|
||||
val pages = resource.value
|
||||
val showNotice = pages.all { it.items.isEmpty() }
|
||||
empty_list_textview?.isVisible = showNotice
|
||||
if (showNotice) {
|
||||
if (libraryViewModel.availableApiNames.size > 1) {
|
||||
empty_list_textview?.setText(R.string.empty_library_logged_in_message)
|
||||
} else {
|
||||
empty_list_textview?.setText(R.string.empty_library_no_accounts_message)
|
||||
|
||||
|
||||
binding?.apply {
|
||||
emptyListTextview.isVisible = showNotice
|
||||
if (showNotice) {
|
||||
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 -> {
|
||||
// Only start loading after 200ms to prevent loading cached lists
|
||||
handler.postDelayed(startLoading, 200)
|
||||
}
|
||||
|
||||
is Resource.Failure -> {
|
||||
stopLoading.run()
|
||||
// No user indication it failed :(
|
||||
|
@ -393,7 +428,7 @@ class LibraryFragment : Fragment() {
|
|||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
(viewpager.adapter as? ViewpagerAdapter)?.rebind()
|
||||
(binding?.viewpager?.adapter as? ViewpagerAdapter)?.rebind()
|
||||
super.onConfigurationChanged(newConfig)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,13 +2,13 @@ package com.lagradost.cloudstream3.ui.library
|
|||
|
||||
import android.view.View
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import kotlinx.android.synthetic.main.library_viewpager_page.view.*
|
||||
import com.lagradost.cloudstream3.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class LibraryScrollTransformer : ViewPager2.PageTransformer {
|
||||
override fun transformPage(page: View, position: Float) {
|
||||
val padding = (-position * page.width).roundToInt()
|
||||
page.page_recyclerview.setPadding(
|
||||
page.findViewById<View>(R.id.page_recyclerview).setPadding(
|
||||
padding, 0,
|
||||
-padding, 0
|
||||
)
|
||||
|
|
|
@ -11,7 +11,6 @@ import com.lagradost.cloudstream3.mvvm.Resource
|
|||
import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
enum class ListSorting(@StringRes val stringRes: Int) {
|
||||
Query(R.string.none),
|
||||
|
|
|
@ -5,15 +5,7 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
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.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) :
|
||||
BaseAdapter() {
|
||||
|
|
|
@ -3,23 +3,21 @@ package com.lagradost.cloudstream3.ui.library
|
|||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.lagradost.cloudstream3.AcraApplication
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding
|
||||
import com.lagradost.cloudstream3.syncproviders.SyncAPI
|
||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||
import com.lagradost.cloudstream3.ui.search.SearchClickCallback
|
||||
import com.lagradost.cloudstream3.ui.search.SearchResultBuilder
|
||||
import com.lagradost.cloudstream3.utils.AppUtils
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import kotlinx.android.synthetic.main.search_result_grid_expanded.view.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
|
@ -32,8 +30,11 @@ class PageAdapter(
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return LibraryItemViewHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.search_result_grid_expanded, parent, false)
|
||||
SearchResultGridExpandedBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -57,8 +58,8 @@ class PageAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
inner class LibraryItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
val cardView: ImageView = itemView.imageView
|
||||
inner class LibraryItemViewHolder(val binding: SearchResultGridExpandedBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
private val compactView = false//itemView.context.getGridIsCompact()
|
||||
private val coverHeight: Int =
|
||||
|
@ -85,11 +86,12 @@ class PageAdapter(
|
|||
|
||||
val fg =
|
||||
getDifferentColor(bg)//palette.getVibrantColor(ContextCompat.getColor(ctx,R.color.ratingColor))
|
||||
itemView.text_rating.apply {
|
||||
binding.textRating.apply {
|
||||
setTextColor(ColorStateList.valueOf(fg))
|
||||
}
|
||||
itemView.text_rating_holder?.backgroundTintList = ColorStateList.valueOf(bg)
|
||||
itemView.watchProgress?.apply {
|
||||
binding.textRating.compoundDrawables.getOrNull(0)?.setTint(fg)
|
||||
binding.textRating.backgroundTintList = ColorStateList.valueOf(bg)
|
||||
binding.watchProgress.apply {
|
||||
progressTintList = ColorStateList.valueOf(fg)
|
||||
progressBackgroundTintList = ColorStateList.valueOf(bg)
|
||||
}
|
||||
|
@ -99,7 +101,7 @@ class PageAdapter(
|
|||
|
||||
// See searchAdaptor for this, it basically fixes the height
|
||||
if (!compactView) {
|
||||
cardView.apply {
|
||||
binding.imageView.apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
coverHeight
|
||||
|
@ -108,23 +110,13 @@ class PageAdapter(
|
|||
}
|
||||
|
||||
val showProgress = item.episodesCompleted != null && item.episodesTotal != null
|
||||
itemView.watchProgress.isVisible = showProgress
|
||||
binding.watchProgress.isVisible = showProgress
|
||||
if (showProgress) {
|
||||
itemView.watchProgress.max = item.episodesTotal!!
|
||||
itemView.watchProgress.progress = item.episodesCompleted!!
|
||||
binding.watchProgress.max = item.episodesTotal!!
|
||||
binding.watchProgress.progress = item.episodesCompleted!!
|
||||
}
|
||||
|
||||
itemView.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"
|
||||
}
|
||||
binding.imageText.text = item.name
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,16 +2,14 @@ package com.lagradost.cloudstream3.ui.library
|
|||
|
||||
import android.os.Build
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.doOnAttach
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
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.ui.search.SearchClickCallback
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
|
||||
import kotlinx.android.synthetic.main.library_viewpager_page.view.*
|
||||
|
||||
class ViewpagerAdapter(
|
||||
var pages: List<SyncAPI.Page>,
|
||||
|
@ -20,8 +18,7 @@ class ViewpagerAdapter(
|
|||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return PageViewHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.library_viewpager_page, parent, false)
|
||||
LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -34,6 +31,7 @@ class ViewpagerAdapter(
|
|||
}
|
||||
|
||||
private val unbound = mutableSetOf<Int>()
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@ -43,44 +41,46 @@ class ViewpagerAdapter(
|
|||
this.notifyItemRangeChanged(0, pages.size)
|
||||
}
|
||||
|
||||
inner class PageViewHolder(private val itemViewTest: View) :
|
||||
RecyclerView.ViewHolder(itemViewTest) {
|
||||
inner class PageViewHolder(private val binding: LibraryViewpagerPageBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(page: SyncAPI.Page, rebind: Boolean) {
|
||||
itemView.page_recyclerview?.spanCount =
|
||||
this@PageViewHolder.itemView.context.getSpanCount() ?: 3
|
||||
|
||||
if (itemViewTest.page_recyclerview?.adapter == null || rebind) {
|
||||
// Only add the items after it has been attached since the items rely on ItemWidth
|
||||
// Which is only determined after the recyclerview is attached.
|
||||
// If this fails then item height becomes 0 when there is only one item
|
||||
itemViewTest.page_recyclerview?.doOnAttach {
|
||||
itemViewTest.page_recyclerview?.adapter = PageAdapter(
|
||||
page.items.toMutableList(),
|
||||
itemViewTest.page_recyclerview,
|
||||
clickCallback
|
||||
)
|
||||
binding.pageRecyclerview.apply {
|
||||
spanCount =
|
||||
this@PageViewHolder.itemView.context.getSpanCount() ?: 3
|
||||
if (adapter == null || rebind) {
|
||||
// Only add the items after it has been attached since the items rely on ItemWidth
|
||||
// Which is only determined after the recyclerview is attached.
|
||||
// If this fails then item height becomes 0 when there is only one item
|
||||
doOnAttach {
|
||||
adapter = PageAdapter(
|
||||
page.items.toMutableList(),
|
||||
this,
|
||||
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) {
|
||||
itemViewTest.page_recyclerview.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY ->
|
||||
val diff = scrollY - oldScrollY
|
||||
if (diff == 0) return@setOnScrollChangeListener
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
||||
val diff = scrollY - oldScrollY
|
||||
if (diff == 0) return@setOnScrollChangeListener
|
||||
|
||||
scrollCallback.invoke(diff > 0)
|
||||
}
|
||||
} else {
|
||||
itemViewTest.page_recyclerview.onFlingListener = object : OnFlingListener() {
|
||||
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
|
||||
scrollCallback.invoke(velocityY > 0)
|
||||
return false
|
||||
scrollCallback.invoke(diff > 0)
|
||||
}
|
||||
} else {
|
||||
onFlingListener = object : OnFlingListener() {
|
||||
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
|
||||
scrollCallback.invoke(velocityY > 0)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,24 +7,29 @@ import android.graphics.drawable.AnimatedVectorDrawable
|
|||
import android.media.metrics.PlaybackErrorEvent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.SubtitleView
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import androidx.media3.ui.TimeBar
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
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.R
|
||||
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.SubtitlesFragment
|
||||
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.hideSystemUI
|
||||
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) {
|
||||
Fit(R.string.resize_fit),
|
||||
|
@ -72,9 +77,15 @@ abstract class AbstractPlayerFragment(
|
|||
var isBuffering = 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
|
||||
protected var layout: Int = R.layout.fragment_player
|
||||
protected open var layout: Int = R.layout.fragment_player
|
||||
|
||||
open fun nextEpisode() {
|
||||
throw NotImplementedError()
|
||||
|
@ -84,11 +95,11 @@ abstract class AbstractPlayerFragment(
|
|||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
open fun playerPositionChanged(posDur: Pair<Long, Long>) {
|
||||
open fun playerPositionChanged(position: Long, duration : Long) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
open fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) {
|
||||
open fun playerDimensionsLoaded(width: Int, height : Int) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
|
@ -124,8 +135,8 @@ abstract class AbstractPlayerFragment(
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateIsPlaying(playing: Pair<CSPlayerLoading, CSPlayerLoading>) {
|
||||
val (wasPlaying, isPlaying) = playing
|
||||
private fun updateIsPlaying(wasPlaying : CSPlayerLoading,
|
||||
isPlaying : CSPlayerLoading) {
|
||||
val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying
|
||||
val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying
|
||||
|
||||
|
@ -133,15 +144,15 @@ abstract class AbstractPlayerFragment(
|
|||
|
||||
isBuffering = CSPlayerLoading.IsBuffering == isPlaying
|
||||
if (isBuffering) {
|
||||
player_pause_play_holder_holder?.isVisible = false
|
||||
player_buffering?.isVisible = true
|
||||
playerPausePlayHolderHolder?.isVisible = false
|
||||
playerBuffering?.isVisible = true
|
||||
} else {
|
||||
player_pause_play_holder_holder?.isVisible = true
|
||||
player_buffering?.isVisible = false
|
||||
playerPausePlayHolderHolder?.isVisible = true
|
||||
playerBuffering?.isVisible = false
|
||||
|
||||
if (wasPlaying != isPlaying) {
|
||||
player_pause_play?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play)
|
||||
val drawable = player_pause_play?.drawable
|
||||
playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play)
|
||||
val drawable = playerPausePlay?.drawable
|
||||
|
||||
var startedAnimation = false
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
|
||||
|
@ -163,23 +174,24 @@ abstract class AbstractPlayerFragment(
|
|||
|
||||
// somehow the phone is wacked
|
||||
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 {
|
||||
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
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isInPIPMode) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
activity?.let { act ->
|
||||
PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow)
|
||||
PlayerPipHelper.updatePIPModeActions(act, isPlayingRightNow, player.getAspectRatio())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pipReceiver: BroadcastReceiver? = null
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
try {
|
||||
isInPIPMode = isInPictureInPictureMode
|
||||
if (isInPictureInPictureMode) {
|
||||
|
@ -197,25 +209,26 @@ abstract class AbstractPlayerFragment(
|
|||
CSPlayerEvent.values()[intent.getIntExtra(
|
||||
EXTRA_CONTROL_TYPE,
|
||||
0
|
||||
)]
|
||||
)], source = PlayerEventSource.UI
|
||||
)
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter()
|
||||
filter.addAction(
|
||||
ACTION_MEDIA_CONTROL
|
||||
)
|
||||
filter.addAction(ACTION_MEDIA_CONTROL)
|
||||
activity?.registerReceiver(pipReceiver, filter)
|
||||
val isPlaying = player.getIsPlaying()
|
||||
val isPlayingValue =
|
||||
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
|
||||
updateIsPlaying(Pair(isPlayingValue, isPlayingValue))
|
||||
updateIsPlaying(isPlayingValue, isPlayingValue)
|
||||
} else {
|
||||
// Restore the full-screen UI.
|
||||
piphide?.isVisible = true
|
||||
exitedPipMode()
|
||||
pipReceiver?.let {
|
||||
activity?.unregisterReceiver(it)
|
||||
// Prevents java.lang.IllegalArgumentException: Receiver not registered
|
||||
normalSafeApiCall {
|
||||
activity?.unregisterReceiver(it)
|
||||
}
|
||||
}
|
||||
activity?.hideSystemUI()
|
||||
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) {
|
||||
if (gotoNext && hasNextMirror()) {
|
||||
showToast(
|
||||
activity,
|
||||
message,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
nextMirror()
|
||||
} else {
|
||||
showToast(
|
||||
activity,
|
||||
context?.getString(R.string.no_links_found_toast) + "\n" + message,
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
|
@ -270,18 +281,21 @@ abstract class AbstractPlayerFragment(
|
|||
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 -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg",
|
||||
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 -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg",
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg",
|
||||
|
@ -290,12 +304,14 @@ abstract class AbstractPlayerFragment(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
is InvalidFileException -> {
|
||||
showToast(
|
||||
"${ctx.getString(R.string.source_error)}\n${exception.message}",
|
||||
gotoNext = true
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
exception.message?.let {
|
||||
showToast(
|
||||
|
@ -313,29 +329,25 @@ abstract class AbstractPlayerFragment(
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun playerUpdated(player: Any?) {
|
||||
if (player is ExoPlayer) {
|
||||
context?.let { ctx ->
|
||||
val mediaButtonReceiver = ComponentName(ctx, MediaButtonReceiver::class.java)
|
||||
MediaSessionCompat(ctx, "Player", mediaButtonReceiver, null).let { media ->
|
||||
//media.setCallback(mMediaSessionCallback)
|
||||
//media.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||
val mediaSessionConnector = MediaSessionConnector(media)
|
||||
mediaSessionConnector.setPlayer(player)
|
||||
media.isActive = true
|
||||
mMediaSessionCompat = media
|
||||
}
|
||||
mMediaSession?.release()
|
||||
mMediaSession = MediaSession.Builder(ctx, player)
|
||||
// Ensure unique ID for concurrent players
|
||||
.setId(unixTimeMs.toString())
|
||||
.build()
|
||||
}
|
||||
|
||||
// Necessary for multiple combined videos
|
||||
player_view?.setShowMultiWindowTimeBar(true)
|
||||
player_view?.player = player
|
||||
player_view?.performClick()
|
||||
playerView?.setShowMultiWindowTimeBar(true)
|
||||
playerView?.player = player
|
||||
playerView?.performClick()
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaSessionConnector: MediaSessionConnector? = null
|
||||
private var mMediaSessionCompat: MediaSessionCompat? = null
|
||||
private var mMediaSession: MediaSession? = null
|
||||
|
||||
// this can be used in the future for players other than exoplayer
|
||||
//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?) {
|
||||
resizeMode = getKey(RESIZE_MODE_KEY) ?: 0
|
||||
resize(resizeMode, false)
|
||||
|
||||
player.releaseCallbacks()
|
||||
player.initCallbacks(
|
||||
playerUpdated = ::playerUpdated,
|
||||
updateIsPlaying = ::updateIsPlaying,
|
||||
playerError = ::playerError,
|
||||
requestAutoFocus = ::requestAudioFocus,
|
||||
nextEpisode = ::nextEpisode,
|
||||
prevEpisode = ::prevEpisode,
|
||||
playerPositionChanged = ::playerPositionChanged,
|
||||
playerDimensionsLoaded = ::playerDimensionsLoaded,
|
||||
eventHandler = ::mainCallback,
|
||||
requestedListeningPercentages = listOf(
|
||||
SKIP_OP_VIDEO_PERCENTAGE,
|
||||
PRELOAD_NEXT_EPISODE_PERCENTAGE,
|
||||
NEXT_WATCH_EPISODE_PERCENTAGE,
|
||||
UPDATE_SYNC_PROGRESS_PERCENTAGE,
|
||||
),
|
||||
subtitlesUpdates = ::subtitlesChanged,
|
||||
embeddedSubtitlesFetched = ::embeddedSubtitlesFetched,
|
||||
onTracksInfoChanged = ::onTracksInfoChanged,
|
||||
onTimestampInvoked = ::onTimestamp,
|
||||
onTimestampSkipped = ::onTimestampSkipped
|
||||
)
|
||||
|
||||
if (player is CS3IPlayer) {
|
||||
subView = player_view?.findViewById(R.id.exo_subtitles)
|
||||
subView = playerView?.findViewById(R.id.exo_subtitles)
|
||||
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
|
||||
|
||||
|
@ -436,6 +515,9 @@ abstract class AbstractPlayerFragment(
|
|||
playerEventListener = null
|
||||
keyEventListener = null
|
||||
canEnterPipMode = false
|
||||
mMediaSession?.release()
|
||||
mMediaSession = null
|
||||
playerView?.player = null
|
||||
SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged
|
||||
|
||||
keepScreenOn(false)
|
||||
|
@ -451,6 +533,7 @@ abstract class AbstractPlayerFragment(
|
|||
resize(PlayerResize.values()[resize], showToast)
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
fun resize(resize: PlayerResize, showToast: Boolean) {
|
||||
setKey(RESIZE_MODE_KEY, resize.ordinal)
|
||||
val type = when (resize) {
|
||||
|
@ -458,10 +541,10 @@ abstract class AbstractPlayerFragment(
|
|||
PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
}
|
||||
player_view?.resizeMode = type
|
||||
playerView?.resizeMode = type
|
||||
|
||||
if (showToast)
|
||||
showToast(activity, resize.nameRes, Toast.LENGTH_SHORT)
|
||||
showToast(resize.nameRes, Toast.LENGTH_SHORT)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
|
@ -482,6 +565,13 @@ abstract class AbstractPlayerFragment(
|
|||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): 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
|
||||
}
|
||||
}
|
|
@ -1,50 +1,71 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.Rational
|
||||
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 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.AcraApplication.Companion.getKey
|
||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||
import com.lagradost.cloudstream3.USER_AGENT
|
||||
import com.lagradost.cloudstream3.app
|
||||
import com.lagradost.cloudstream3.mvvm.debugAssert
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
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.DrmExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
import com.lagradost.cloudstream3.utils.ExtractorUri
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage
|
||||
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.SSLContext
|
||||
import javax.net.ssl.SSLSession
|
||||
|
@ -52,11 +73,25 @@ import javax.net.ssl.SSLSession
|
|||
const val TAG = "CS3ExoPlayer"
|
||||
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 {
|
||||
private var isPlaying = false
|
||||
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 simpleCacheSize = 0L
|
||||
var videoBufferMs = 0L
|
||||
|
@ -83,7 +118,16 @@ class CS3IPlayer : IPlayer {
|
|||
* */
|
||||
data class MediaItemSlice(
|
||||
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
|
||||
|
@ -97,80 +141,24 @@ class CS3IPlayer : IPlayer {
|
|||
* Boolean = if it's active
|
||||
* */
|
||||
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
|
||||
|
||||
/** Fired when seeking the player or on requestedListeningPercentages,
|
||||
* used to make things appear on que
|
||||
* position, duration */
|
||||
private var playerPositionChanged: ((Pair<Long, Long>) -> Unit)? = null
|
||||
private var eventHandler: ((PlayerEvent) -> Unit)? = null
|
||||
|
||||
private var nextEpisode: (() -> Unit)? = null
|
||||
private var prevEpisode: (() -> Unit)? = null
|
||||
|
||||
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
|
||||
fun event(event: PlayerEvent) {
|
||||
eventHandler?.invoke(event)
|
||||
}
|
||||
|
||||
override fun releaseCallbacks() {
|
||||
playerUpdated = 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
|
||||
eventHandler = null
|
||||
}
|
||||
|
||||
override fun initCallbacks(
|
||||
playerUpdated: (Any?) -> Unit,
|
||||
updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)?,
|
||||
requestAutoFocus: (() -> Unit)?,
|
||||
playerError: ((Exception) -> Unit)?,
|
||||
playerDimensionsLoaded: ((Pair<Int, Int>) -> Unit)?,
|
||||
eventHandler: ((PlayerEvent) -> Unit),
|
||||
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.playerPositionChanged = playerPositionChanged
|
||||
this.nextEpisode = nextEpisode
|
||||
this.prevEpisode = prevEpisode
|
||||
this.subtitlesUpdates = subtitlesUpdates
|
||||
this.embeddedSubtitlesFetched = embeddedSubtitlesFetched
|
||||
this.onTracksInfoChanged = onTracksInfoChanged
|
||||
this.onTimestampInvoked = onTimestampInvoked
|
||||
this.onTimestampSkipped = onTimestampSkipped
|
||||
this.eventHandler = eventHandler
|
||||
}
|
||||
|
||||
// I know, this is not a perfect solution, however it works for fixing subs
|
||||
|
@ -179,7 +167,7 @@ class CS3IPlayer : IPlayer {
|
|||
try {
|
||||
Handler(it).post {
|
||||
try {
|
||||
seekTime(1L)
|
||||
seekTime(1L, source = PlayerEventSource.Player)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
|
@ -233,8 +221,9 @@ class CS3IPlayer : IPlayer {
|
|||
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>? {
|
||||
if (id == null) return null
|
||||
// This beast of an expression does:
|
||||
|
@ -319,6 +308,7 @@ class CS3IPlayer : IPlayer {
|
|||
}.flatten()
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun Tracks.Group.getFormats(): List<Pair<Format, Int>> {
|
||||
return (0 until this.mediaTrackGroup.length).mapNotNull { i ->
|
||||
if (this.isSupported)
|
||||
|
@ -347,6 +337,7 @@ class CS3IPlayer : IPlayer {
|
|||
)
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
override fun getVideoTracks(): CurrentTracks {
|
||||
val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList()
|
||||
val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO }
|
||||
|
@ -366,6 +357,7 @@ class CS3IPlayer : IPlayer {
|
|||
/**
|
||||
* @return True if the player should be reloaded
|
||||
* */
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean {
|
||||
Log.i(TAG, "setPreferredSubtitles init $subtitle")
|
||||
currentSubtitles = subtitle
|
||||
|
@ -378,7 +370,7 @@ class CS3IPlayer : IPlayer {
|
|||
if (subtitle == null) {
|
||||
trackSelector.setParameters(
|
||||
trackSelector.buildUponParameters()
|
||||
.setPreferredTextLanguage(null)
|
||||
.setTrackTypeDisabled(TRACK_TYPE_TEXT, true)
|
||||
.clearOverridesOfType(TRACK_TYPE_TEXT)
|
||||
)
|
||||
} else {
|
||||
|
@ -387,6 +379,7 @@ class CS3IPlayer : IPlayer {
|
|||
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
|
||||
return@let true
|
||||
}
|
||||
|
||||
SubtitleStatus.IS_ACTIVE -> {
|
||||
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
|
||||
|
||||
|
@ -395,6 +388,7 @@ class CS3IPlayer : IPlayer {
|
|||
.apply {
|
||||
val track = getTextTrack(subtitle.getId())
|
||||
if (track != null) {
|
||||
setTrackTypeDisabled(TRACK_TYPE_TEXT, false)
|
||||
setOverrideForType(
|
||||
TrackSelectionOverride(
|
||||
track.first,
|
||||
|
@ -412,6 +406,7 @@ class CS3IPlayer : IPlayer {
|
|||
// }, 1)
|
||||
//}
|
||||
}
|
||||
|
||||
SubtitleStatus.NOT_FOUND -> {
|
||||
Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
|
||||
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) {
|
||||
subtitleHelper.setSubStyle(style)
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
override fun saveData() {
|
||||
Log.i(TAG, "saveData")
|
||||
updatedTime()
|
||||
|
@ -474,14 +477,14 @@ class CS3IPlayer : IPlayer {
|
|||
Log.i(TAG, "onStop")
|
||||
|
||||
saveData()
|
||||
exoPlayer?.pause()
|
||||
handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
|
||||
//releasePlayer()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
Log.i(TAG, "onPause")
|
||||
saveData()
|
||||
exoPlayer?.pause()
|
||||
handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player)
|
||||
//releasePlayer()
|
||||
}
|
||||
|
||||
|
@ -518,6 +521,7 @@ class CS3IPlayer : IPlayer {
|
|||
|
||||
var requestSubtitleUpdate: (() -> Unit)? = null
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun createOnlineSource(headers: Map<String, String>): HttpDataSource.Factory {
|
||||
val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT)
|
||||
return source.apply {
|
||||
|
@ -525,6 +529,7 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory {
|
||||
val provider = getApiFromNameNull(link.source)
|
||||
val interceptor = provider?.getVideoInterceptor(link)
|
||||
|
@ -557,6 +562,7 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun Context.createOfflineSource(): DataSource.Factory {
|
||||
return DefaultDataSourceFactory(this, USER_AGENT)
|
||||
}
|
||||
|
@ -602,6 +608,7 @@ class CS3IPlayer : IPlayer {
|
|||
return Pair(subSources, activeSubtitles)
|
||||
}*/
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun getCache(context: Context, cacheSize: Long): SimpleCache? {
|
||||
return try {
|
||||
val databaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
@ -633,14 +640,10 @@ class CS3IPlayer : IPlayer {
|
|||
return getMediaItemBuilder(mimeType).setUri(url).build()
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector {
|
||||
val trackSelector = DefaultTrackSelector(context)
|
||||
trackSelector.parameters = DefaultTrackSelector.ParametersBuilder(context)
|
||||
// .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)
|
||||
trackSelector.parameters = trackSelector.buildUponParameters()
|
||||
// This will not force higher quality videos to fail
|
||||
// but will make the m3u8 pick the correct preferred
|
||||
.setMaxVideoSize(Int.MAX_VALUE, maxVideoHeight ?: Int.MAX_VALUE)
|
||||
|
@ -651,6 +654,7 @@ class CS3IPlayer : IPlayer {
|
|||
|
||||
var currentTextRenderer: CustomTextRenderer? = null
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun buildExoPlayer(
|
||||
context: Context,
|
||||
mediaItemSlices: List<MediaItemSlice>,
|
||||
|
@ -674,26 +678,26 @@ class CS3IPlayer : IPlayer {
|
|||
ExoPlayer.Builder(context)
|
||||
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
|
||||
DefaultRenderersFactory(context).apply {
|
||||
// setEnableDecoderFallback(true)
|
||||
setEnableDecoderFallback(true)
|
||||
// Enable Ffmpeg extension
|
||||
// setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
|
||||
setExtensionRendererMode(EXTENSION_RENDERER_MODE_ON)
|
||||
}.createRenderers(
|
||||
eventHandler,
|
||||
videoRendererEventListener,
|
||||
audioRendererEventListener,
|
||||
textRendererOutput,
|
||||
metadataRendererOutput
|
||||
).map {
|
||||
if (it is TextRenderer) {
|
||||
currentTextRenderer = CustomTextRenderer(
|
||||
subtitleOffset,
|
||||
textRendererOutput,
|
||||
eventHandler.looper,
|
||||
CustomSubtitleDecoderFactory()
|
||||
)
|
||||
currentTextRenderer!!
|
||||
} else it
|
||||
}.toTypedArray()
|
||||
eventHandler,
|
||||
videoRendererEventListener,
|
||||
audioRendererEventListener,
|
||||
textRendererOutput,
|
||||
metadataRendererOutput
|
||||
).map {
|
||||
if (it is TextRenderer) {
|
||||
val currentTextRenderer = CustomTextRenderer(
|
||||
subtitleOffset,
|
||||
textRendererOutput,
|
||||
eventHandler.looper,
|
||||
CustomSubtitleDecoderFactory()
|
||||
).also { this.currentTextRenderer = it }
|
||||
currentTextRenderer
|
||||
} else it
|
||||
}.toTypedArray()
|
||||
}
|
||||
.setTrackSelector(
|
||||
trackSelector ?: getTrackSelector(
|
||||
|
@ -702,7 +706,7 @@ class CS3IPlayer : IPlayer {
|
|||
)
|
||||
)
|
||||
// Allows any seeking to be +- 0.3s to allow for faster seeking
|
||||
.setSeekParameters(SeekParameters(300_000, 300_000))
|
||||
.setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs))
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder()
|
||||
.setTargetBufferBytes(
|
||||
|
@ -735,15 +739,33 @@ class CS3IPlayer : IPlayer {
|
|||
|
||||
// If there is only one item then treat it as normal, if multiple: concatenate the items.
|
||||
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 {
|
||||
val source = ConcatenatingMediaSource()
|
||||
mediaItemSlices.map {
|
||||
mediaItemSlices.map { item ->
|
||||
source.addMediaSource(
|
||||
// The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727
|
||||
ClippingMediaSource(
|
||||
factory.createMediaSource(it.mediaItem),
|
||||
it.durationUs
|
||||
factory.createMediaSource(item.mediaItem),
|
||||
item.durationUs
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -769,50 +791,65 @@ class CS3IPlayer : IPlayer {
|
|||
private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? {
|
||||
val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null
|
||||
for (lastTimeStamp in lastTimeStamps) {
|
||||
if (lastTimeStamp.startMs <= position && position < lastTimeStamp.endMs) {
|
||||
if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) {
|
||||
return lastTimeStamp
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun updatedTime(writePosition: Long? = null) {
|
||||
getCurrentTimestamp(writePosition)?.let { timestamp ->
|
||||
onTimestampInvoked?.invoke(timestamp)
|
||||
fun updatedTime(
|
||||
writePosition: Long? = null,
|
||||
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
|
||||
if (duration != null && position != null) {
|
||||
playerPositionChanged?.invoke(Pair(position, duration))
|
||||
event(
|
||||
PositionEvent(
|
||||
source,
|
||||
fromMs = exoPlayer?.currentPosition ?: 0,
|
||||
position,
|
||||
duration
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekTime(time: Long) {
|
||||
exoPlayer?.seekTime(time)
|
||||
override fun seekTime(time: Long, source: PlayerEventSource) {
|
||||
exoPlayer?.seekTime(time, source)
|
||||
}
|
||||
|
||||
override fun seekTo(time: Long) {
|
||||
updatedTime(time)
|
||||
override fun seekTo(time: Long, source: PlayerEventSource) {
|
||||
updatedTime(time, source)
|
||||
exoPlayer?.seekTo(time)
|
||||
}
|
||||
|
||||
private fun ExoPlayer.seekTime(time: Long) {
|
||||
updatedTime(currentPosition + time)
|
||||
private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) {
|
||||
updatedTime(currentPosition + time, source)
|
||||
seekTo(currentPosition + time)
|
||||
}
|
||||
|
||||
override fun handleEvent(event: CSPlayerEvent) {
|
||||
override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) {
|
||||
Log.i(TAG, "handleEvent ${event.name}")
|
||||
try {
|
||||
exoPlayer?.apply {
|
||||
when (event) {
|
||||
CSPlayerEvent.Play -> {
|
||||
event(PlayEvent(source))
|
||||
play()
|
||||
}
|
||||
|
||||
CSPlayerEvent.Pause -> {
|
||||
event(PauseEvent(source))
|
||||
pause()
|
||||
}
|
||||
|
||||
CSPlayerEvent.ToggleMute -> {
|
||||
if (volume <= 0) {
|
||||
//is muted
|
||||
|
@ -823,33 +860,35 @@ class CS3IPlayer : IPlayer {
|
|||
volume = 0f
|
||||
}
|
||||
}
|
||||
|
||||
CSPlayerEvent.PlayPauseToggle -> {
|
||||
if (isPlaying) {
|
||||
pause()
|
||||
handleEvent(CSPlayerEvent.Pause, source)
|
||||
} else {
|
||||
play()
|
||||
handleEvent(CSPlayerEvent.Play, source)
|
||||
}
|
||||
}
|
||||
CSPlayerEvent.SeekForward -> seekTime(seekActionTime)
|
||||
CSPlayerEvent.SeekBack -> seekTime(-seekActionTime)
|
||||
CSPlayerEvent.NextEpisode -> nextEpisode?.invoke()
|
||||
CSPlayerEvent.PrevEpisode -> prevEpisode?.invoke()
|
||||
|
||||
CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source)
|
||||
CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source)
|
||||
CSPlayerEvent.NextEpisode -> event(EpisodeSeekEvent(offset = 1, source = source))
|
||||
CSPlayerEvent.PrevEpisode -> event(EpisodeSeekEvent(offset = -1, source = source))
|
||||
CSPlayerEvent.SkipCurrentChapter -> {
|
||||
//val dur = this@CS3IPlayer.getDuration() ?: return@apply
|
||||
getCurrentTimestamp()?.let { lastTimeStamp ->
|
||||
if (lastTimeStamp.skipToNextEpisode) {
|
||||
handleEvent(CSPlayerEvent.NextEpisode)
|
||||
handleEvent(CSPlayerEvent.NextEpisode, source)
|
||||
} else {
|
||||
seekTo(lastTimeStamp.endMs + 1L)
|
||||
}
|
||||
onTimestampSkipped?.invoke(lastTimeStamp)
|
||||
event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "handleEvent error", e)
|
||||
playerError?.invoke(e)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "handleEvent error", t)
|
||||
event(ErrorEvent(t))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -888,18 +927,14 @@ class CS3IPlayer : IPlayer {
|
|||
|
||||
requestSubtitleUpdate = ::reloadSubs
|
||||
|
||||
playerUpdated?.invoke(exoPlayer)
|
||||
event(PlayerAttachedEvent(exoPlayer))
|
||||
exoPlayer?.prepare()
|
||||
|
||||
exoPlayer?.let { exo ->
|
||||
updateIsPlaying?.invoke(
|
||||
Pair(
|
||||
CSPlayerLoading.IsBuffering,
|
||||
CSPlayerLoading.IsBuffering
|
||||
)
|
||||
)
|
||||
event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering))
|
||||
isPlaying = exo.isPlaying
|
||||
}
|
||||
|
||||
exoPlayer?.addListener(object : Player.Listener {
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
normalSafeApiCall {
|
||||
|
@ -933,18 +968,19 @@ class CS3IPlayer : IPlayer {
|
|||
)
|
||||
}
|
||||
|
||||
embeddedSubtitlesFetched?.invoke(exoPlayerReportedTracks)
|
||||
onTracksInfoChanged?.invoke()
|
||||
subtitlesUpdates?.invoke()
|
||||
event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks))
|
||||
event(TracksChangedEvent())
|
||||
event(SubtitlesUpdatedEvent())
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||||
exoPlayer?.let { exo ->
|
||||
updateIsPlaying?.invoke(
|
||||
Pair(
|
||||
if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused,
|
||||
if (playbackState == Player.STATE_BUFFERING) CSPlayerLoading.IsBuffering else if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused
|
||||
event(
|
||||
StatusEvent(
|
||||
wasPlaying = if (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
|
||||
|
@ -954,6 +990,7 @@ class CS3IPlayer : IPlayer {
|
|||
Player.STATE_READY -> {
|
||||
onRenderFirst()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
|
@ -963,23 +1000,19 @@ class CS3IPlayer : IPlayer {
|
|||
Player.STATE_READY -> {
|
||||
|
||||
}
|
||||
|
||||
Player.STATE_ENDED -> {
|
||||
// Only play next episode if autoplay is on (default)
|
||||
if (PreferenceManager.getDefaultSharedPreferences(context)
|
||||
?.getBoolean(
|
||||
context.getString(com.lagradost.cloudstream3.R.string.autoplay_next_key),
|
||||
true
|
||||
) == true
|
||||
) {
|
||||
handleEvent(CSPlayerEvent.NextEpisode)
|
||||
}
|
||||
event(VideoEndedEvent())
|
||||
}
|
||||
|
||||
Player.STATE_BUFFERING -> {
|
||||
updatedTime()
|
||||
updatedTime(source = PlayerEventSource.Player)
|
||||
}
|
||||
|
||||
Player.STATE_IDLE -> {
|
||||
// IDLE
|
||||
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
@ -994,13 +1027,15 @@ class CS3IPlayer : IPlayer {
|
|||
&& exoPlayer?.duration != TIME_UNSET -> {
|
||||
exoPlayer?.prepare()
|
||||
}
|
||||
|
||||
error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> {
|
||||
// Re-initialize player at the current live window default position.
|
||||
exoPlayer?.seekToDefaultPosition()
|
||||
exoPlayer?.prepare()
|
||||
}
|
||||
|
||||
else -> {
|
||||
playerError?.invoke(error)
|
||||
event(ErrorEvent(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1014,7 +1049,7 @@ class CS3IPlayer : IPlayer {
|
|||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
super.onIsPlayingChanged(isPlaying)
|
||||
if (isPlaying) {
|
||||
requestAutoFocus?.invoke()
|
||||
event(RequestAudioFocusEvent())
|
||||
onRenderFirst()
|
||||
}
|
||||
}
|
||||
|
@ -1025,6 +1060,7 @@ class CS3IPlayer : IPlayer {
|
|||
Player.STATE_READY -> {
|
||||
|
||||
}
|
||||
|
||||
Player.STATE_ENDED -> {
|
||||
// Only play next episode if autoplay is on (default)
|
||||
if (PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
@ -1033,42 +1069,50 @@ class CS3IPlayer : IPlayer {
|
|||
true
|
||||
) == true
|
||||
) {
|
||||
handleEvent(CSPlayerEvent.NextEpisode)
|
||||
handleEvent(
|
||||
CSPlayerEvent.NextEpisode,
|
||||
source = PlayerEventSource.Player
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Player.STATE_BUFFERING -> {
|
||||
updatedTime()
|
||||
updatedTime(source = PlayerEventSource.Player)
|
||||
}
|
||||
|
||||
Player.STATE_IDLE -> {
|
||||
// IDLE
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
super.onVideoSizeChanged(videoSize)
|
||||
playerDimensionsLoaded?.invoke(Pair(videoSize.width, videoSize.height))
|
||||
event(ResizedEvent(height = videoSize.height, width = videoSize.width))
|
||||
}
|
||||
|
||||
override fun onRenderedFirstFrame() {
|
||||
updatedTime()
|
||||
super.onRenderedFirstFrame()
|
||||
onRenderFirst()
|
||||
updatedTime(source = PlayerEventSource.Player)
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "loadExo error", e)
|
||||
playerError?.invoke(e)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "loadExo error", t)
|
||||
event(ErrorEvent(t))
|
||||
}
|
||||
}
|
||||
|
||||
private var lastTimeStamps: List<EpisodeSkip.SkipStamp> = emptyList()
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
override fun addTimeStamps(timeStamps: List<EpisodeSkip.SkipStamp>) {
|
||||
lastTimeStamps = timeStamps
|
||||
timeStamps.forEach { timestamp ->
|
||||
exoPlayer?.createMessage { _, _ ->
|
||||
updatedTime()
|
||||
updatedTime(source = PlayerEventSource.Player)
|
||||
//if (payload is EpisodeSkip.SkipStamp) // this should always be true
|
||||
// onTimestampInvoked?.invoke(payload)
|
||||
}
|
||||
|
@ -1078,46 +1122,48 @@ class CS3IPlayer : IPlayer {
|
|||
?.setDeleteAfterDelivery(false)
|
||||
?.send()
|
||||
}
|
||||
updatedTime()
|
||||
updatedTime(source = PlayerEventSource.Player)
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
fun onRenderFirst() {
|
||||
if (!hasUsedFirstRender) { // this insures that we only call this once per player load
|
||||
Log.i(TAG, "Rendered first frame")
|
||||
val invalid = exoPlayer?.duration?.let { duration ->
|
||||
// Only errors short playback when not playing downloaded files
|
||||
duration < 20_000L && currentDownloadedFile == null
|
||||
// Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period
|
||||
// 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 (hasUsedFirstRender) { // this insures that we only call this once per player load
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Rendered first frame")
|
||||
hasUsedFirstRender = true
|
||||
val invalid = exoPlayer?.duration?.let { duration ->
|
||||
// Only errors short playback when not playing downloaded files
|
||||
duration < 20_000L && currentDownloadedFile == null
|
||||
// Concatenated sources (non 1 periodCount) bypasses the invalid check as exoPlayer.duration gives only the current period
|
||||
// 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) {
|
||||
releasePlayer(saveTime = false)
|
||||
playerError?.invoke(InvalidFileException("Too short playback"))
|
||||
return
|
||||
}
|
||||
if (invalid) {
|
||||
releasePlayer(saveTime = false)
|
||||
event(ErrorEvent(InvalidFileException("Too short playback")))
|
||||
return
|
||||
}
|
||||
|
||||
setPreferredSubtitles(currentSubtitles)
|
||||
hasUsedFirstRender = true
|
||||
val format = exoPlayer?.videoFormat
|
||||
val width = format?.width
|
||||
val height = format?.height
|
||||
if (height != null && width != null) {
|
||||
playerDimensionsLoaded?.invoke(Pair(width, height))
|
||||
updatedTime()
|
||||
exoPlayer?.apply {
|
||||
requestedListeningPercentages?.forEach { percentage ->
|
||||
createMessage { _, _ ->
|
||||
updatedTime()
|
||||
}
|
||||
.setLooper(Looper.getMainLooper())
|
||||
.setPosition( /* positionMs= */contentDuration * percentage / 100)
|
||||
// .setPayload(customPayloadData)
|
||||
.setDeleteAfterDelivery(false)
|
||||
.send()
|
||||
setPreferredSubtitles(currentSubtitles)
|
||||
val format = exoPlayer?.videoFormat
|
||||
val width = format?.width
|
||||
val height = format?.height
|
||||
if (height != null && width != null) {
|
||||
event(ResizedEvent(width = width, height = height))
|
||||
updatedTime()
|
||||
exoPlayer?.apply {
|
||||
requestedListeningPercentages?.forEach { percentage ->
|
||||
createMessage { _, _ ->
|
||||
updatedTime()
|
||||
}
|
||||
.setLooper(Looper.getMainLooper())
|
||||
.setPosition(contentDuration * percentage / 100)
|
||||
// .setPayload(customPayloadData)
|
||||
.setDeleteAfterDelivery(false)
|
||||
.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1140,12 +1186,13 @@ class CS3IPlayer : IPlayer {
|
|||
|
||||
subtitleHelper.setActiveSubtitles(activeSubtitles.toSet())
|
||||
loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "loadOfflinePlayer error", e)
|
||||
playerError?.invoke(e)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "loadOfflinePlayer error", t)
|
||||
event(ErrorEvent(t))
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun getSubSources(
|
||||
onlineSourceFactory: HttpDataSource.Factory?,
|
||||
offlineSourceFactory: DataSource.Factory?,
|
||||
|
@ -1169,6 +1216,7 @@ class CS3IPlayer : IPlayer {
|
|||
null
|
||||
}
|
||||
}
|
||||
|
||||
SubtitleOrigin.URL -> {
|
||||
if (onlineSourceFactory != null) {
|
||||
activeSubtitles.add(sub)
|
||||
|
@ -1181,6 +1229,7 @@ class CS3IPlayer : IPlayer {
|
|||
null
|
||||
}
|
||||
}
|
||||
|
||||
SubtitleOrigin.EMBEDDED_IN_VIDEO -> {
|
||||
if (offlineSourceFactory != null) {
|
||||
activeSubtitles.add(sub)
|
||||
|
@ -1199,6 +1248,7 @@ class CS3IPlayer : IPlayer {
|
|||
return exoPlayer != null
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
private fun loadOnlinePlayer(context: Context, link: ExtractorLink) {
|
||||
Log.i(TAG, "loadOnlinePlayer $link")
|
||||
try {
|
||||
|
@ -1215,18 +1265,37 @@ class CS3IPlayer : IPlayer {
|
|||
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)
|
||||
}
|
||||
|
||||
val mime = when {
|
||||
link.isM3u8 -> MimeTypes.APPLICATION_M3U8
|
||||
link.isDash -> MimeTypes.APPLICATION_MPD
|
||||
else -> MimeTypes.VIDEO_MP4
|
||||
val mime = when (link.type) {
|
||||
ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8
|
||||
ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD
|
||||
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)
|
||||
}
|
||||
} 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
|
||||
MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE)
|
||||
)
|
||||
|
@ -1252,16 +1321,16 @@ class CS3IPlayer : IPlayer {
|
|||
}
|
||||
|
||||
loadExo(context, mediaItems, subSources, cacheFactory)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "loadOnlinePlayer error", e)
|
||||
playerError?.invoke(e)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "loadOnlinePlayer error", t)
|
||||
event(ErrorEvent(t))
|
||||
}
|
||||
}
|
||||
|
||||
override fun reloadPlayer(context: Context) {
|
||||
Log.i(TAG, "reloadPlayer")
|
||||
|
||||
exoPlayer?.release()
|
||||
releasePlayer(false)
|
||||
currentLink?.let {
|
||||
loadOnlinePlayer(context, it)
|
||||
} ?: currentDownloadedFile?.let {
|
||||
|
|
|
@ -3,19 +3,23 @@ package com.lagradost.cloudstream3.ui.player
|
|||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.exoplayer2.Format
|
||||
import com.google.android.exoplayer2.text.*
|
||||
import com.google.android.exoplayer2.text.cea.Cea608Decoder
|
||||
import com.google.android.exoplayer2.text.cea.Cea708Decoder
|
||||
import com.google.android.exoplayer2.text.dvb.DvbDecoder
|
||||
import com.google.android.exoplayer2.text.pgs.PgsDecoder
|
||||
import com.google.android.exoplayer2.text.ssa.SsaDecoder
|
||||
import com.google.android.exoplayer2.text.subrip.SubripDecoder
|
||||
import com.google.android.exoplayer2.text.ttml.TtmlDecoder
|
||||
import com.google.android.exoplayer2.text.tx3g.Tx3gDecoder
|
||||
import com.google.android.exoplayer2.text.webvtt.Mp4WebvttDecoder
|
||||
import com.google.android.exoplayer2.text.webvtt.WebvttDecoder
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.exoplayer.text.ExoplayerCuesDecoder
|
||||
import androidx.media3.exoplayer.text.SubtitleDecoderFactory
|
||||
import androidx.media3.extractor.text.SubtitleDecoder
|
||||
import androidx.media3.extractor.text.SubtitleInputBuffer
|
||||
import androidx.media3.extractor.text.SubtitleOutputBuffer
|
||||
import androidx.media3.extractor.text.cea.Cea608Decoder
|
||||
import androidx.media3.extractor.text.cea.Cea708Decoder
|
||||
import androidx.media3.extractor.text.dvb.DvbDecoder
|
||||
import androidx.media3.extractor.text.pgs.PgsDecoder
|
||||
import androidx.media3.extractor.text.ssa.SsaDecoder
|
||||
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.mvvm.logError
|
||||
import org.mozilla.universalchardet.UniversalDetector
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import android.os.Looper
|
||||
import com.google.android.exoplayer2.text.SubtitleDecoderFactory
|
||||
import com.google.android.exoplayer2.text.TextOutput
|
||||
import androidx.media3.exoplayer.text.SubtitleDecoderFactory
|
||||
import androidx.media3.exoplayer.text.TextOutput
|
||||
|
||||
class CustomTextRenderer(
|
||||
offset: Long,
|
||||
|
|
|
@ -50,47 +50,60 @@ class DownloadFileGenerator(
|
|||
return null
|
||||
}
|
||||
|
||||
fun cleanDisplayName(name: String): String {
|
||||
return name.substringBeforeLast('.').trim()
|
||||
}
|
||||
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
isCasting: Boolean,
|
||||
type: LoadType,
|
||||
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
||||
subtitleCallback: (SubtitleData) -> Unit,
|
||||
offset: Int,
|
||||
offset: Int
|
||||
): Boolean {
|
||||
val meta = episodes[currentIndex + offset]
|
||||
callback(Pair(null, meta))
|
||||
callback(null to meta)
|
||||
|
||||
context?.let { ctx ->
|
||||
val relative = meta.relativePath
|
||||
val display = meta.displayName
|
||||
val ctx = context ?: return true
|
||||
val relative = meta.relativePath ?: return true
|
||||
val display = meta.displayName ?: return true
|
||||
|
||||
if (display == null || relative == null) {
|
||||
return@let
|
||||
val cleanDisplay = cleanDisplayName(display)
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.lagradost.cloudstream3.CommonActivity
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.utils.ExtractorUri
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.safefile.SafeFile
|
||||
|
||||
const val DTAG = "PlayerActivity"
|
||||
|
||||
|
@ -50,14 +51,17 @@ class DownloadedPlayerActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
private fun playUri(uri: Uri) {
|
||||
val name = UniFile.fromUri(this, uri).name
|
||||
val name = SafeFile.fromUri(this, uri)?.name()
|
||||
this.navigate(
|
||||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||
DownloadFileGenerator(
|
||||
listOf(
|
||||
ExtractorUri(
|
||||
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()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
@ -37,14 +37,17 @@ class ExtractorLinkGenerator(
|
|||
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
isCasting: Boolean,
|
||||
type: LoadType,
|
||||
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
||||
subtitleCallback: (SubtitleData) -> Unit,
|
||||
offset: Int
|
||||
): Boolean {
|
||||
subtitles.forEach(subtitleCallback)
|
||||
val allowedTypes = type.toSet()
|
||||
links.forEach {
|
||||
callback.invoke(it to null)
|
||||
if(allowedTypes.contains(it.type)) {
|
||||
callback.invoke(it to null)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -19,12 +19,15 @@ import androidx.core.view.isGone
|
|||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.exoplayer2.Format.NO_VALUE
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import com.hippo.unifile.UniFile
|
||||
import androidx.media3.common.Format.NO_VALUE
|
||||
import androidx.media3.common.MimeTypes
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||
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.subtitles.AbstractSubtitleEntities
|
||||
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.source_priority.QualityDataHelper
|
||||
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.settings.SettingsFragment.Companion.isTvSettings
|
||||
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.popCurrentPage
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import kotlinx.android.synthetic.main.dialog_online_subtitles.*
|
||||
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 com.lagradost.safefile.SafeFile
|
||||
import kotlinx.coroutines.Job
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.collections.HashMap
|
||||
import kotlin.math.abs
|
||||
|
||||
class GeneratorPlayer : FullScreenPlayer() {
|
||||
|
@ -100,12 +92,14 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none
|
||||
|
||||
private var binding: FragmentPlayerBinding? = null
|
||||
|
||||
private fun startLoading() {
|
||||
player.release()
|
||||
currentSelectedSubtitles = null
|
||||
isActive = false
|
||||
overlay_loading_skip_button?.isVisible = false
|
||||
player_loading_overlay?.isVisible = true
|
||||
binding?.overlayLoadingSkipButton?.isVisible = false
|
||||
binding?.playerLoadingOverlay?.isVisible = true
|
||||
}
|
||||
|
||||
private fun setSubtitles(sub: SubtitleData?): Boolean {
|
||||
|
@ -120,7 +114,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
override fun onTracksInfoChanged() {
|
||||
val tracks = player.getVideoTracks()
|
||||
player_tracks_btt?.isVisible =
|
||||
playerBinding?.playerTracksBtt?.isVisible =
|
||||
tracks.allVideoTracks.size > 1 || tracks.allAudioTracks.size > 1
|
||||
// Only set the preferred language if it is available.
|
||||
// Otherwise it may give some users audio track init failed!
|
||||
|
@ -142,7 +136,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
return durPos.position
|
||||
}
|
||||
|
||||
var currentVerifyLink: Job? = null
|
||||
private var currentVerifyLink: Job? = null
|
||||
|
||||
private fun loadExtractorJob(extractorLink: ExtractorLink?) {
|
||||
currentVerifyLink?.cancel()
|
||||
|
@ -160,12 +154,12 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
if (link == null) return
|
||||
|
||||
// manage UI
|
||||
player_loading_overlay?.isVisible = false
|
||||
binding?.playerLoadingOverlay?.isVisible = false
|
||||
uiReset()
|
||||
currentSelectedLink = link
|
||||
currentMeta = viewModel.getMeta()
|
||||
nextMeta = viewModel.getNextMeta()
|
||||
setEpisodes(viewModel.getAllMeta() ?: emptyList())
|
||||
// setEpisodes(viewModel.getAllMeta() ?: emptyList())
|
||||
isActive = true
|
||||
setPlayerDimen(null)
|
||||
setTitle()
|
||||
|
@ -211,7 +205,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
closestQuality(linkData?.quality)
|
||||
)
|
||||
val sourcePriority =
|
||||
QualityDataHelper.getSourcePriority(qualityProfile, linkData?.name)
|
||||
QualityDataHelper.getSourcePriority(qualityProfile, linkData?.source)
|
||||
|
||||
// negative because we want to sort highest quality first
|
||||
return qualityPriority + sourcePriority
|
||||
|
@ -259,7 +253,9 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
val isSingleProvider = subsProviders.size == 1
|
||||
|
||||
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 currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null
|
||||
|
@ -297,6 +293,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
imageViewEnd.setImageDrawable(drawableEnd)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(layout, null)
|
||||
|
||||
|
@ -320,16 +317,16 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
dialog.show()
|
||||
dialog.cancel_btt.setOnClickListener {
|
||||
binding.cancelBtt.setOnClickListener {
|
||||
dialog.dismissSafe()
|
||||
}
|
||||
|
||||
dialog.subtitle_adapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE
|
||||
dialog.subtitle_adapter.adapter = arrayAdapter
|
||||
binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE
|
||||
binding.subtitleAdapter.adapter = arrayAdapter
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -345,16 +342,16 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
val currentTempMeta = getMetaData()
|
||||
// bruh idk why it is not correct
|
||||
val color = ColorStateList.valueOf(context.colorFromAttribute(R.attr.colorAccent))
|
||||
dialog.search_loading_bar.progressTintList = color
|
||||
dialog.search_loading_bar.indeterminateTintList = color
|
||||
binding.searchLoadingBar.progressTintList = color
|
||||
binding.searchLoadingBar.indeterminateTintList = color
|
||||
|
||||
observeNullable(viewModel.currentSubtitleYear) {
|
||||
// When year is changed search again
|
||||
dialog.subtitles_search.setQuery(dialog.subtitles_search.query, true)
|
||||
dialog.year_btt.text = it?.toString() ?: txt(R.string.none).asString(context)
|
||||
binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true)
|
||||
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 currentYear = Calendar.getInstance().get(Calendar.YEAR)
|
||||
val earliestYear = 1900
|
||||
|
@ -382,10 +379,10 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
)
|
||||
}
|
||||
|
||||
dialog.subtitles_search.setOnQueryTextListener(object :
|
||||
binding.subtitlesSearch.setOnQueryTextListener(object :
|
||||
androidx.appcompat.widget.SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
dialog.search_loading_bar?.show()
|
||||
binding.searchLoadingBar.show()
|
||||
ioSafe {
|
||||
val search =
|
||||
AbstractSubtitleEntities.SubtitleSearch(
|
||||
|
@ -417,7 +414,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
// ugly ik
|
||||
activity?.runOnUiThread {
|
||||
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 }
|
||||
activity?.showDialog(languages.map { it.languageName },
|
||||
lang639_1.indexOf(currentLanguageTwoLetters),
|
||||
|
@ -438,11 +435,11 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
true,
|
||||
{ }) { 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 ->
|
||||
providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api ->
|
||||
ioSafe {
|
||||
|
@ -468,7 +465,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
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
|
||||
//dialog.subtitles_search_year?.setText(currentTempMeta.year)
|
||||
}
|
||||
|
@ -511,7 +508,6 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
selectSourceDialog?.dismissSafe()
|
||||
|
||||
showToast(
|
||||
activity,
|
||||
String.format(ctx.getString(R.string.player_loaded_subtitles), subtitleData.name),
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
|
@ -525,15 +521,16 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
if (uri == null) return@normalSafeApiCall
|
||||
val ctx = context ?: AcraApplication.context ?: return@normalSafeApiCall
|
||||
// RW perms for the path
|
||||
val flags =
|
||||
ctx.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
|
||||
ctx.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
|
||||
val file = UniFile.fromUri(ctx, uri)
|
||||
println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}")
|
||||
val file = SafeFile.fromUri(ctx, uri)
|
||||
val fileName = file?.name()
|
||||
println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName")
|
||||
// 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(
|
||||
name,
|
||||
|
@ -556,17 +553,19 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
//println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs")
|
||||
context?.let { ctx ->
|
||||
val isPlaying = player.getIsPlaying()
|
||||
player.handleEvent(CSPlayerEvent.Pause)
|
||||
player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI)
|
||||
val currentSubtitles = sortSubs(currentSubs)
|
||||
|
||||
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
|
||||
|
||||
sourceDialog.show()
|
||||
val providerList = sourceDialog.sort_providers
|
||||
val subtitleList = sourceDialog.sort_subtitles
|
||||
val providerList = binding.sortProviders
|
||||
val subtitleList = binding.sortSubtitles
|
||||
|
||||
val loadFromFileFooter: 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)
|
||||
}
|
||||
|
||||
fun setProfileName(profile: Int) {
|
||||
sourceDialog.source_settings_btt.setText(
|
||||
binding.sourceSettingsBtt.setText(
|
||||
QualityDataHelper.getProfileName(
|
||||
profile
|
||||
)
|
||||
|
@ -687,7 +686,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
setProfileName(currentQualityProfile)
|
||||
|
||||
sourceDialog.profiles_click_settings.setOnClickListener {
|
||||
binding.profilesClickSettings.setOnClickListener {
|
||||
val activity = activity ?: return@setOnClickListener
|
||||
QualityProfileDialog(
|
||||
activity,
|
||||
|
@ -701,7 +700,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}.show()
|
||||
}
|
||||
|
||||
sourceDialog.subtitles_encoding_format?.apply {
|
||||
binding.subtitlesEncodingFormat.apply {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
sourceDialog.subtitles_click_settings?.setOnClickListener {
|
||||
binding.subtitlesClickSettings.setOnClickListener {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
.attachBackupListener(ctx.getSyncPrefs()).self
|
||||
|
||||
|
@ -744,7 +743,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
}
|
||||
|
||||
sourceDialog.apply_btt?.setOnClickListener {
|
||||
binding.applyBtt.setOnClickListener {
|
||||
var init = false
|
||||
if (sourceIndex != startSource) {
|
||||
init = true
|
||||
|
@ -784,18 +783,19 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
it.height?.times(-1)
|
||||
}
|
||||
val currentAudioTracks = tracks.allAudioTracks
|
||||
|
||||
val binding: PlayerSelectTracksBinding =
|
||||
PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false)
|
||||
val trackDialog = Dialog(ctx, R.style.AlertDialogCustomBlack)
|
||||
trackDialog.setContentView(R.layout.player_select_tracks)
|
||||
trackDialog.setContentView(binding.root)
|
||||
trackDialog.show()
|
||||
|
||||
// selectTracksDialog = tracksDialog
|
||||
|
||||
val videosList = trackDialog.video_tracks_list
|
||||
val audioList = trackDialog.auto_tracks_list
|
||||
val videosList = binding.videoTracksList
|
||||
val audioList = binding.autoTracksList
|
||||
|
||||
trackDialog.video_tracks_holder.isVisible = currentVideoTracks.size > 1
|
||||
trackDialog.audio_tracks_holder.isVisible = currentAudioTracks.size > 1
|
||||
binding.videoTracksHolder.isVisible = currentVideoTracks.size > 1
|
||||
binding.audioTracksHolder.isVisible = currentAudioTracks.size > 1
|
||||
|
||||
fun dismiss() {
|
||||
if (isPlaying) {
|
||||
|
@ -860,11 +860,11 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
audioList.setItemChecked(which, true)
|
||||
}
|
||||
|
||||
trackDialog.cancel_btt?.setOnClickListener {
|
||||
binding.cancelBtt.setOnClickListener {
|
||||
trackDialog.dismissSafe(activity)
|
||||
}
|
||||
|
||||
trackDialog.apply_btt?.setOnClickListener {
|
||||
binding.applyBtt.setOnClickListener {
|
||||
val currentTrack = currentAudioTracks.getOrNull(audioIndexStart)
|
||||
player.setPreferredAudioTrack(
|
||||
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")
|
||||
super.playerError(exception)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
@ -948,14 +948,13 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
var maxEpisodeSet: Int? = null
|
||||
var hasRequestedStamps: Boolean = false
|
||||
override fun playerPositionChanged(posDur: Pair<Long, Long>) {
|
||||
override fun playerPositionChanged(position: Long, duration : Long) {
|
||||
// Don't save livestream data
|
||||
if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return
|
||||
|
||||
// Don't save NSFW data
|
||||
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 (!hasRequestedStamps) {
|
||||
hasRequestedStamps = true
|
||||
|
@ -1033,8 +1032,10 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
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) {
|
||||
viewModel.preLoadNextLinks()
|
||||
|
@ -1171,7 +1172,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
//Hide title, if set in setting
|
||||
if (limitTitle < 0) {
|
||||
player_video_title?.visibility = View.GONE
|
||||
playerBinding?.playerVideoTitle?.visibility = View.GONE
|
||||
} else {
|
||||
//Truncate video title if it exceeds limit
|
||||
val differenceInLength = playerVideoTitle.length - limitTitle
|
||||
|
@ -1182,8 +1183,8 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
val isFiller: Boolean? = (currentMeta as? ResultEpisode)?.isFiller
|
||||
|
||||
player_episode_filler_holder?.isVisible = isFiller ?: false
|
||||
player_video_title?.text = playerVideoTitle
|
||||
playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false
|
||||
playerBinding?.playerVideoTitle?.text = playerVideoTitle
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
|
@ -1204,12 +1205,14 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
3 -> "$source - $extra"
|
||||
else -> ""
|
||||
}
|
||||
player_video_title_rez?.text = title
|
||||
player_video_title_rez?.isVisible = title.isNotBlank()
|
||||
playerBinding?.playerVideoTitleRez?.apply {
|
||||
text = title
|
||||
isVisible = title.isNotBlank()
|
||||
}
|
||||
}
|
||||
|
||||
override fun playerDimensionsLoaded(widthHeight: Pair<Int, Int>) {
|
||||
setPlayerDimen(widthHeight)
|
||||
override fun playerDimensionsLoaded(width: Int, height : Int) {
|
||||
setPlayerDimen(width to height)
|
||||
}
|
||||
|
||||
private fun unwrapBundle(savedInstanceState: Bundle?) {
|
||||
|
@ -1233,7 +1236,14 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
unwrapBundle(savedInstanceState)
|
||||
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
|
||||
|
@ -1244,9 +1254,8 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
private fun displayTimeStamp(show: Boolean) {
|
||||
if (timestampShowState == show) return
|
||||
skipIndex++
|
||||
println("displayTimeStamp = $show")
|
||||
timestampShowState = show
|
||||
skip_chapter_button?.apply {
|
||||
playerBinding?.skipChapterButton?.apply {
|
||||
val showWidth = 170.toPx
|
||||
val noShowWidth = 10.toPx
|
||||
//if((show && width == showWidth) || (!show && width == noShowWidth)) {
|
||||
|
@ -1266,7 +1275,18 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
from, to
|
||||
).apply {
|
||||
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 ->
|
||||
val value = valueAnimator.animatedValue as Int
|
||||
|
@ -1286,10 +1306,10 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
|
||||
override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) {
|
||||
if (timestamp != null) {
|
||||
skip_chapter_button.setText(timestamp.uiText)
|
||||
playerBinding?.skipChapterButton?.setText(timestamp.uiText)
|
||||
displayTimeStamp(true)
|
||||
val currentIndex = skipIndex
|
||||
skip_chapter_button?.handler?.postDelayed({
|
||||
playerBinding?.skipChapterButton?.handler?.postDelayed({
|
||||
if (skipIndex == currentIndex)
|
||||
displayTimeStamp(false)
|
||||
}, 6000)
|
||||
|
@ -1332,11 +1352,11 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
viewModel.loadLinks()
|
||||
}
|
||||
|
||||
overlay_loading_skip_button?.setOnClickListener {
|
||||
binding?.overlayLoadingSkipButton?.setOnClickListener {
|
||||
startPlayer()
|
||||
}
|
||||
|
||||
player_loading_go_back?.setOnClickListener {
|
||||
binding?.playerLoadingGoBack?.setOnClickListener {
|
||||
player.release()
|
||||
activity?.popCurrentPage()
|
||||
}
|
||||
|
@ -1360,7 +1380,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
is Resource.Failure -> {
|
||||
showToast(activity, it.errorString, Toast.LENGTH_LONG)
|
||||
showToast(it.errorString, Toast.LENGTH_LONG)
|
||||
startPlayer()
|
||||
}
|
||||
}
|
||||
|
@ -1369,8 +1389,8 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
observe(viewModel.currentLinks) {
|
||||
currentLinks = it
|
||||
val turnVisible = it.isNotEmpty()
|
||||
val wasGone = overlay_loading_skip_button?.isGone == true
|
||||
overlay_loading_skip_button?.isVisible = turnVisible
|
||||
val wasGone = binding?.overlayLoadingSkipButton?.isGone == true
|
||||
binding?.overlayLoadingSkipButton?.isVisible = turnVisible
|
||||
|
||||
normalSafeApiCall {
|
||||
if (currentLinks.any { link ->
|
||||
|
@ -1383,7 +1403,7 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
|
||||
if (turnVisible && wasGone) {
|
||||
overlay_loading_skip_button?.requestFocus()
|
||||
binding?.overlayLoadingSkipButton?.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1413,4 +1433,4 @@ class GeneratorPlayer : FullScreenPlayer() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,43 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLinkType
|
||||
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 {
|
||||
val hasCache: Boolean
|
||||
|
||||
|
@ -13,15 +48,15 @@ interface IGenerator {
|
|||
fun goto(index: Int)
|
||||
|
||||
fun getCurrentId(): Int? // this is used to save data or read data about this id
|
||||
fun getCurrent(offset : Int = 0): Any? // this is used to get metadata about the current playing, can return null
|
||||
fun getAll() : List<Any>? // this us used to get the metadata about all entries, not needed
|
||||
fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null
|
||||
fun getAll(): List<Any>? // this us used to get the metadata about all entries, not needed
|
||||
|
||||
/* not safe, must use try catch */
|
||||
suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
isCasting: Boolean,
|
||||
type: LoadType,
|
||||
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
||||
subtitleCallback: (SubtitleData) -> Unit,
|
||||
offset : Int = 0,
|
||||
offset: Int = 0,
|
||||
): Boolean
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package com.lagradost.cloudstream3.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Rational
|
||||
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
|
||||
import com.lagradost.cloudstream3.utils.EpisodeSkip
|
||||
import com.lagradost.cloudstream3.utils.ExtractorLink
|
||||
|
@ -44,9 +45,120 @@ enum class CSPlayerLoading {
|
|||
IsPaused,
|
||||
IsPlaying,
|
||||
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 {
|
||||
/**
|
||||
|
@ -107,27 +219,16 @@ interface IPlayer {
|
|||
fun getDuration(): Long?
|
||||
fun getPosition(): Long?
|
||||
|
||||
fun seekTime(time: Long)
|
||||
fun seekTo(time: Long)
|
||||
fun seekTime(time: Long, source: PlayerEventSource = PlayerEventSource.UI)
|
||||
fun seekTo(time: Long, source: PlayerEventSource = PlayerEventSource.UI)
|
||||
|
||||
fun getSubtitleOffset(): Long // in ms
|
||||
fun setSubtitleOffset(offset: Long) // in ms
|
||||
|
||||
fun initCallbacks(
|
||||
playerUpdated: (Any?) -> Unit, // attach player to view
|
||||
updateIsPlaying: ((Pair<CSPlayerLoading, CSPlayerLoading>) -> Unit)? = null, // (wasPlaying, isPlaying)
|
||||
requestAutoFocus: (() -> Unit)? = null, // current player starts, asking for all other programs to shut the fuck up
|
||||
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)
|
||||
eventHandler: ((PlayerEvent) -> Unit),
|
||||
/** this is used to request when the player should report back view percentage */
|
||||
requestedListeningPercentages: List<Int>? = null,
|
||||
)
|
||||
|
||||
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 getCurrentPreferredSubtitle(): SubtitleData?
|
||||
|
||||
fun handleEvent(event: CSPlayerEvent)
|
||||
fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI)
|
||||
|
||||
fun onStop()
|
||||
fun onPause()
|
||||
|
@ -167,6 +268,19 @@ interface IPlayer {
|
|||
|
||||
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. */
|
||||
fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null)
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ class LinkGenerator(
|
|||
|
||||
override suspend fun generateLinks(
|
||||
clearCache: Boolean,
|
||||
isCasting: Boolean,
|
||||
type: LoadType,
|
||||
callback: (Pair<ExtractorLink?, ExtractorUri?>) -> Unit,
|
||||
subtitleCallback: (SubtitleData) -> Unit,
|
||||
offset: Int
|
||||
|
@ -67,9 +67,8 @@ class LinkGenerator(
|
|||
link.name ?: link.url,
|
||||
unshortenLinkSafe(link.url), // unshorten because it might be a raw link
|
||||
referer ?: "",
|
||||
Qualities.Unknown.value, isM3u8 ?: normalSafeApiCall {
|
||||
URI(link.url).path?.substringAfterLast(".")?.contains("m3u")
|
||||
} ?: false
|
||||
Qualities.Unknown.value,
|
||||
type = INFER_TYPE,
|
||||
) to null
|
||||
)
|
||||
}
|
||||
|
|
|
@ -15,10 +15,10 @@
|
|||
*/
|
||||
package com.lagradost.cloudstream3.ui.player;
|
||||
|
||||
import static com.google.android.exoplayer2.text.Cue.DIMEN_UNSET;
|
||||
import static com.google.android.exoplayer2.text.Cue.LINE_TYPE_NUMBER;
|
||||
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
|
||||
import static com.google.android.exoplayer2.util.Assertions.checkState;
|
||||
import static androidx.media3.common.text.Cue.DIMEN_UNSET;
|
||||
import static androidx.media3.common.text.Cue.LINE_TYPE_NUMBER;
|
||||
import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||
import static androidx.media3.common.util.Assertions.checkState;
|
||||
import static java.lang.annotation.ElementType.TYPE_USE;
|
||||
|
||||
import android.os.Handler;
|
||||
|
@ -28,25 +28,24 @@ import android.os.Message;
|
|||
|
||||
import androidx.annotation.IntDef;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.BaseRenderer;
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.Format;
|
||||
import com.google.android.exoplayer2.FormatHolder;
|
||||
import com.google.android.exoplayer2.RendererCapabilities;
|
||||
import com.google.android.exoplayer2.source.SampleStream.ReadDataResult;
|
||||
import com.google.android.exoplayer2.text.Cue;
|
||||
import com.google.android.exoplayer2.text.CueGroup;
|
||||
import com.google.android.exoplayer2.text.Subtitle;
|
||||
import com.google.android.exoplayer2.text.SubtitleDecoder;
|
||||
import com.google.android.exoplayer2.text.SubtitleDecoderException;
|
||||
import com.google.android.exoplayer2.text.SubtitleDecoderFactory;
|
||||
import com.google.android.exoplayer2.text.SubtitleInputBuffer;
|
||||
import com.google.android.exoplayer2.text.SubtitleOutputBuffer;
|
||||
import com.google.android.exoplayer2.text.TextOutput;
|
||||
import com.google.android.exoplayer2.util.Log;
|
||||
import com.google.android.exoplayer2.util.MimeTypes;
|
||||
import com.google.android.exoplayer2.util.Util;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.text.Cue;
|
||||
import androidx.media3.common.text.CueGroup;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.Log;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.exoplayer.BaseRenderer;
|
||||
import androidx.media3.exoplayer.FormatHolder;
|
||||
import androidx.media3.exoplayer.RendererCapabilities;
|
||||
import androidx.media3.exoplayer.source.SampleStream;
|
||||
import androidx.media3.exoplayer.text.SubtitleDecoderFactory;
|
||||
import androidx.media3.exoplayer.text.TextOutput;
|
||||
import androidx.media3.extractor.text.Subtitle;
|
||||
import androidx.media3.extractor.text.SubtitleDecoder;
|
||||
import androidx.media3.extractor.text.SubtitleDecoderException;
|
||||
import androidx.media3.extractor.text.SubtitleInputBuffer;
|
||||
import androidx.media3.extractor.text.SubtitleOutputBuffer;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.Retention;
|
||||
|
@ -310,7 +309,7 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback {
|
|||
return;
|
||||
}
|
||||
// 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 (nextInputBuffer.isEndOfStream()) {
|
||||
inputStreamEnded = true;
|
||||
|
|
|
@ -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]
|
||||
}
|
|
@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.launchSafe
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import com.lagradost.cloudstream3.mvvm.safeApiCall
|
||||
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.ExtractorUri
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PlayerGeneratorViewModel : ViewModel() {
|
||||
companion object {
|
||||
|
@ -38,6 +40,11 @@ class PlayerGeneratorViewModel : ViewModel() {
|
|||
private val _currentSubtitleYear = MutableLiveData<Int?>(null)
|
||||
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?) {
|
||||
_currentSubtitleYear.postValue(year)
|
||||
}
|
||||
|
@ -72,18 +79,32 @@ class PlayerGeneratorViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
fun preLoadNextLinks() {
|
||||
val id = getId()
|
||||
// Do not preload if already loading
|
||||
if (id == currentLoadingEpisodeId) return
|
||||
|
||||
Log.i(TAG, "preLoadNextLinks")
|
||||
currentJob?.cancel()
|
||||
currentJob = viewModelScope.launchSafe {
|
||||
if (generator?.hasCache == true && generator?.hasNext() == true) {
|
||||
safeApiCall {
|
||||
generator?.generateLinks(
|
||||
clearCache = false,
|
||||
isCasting = false,
|
||||
{},
|
||||
{},
|
||||
offset = 1
|
||||
)
|
||||
currentLoadingEpisodeId = id
|
||||
|
||||
currentJob = viewModelScope.launch {
|
||||
try {
|
||||
if (generator?.hasCache == true && generator?.hasNext() == true) {
|
||||
safeApiCall {
|
||||
generator?.generateLinks(
|
||||
type = LoadType.InApp,
|
||||
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")
|
||||
currentJob?.cancel()
|
||||
|
||||
|
@ -162,14 +183,14 @@ class PlayerGeneratorViewModel : ViewModel() {
|
|||
// load more data
|
||||
_loadingLinks.postValue(Resource.Loading())
|
||||
val loadingState = safeApiCall {
|
||||
generator?.generateLinks(clearCache = clearCache, isCasting = isCasting, {
|
||||
generator?.generateLinks(type = type, clearCache = clearCache, callback = {
|
||||
currentLinks.add(it)
|
||||
// Clone to prevent ConcurrentModificationException
|
||||
normalSafeApiCall {
|
||||
// Extra normalSafeApiCall since .toSet() iterates.
|
||||
_currentLinks.postValue(currentLinks.toSet())
|
||||
}
|
||||
}, {
|
||||
}, subtitleCallback = {
|
||||
currentSubs.add(it)
|
||||
normalSafeApiCall {
|
||||
_currentSubs.postValue(currentSubs.toSet())
|
||||
|
|
|
@ -7,28 +7,23 @@ import android.app.RemoteAction
|
|||
import android.content.Intent
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.StringRes
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class PlayerPipHelper {
|
||||
companion object {
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun getPen(activity: Activity, code: Int): PendingIntent {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.getBroadcast(
|
||||
activity,
|
||||
code,
|
||||
Intent("media_control").putExtra("control_type", code),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
} else {
|
||||
PendingIntent.getBroadcast(
|
||||
activity,
|
||||
code,
|
||||
Intent("media_control").putExtra("control_type", code),
|
||||
0
|
||||
)
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
activity,
|
||||
code,
|
||||
Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
|
@ -48,7 +43,7 @@ class PlayerPipHelper {
|
|||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun updatePIPModeActions(activity: Activity, isPlaying: Boolean) {
|
||||
fun updatePIPModeActions(activity: Activity, isPlaying: Boolean, aspectRatio: Rational?) {
|
||||
val actions: ArrayList<RemoteAction> = ArrayList()
|
||||
actions.add(
|
||||
getRemoteAction(
|
||||
|
@ -87,9 +82,32 @@ class PlayerPipHelper {
|
|||
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
Loading…
Add table
Add a link
Reference in a new issue