Merge pull request #1 from CranberrySoup/sync

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

View file

@ -32,10 +32,10 @@ jobs:
private_key: ${{ secrets.GH_APP_KEY }}
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"

View file

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

View file

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

View file

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

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

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

View file

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

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

View file

@ -1,4 +1,4 @@
import com.android.build.gradle.api.BaseVariantOutput
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
import org.jetbrains.dokka.gradle.DokkaTask
import 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")

View file

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

View file

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

View file

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

View file

@ -43,16 +43,16 @@ class CustomReportSender : ReportSender {
override fun send(context: Context, errorContent: CrashReportData) {
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()
)
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@ import java.net.URI
open class AsianLoad : ExtractorApi() {
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
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,15 +7,22 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.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,
)
}
}

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.amap
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.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
)
)
}

View file

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

View file

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

View file

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

View file

@ -21,10 +21,11 @@ class CrossTmdbProvider : TmdbProvider() {
return Regex("""[^a-zA-Z0-9-]""").replace(name, "")
}
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")
}

View file

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

View file

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

View file

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

View file

@ -137,6 +137,20 @@ object PluginManager {
}
}
/**
* Deletes all generated oat files which will force Android to recompile the dex extensions.
* This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update.
*/
fun deleteAllOatFiles(context: Context) {
File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo ->
repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file ->
val success = file.deleteRecursively()
Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success")
}
}
}
fun getPluginsOnline(): Array<PluginData> {
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
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -158,7 +158,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI {
)
}
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
override suspend fun getStatus(id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null
val 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),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import android.content.DialogInterface
import android.widget.Toast
import 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)
}
}
}

View file

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

View file

@ -3,18 +3,12 @@ package com.lagradost.cloudstream3.ui.download
import android.view.LayoutInflater
import android.view.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
)
}
}
}
}

View file

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

View file

@ -34,12 +34,14 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,22 +1,23 @@
package com.lagradost.cloudstream3.ui.home
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import 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())

View file

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