diff --git a/.github/ISSUE_TEMPLATE/application-bug.yml b/.github/ISSUE_TEMPLATE/application-bug.yml index f3590067312..0b6af0ab0a8 100644 --- a/.github/ISSUE_TEMPLATE/application-bug.yml +++ b/.github/ISSUE_TEMPLATE/application-bug.yml @@ -86,7 +86,7 @@ body: required: true - label: I have written a short but informative title. required: true - - label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**. + - label: I have updated the app to pre-release version **[Latest](https://github.com/3a4oT/cloudstream/releases)**. required: true - label: I will fill out all of the requested information in this form. required: true diff --git a/.github/locales.py b/.github/locales.py index dd23ebbe225..a74d7258834 100644 --- a/.github/locales.py +++ b/.github/locales.py @@ -1,6 +1,7 @@ import re import glob import requests +import os import lxml.etree as ET # builtin library doesn't preserve comments @@ -61,5 +62,8 @@ with open(file, 'wb') as fp: fp.write(b'\n') tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) + # Remove trailing new line to be consistent with weblate + fp.seek(-1, os.SEEK_END) + fp.truncate() except ET.ParseError as ex: print(f"[{file}] {ex}") diff --git a/README.md b/README.md index 8949304e94c..e26286f6838 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,18 @@ -# CloudStream +# Модифікований клієнт [CloudStream](https://github.com/recloudstream/cloudstream) -**⚠️ Warning: By default this app doesn't provide any video sources, you have to install extensions in order to add functionality to the app.** + +## 📖 Що це таке? +Це спеціальна програма для OS Android iз запропонованим розширенням (ви самі маєте вирішити чи активувати запропоноване розширення) для перегляду фільмів, серіалів та аніме в якісному українському дубляжу від різних постачальників в стрімінговій програмі [Cloudstream](https://github.com/recloudstream/cloudstream). Підтримує AndroidTV -[![Discord](https://invidget.switchblade.xyz/5Hus6fM)](https://discord.gg/5Hus6fM) +## 👨🏻‍🔧 Як завантажити? +Можна [завантажити APK файл](https://github.com/3a4oT/cloudstream/releases/download/4.3.333/stream-UKR-4-3-333.apk). Або зібрати самому із вихідного коду. -### Features: -+ **AdFree**, No ads whatsoever -+ No tracking/analytics -+ Bookmarks -+ Phone and TV support -+ Chromecast -+ Extension system for personal customization +## ⚙️ Активація розширення +При першому запуску, у вас з'явиться екран налаштування. Після вибору мови, ви потрапите на екран `Розширення` де буде опція під назвою - **"піратити - це погано, ризикуєш? :)"**. Все що вам потрібно - це натиснути на кнопку завантаження, розширення встановиться автоматично. (якщо пропустили цей екран - перейдіть в `Додаток -> Параметри -> Розширення`) + +## 🇺🇦 Як відрізняється від оригінального клієнта **CloudStream** +- Вирізані всі локалізації окрім української та англійської +- Модифіковно розширення за замовчуванням. Використано форк розширення від [CakesTwix](https://github.com/CakesTwix/cloudstream-extensions-uk). Велика дяка за роботу :)) +- Змінено URL для функції автооновлення. -### Supported languages: - - Translation status - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e439d53858..956909a948b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,7 +61,7 @@ android { targetSdk = 33 /* Android 14 is Fu*ked ^ https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading*/ versionCode = 63 - versionName = "4.3.2" + versionName = "4.3.333" resValue("string", "app_version", "${defaultConfig.versionName}${versionNameSuffix ?: ""}") resValue("string", "commit_hash", "git rev-parse --short HEAD".execute() ?: "") @@ -290,7 +290,7 @@ tasks.withType().configureEach { localDirectory.set(file("src/main/java")) // URL showing where the source code can be accessed through the web browser - remoteUrl.set(URL("https://github.com/recloudstream/cloudstream/tree/master/app/src/main/java")) + remoteUrl.set(URL("https://github.com/3a4oT/cloudstream/tree/master/app/src/main/java")) // Suffix which is used to append the line number to the URL. Use #L for GitHub remoteLineSuffix.set("#L") diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt index faacdf50141..c7f02baff8a 100644 --- a/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt @@ -154,7 +154,7 @@ class ExampleInstrumentedTest { fun providerCorrectHomepage() { runBlocking { getAllProviders().toList().amap { api -> - TestingUtils.testHomepage(api, ::println) + TestingUtils.testHomepage(api, TestingUtils.Logger()) } } println("Done providerCorrectHomepage") @@ -166,7 +166,6 @@ class ExampleInstrumentedTest { TestingUtils.getDeferredProviderTests( this, getAllProviders(), - ::println ) { _, _ -> } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a23ef72588f..888be999025 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -97,7 +97,7 @@ --> Unit)) : ACRA.errorReporter.handleException(error) try { PrintStream(errorFile).use { ps -> - ps.println(String.format("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}")) - ps.println( - String.format( - "Fatal exception on thread %s (%d)", - thread.name, - thread.id - ) - ) + ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") + ps.println("Fatal exception on thread ${thread.name} (${thread.id})") error.printStackTrace(ps) } } catch (ignored: FileNotFoundException) { @@ -106,7 +101,6 @@ class AcraApplication : Application() { override fun onCreate() { super.onCreate() - //NativeCrashHandler.initCrashHandler() ExceptionHandler(filesDir.resolve("last_error")) { val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) startActivity(Intent.makeRestartActivityTask(intent!!.component)) diff --git a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt index ba303feff7e..ee3a5d122c7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt @@ -5,6 +5,7 @@ import android.app.Activity import android.app.PictureInPictureParams import android.content.Context import android.content.pm.PackageManager +import android.content.res.Configuration import android.content.res.Resources import android.os.Build import android.util.DisplayMetrics @@ -164,7 +165,7 @@ object CommonActivity { val toast = Toast(act) toast.duration = duration ?: Toast.LENGTH_SHORT toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) - toast.view = binding.root + toast.view = binding.root //fixme Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version. currentToast = toast toast.show() @@ -276,12 +277,35 @@ object CommonActivity { } } + fun updateTheme(act: Activity) { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(act) + if (settingsManager + .getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System" + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + loadThemes(act) + } + } + + private fun mapSystemTheme(act: Activity): Int { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val currentNightMode = + act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + return when (currentNightMode) { + Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme + else -> R.style.AppTheme // Night mode is active, we're using dark theme + } + } else { + return R.style.AppTheme + } + } + fun loadThemes(act: Activity?) { if (act == null) return val settingsManager = PreferenceManager.getDefaultSharedPreferences(act) val currentTheme = when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) { + "System" -> mapSystemTheme(act) "Black" -> R.style.AppTheme "Light" -> R.style.LightMode "Amoled" -> R.style.AmoledMode @@ -352,8 +376,8 @@ object CommonActivity { currentLook = currentLook.parent as? View ?: break }*/ - private fun View.hasContent() : Boolean { - return isShown && when(this) { + private fun View.hasContent(): Boolean { + return isShown && when (this) { //is RecyclerView -> this.childCount > 0 is ViewGroup -> this.childCount > 0 else -> true @@ -464,20 +488,6 @@ object CommonActivity { fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?) { - //println("Keycode: $keyCode") - //showToast( - // this, - // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", - // Toast.LENGTH_LONG - //) - - // Tested keycodes on remote: - // KeyEvent.KEYCODE_MEDIA_FAST_FORWARD - // KeyEvent.KEYCODE_MEDIA_REWIND - // KeyEvent.KEYCODE_MENU - // KeyEvent.KEYCODE_MEDIA_NEXT - // KeyEvent.KEYCODE_MEDIA_PREVIOUS - // KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE // 149 keycode_numpad 5 when (keyCode) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt index 934dd58a351..8da7ca384b5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt @@ -11,7 +11,7 @@ import java.util.concurrent.TimeUnit class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() { - private val client: OkHttpClient + private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build() override fun execute(request: Request): Response { val httpMethod: String = request.httpMethod() val url: String = request.url() @@ -74,8 +74,4 @@ class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Do return instance } } - - init { - client = builder.readTimeout(30, TimeUnit.SECONDS).build() - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index 59f499c5f2e..5408d2a893b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -68,6 +68,7 @@ import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.setActivityInstance import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.updateLocale +import com.lagradost.cloudstream3.CommonActivity.updateTheme import com.lagradost.cloudstream3.databinding.ActivityMainBinding import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding @@ -82,13 +83,13 @@ import com.lagradost.cloudstream3.plugins.PluginManager.loadAllOnlinePlugins import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver import com.lagradost.cloudstream3.services.SubscriptionWorkManager +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.OAuth2Apis import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.accountManagers -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appString -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringPlayer -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringRepo -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.inAppAuths import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi import com.lagradost.cloudstream3.syncproviders.SyncAPI @@ -107,6 +108,7 @@ import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.result.setImage import com.lagradost.cloudstream3.ui.result.setText +import com.lagradost.cloudstream3.ui.result.setTextHtml import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.ui.search.SearchFragment import com.lagradost.cloudstream3.ui.search.SearchResultBuilder @@ -131,6 +133,8 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BackupUtils.backup import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback @@ -149,6 +153,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog +import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute @@ -346,7 +351,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa println("Repository url: $realUrl") loadRepository(realUrl) return true - } else if (str.contains(appString)) { + } else if (str.contains(APP_STRING)) { for (api in OAuth2Apis) { if (str.contains("/${api.redirectUrl}")) { ioSafe { @@ -376,15 +381,15 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } // This specific intent is used for the gradle deployWithAdb // https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46 - if (str == "$appString:") { + if (str == "$APP_STRING:") { PluginManager.hotReloadAllLocalPlugins(activity) } - } else if (safeURI(str)?.scheme == appStringRepo) { - val url = str.replaceFirst(appStringRepo, "https") + } else if (safeURI(str)?.scheme == APP_STRING_REPO) { + val url = str.replaceFirst(APP_STRING_REPO, "https") loadRepository(url) return true - } else if (safeURI(str)?.scheme == appStringSearch) { - val query = str.substringAfter("$appStringSearch://") + } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) { + val query = str.substringAfter("$APP_STRING_SEARCH://") nextSearchQuery = try { URLDecoder.decode(query, "UTF-8") @@ -398,7 +403,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa R.id.navigation_search activity?.findViewById(R.id.nav_rail_view)?.selectedItemId = R.id.navigation_search - } else if (safeURI(str)?.scheme == appStringPlayer) { + } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) { val uri = Uri.parse(str) val name = uri.getQueryParameter("name") val url = URLDecoder.decode(uri.authority, "UTF-8") @@ -412,9 +417,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ) ) ) - } else if (safeURI(str)?.scheme == appStringResumeWatching) { + } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { val id = - str.substringAfter("$appStringResumeWatching://").toIntOrNull() + str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull() ?: return false ioSafe { val resumeWatchingCard = @@ -468,7 +473,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ) DubStatus.Dubbed else DubStatus.Subbed, null ) } else { - viewModel.loadSmall(this, result) + viewModel.loadSmall(result) } } @@ -483,6 +488,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) updateLocale() // android fucks me by chaining lang when rotating the phone + updateTheme(this) // Update if system theme val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment @@ -571,11 +577,40 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa binding?.apply { navRailView.isVisible = isNavVisible && landscape navView.isVisible = isNavVisible && !landscape + + /** + * We need to make sure if we return to a sub-fragment, + * the correct navigation item is selected so that it does not + * highlight the wrong one in UI. + */ + when (destination.id) { + in listOf(R.id.navigation_downloads, R.id.navigation_download_child) -> { + navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true + navView.menu.findItem(R.id.navigation_downloads).isChecked = true + } + in listOf( + R.id.navigation_settings, + R.id.navigation_subtitles, + R.id.navigation_chrome_subtitles, + R.id.navigation_settings_player, + R.id.navigation_settings_updates, + R.id.navigation_settings_ui, + R.id.navigation_settings_account, + R.id.navigation_settings_providers, + R.id.navigation_settings_general, + R.id.navigation_settings_extensions, + R.id.navigation_settings_plugins, + R.id.navigation_test_providers + ) -> { + navRailView.menu.findItem(R.id.navigation_settings).isChecked = true + navView.menu.findItem(R.id.navigation_settings).isChecked = true + } + } } } //private var mCastSession: CastSession? = null - lateinit var mSessionManager: SessionManager + var mSessionManager: SessionManager? = null private val mSessionManagerListener: SessionManagerListener by lazy { SessionManagerListenerImpl() } private inner class SessionManagerListenerImpl : SessionManagerListener { @@ -615,8 +650,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa setActivityInstance(this) try { if (isCastApiAvailable()) { - //mCastSession = mSessionManager.currentCastSession - mSessionManager.addSessionManagerListener(mSessionManagerListener) + mSessionManager?.addSessionManagerListener(mSessionManagerListener) } } catch (e: Exception) { logError(e) @@ -632,7 +666,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa } try { if (isCastApiAvailable()) { - mSessionManager.removeSessionManagerListener(mSessionManagerListener) + mSessionManager?.removeSessionManagerListener(mSessionManagerListener) //mCastSession = null } } catch (e: Exception) { @@ -736,7 +770,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa list.forEach { custom -> allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass } ?.let { - allProviders.add(it.javaClass.newInstance().apply { + allProviders.add(it.javaClass.getDeclaredConstructor().newInstance().apply { name = custom.name lang = custom.lang mainUrl = custom.url.trimEnd('/') @@ -1117,7 +1151,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa super.onCreate(savedInstanceState) try { if (isCastApiAvailable()) { - mSessionManager = CastContext.getSharedInstance(this).sessionManager + CastContext.getSharedInstance(this) {it.run()}.addOnSuccessListener { mSessionManager = it.sessionManager } } } catch (t: Throwable) { logError(t) @@ -1223,17 +1257,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa this.setKey(getString(R.string.jsdelivr_proxy_key), false) } else { this.setKey(getString(R.string.jsdelivr_proxy_key), true) - val parentView: View = findViewById(android.R.id.content) - Snackbar.make(parentView, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG) - .let { snackbar -> - snackbar.setAction(R.string.revert) { - setKey(getString(R.string.jsdelivr_proxy_key), false) - } - snackbar.setBackgroundTint(colorFromAttribute(R.attr.primaryGrayBackground)) - snackbar.setTextColor(colorFromAttribute(R.attr.textColor)) - snackbar.setActionTextColor(colorFromAttribute(R.attr.colorPrimary)) - snackbar.show() - } + showSnackbar( + this@MainActivity, + R.string.jsdelivr_enabled, + Snackbar.LENGTH_LONG, + R.string.revert + ) { setKey(getString(R.string.jsdelivr_proxy_key), false) } } } } @@ -1404,7 +1433,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa resultviewPreviewMetaDuration.setText(d.durationText) resultviewPreviewMetaRating.setText(d.ratingText) - resultviewPreviewDescription.setText(d.plotText) + resultviewPreviewDescription.setTextHtml(d.plotText) resultviewPreviewPoster.setImage( d.posterImage ?: d.posterBackgroundImage ) @@ -1419,13 +1448,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa val value = viewModel.watchStatus.value ?: WatchType.NONE this@MainActivity.showBottomDialog( - WatchType.values().map { getString(it.stringRes) }.toList(), + WatchType.entries.map { getString(it.stringRes) }.toList(), value.ordinal, this@MainActivity.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { viewModel.updateWatchStatus( - WatchType.values()[it], + WatchType.entries[it], this@MainActivity ) } @@ -1435,12 +1464,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa ?: SyncWatchType.NONE this@MainActivity.showBottomDialog( - SyncWatchType.values().map { getString(it.stringRes) }.toList(), + SyncWatchType.entries.map { getString(it.stringRes) }.toList(), value.ordinal, this@MainActivity.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { - syncViewModel.setStatus(SyncWatchType.values()[it].internalId) + syncViewModel.setStatus(SyncWatchType.entries[it].internalId) syncViewModel.publishUserData() } } @@ -1572,7 +1601,12 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa if (isLayout(TV or EMULATOR)) { if (navDestination.matchDestination(R.id.navigation_home)) { - attachBackPressedCallback() + attachBackPressedCallback { + showConfirmExitDialog() + window?.navigationBarColor = + colorFromAttribute(R.attr.primaryGrayBackground) + updateLocale() + } } else detachBackPressedCallback() } } @@ -1817,28 +1851,6 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa finish() } - private var backPressedCallback: OnBackPressedCallback? = null - - private fun attachBackPressedCallback() { - if (backPressedCallback == null) { - backPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - showConfirmExitDialog() - window?.navigationBarColor = - colorFromAttribute(R.attr.primaryGrayBackground) - updateLocale() - } - } - } - - backPressedCallback?.isEnabled = true - onBackPressedDispatcher.addCallback(this, backPressedCallback ?: return) - } - - private fun detachBackPressedCallback() { - backPressedCallback?.isEnabled = false - } - suspend fun checkGithubConnectivity(): Boolean { return try { app.get( @@ -1849,4 +1861,4 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa false } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt b/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt deleted file mode 100644 index 7be904405a9..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/NativeCrashHandler.kt +++ /dev/null @@ -1,53 +0,0 @@ -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() - }*/ -} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt index 75e96bec137..bc646a8d2a2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/SyncRedirector.kt @@ -2,15 +2,13 @@ package com.lagradost.cloudstream3.metaproviders import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis import com.lagradost.cloudstream3.syncproviders.SyncIdName object SyncRedirector { - val syncApis = SyncApis private val syncIds = listOf( - SyncIdName.MyAnimeList to Regex("""myanimelist\.net\/anime\/(\d+)"""), - SyncIdName.Anilist to Regex("""anilist\.co\/anime\/(\d+)""") + SyncIdName.MyAnimeList to Regex("""myanimelist\.net/anime/(\d+)"""), + SyncIdName.Anilist to Regex("""anilist\.co/anime/(\d+)""") ) suspend fun redirect( diff --git a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt index 7c375e0af6f..addee9a02b7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/metaproviders/TraktProvider.kt @@ -236,6 +236,7 @@ open class TraktProvider : MainAPI() { posterUrl = fixPath(episode.images?.screenshot?.firstOrNull()), rating = episode.rating?.times(10)?.roundToInt(), description = episode.overview, + runTime = episode.runtime ).apply { this.addDate(episode.firstAired, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") if (nextAir == null && this.date != null && this.date!! > unixTimeMS && this.season != 0) { @@ -295,7 +296,7 @@ open class TraktProvider : MainAPI() { return try { val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) val dateTime = dateString?.let { format.parse(it)?.time } ?: return false - APIHolder.unixTimeMS < dateTime + unixTimeMS < dateTime } catch (t: Throwable) { logError(t) false diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt index ce2fb3a216d..85a9db5db73 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.network -import android.util.Base64 import android.util.Log import android.webkit.CookieManager import androidx.annotation.AnyThread @@ -10,7 +9,10 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.nicehttp.Requests.Companion.await import com.lagradost.nicehttp.cookies import kotlinx.coroutines.runBlocking -import okhttp3.* +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response import java.net.URI diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt index e89ccfeb316..ddf5b286870 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/CloudstreamPlugin.kt @@ -2,5 +2,4 @@ package com.lagradost.cloudstream3.plugins @Suppress("unused") @Target(AnnotationTarget.CLASS) -annotation class CloudstreamPlugin( -) \ No newline at end of file +annotation class CloudstreamPlugin \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt index 7f08af924ad..fc8365876bf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -34,7 +34,7 @@ abstract class Plugin { */ fun registerMainAPI(element: MainAPI) { Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) MainAPI") - element.sourcePlugin = this.__filename + element.sourcePlugin = this.filename // Race condition causing which would case duplicates if not for distinctBy synchronized(APIHolder.allProviders) { APIHolder.allProviders.add(element) @@ -48,7 +48,7 @@ abstract class Plugin { */ fun registerExtractorAPI(element: ExtractorApi) { Log.i(PLUGIN_TAG, "Adding ${element.name} (${element.mainUrl}) ExtractorApi") - element.sourcePlugin = this.__filename + element.sourcePlugin = this.filename extractorApis.add(element) } @@ -68,7 +68,11 @@ abstract class Plugin { */ var resources: Resources? = null /** Full file path to the plugin. */ - var __filename: String? = null + @Deprecated("Renamed to `filename` to follow conventions", replaceWith = ReplaceWith("filename")) + var __filename: String? + get() = filename + set(value) {filename = value} + var filename: String? = null /** * This will add a button in the settings allowing you to add custom settings diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt index 6b2b75f25a2..bc2a1780314 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -1,13 +1,16 @@ package com.lagradost.cloudstream3.plugins +import android.Manifest import android.app.* import android.content.Context +import android.content.pm.PackageManager import android.content.res.AssetManager import android.content.res.Resources import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.fragment.app.FragmentActivity @@ -163,7 +166,7 @@ object PluginManager { private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins" - public var currentlyLoading: String? = null + var currentlyLoading: String? = null // Maps filepath to plugin val plugins: MutableMap = @@ -339,7 +342,7 @@ object PluginManager { //Omit non-NSFW if mode is set to NSFW only if (mode == AutoDownloadMode.NsfwOnly) { - if (tvtypes.contains(TvType.NSFW.name) == false) { + if (!tvtypes.contains(TvType.NSFW.name)) { return@mapNotNull null } } @@ -504,10 +507,12 @@ object PluginManager { val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also { Log.d(TAG, "No manifest version for ${data.internalName}") } + + @Suppress("UNCHECKED_CAST") val pluginClass: Class<*> = loader.loadClass(manifest.pluginClassName) as Class val pluginInstance: Plugin = - pluginClass.newInstance() as Plugin + pluginClass.getDeclaredConstructor().newInstance() as Plugin // Sets with the proper version setPluginData(data.copy(version = version)) @@ -517,14 +522,16 @@ object PluginManager { return true } - pluginInstance.__filename = file.absolutePath + pluginInstance.filename = file.absolutePath if (manifest.requiresResources) { Log.d(TAG, "Loading resources for ${data.internalName}") // based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk - val assets = AssetManager::class.java.newInstance() + val assets = AssetManager::class.java.getDeclaredConstructor().newInstance() val addAssetPath = AssetManager::class.java.getMethod("addAssetPath", String::class.java) addAssetPath.invoke(assets, file.absolutePath) + + @Suppress("DEPRECATION") pluginInstance.resources = Resources( assets, context.resources.displayMetrics, @@ -566,14 +573,14 @@ object PluginManager { // remove all registered apis synchronized(APIHolder.apis) { - APIHolder.apis.filter { api -> api.sourcePlugin == plugin.__filename }.forEach { + APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { removePluginMapping(it) } } synchronized(APIHolder.allProviders) { - APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.__filename } + APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } } - extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.__filename } + extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } classLoaders.values.removeIf { v -> v == plugin } @@ -720,9 +727,14 @@ object PluginManager { } val notification = builder.build() - with(NotificationManagerCompat.from(context)) { - // notificationId is a unique int for each notification that you must define - notify((System.currentTimeMillis() / 1000).toInt(), notification) + // notificationId is a unique int for each notification that you must define + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + NotificationManagerCompat.from(context) + .notify((System.currentTimeMillis() / 1000).toInt(), notification) } return notification } catch (e: Exception) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt index b80a590ef30..fd3712ba56a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -71,9 +71,15 @@ data class SitePlugin( object RepositoryManager { const val ONLINE_PLUGINS_FOLDER = "Extensions" val PREBUILT_REPOSITORIES: Array by lazy { - getKey("PREBUILT_REPOSITORIES") ?: emptyArray() + getKey("PREBUILT_REPOSITORIES") ?: + arrayOf( + RepositoryData( + name = "піратити це - погано, ризикуєш? :)", + url = "https://raw.githubusercontent.com/3a4oT/cloudstream-extensions-uk/master/repo.json" + ) + ) } - val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt index a45ab5f0239..d1b702f4ce3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.sync.withLock object VotingApi { // please do not cheat the votes lol private const val LOGKEY = "VotingApi" - private const val apiDomain = "https://counterapi.com/api" + private const val API_DOMAIN = "https://counterapi.com/api" private fun transformUrl(url: String): String = // dont touch or all votes get reset MessageDigest @@ -49,13 +49,13 @@ object VotingApi { // please do not cheat the votes lol .joinToString("-") private suspend fun readVote(pluginUrl: String): Int { - var url = "${apiDomain}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" + val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" Log.d(LOGKEY, "Requesting: $url") return app.get(url).parsedSafe()?.value ?: 0 } private suspend fun writeVote(pluginUrl: String): Boolean { - var url = "${apiDomain}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}" + val url = "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}" Log.d(LOGKEY, "Requesting: $url") return app.get(url).parsedSafe()?.value != null } @@ -69,8 +69,7 @@ object VotingApi { // please do not cheat the votes lol getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false fun canVote(pluginUrl: String): Boolean { - if (!PluginManager.urlPlugins.contains(pluginUrl)) return false - return true + return PluginManager.urlPlugins.contains(pluginUrl) } private val voteLock = Mutex() diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt index 857fba11676..df64caabc9f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt @@ -59,7 +59,7 @@ class SubtitleResource { return file } - fun unzip(file: File): List> { + private fun unzip(file: File): List> { val entries = mutableListOf>() ZipInputStream(file.inputStream()).use { zipInputStream -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt index ed4ccb74a9b..685b499bb59 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.subtitles -import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.TvType class AbstractSubtitleEntities { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt index 0259ccad35a..2e14c3c46fd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -56,22 +56,22 @@ abstract class AccountManager(private val defIndex: Int) : AuthAPI { subSourceApi ) - const val appString = "cloudstreamapp" - const val appStringRepo = "cloudstreamrepo" - const val appStringPlayer = "cloudstreamplayer" + const val APP_STRING = "cloudstreamapp" + const val APP_STRING_REPO = "cloudstreamrepo" + const val APP_STRING_PLAYER = "cloudstreamplayer" // Instantly start the search given a query - const val appStringSearch = "cloudstreamsearch" + const val APP_STRING_SEARCH = "cloudstreamsearch" // Instantly resume watching a show - const val appStringResumeWatching = "cloudstreamcontinuewatching" + const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" val unixTime: Long get() = System.currentTimeMillis() / 1000L val unixTimeMs: Long get() = System.currentTimeMillis() - const val maxStale = 60 * 10 + const val MAX_STALE = 60 * 10 fun secondsToReadable(seconds: Int, completedValue: String): String { var secondsLong = seconds.toLong() diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt index 878e0cb3bfe..dcb8bbead06 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncApi.kt @@ -5,6 +5,7 @@ import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.UiText import me.xdrop.fuzzywuzzy.FuzzySearch +import java.util.Date interface SyncAPI : OAuth2API { /** @@ -124,6 +125,8 @@ interface SyncAPI : OAuth2API { ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } + ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } + ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } else -> items } } @@ -158,9 +161,10 @@ interface SyncAPI : OAuth2API { override var posterUrl: String?, override var posterHeaders: Map?, override var quality: SearchQuality?, + val releaseDate: Date?, override var id: Int? = null, val plot : String? = null, val rating: Int? = null, - val tags: List? = null, + val tags: List? = null ) : SearchResponse } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt index 507c5e2acf7..db4676393ab 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt @@ -18,13 +18,13 @@ class Addic7ed : AbstractSubApi { override fun logOut() {} companion object { - const val host = "https://www.addic7ed.com" + const val HOST = "https://www.addic7ed.com" const val TAG = "ADDIC7ED" } private fun fixUrl(url: String): String { - return if (url.startsWith("/")) host + url - else if (!url.startsWith("http")) "$host/$url" + return if (url.startsWith("/")) HOST + url + else if (!url.startsWith("http")) "$HOST/$url" else url } @@ -62,7 +62,7 @@ class Addic7ed : AbstractSubApi { } val title = queryText.substringBefore("(").trim() - val url = "$host/search.php?search=${title}&Submit=Search" + val url = "$HOST/search.php?search=${title}&Submit=Search" val hostDocument = app.get(url).document var searchResult = "" if (!hostDocument.select("span:contains($title)").isNullOrEmpty()) searchResult = url @@ -74,8 +74,8 @@ class Addic7ed : AbstractSubApi { hostDocument.selectFirst("#sl button")?.attr("onmouseup")?.substringAfter("(") ?.substringBefore(",") val doc = app.get( - "$host/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", - referer = "$host/" + "$HOST/ajax_loadShow.php?show=$show&season=$seasonNum&langs=&hd=undefined&hi=undefined", + referer = "$HOST/" ).document doc.select("#season tr:contains($queryLang)").mapNotNull { node -> if (node.selectFirst("td")?.text() @@ -97,7 +97,7 @@ class Addic7ed : AbstractSubApi { val link = fixUrl(node.select("a.buttonDownload").attr("href")) val isHearingImpaired = !node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNullOrEmpty() - cleanResources(results, name, link, mapOf("referer" to "$host/"), isHearingImpaired) + cleanResources(results, name, link, mapOf("referer" to "$HOST/"), isHearingImpaired) } return results } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt index 8a82cf94e5d..6112c7dbede 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt @@ -16,15 +16,16 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.txt -import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery +import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject +import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import java.net.URL import java.net.URLEncoder -import java.util.* +import java.util.Locale class AniListApi(index: Int) : AccountManager(index), SyncAPI { override var name = "AniList" @@ -63,7 +64,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun handleRedirect(url: String): Boolean { val sanitizer = - splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR + splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR val token = sanitizer["access_token"]!! val expiresIn = sanitizer["expires_in"]!! @@ -87,7 +88,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun search(name: String): List? { val data = searchShows(name) ?: return null - return data.data?.Page?.media?.map { + return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, this.name, @@ -101,7 +102,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun getResult(id: String): SyncAPI.SyncResult { val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") - val season = getSeason(internalId).data.Media + val season = getSeason(internalId).data.media return SyncAPI.SyncResult( season.id.toString(), @@ -301,12 +302,12 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { //println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}") val shows = searchShows(name.replace(blackListRegex, "")) - shows?.data?.Page?.media?.find { + shows?.data?.page?.media?.find { (malId ?: "NONE") == it.idMal.toString() }?.let { return it } val filtered = - shows?.data?.Page?.media?.filter { + shows?.data?.page?.media?.filter { (((it.startDate.year ?: year.toString()) == year.toString() || year == null)) } @@ -496,7 +497,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { val data = postApi(q, true) val d = parseJson(data ?: return null) - val main = d.data?.Media + val main = d.data?.media if (main?.mediaListEntry != null) { return AniListTitleHolder( title = main.title, @@ -536,7 +537,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { headers = mapOf( "Authorization" to "Bearer " + (getAuth() ?: return@suspendSafeApiCall null), - if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache" + if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache" ), cacheTime = 0, data = mapOf( @@ -631,8 +632,9 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ?: this.media.coverImage.medium, null, null, + this.media.seasonYear.toYear(), null, - plot = this.media.description + plot = this.media.description, ) } } @@ -647,7 +649,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class Data( - @JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection + @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection ) private fun getAniListListCached(): Array? { @@ -659,7 +661,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { if (checkToken()) return null return if (requireLibraryRefresh) { - val list = getFullAniListList()?.data?.MediaListCollection?.lists?.toTypedArray() + val list = getFullAniListList()?.data?.mediaListCollection?.lists?.toTypedArray() if (list != null) { setKey(ANILIST_CACHED_LIST, list) } @@ -678,7 +680,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { // To fill empty lists when AniList does not return them val baseMap = - AniListStatusType.values().filter { it.value >= 0 }.associate { + AniListStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -689,6 +691,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) @@ -764,7 +768,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { /** Used to query a saved MediaItem on the list to get the id for removal */ data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null) - data class MediaListItem(@JsonProperty("MediaList") val MediaList: MediaListId? = null) + data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null) data class MediaListId(@JsonProperty("id") val id: Long? = null) private suspend fun postDataAboutId( @@ -787,7 +791,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { """ val response = postApi(idQuery) val listId = - tryParseJson(response)?.data?.MediaList?.id ?: return false + tryParseJson(response)?.data?.mediaList?.id ?: return false """ mutation(${'$'}id: Int = $listId) { DeleteMediaListEntry(id: ${'$'}id) { @@ -836,7 +840,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { val data = postApi(q) if (data.isNullOrBlank()) return null val userData = parseJson(data) - val u = userData.data?.Viewer + val u = userData.data?.viewer val user = AniListUser( u?.id, u?.name, @@ -858,8 +862,8 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { suspend fun getSeasonRecursive(id: Int) { val season = getSeason(id) seasons.add(season) - if (season.data.Media.format?.startsWith("TV") == true) { - season.data.Media.relations?.edges?.forEach { + if (season.data.media.format?.startsWith("TV") == true) { + season.data.media.relations?.edges?.forEach { if (it.node?.format != null) { if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) { getSeasonRecursive(it.node.id) @@ -878,7 +882,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class SeasonData( - @JsonProperty("Media") val Media: SeasonMedia, + @JsonProperty("Media") val media: SeasonMedia, ) data class SeasonMedia( @@ -1050,7 +1054,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class AniListData( - @JsonProperty("Viewer") val Viewer: AniListViewer?, + @JsonProperty("Viewer") val viewer: AniListViewer?, ) data class AniListRoot( @@ -1090,7 +1094,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class LikeData( - @JsonProperty("Viewer") val Viewer: LikeViewer?, + @JsonProperty("Viewer") val viewer: LikeViewer?, ) data class LikeRoot( @@ -1130,7 +1134,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class GetDataData( - @JsonProperty("Media") val Media: GetDataMedia?, + @JsonProperty("Media") val media: GetDataMedia?, ) data class GetDataRoot( @@ -1163,7 +1167,7 @@ class AniListApi(index: Int) : AccountManager(index), SyncAPI { ) data class GetSearchPage( - @JsonProperty("Page") val Page: GetSearchData?, + @JsonProperty("Page") val page: GetSearchData?, ) data class GetSearchData( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt index 00f8d00cfb8..0d9a4d1383d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt @@ -119,8 +119,11 @@ class LocalList : SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, // ListSorting.RatingHigh, // ListSorting.RatingLow, + ) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt index 24ef7136bda..08c1865311e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt @@ -19,14 +19,19 @@ import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.txt -import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery +import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import java.net.URL import java.security.SecureRandom import java.text.ParseException import java.text.SimpleDateFormat -import java.util.* +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone /** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */ const val MAL_MAX_SEARCH_LIMIT = 25 @@ -51,7 +56,6 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } override fun loginInfo(): AuthAPI.LoginInfo? { - //getMalUser(true)? getKey(accountId, MAL_USER_KEY)?.let { user -> return AuthAPI.LoginInfo( profilePicture = user.picture, @@ -84,7 +88,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { this.name, node.id.toString(), "$mainUrl/anime/${node.id}/", - node.main_picture?.large ?: node.main_picture?.medium + node.mainPicture?.large ?: node.mainPicture?.medium ) } } @@ -178,7 +182,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private fun parseDate(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd")?.parse(string ?: return null)?.time + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time } catch (e: Exception) { null } @@ -190,7 +194,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { apiName = this.name, syncId = node.id.toString(), url = "$mainUrl/anime/${node.id}", - posterUrl = node.main_picture?.large + posterUrl = node.mainPicture?.large ) } @@ -244,12 +248,12 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { val internalId = id.toIntOrNull() ?: return null val data = - getDataAboutMalId(internalId)?.my_list_status //?: throw ErrorLoadingException("No my_list_status") + getDataAboutMalId(internalId)?.myListStatus //?: throw ErrorLoadingException("No my_list_status") return SyncAPI.SyncStatus( score = data?.score, - status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)) , + status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)), isFavorite = null, - watchedEpisodes = data?.num_episodes_watched, + watchedEpisodes = data?.numEpisodesWatched, ) } @@ -291,7 +295,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { private fun parseDateLong(string: String?): Long? { return try { - SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( string ?: return null )?.time?.div(1000) } catch (e: Exception) { @@ -302,7 +306,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun handleRedirect(url: String): Boolean { val sanitizer = - splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR + splitQuery(URL(url.replace(APP_STRING, "https").replace("/#", "?"))) // FIX ERROR val state = sanitizer["state"]!! if (state == "RequestID$requestId") { val currentCode = sanitizer["code"]!! @@ -351,9 +355,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { try { if (response != "") { val token = parseJson(response) - setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime)) - setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token) - setKey(accountId, MAL_TOKEN_KEY, token.access_token) + setKey(accountId, MAL_UNIXTIME_KEY, (token.expiresIn + unixTime)) + setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refreshToken) + setKey(accountId, MAL_TOKEN_KEY, token.accessToken) requireLibraryRefresh = true } } catch (e: Exception) { @@ -395,56 +399,62 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class Node( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String, - @JsonProperty("main_picture") val main_picture: MainPicture?, - @JsonProperty("alternative_titles") val alternative_titles: AlternativeTitles?, - @JsonProperty("media_type") val media_type: String?, - @JsonProperty("num_episodes") val num_episodes: Int?, + @JsonProperty("main_picture") val mainPicture: MainPicture?, + @JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?, + @JsonProperty("media_type") val mediaType: String?, + @JsonProperty("num_episodes") val numEpisodes: Int?, @JsonProperty("status") val status: String?, - @JsonProperty("start_date") val start_date: String?, - @JsonProperty("end_date") val end_date: String?, - @JsonProperty("average_episode_duration") val average_episode_duration: Int?, + @JsonProperty("start_date") val startDate: String?, + @JsonProperty("end_date") val endDate: String?, + @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?, @JsonProperty("synopsis") val synopsis: String?, @JsonProperty("mean") val mean: Double?, @JsonProperty("genres") val genres: List?, @JsonProperty("rank") val rank: Int?, @JsonProperty("popularity") val popularity: Int?, - @JsonProperty("num_list_users") val num_list_users: Int?, - @JsonProperty("num_favorites") val num_favorites: Int?, - @JsonProperty("num_scoring_users") val num_scoring_users: Int?, - @JsonProperty("start_season") val start_season: StartSeason?, + @JsonProperty("num_list_users") val numListUsers: Int?, + @JsonProperty("num_favorites") val numFavorites: Int?, + @JsonProperty("num_scoring_users") val numScoringUsers: Int?, + @JsonProperty("start_season") val startSeason: StartSeason?, @JsonProperty("broadcast") val broadcast: Broadcast?, @JsonProperty("nsfw") val nsfw: String?, - @JsonProperty("created_at") val created_at: String?, - @JsonProperty("updated_at") val updated_at: String? + @JsonProperty("created_at") val createdAt: String?, + @JsonProperty("updated_at") val updatedAt: String? ) data class ListStatus( @JsonProperty("status") val status: String?, @JsonProperty("score") val score: Int, - @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, - @JsonProperty("is_rewatching") val is_rewatching: Boolean, - @JsonProperty("updated_at") val updated_at: String, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) data class Data( @JsonProperty("node") val node: Node, - @JsonProperty("list_status") val list_status: ListStatus?, + @JsonProperty("list_status") val listStatus: ListStatus?, ) { fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( this.node.title, "https://myanimelist.net/anime/${this.node.id}/", this.node.id.toString(), - this.list_status?.num_episodes_watched, - this.node.num_episodes, - this.list_status?.score?.times(10), - parseDateLong(this.list_status?.updated_at), + this.listStatus?.numEpisodesWatched, + this.node.numEpisodes, + this.listStatus?.score?.times(10), + parseDateLong(this.listStatus?.updatedAt), "MAL", TvType.Anime, - this.node.main_picture?.large ?: this.node.main_picture?.medium, + this.node.mainPicture?.large ?: this.node.mainPicture?.medium, null, null, plot = this.node.synopsis, + releaseDate = if (this.node.startDate == null) null else try {Date.from( + Instant.from( + DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") + .parse(this.node.startDate) + ) + )} catch (_: RuntimeException) {null} ) } } @@ -470,8 +480,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ) data class Broadcast( - @JsonProperty("day_of_the_week") val day_of_the_week: String?, - @JsonProperty("start_time") val start_time: String? + @JsonProperty("day_of_the_week") val dayOfTheWeek: String?, + @JsonProperty("start_time") val startTime: String? ) private fun getMalAnimeListCached(): Array? { @@ -491,14 +501,14 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata { val list = getMalAnimeListSmart()?.groupBy { - convertToStatus(it.list_status?.status ?: "").stringRes + convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } } ?: emptyMap() // To fill empty lists when MAL does not return them val baseMap = - MalStatusType.values().filter { it.value >= 0 }.associate { + MalStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } @@ -509,6 +519,8 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) @@ -573,7 +585,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { ).text val values = parseJson(res) val titles = - values.data.map { MalTitleHolder(it.list_status, it.node.id, it.node.title) } + values.data.map { MalTitleHolder(it.listStatus, it.node.id, it.node.title) } for (t in titles) { allTitles[t.id] = t } @@ -582,11 +594,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } - fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? { + private fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? { // No time remaining if the show has already ended try { endDate?.let { - if (SimpleDateFormat("yyyy-MM-dd").parse(it).time < System.currentTimeMillis()) return@convertJapanTimeToTimeRemaining null + if (SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(it) + ?.before(Date.from(Instant.now())) != false + ) return@convertJapanTimeToTimeRemaining null } } catch (e: ParseException) { logError(e) @@ -603,7 +617,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH) val currentYear = currentDate.get(Calendar.YEAR) - val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm") + val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm", Locale.getDefault()) dateFormat.timeZone = TimeZone.getTimeZone("Japan") val parsedDate = dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null @@ -647,13 +661,13 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { id: Int, status: MalStatusType? = null, score: Int? = null, - num_watched_episodes: Int? = null, + numWatchedEpisodes: Int? = null, ): Boolean { val res = setScoreRequest( id, if (status == null) null else malStatusAsString[maxOf(0, status.value)], score, - num_watched_episodes + numWatchedEpisodes ) return if (res.isNullOrBlank()) { @@ -670,17 +684,18 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { } } + @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( id: Int, status: String? = null, score: Int? = null, - num_watched_episodes: Int? = null, + numWatchedEpisodes: Int? = null, ): String? { val data = mapOf( "status" to status, "score" to score?.toString(), - "num_watched_episodes" to num_watched_episodes?.toString() - ).filter { it.value != null } as Map + "num_watched_episodes" to numWatchedEpisodes?.toString() + ).filterValues { it != null } as Map return app.put( "$apiUrl/v2/anime/$id/my_list_status", @@ -693,10 +708,10 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class ResponseToken( - @JsonProperty("token_type") val token_type: String, - @JsonProperty("expires_in") val expires_in: Int, - @JsonProperty("access_token") val access_token: String, - @JsonProperty("refresh_token") val refresh_token: String, + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("refresh_token") val refreshToken: String, ) data class MalRoot( @@ -705,7 +720,7 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class MalDatum( @JsonProperty("node") val node: MalNode, - @JsonProperty("list_status") val list_status: MalStatus, + @JsonProperty("list_status") val listStatus: MalStatus, ) data class MalNode( @@ -722,16 +737,16 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class MalStatus( @JsonProperty("status") val status: String, @JsonProperty("score") val score: Int, - @JsonProperty("num_episodes_watched") val num_episodes_watched: Int, - @JsonProperty("is_rewatching") val is_rewatching: Boolean, - @JsonProperty("updated_at") val updated_at: String, + @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, + @JsonProperty("is_rewatching") val isRewatching: Boolean, + @JsonProperty("updated_at") val updatedAt: String, ) data class MalUser( @JsonProperty("id") val id: Int, @JsonProperty("name") val name: String, @JsonProperty("location") val location: String, - @JsonProperty("joined_at") val joined_at: String, + @JsonProperty("joined_at") val joinedAt: String, @JsonProperty("picture") val picture: String?, ) @@ -744,9 +759,9 @@ class MALApi(index: Int) : AccountManager(index), SyncAPI { data class SmallMalAnime( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String?, - @JsonProperty("num_episodes") val num_episodes: Int, - @JsonProperty("my_list_status") val my_list_status: MalStatus?, - @JsonProperty("main_picture") val main_picture: MalMainPicture?, + @JsonProperty("num_episodes") val numEpisodes: Int, + @JsonProperty("my_list_status") val myListStatus: MalStatus?, + @JsonProperty("main_picture") val mainPicture: MalMainPicture?, ) data class MalSearchNode( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt index 6412ff1b79b..37b95614661 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt @@ -15,7 +15,6 @@ import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPI import com.lagradost.cloudstream3.syncproviders.InAppAuthAPIManager -import com.lagradost.cloudstream3.utils.AppContextUtils import com.lagradost.cloudstream3.utils.AppUtils import okhttp3.Interceptor import okhttp3.Response @@ -30,10 +29,10 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi companion object { const val OPEN_SUBTITLES_USER_KEY: String = "open_subtitles_user" // user data like profile - const val apiKey = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" - const val host = "https://api.opensubtitles.com/api/v1" + const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" + const val HOST = "https://api.opensubtitles.com/api/v1" const val TAG = "OPENSUBS" - const val coolDownDuration: Long = 1000L * 30L // CoolDown if 429 error code in ms + const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms var currentCoolDown: Long = 0L var currentSession: SubtitleOAuthEntity? = null } @@ -49,7 +48,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi chain.request().newBuilder() .removeHeader("user-agent") .addHeader("user-agent", userAgent) - .addHeader("Api-Key", apiKey) + .addHeader("Api-Key", API_KEY) .build() ) } @@ -66,7 +65,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi } private fun throwGotTooManyRequests() { - currentCoolDown = unixTimeMs + coolDownDuration + currentCoolDown = unixTimeMs + COOLDOWN_DURATION throw ErrorLoadingException("Too many requests") } @@ -115,7 +114,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi private suspend fun initLogin(username: String, password: String): Boolean { //Log.i(TAG, "DATA = [$username] [$password]") val response = app.post( - url = "$host/login", + url = "$HOST/login", headers = mapOf( "Content-Type" to "application/json", ), @@ -134,7 +133,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi SubtitleOAuthEntity( user = username, pass = password, - access_token = token.token ?: run { + accessToken = token.token ?: run { return false }) ) @@ -197,8 +196,8 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi val searchQueryUrl = when (imdbId > 0) { //Use imdb_id to search if its valid - true -> "$host/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" - false -> "$host/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" + true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" + false -> "$HOST/subtitles?query=${queryText}&languages=${fixedLang}$yearQuery$epQuery$seasonQuery" } val req = app.get( @@ -233,7 +232,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie - val isHearingImpaired = attr.hearing_impaired ?: false + val isHearingImpaired = attr.hearingImpaired ?: false //Log.i(TAG, "Result id/name => ${item.id} / $name") item.attributes?.files?.forEach { file -> val resultData = file.fileId?.toString() ?: "" @@ -266,11 +265,11 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi throwIfCantDoRequest() val req = app.post( - url = "$host/download", + url = "$HOST/download", headers = mapOf( Pair( "Authorization", - "Bearer ${currentSession?.access_token ?: throw ErrorLoadingException("No access token active in current session")}" + "Bearer ${currentSession?.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" ), Pair("Content-Type", "application/json"), Pair("Accept", "*/*") @@ -299,7 +298,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi data class SubtitleOAuthEntity( var user: String, var pass: String, - var access_token: String, + var accessToken: String, ) data class OAuthToken( @@ -324,7 +323,7 @@ class OpenSubtitlesApi(index: Int) : InAppAuthAPIManager(index), AbstractSubApi @JsonProperty("url") var url: String? = null, @JsonProperty("files") var files: List? = listOf(), @JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(), - @JsonProperty("hearing_impaired") var hearing_impaired: Boolean? = null, + @JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null, ) data class ResultFiles( diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt index 27975d1902d..50517f9d1ac 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt @@ -31,6 +31,7 @@ import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.txt import com.lagradost.cloudstream3.utils.AppUtils.toJson +import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import okhttp3.Interceptor import okhttp3.Response import java.math.BigInteger @@ -38,6 +39,7 @@ import java.security.SecureRandom import java.text.SimpleDateFormat import java.time.Instant import java.util.Date +import java.util.Locale import java.util.TimeZone import kotlin.time.Duration import kotlin.time.DurationUnit @@ -144,8 +146,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } companion object { - private const val clientId: String = BuildConfig.SIMKL_CLIENT_ID - private const val clientSecret: String = BuildConfig.SIMKL_CLIENT_SECRET + private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID + private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET private var lastLoginState = "" const val SIMKL_TOKEN_KEY: String = "simkl_token" @@ -154,10 +156,10 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time" /** 2014-09-01T09:10:11Z -> 1409562611 */ - private const val simklDateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + private const val SIMKL_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'" fun getUnixTime(string: String?): Long? { return try { - SimpleDateFormat(simklDateFormat).apply { + SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { this.timeZone = TimeZone.getTimeZone("UTC") }.parse( string ?: return null @@ -171,7 +173,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { /** 1409562611 -> 2014-09-01T09:10:11Z */ fun getDateTime(unixTime: Long?): String? { return try { - SimpleDateFormat(simklDateFormat).apply { + SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { this.timeZone = TimeZone.getTimeZone("UTC") }.format( Date.from( @@ -208,7 +210,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { companion object { fun fromString(string: String): SimklListStatusType? { - return SimklListStatusType.values().firstOrNull { + return SimklListStatusType.entries.firstOrNull { it.originalName == string } } @@ -219,17 +221,17 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonInclude(JsonInclude.Include.NON_EMPTY) data class TokenRequest( @JsonProperty("code") val code: String, - @JsonProperty("client_id") val client_id: String = clientId, - @JsonProperty("client_secret") val client_secret: String = clientSecret, - @JsonProperty("redirect_uri") val redirect_uri: String = "$appString://simkl", - @JsonProperty("grant_type") val grant_type: String = "authorization_code" + @JsonProperty("client_id") val clientId: String = CLIENT_ID, + @JsonProperty("client_secret") val clientSecret: String = CLIENT_SECRET, + @JsonProperty("redirect_uri") val redirectUri: String = "$APP_STRING://simkl", + @JsonProperty("grant_type") val grantType: String = "authorization_code" ) data class TokenResponse( /** No expiration date */ - val access_token: String, - val token_type: String, - val scope: String + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("scope") val scope: String ) // ------------------- @@ -261,15 +263,15 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { // ------------------- data class ActivitiesResponse( - val all: String?, - val tv_shows: UpdatedAt, - val anime: UpdatedAt, - val movies: UpdatedAt, + @JsonProperty("all") val all: String?, + @JsonProperty("tv_shows") val tvShows: UpdatedAt, + @JsonProperty("anime") val anime: UpdatedAt, + @JsonProperty("movies") val movies: UpdatedAt, ) { data class UpdatedAt( - val all: String?, - val removed_from_list: String?, - val rated_at: String?, + @JsonProperty("all") val all: String?, + @JsonProperty("removed_from_list") val removedFromList: String?, + @JsonProperty("rated_at") val ratedAt: String?, ) } @@ -308,7 +310,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("title") val title: String?, @JsonProperty("year") val year: Int?, @JsonProperty("ids") val ids: Ids?, - @JsonProperty("total_episodes") val total_episodes: Int? = null, + @JsonProperty("total_episodes") val totalEpisodes: Int? = null, @JsonProperty("status") val status: String? = null, @JsonProperty("poster") val poster: String? = null, @JsonProperty("type") val type: String? = null, @@ -540,7 +542,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } debugPrint { "Requesting episodes from $url" } - return app.get(url, params = mapOf("client_id" to clientId)) + return app.get(url, params = mapOf("client_id" to CLIENT_ID)) .parsedSafe>()?.also { val cacheTime = if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value @@ -558,7 +560,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("seasons") seasons: List? = null, @JsonProperty("episodes") episodes: List? = null, @JsonProperty("rating") val rating: Int? = null, - @JsonProperty("rated_at") val rated_at: String? = null, + @JsonProperty("rated_at") val ratedAt: String? = null, ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -567,7 +569,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("rating") val rating: Int, - @JsonProperty("rated_at") val rated_at: String? = getDateTime(unixTime) + @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime) ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -576,7 +578,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("to") val to: String, - @JsonProperty("watched_at") val watched_at: String? = getDateTime(unixTime) + @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime) ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -631,24 +633,24 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { } interface Metadata { - val last_watched_at: String? + val lastWatchedAt: String? val status: String? - val user_rating: Int? - val last_watched: String? - val watched_episodes_count: Int? - val total_episodes_count: Int? + val userRating: Int? + val lastWatched: String? + val watchedEpisodesCount: Int? + val totalEpisodesCount: Int? fun getIds(): ShowMetadata.Show.Ids fun toLibraryItem(): SyncAPI.LibraryItem } data class MovieMetadata( - override val last_watched_at: String?, - override val status: String, - override val user_rating: Int?, - override val last_watched: String?, - override val watched_episodes_count: Int?, - override val total_episodes_count: Int?, + @JsonProperty("last_watched_at") override val lastWatchedAt: String?, + @JsonProperty("status") override val status: String, + @JsonProperty("user_rating") override val userRating: Int?, + @JsonProperty("last_watched") override val lastWatched: String?, + @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, + @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, val movie: ShowMetadata.Show ) : Metadata { override fun getIds(): ShowMetadata.Show.Ids { @@ -660,27 +662,28 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { this.movie.title, "https://simkl.com/tv/${movie.ids.simkl}", movie.ids.simkl.toString(), - this.watched_episodes_count, - this.total_episodes_count, - this.user_rating?.times(10), - getUnixTime(last_watched_at) ?: 0, + this.watchedEpisodesCount, + this.totalEpisodesCount, + this.userRating?.times(10), + getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Movie, this.movie.poster?.let { getPosterUrl(it) }, null, null, - movie.ids.simkl, + this.movie.year?.toYear(), + movie.ids.simkl ) } } data class ShowMetadata( - @JsonProperty("last_watched_at") override val last_watched_at: String?, + @JsonProperty("last_watched_at") override val lastWatchedAt: String?, @JsonProperty("status") override val status: String, - @JsonProperty("user_rating") override val user_rating: Int?, - @JsonProperty("last_watched") override val last_watched: String?, - @JsonProperty("watched_episodes_count") override val watched_episodes_count: Int?, - @JsonProperty("total_episodes_count") override val total_episodes_count: Int?, + @JsonProperty("user_rating") override val userRating: Int?, + @JsonProperty("last_watched") override val lastWatched: String?, + @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, + @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, @JsonProperty("show") val show: Show ) : Metadata { override fun getIds(): Show.Ids { @@ -692,15 +695,16 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { this.show.title, "https://simkl.com/tv/${show.ids.simkl}", show.ids.simkl.toString(), - this.watched_episodes_count, - this.total_episodes_count, - this.user_rating?.times(10), - getUnixTime(last_watched_at) ?: 0, + this.watchedEpisodesCount, + this.totalEpisodesCount, + this.userRating?.times(10), + getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Anime, this.show.poster?.let { getPosterUrl(it) }, null, null, + this.show.year?.toYear(), show.ids.simkl ) } @@ -749,7 +753,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { chain.request() .newBuilder() .addHeader("Authorization", "Bearer $token") - .addHeader("simkl-api-key", clientId) + .addHeader("simkl-api-key", CLIENT_ID) .build() ) } @@ -810,7 +814,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { val episodeConstructor = SimklEpisodeConstructor( searchResult.ids?.simkl, searchResult.type, - searchResult.total_episodes, + searchResult.totalEpisodes, searchResult.hasEnded() ) @@ -832,12 +836,12 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ) } ?: return null, - score = foundItem.user_rating, - watchedEpisodes = foundItem.watched_episodes_count, - maxEpisodes = searchResult.total_episodes, + score = foundItem.userRating, + watchedEpisodes = foundItem.watchedEpisodesCount, + maxEpisodes = searchResult.totalEpisodes, episodeConstructor = episodeConstructor, - oldEpisodes = foundItem.watched_episodes_count ?: 0, - oldScore = foundItem.user_rating, + oldEpisodes = foundItem.watchedEpisodesCount ?: 0, + oldScore = foundItem.userRating, oldStatus = foundItem.status ) } else { @@ -845,7 +849,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { status = SyncWatchType.fromInternalId(SimklListStatusType.None.value), score = 0, watchedEpisodes = 0, - maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.total_episodes, + maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes, episodeConstructor = episodeConstructor, oldEpisodes = 0, oldStatus = null, @@ -891,12 +895,12 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { /** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */ - suspend fun searchByIds(serviceMap: Map): Array? { + private suspend fun searchByIds(serviceMap: Map): Array? { if (serviceMap.isEmpty()) return emptyArray() return app.get( "$mainUrl/search/id", - params = mapOf("client_id" to clientId) + serviceMap.map { (service, id) -> + params = mapOf("client_id" to CLIENT_ID) + serviceMap.map { (service, id) -> service.originalName to id } ).parsedSafe() @@ -904,14 +908,14 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun search(name: String): List? { return app.get( - "$mainUrl/search/", params = mapOf("client_id" to clientId, "q" to name) + "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } } override fun authenticate(activity: FragmentActivity?) { lastLoginState = BigInteger(130, SecureRandom()).toString(32) val url = - "https://simkl.com/oauth/authorize?response_type=code&client_id=$clientId&redirect_uri=$appString://${redirectUrl}&state=$lastLoginState" + "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}&state=$lastLoginState" openBrowser(url, activity) } @@ -961,15 +965,15 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { val activities = getActivities() val lastCacheUpdate = getKey(accountId, SIMKL_CACHED_LIST_TIME) val lastRemoval = listOf( - activities?.tv_shows?.removed_from_list, - activities?.anime?.removed_from_list, - activities?.movies?.removed_from_list + activities?.tvShows?.removedFromList, + activities?.anime?.removedFromList, + activities?.movies?.removedFromList ).maxOf { getUnixTime(it) ?: -1 } val lastRealUpdate = listOf( - activities?.tv_shows?.all, + activities?.tvShows?.all, activities?.anime?.all, activities?.movies?.all, ).maxOf { @@ -1026,6 +1030,8 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, + ListSorting.ReleaseDateNew, + ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) @@ -1039,7 +1045,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun getDevicePin(): OAuth2API.PinAuthData? { val pinAuthResp = app.get( - "$mainUrl/oauth/pin?client_id=$clientId&redirect_uri=$appString://${redirectUrl}" + "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrl}" ).parsedSafe() ?: return null return OAuth2API.PinAuthData( @@ -1053,7 +1059,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { override suspend fun handleDeviceAuth(pinAuthData: OAuth2API.PinAuthData): Boolean { val pinAuthResp = app.get( - "$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$clientId" + "$mainUrl/oauth/pin/${pinAuthData.userCode}?client_id=$CLIENT_ID" ).parsedSafe() ?: return false if (pinAuthResp.accessToken != null) { @@ -1088,7 +1094,7 @@ class SimklApi(index: Int) : AccountManager(index), SyncAPI { ).parsedSafe() ?: return false switchToNewAccount() - setKey(accountId, SIMKL_TOKEN_KEY, token.access_token) + setKey(accountId, SIMKL_TOKEN_KEY, token.accessToken) val user = getUser() if (user == null) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt index 0e233ece8da..8dad1f88cfe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt @@ -59,6 +59,7 @@ class SubSourceApi : AbstractSubProvider { it?.subs?.filter { sub -> sub.releaseName!!.contains( String.format( + null, "E%02d", query.epNumber ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index a075cc2e3ba..9150cfc5e0c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -50,7 +50,7 @@ class APIRepository(val api: MainAPI) { private val cache = threadSafeListOf() private var cacheIndex: Int = 0 - const val cacheSize = 20 + const val CACHE_SIZE = 20 } private fun afterPluginsLoaded(forceReload: Boolean) { @@ -94,9 +94,9 @@ class APIRepository(val api: MainAPI) { val add = SavedLoadResponse(unixTime, response, lookingForHash) synchronized(cache) { - if (cache.size > cacheSize) { + if (cache.size > CACHE_SIZE) { cache[cacheIndex] = add // rolling cache - cacheIndex = (cacheIndex + 1) % cacheSize + cacheIndex = (cacheIndex + 1) % CACHE_SIZE } else { cache.add(add) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt index d90177f5069..e930961c550 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt @@ -112,6 +112,7 @@ abstract class BaseAdapter< holder.onViewDetachedFromWindow() } + @Suppress("UNCHECKED_CAST") fun save(recyclerView: RecyclerView) { for (child in recyclerView.children) { val holder = @@ -124,6 +125,7 @@ abstract class BaseAdapter< stateViewModel.layoutManagerStates[id]?.clear() } + @Suppress("UNCHECKED_CAST") private fun getState(holder: ViewHolderState): S? = stateViewModel.layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt index 6bafa975f49..1eaac5056ef 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt @@ -6,6 +6,7 @@ import android.view.Menu import android.view.View.* import android.widget.* import androidx.appcompat.app.AlertDialog +import androidx.media3.common.util.UnstableApi import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.kotlinModule @@ -263,6 +264,7 @@ class SelectSourceController(val view: ImageView, val activity: ControllerActivi var isLoadingMore = false + override fun onMediaStatusUpdated() { super.onMediaStatusUpdated() val meta = getCurrentMetaData() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt index 1a9549e175e..78ad2a6bfcb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt @@ -8,8 +8,8 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.abs -class GrdLayoutManager(val context: Context, _spanCount: Int) : - GridLayoutManager(context, _spanCount) { +class GrdLayoutManager(val context: Context, spanCount: Int) : + GridLayoutManager(context, spanCount) { override fun onFocusSearchFailed( focused: View, focusDirection: Int, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt index c70417763d0..4879d2e0b8e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonke.kt @@ -51,7 +51,7 @@ class EasterEggMonke : AppCompatActivity() { FrameLayout.LayoutParams.WRAP_CONTENT) binding.frame.addView(newStar) - newStar.scaleX = Math.random().toFloat() * 1.5f + newStar.scaleX + newStar.scaleX += Math.random().toFloat() * 1.5f newStar.scaleY = newStar.scaleX starW *= newStar.scaleX starH *= newStar.scaleY diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt index f721401e7d2..12a5ae2a29e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt @@ -15,7 +15,7 @@ open class NonFinalAdapterListUpdateCallback /** * Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter. * - * @param adapter The Adapter to send updates to. + * @param mAdapter The Adapter to send updates to. */(private var mAdapter: RecyclerView.Adapter<*>) : ListUpdateCallback { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt index 9532d1a9b91..b778ba5a754 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt @@ -13,7 +13,7 @@ enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @Drawab NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24); companion object { - fun fromInternalId(id: Int?) = values().find { value -> value.internalId == id } ?: NONE + fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE } } @@ -36,6 +36,6 @@ enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @Dr REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24); companion object { - fun fromInternalId(id: Int?) = values().find { value -> value.internalId == id } ?: NONE + fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt index 15e66b38f3e..5e2b97e57d5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt @@ -8,8 +8,10 @@ import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import androidx.annotation.OptIn import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.media3.common.util.UnstableApi import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.USER_AGENT @@ -29,6 +31,7 @@ class WebviewFragment : Fragment() { } binding?.webView?.webViewClient = object : WebViewClient() { + @OptIn(UnstableApi::class) override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt index 9a0263342b2..d211cb87ce6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt @@ -1,9 +1,9 @@ package com.lagradost.cloudstream3.ui.download -import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.ViewGroup +import android.widget.CheckBox import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil @@ -31,47 +31,30 @@ const val DOWNLOAD_ACTION_LONG_CLICK = 5 const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_LOAD_RESULT = 1 -abstract class VisualDownloadCached( - open val currentBytes: Long, - open val totalBytes: Long, - open val data: VideoDownloadHelper.DownloadCached -) { - - // Just to be extra-safe with areContentsTheSame - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is VisualDownloadCached) return false - - if (currentBytes != other.currentBytes) return false - if (totalBytes != other.totalBytes) return false - if (data != other.data) return false - - return true - } - - override fun hashCode(): Int { - var result = currentBytes.hashCode() - result = 31 * result + totalBytes.hashCode() - result = 31 * result + data.hashCode() - return result - } +sealed class VisualDownloadCached { + abstract val currentBytes: Long + abstract val totalBytes: Long + abstract val data: VideoDownloadHelper.DownloadCached + abstract var isSelected: Boolean + + data class Child( + override val currentBytes: Long, + override val totalBytes: Long, + override val data: VideoDownloadHelper.DownloadEpisodeCached, + override var isSelected: Boolean, + ) : VisualDownloadCached() + + data class Header( + override val currentBytes: Long, + override val totalBytes: Long, + override val data: VideoDownloadHelper.DownloadHeaderCached, + override var isSelected: Boolean, + val child: VideoDownloadHelper.DownloadEpisodeCached?, + val currentOngoingDownloads: Int, + val totalDownloads: Int, + ) : VisualDownloadCached() } -data class VisualDownloadChildCached( - override val currentBytes: Long, - override val totalBytes: Long, - override val data: VideoDownloadHelper.DownloadEpisodeCached, -): VisualDownloadCached(currentBytes, totalBytes, data) - -data class VisualDownloadHeaderCached( - override val currentBytes: Long, - override val totalBytes: Long, - override val data: VideoDownloadHelper.DownloadHeaderCached, - val child: VideoDownloadHelper.DownloadEpisodeCached?, - val currentOngoingDownloads: Int, - val totalDownloads: Int, -): VisualDownloadCached(currentBytes, totalBytes, data) - data class DownloadClickEvent( val action: Int, val data: VideoDownloadHelper.DownloadEpisodeCached @@ -83,108 +66,191 @@ data class DownloadHeaderClickEvent( ) class DownloadAdapter( - private val clickCallback: (DownloadHeaderClickEvent) -> Unit, - private val mediaClickCallback: (DownloadClickEvent) -> Unit, + private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, + private val onItemClickEvent: (DownloadClickEvent) -> Unit, + private val onItemSelectionChanged: (Int, Boolean) -> Unit, ) : ListAdapter(DiffCallback()) { + private var isMultiDeleteState: Boolean = false + companion object { private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_CHILD = 1 } inner class DownloadViewHolder( - private val binding: ViewBinding, - private val clickCallback: (DownloadHeaderClickEvent) -> Unit, - private val mediaClickCallback: (DownloadClickEvent) -> Unit, + private val binding: ViewBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(card: VisualDownloadCached?) { when (binding) { - is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadHeaderCached) - is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadChildCached) + is DownloadHeaderEpisodeBinding -> bindHeader(card as? VisualDownloadCached.Header) + is DownloadChildEpisodeBinding -> bindChild(card as? VisualDownloadCached.Child) } } - @SuppressLint("SetTextI18n") - private fun bindHeader(card: VisualDownloadHeaderCached?) { - if (binding !is DownloadHeaderEpisodeBinding) return - card ?: return - val d = card.data + private fun bindHeader(card: VisualDownloadCached.Header?) { + if (binding !is DownloadHeaderEpisodeBinding || card == null) return + val data = card.data binding.apply { - downloadHeaderPoster.apply { - setImage(d.poster) - setOnClickListener { - clickCallback.invoke(DownloadHeaderClickEvent(DOWNLOAD_ACTION_LOAD_RESULT, d)) + episodeHolder.apply { + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } } - } - downloadHeaderTitle.text = d.name - val formattedSizeString = formatShortFileSize(itemView.context, card.totalBytes) + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } - if (card.child != null) { - downloadHeaderGotoChild.isVisible = false - - val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) - if (status == DownloadStatusTell.IsDone) { - // We do this here instead if we are finished downloading - // so that we can use the value from the view model - // rather than extra unneeded disk operations and to prevent a - // delay in updating download icon state. - downloadButton.setProgress(card.currentBytes, card.totalBytes) - downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) - // We will let the view model handle this - downloadButton.doSetProgress = false - downloadButton.progressBar.progressDrawable = - downloadButton.getDrawableFromStatus(status) - ?.let { ContextCompat.getDrawable(downloadButton.context, it) } - downloadHeaderInfo.text = formattedSizeString + downloadHeaderPoster.apply { + setImage(data.poster) + if (isMultiDeleteState) { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } } else { - downloadButton.doSetProgress = true - downloadButton.progressBar.progressDrawable = - ContextCompat.getDrawable(downloadButton.context, downloadButton.progressDrawable) + setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_LOAD_RESULT, + data + ) + ) + } } - downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, mediaClickCallback) - downloadButton.isVisible = true - - episodeHolder.setOnClickListener { - mediaClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, card.child)) - } - } else { - downloadButton.isVisible = false - downloadHeaderGotoChild.isVisible = true - - try { - downloadHeaderInfo.text = downloadHeaderInfo.context.getString(R.string.extra_info_format) - .format( - card.totalDownloads, - downloadHeaderInfo.context.resources.getQuantityString( - R.plurals.episodes, - card.totalDownloads - ), - formattedSizeString - ) - } catch (e: Exception) { - // You probably formatted incorrectly - downloadHeaderInfo.text = "Error" - logError(e) + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true } + } + downloadHeaderTitle.text = data.name + val formattedSize = formatShortFileSize(itemView.context, card.totalBytes) - episodeHolder.setOnClickListener { - clickCallback.invoke(DownloadHeaderClickEvent(DOWNLOAD_ACTION_GO_TO_CHILD, d)) + if (card.child != null) { + handleChildDownload(card, formattedSize) + } else handleParentDownload(card, formattedSize) + + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected } } } - private fun bindChild(card: VisualDownloadChildCached?) { - if (binding !is DownloadChildEpisodeBinding) return - card ?: return - val d = card.data + private fun DownloadHeaderEpisodeBinding.handleChildDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + card.child ?: return + downloadHeaderGotoChild.isVisible = false + + val posDur = getViewPos(card.data.id) + downloadHeaderEpisodeProgress.apply { + isVisible = posDur != null + posDur?.let { + val visualPos = it.fixVisual() + max = (visualPos.duration / 1000).toInt() + progress = (visualPos.position / 1000).toInt() + } + } + + val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) + if (status == DownloadStatusTell.IsDone) { + // We do this here instead if we are finished downloading + // so that we can use the value from the view model + // rather than extra unneeded disk operations and to prevent a + // delay in updating download icon state. + downloadButton.setProgress(card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) + // We will let the view model handle this + downloadButton.doSetProgress = false + downloadButton.progressBar.progressDrawable = + downloadButton.getDrawableFromStatus(status) + ?.let { ContextCompat.getDrawable(downloadButton.context, it) } + downloadHeaderInfo.text = formattedSize + } else { + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. + downloadButton.resetView() + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) + downloadButton.progressBar.progressDrawable = + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) + } + downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) + downloadButton.isVisible = !isMultiDeleteState + + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + card.child + ) + ) + } + } + } + + private fun DownloadHeaderEpisodeBinding.handleParentDownload( + card: VisualDownloadCached.Header, + formattedSize: String + ) { + downloadButton.isVisible = false + downloadHeaderEpisodeProgress.isVisible = false + downloadHeaderGotoChild.isVisible = !isMultiDeleteState + + try { + downloadHeaderInfo.text = + downloadHeaderInfo.context.getString(R.string.extra_info_format).format( + card.totalDownloads, + downloadHeaderInfo.context.resources.getQuantityString( + R.plurals.episodes, + card.totalDownloads + ), + formattedSize + ) + } catch (e: Exception) { + downloadHeaderInfo.text = null + logError(e) + } + + if (!isMultiDeleteState) { + episodeHolder.setOnClickListener { + onHeaderClickEvent.invoke( + DownloadHeaderClickEvent( + DOWNLOAD_ACTION_GO_TO_CHILD, + card.data + ) + ) + } + } + } + + private fun bindChild(card: VisualDownloadCached.Child?) { + if (binding !is DownloadChildEpisodeBinding || card == null) return + + val data = card.data binding.apply { - val posDur = getViewPos(d.id) + val posDur = getViewPos(data.id) downloadChildEpisodeProgress.apply { isVisible = posDur != null posDur?.let { @@ -194,36 +260,87 @@ class DownloadAdapter( } } - val status = downloadButton.getStatus(d.id, card.currentBytes, card.totalBytes) + val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading // so that we can use the value from the view model // rather than extra unneeded disk operations and to prevent a // delay in updating download icon state. downloadButton.setProgress(card.currentBytes, card.totalBytes) - downloadButton.applyMetaData(d.id, card.currentBytes, card.totalBytes) + downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) // We will let the view model handle this downloadButton.doSetProgress = false downloadButton.progressBar.progressDrawable = downloadButton.getDrawableFromStatus(status) ?.let { ContextCompat.getDrawable(downloadButton.context, it) } - downloadChildEpisodeTextExtra.text = formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) + downloadChildEpisodeTextExtra.text = + formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) } else { - downloadButton.doSetProgress = true + // We need to make sure we restore the correct progress + // when we refresh data in the adapter. + downloadButton.resetView() + val drawable = downloadButton.getDrawableFromStatus(status)?.let { + ContextCompat.getDrawable(downloadButton.context, it) + } + downloadButton.statusView.setImageDrawable(drawable) downloadButton.progressBar.progressDrawable = - ContextCompat.getDrawable(downloadButton.context, downloadButton.progressDrawable) + ContextCompat.getDrawable( + downloadButton.context, + downloadButton.progressDrawable + ) } - downloadButton.setDefaultClickListener(d, downloadChildEpisodeTextExtra, mediaClickCallback) - downloadButton.isVisible = true + downloadButton.setDefaultClickListener( + data, + downloadChildEpisodeTextExtra, + onItemClickEvent + ) + downloadButton.isVisible = !isMultiDeleteState downloadChildEpisodeText.apply { - text = context.getNameFull(d.name, d.episode, d.season) + text = context.getNameFull(data.name, data.episode, data.season) isSelected = true // Needed for text repeating } downloadChildEpisodeHolder.setOnClickListener { - mediaClickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, d)) + onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) + } + + downloadChildEpisodeHolder.apply { + when { + isMultiDeleteState -> { + setOnClickListener { + toggleIsChecked(deleteCheckbox, data.id) + } + } + + else -> { + setOnClickListener { + onItemClickEvent.invoke( + DownloadClickEvent( + DOWNLOAD_ACTION_PLAY_FILE, + data + ) + ) + } + } + } + + setOnLongClickListener { + toggleIsChecked(deleteCheckbox, data.id) + true + } + } + + if (isMultiDeleteState) { + deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> + onItemSelectionChanged.invoke(data.id, isChecked) + } + } else deleteCheckbox.setOnCheckedChangeListener(null) + + deleteCheckbox.apply { + isVisible = isMultiDeleteState + isChecked = card.isSelected } } } @@ -236,7 +353,7 @@ class DownloadAdapter( VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) else -> throw IllegalArgumentException("Invalid view type") } - return DownloadViewHolder(binding, clickCallback, mediaClickCallback) + return DownloadViewHolder(binding) } override fun onBindViewHolder(holder: DownloadViewHolder, position: Int) { @@ -245,18 +362,52 @@ class DownloadAdapter( override fun getItemViewType(position: Int): Int { return when (getItem(position)) { - is VisualDownloadChildCached -> VIEW_TYPE_CHILD - is VisualDownloadHeaderCached -> VIEW_TYPE_HEADER + is VisualDownloadCached.Child -> VIEW_TYPE_CHILD + is VisualDownloadCached.Header -> VIEW_TYPE_HEADER else -> throw IllegalArgumentException("Invalid data type at position $position") } } + fun setIsMultiDeleteState(value: Boolean) { + if (isMultiDeleteState == value) return + isMultiDeleteState = value + notifyItemRangeChanged(0, itemCount) + } + + fun notifyAllSelected() { + currentList.indices.forEach { index -> + if (!currentList[index].isSelected) { + notifyItemChanged(index) + } + } + } + + fun notifySelectionStates() { + currentList.indices.forEach { index -> + if (currentList[index].isSelected) { + notifyItemChanged(index) + } + } + } + + private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { + val isChecked = !checkbox.isChecked + checkbox.isChecked = isChecked + onItemSelectionChanged.invoke(itemId, isChecked) + } + class DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: VisualDownloadCached, newItem: VisualDownloadCached): Boolean { + override fun areItemsTheSame( + oldItem: VisualDownloadCached, + newItem: VisualDownloadCached + ): Boolean { return oldItem.data.id == newItem.data.id } - override fun areContentsTheSame(oldItem: VisualDownloadCached, newItem: VisualDownloadCached): Boolean { + override fun areContentsTheSame( + oldItem: VisualDownloadCached, + newItem: VisualDownloadCached + ): Boolean { return oldItem == newItem } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt index c8c40e292cd..494e82e55e6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -1,11 +1,12 @@ package com.lagradost.cloudstream3.ui.download import android.content.DialogInterface -import android.widget.Toast +import android.net.Uri import androidx.appcompat.app.AlertDialog +import com.google.android.material.snackbar.Snackbar import com.lagradost.cloudstream3.AcraApplication.Companion.getKey +import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys import com.lagradost.cloudstream3.CommonActivity.activity -import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator @@ -13,10 +14,13 @@ import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus +import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.VideoDownloadHelper import com.lagradost.cloudstream3.utils.VideoDownloadManager +import kotlinx.coroutines.MainScope object DownloadButtonSetup { fun handleDownloadClick(click: DownloadClickEvent) { @@ -29,9 +33,15 @@ object DownloadButtonSetup { DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { - VideoDownloadManager.deleteFileAndUpdateSettings(ctx, id) + VideoDownloadManager.deleteFilesAndUpdateSettings( + ctx, + setOf(id), + MainScope() + ) } + DialogInterface.BUTTON_NEGATIVE -> { + // Do nothing on cancel } } } @@ -56,11 +66,13 @@ object DownloadButtonSetup { } } } + DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause) ) } + DOWNLOAD_ACTION_RESUME_DOWNLOAD -> { activity?.let { ctx -> if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) { @@ -79,6 +91,7 @@ object DownloadButtonSetup { } } } + DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = @@ -88,64 +101,80 @@ object DownloadButtonSetup { )?.fileLength ?: 0 if (length > 0) { - showToast(R.string.delete, Toast.LENGTH_LONG) - } else { - showToast(R.string.download, Toast.LENGTH_LONG) + showSnackbar( + act, + R.string.offline_file, + Snackbar.LENGTH_LONG + ) } } } + DOWNLOAD_ACTION_PLAY_FILE -> { activity?.let { act -> - val info = - VideoDownloadManager.getDownloadFileInfoAndUpdateSettings( - act, - click.data.id - ) ?: return - val keyInfo = getKey( - VideoDownloadManager.KEY_DOWNLOAD_INFO, - click.data.id.toString() - ) ?: return val parent = getKey( DOWNLOAD_HEADER_CACHE, click.data.parentId.toString() ) ?: return + val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) + ?.mapNotNull { + getKey(it) + } + ?.filter { it.parentId == click.data.parentId } + + val currentSeason = click.data.season ?: 0 + val currentEpisode = click.data.episode + + val items = mutableListOf() + + // Make sure we only get this episode and episodes after it, + // and that we can go to the next season if we need to. + val allRelevantEpisodes = episodes + ?.sortedWith( + compareByDescending { it.id == click.data.id } + .thenBy { it.season ?: 0 } + .thenBy { it.episode } + ) + ?.filter { + if (it.season == null) return@filter true + val isCurrentOrLaterInSeason = it.season == currentSeason && (it.episode >= currentEpisode || it.id == click.data.id) + val isInFutureSeasons = it.season > currentSeason + + isCurrentOrLaterInSeason || isInFutureSeasons + } + + allRelevantEpisodes?.forEach { + val keyInfo = getKey( + VideoDownloadManager.KEY_DOWNLOAD_INFO, + it.id.toString() + ) ?: return@forEach + + items.add( + ExtractorUri( + // We just use a temporary placeholder for the URI, + // it will be updated in generateLinks(). + // We just do this for performance since getting + // all paths at once can be quite expensive. + uri = Uri.EMPTY, + id = it.id, + parentId = it.parentId, + name = act.getString(R.string.downloaded_file), + season = it.season, + episode = it.episode, + headerName = parent.name, + tvType = parent.type, + basePath = keyInfo.basePath, + displayName = keyInfo.displayName, + relativePath = keyInfo.relativePath, + ) + ) + } + act.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( - DownloadFileGenerator( - listOf( - ExtractorUri( - uri = info.path, - - id = click.data.id, - parentId = click.data.parentId, - name = act.getString(R.string.downloaded_file), //click.data.name ?: keyInfo.displayName - season = click.data.season, - episode = click.data.episode, - headerName = parent.name, - tvType = parent.type, - - basePath = keyInfo.basePath, - displayName = keyInfo.displayName, - relativePath = keyInfo.relativePath, - ) - ) - ) + DownloadFileGenerator(items) ) - //R.id.global_to_navigation_player, PlayerFragment.newInstance( - // UriData( - // info.path.toString(), - // keyInfo.basePath, - // keyInfo.relativePath, - // keyInfo.displayName, - // click.data.parentId, - // click.data.id, - // headerName ?: "null", - // if (click.data.episode <= 0) null else click.data.episode, - // click.data.season - // ), - // getViewPos(click.data.id)?.position ?: 0 - //) ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt index 03db948c428..09c48a04337 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt @@ -1,29 +1,33 @@ package com.lagradost.cloudstream3.ui.download import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding +import com.lagradost.cloudstream3.mvvm.observe 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.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -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.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV -import com.lagradost.cloudstream3.utils.VideoDownloadHelper -import com.lagradost.cloudstream3.utils.VideoDownloadManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext class DownloadChildFragment : Fragment() { + private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentChildDownloadsBinding? = null + companion object { fun newInstance(headerName: String, folder: String): Bundle { return Bundle().apply { @@ -34,61 +38,54 @@ class DownloadChildFragment : Fragment() { } override fun onDestroyView() { - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } - downloadDeleteEventListener = null + detachBackPressedCallback() binding = null super.onDestroyView() } - private var binding: FragmentChildDownloadsBinding? = null - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { + downloadsViewModel = ViewModelProvider(this)[DownloadViewModel::class.java] val localBinding = FragmentChildDownloadsBinding.inflate(inflater, container, false) binding = localBinding return localBinding.root } - private fun updateList(folder: String) = main { - context?.let { ctx -> - val data = withContext(Dispatchers.IO) { ctx.getKeys(folder) } - val eps = withContext(Dispatchers.IO) { - data.mapNotNull { key -> - context?.getKey(key) - }.mapNotNull { - val info = VideoDownloadManager.getDownloadFileInfoAndUpdateSettings(ctx, it.id) - ?: return@mapNotNull null - VisualDownloadChildCached( - currentBytes = info.fileLength, - totalBytes = info.totalBytes, - data = it, - ) - } - }.sortedBy { it.data.episode + (it.data.season ?: 0) * 100000 } - if (eps.isEmpty()) { - activity?.onBackPressedDispatcher?.onBackPressed() - return@main - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) - (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(eps) + /** + * We never want to retain multi-delete state + * when navigating to downloads. Setting this state + * immediately can sometimes result in the observer + * not being notified in time to update the UI. + * + * By posting to the main looper, we ensure that this + * operation is executed after the view has been fully created + * and all initializations are completed, allowing the + * observer to properly receive and handle the state change. + */ + Handler(Looper.getMainLooper()).post { + downloadsViewModel.setIsMultiDeleteState(false) } - } - private var downloadDeleteEventListener: ((Int) -> Unit)? = null - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + /** + * We have to make sure selected items are + * cleared here as well so we don't run in an + * inconsistent state where selected items do + * not match the multi delete state we are in. + */ + downloadsViewModel.clearSelectedItems() val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { - activity?.onBackPressedDispatcher?.onBackPressed() // TODO FIX + activity?.onBackPressedDispatcher?.onBackPressed() return } - fixPaddingStatusbar(binding?.downloadChildRoot) binding?.downloadChildToolbar?.apply { title = name @@ -101,13 +98,55 @@ class DownloadChildFragment : Fragment() { setAppBarNoScrollFlagsOnTV() } + binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() + + observe(downloadsViewModel.childCards) { + if (it.isEmpty()) { + activity?.onBackPressedDispatcher?.onBackPressed() + return@observe + } + + (binding?.downloadChildList?.adapter as? DownloadAdapter)?.submitList(it) + } + observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState + if (!isMultiDeleteState) { + detachBackPressedCallback() + downloadsViewModel.clearSelectedItems() + binding?.downloadChildToolbar?.isVisible = true + } + } + observe(downloadsViewModel.selectedBytes) { + updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) + } + observe(downloadsViewModel.selectedItemIds) { + handleSelectedChange(it) + updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) + + binding?.btnDelete?.isVisible = it.isNotEmpty() + binding?.selectItemsText?.isVisible = it.isEmpty() + + val allSelected = downloadsViewModel.isAllSelected() + if (allSelected) { + binding?.btnToggleAll?.setText(R.string.deselect_all) + } else binding?.btnToggleAll?.setText(R.string.select_all) + } + val adapter = DownloadAdapter( {}, - { downloadClickEvent -> - handleDownloadClick(downloadClickEvent) - if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) { - setUpDownloadDeleteListener(folder) - } + { click -> + if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { + context?.let { ctx -> + downloadsViewModel.handleSingleDelete(ctx, click.data.id) + } + } else handleDownloadClick(click) + }, + { itemId, isChecked -> + if (isChecked) { + downloadsViewModel.addSelected(itemId) + } else downloadsViewModel.removeSelected(itemId) } ) @@ -122,18 +161,47 @@ class DownloadChildFragment : Fragment() { ) } - updateList(folder) + context?.let { downloadsViewModel.updateChildList(it, folder) } + fixPaddingStatusbar(binding?.downloadChildRoot) } - private fun setUpDownloadDeleteListener(folder: String) { - downloadDeleteEventListener = { id: Int -> - val list = (binding?.downloadChildList?.adapter as? DownloadAdapter)?.currentList - if (list != null) { - if (list.any { it.data.id == id }) { - updateList(folder) + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadChildToolbar?.isVisible = false + activity?.attachBackPressedCallback { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnDelete?.setOnClickListener { + context?.let { ctx -> + downloadsViewModel.handleMultiDelete(ctx) } } + + binding?.btnCancel?.setOnClickListener { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnToggleAll?.setOnClickListener { + val allSelected = downloadsViewModel.isAllSelected() + val adapter = binding?.downloadChildList?.adapter as? DownloadAdapter + if (allSelected) { + adapter?.notifySelectionStates() + downloadsViewModel.clearSelectedItems() + } else { + adapter?.notifyAllSelected() + downloadsViewModel.selectAllItems() + } + } + + downloadsViewModel.setIsMultiDeleteState(true) } - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } + } + + private fun updateDeleteButton(count: Int, selectedBytes: Long) { + val formattedSize = formatShortFileSize(context, selectedBytes) + binding?.btnDelete?.text = + getString(R.string.delete_format).format(count, formattedSize) } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index 23d546e1083..447b4f1310f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -8,6 +8,8 @@ import android.content.Intent import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.View @@ -17,7 +19,6 @@ import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged @@ -27,7 +28,7 @@ import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding import com.lagradost.cloudstream3.databinding.StreamInputBinding -import com.lagradost.cloudstream3.isMovieType +import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick @@ -40,20 +41,22 @@ import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE -import com.lagradost.cloudstream3.utils.DataStore +import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV -import com.lagradost.cloudstream3.utils.VideoDownloadManager import java.net.URI const val DOWNLOAD_NAVIGATE_TO = "downloadpage" class DownloadFragment : Fragment() { private lateinit var downloadsViewModel: DownloadViewModel + private var binding: FragmentDownloadsBinding? = null private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( @@ -65,14 +68,11 @@ class DownloadFragment : Fragment() { } override fun onDestroyView() { - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent -= it } - downloadDeleteEventListener = null + detachBackPressedCallback() binding = null super.onDestroyView() } - private var binding: FragmentDownloadsBinding? = null - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -84,12 +84,34 @@ class DownloadFragment : Fragment() { return localBinding.root } - private var downloadDeleteEventListener: ((Int) -> Unit)? = null - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) hideKeyboard() binding?.downloadStorageAppbar?.setAppBarNoScrollFlagsOnTV() + binding?.downloadDeleteAppbar?.setAppBarNoScrollFlagsOnTV() + + /** + * We never want to retain multi-delete state + * when navigating to downloads. Setting this state + * immediately can sometimes result in the observer + * not being notified in time to update the UI. + * + * By posting to the main looper, we ensure that this + * operation is executed after the view has been fully created + * and all initializations are completed, allowing the + * observer to properly receive and handle the state change. + */ + Handler(Looper.getMainLooper()).post { + downloadsViewModel.setIsMultiDeleteState(false) + } + + /** + * We have to make sure selected items are + * cleared here as well so we don't run in an + * inconsistent state where selected items do + * not match the multi delete state we are in. + */ + downloadsViewModel.clearSelectedItems() observe(downloadsViewModel.headerCards) { (binding?.downloadList?.adapter as? DownloadAdapter)?.submitList(it) @@ -97,25 +119,82 @@ class DownloadFragment : Fragment() { binding?.textNoDownloads?.isVisible = it.isEmpty() } observe(downloadsViewModel.availableBytes) { - updateStorageInfo(view.context, it, R.string.free_storage, binding?.downloadFreeTxt, binding?.downloadFree) + updateStorageInfo( + view.context, + it, + R.string.free_storage, + binding?.downloadFreeTxt, + binding?.downloadFree + ) } observe(downloadsViewModel.usedBytes) { - updateStorageInfo(view.context, it, R.string.used_storage, binding?.downloadUsedTxt, binding?.downloadUsed) - binding?.downloadStorageAppbar?.isVisible = it > 0 + updateStorageInfo( + view.context, + it, + R.string.used_storage, + binding?.downloadUsedTxt, + binding?.downloadUsed + ) + + // Prevent race condition and make sure + // we don't display it early + if ( + downloadsViewModel.isMultiDeleteState.value == null || + downloadsViewModel.isMultiDeleteState.value == false + ) binding?.downloadStorageAppbar?.isVisible = it > 0 } observe(downloadsViewModel.downloadBytes) { - updateStorageInfo(view.context, it, R.string.app_storage, binding?.downloadAppTxt, binding?.downloadApp) + updateStorageInfo( + view.context, + it, + R.string.app_storage, + binding?.downloadAppTxt, + binding?.downloadApp + ) + } + observe(downloadsViewModel.selectedBytes) { + updateDeleteButton(downloadsViewModel.selectedItemIds.value?.count() ?: 0, it) + } + observe(downloadsViewModel.isMultiDeleteState) { isMultiDeleteState -> + val adapter = binding?.downloadList?.adapter as? DownloadAdapter + adapter?.setIsMultiDeleteState(isMultiDeleteState) + binding?.downloadDeleteAppbar?.isVisible = isMultiDeleteState + if (!isMultiDeleteState) { + detachBackPressedCallback() + downloadsViewModel.clearSelectedItems() + // Prevent race condition and make sure + // we don't display it early + if (downloadsViewModel.usedBytes.value?.let { it > 0 } == true) { + binding?.downloadStorageAppbar?.isVisible = true + } + } + } + observe(downloadsViewModel.selectedItemIds) { + handleSelectedChange(it) + updateDeleteButton(it.count(), downloadsViewModel.selectedBytes.value ?: 0L) + + binding?.btnDelete?.isVisible = it.isNotEmpty() + binding?.selectItemsText?.isVisible = it.isEmpty() + + val allSelected = downloadsViewModel.isAllSelected() + if (allSelected) { + binding?.btnToggleAll?.setText(R.string.deselect_all) + } else binding?.btnToggleAll?.setText(R.string.select_all) } val adapter = DownloadAdapter( + { click -> handleItemClick(click) }, { click -> - handleItemClick(click) + if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { + context?.let { ctx -> + downloadsViewModel.handleSingleDelete(ctx, click.data.id) + } + } else handleDownloadClick(click) }, - { downloadClickEvent -> - handleDownloadClick(downloadClickEvent) - if (downloadClickEvent.action == DOWNLOAD_ACTION_DELETE_FILE) { - setUpDownloadDeleteListener() - } + { itemId, isChecked -> + if (isChecked) { + downloadsViewModel.addSelected(itemId) + } else downloadsViewModel.removeSelected(itemId) } ) @@ -126,7 +205,6 @@ class DownloadFragment : Fragment() { setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, - nextUp = FOCUS_SELF, nextDown = FOCUS_SELF, ) } @@ -147,35 +225,68 @@ class DownloadFragment : Fragment() { handleScroll(scrollY - oldScrollY) } } - downloadsViewModel.updateList(requireContext()) + + context?.let { downloadsViewModel.updateHeaderList(it) } fixPaddingStatusbar(binding?.downloadRoot) } private fun handleItemClick(click: DownloadHeaderClickEvent) { when (click.action) { DOWNLOAD_ACTION_GO_TO_CHILD -> { - if (!click.data.type.isMovieType()) { - val folder = DataStore.getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) + if (click.data.type.isEpisodeBased()) { + val folder = + getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) activity?.navigate( R.id.action_navigation_downloads_to_navigation_download_child, DownloadChildFragment.newInstance(click.data.name, folder) ) } } + DOWNLOAD_ACTION_LOAD_RESULT -> { - (activity as AppCompatActivity?)?.loadResult(click.data.url, click.data.apiName) + activity?.loadResult(click.data.url, click.data.apiName) } } } - private fun setUpDownloadDeleteListener() { - downloadDeleteEventListener = { id -> - val list = (binding?.downloadList?.adapter as? DownloadAdapter)?.currentList - if (list?.any { it.data.id == id } == true) { - context?.let { downloadsViewModel.updateList(it) } + private fun handleSelectedChange(selected: MutableSet) { + if (selected.isNotEmpty()) { + binding?.downloadDeleteAppbar?.isVisible = true + binding?.downloadStorageAppbar?.isVisible = false + activity?.attachBackPressedCallback { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnDelete?.setOnClickListener { + context?.let { ctx -> + downloadsViewModel.handleMultiDelete(ctx) + } + } + + binding?.btnCancel?.setOnClickListener { + downloadsViewModel.setIsMultiDeleteState(false) + } + + binding?.btnToggleAll?.setOnClickListener { + val allSelected = downloadsViewModel.isAllSelected() + val adapter = binding?.downloadList?.adapter as? DownloadAdapter + if (allSelected) { + adapter?.notifySelectionStates() + downloadsViewModel.clearSelectedItems() + } else { + adapter?.notifyAllSelected() + downloadsViewModel.selectAllItems() + } } + + downloadsViewModel.setIsMultiDeleteState(true) } - downloadDeleteEventListener?.let { VideoDownloadManager.downloadDeleteEvent += it } + } + + private fun updateDeleteButton(count: Int, selectedBytes: Long) { + val formattedSize = formatShortFileSize(context, selectedBytes) + binding?.btnDelete?.text = + getString(R.string.delete_format).format(count, formattedSize) } private fun updateStorageInfo( @@ -185,7 +296,10 @@ class DownloadFragment : Fragment() { textView: TextView?, view: View? ) { - textView?.text = getString(R.string.storage_size_format).format(getString(stringRes), formatShortFileSize(context, bytes)) + textView?.text = getString(R.string.storage_size_format).format( + getString(stringRes), + formatShortFileSize(context, bytes) + ) view?.setLayoutWidth(bytes) } @@ -218,7 +332,9 @@ class DownloadFragment : Fragment() { if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding) } - (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt(0)?.text?.toString()?.let { copy -> + (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( + 0 + )?.text?.toString()?.let { copy -> val fixedText = copy.trim() binding.streamUrl.setText(fixedText) activateSwitchOnHls(fixedText, binding) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index 83d96592809..137f1355e25 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -1,122 +1,439 @@ package com.lagradost.cloudstream3.ui.download import android.content.Context +import android.content.DialogInterface import android.os.Environment import android.os.StatFs +import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.lagradost.cloudstream3.isMovieType +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull +import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.VideoDownloadHelper +import com.lagradost.cloudstream3.utils.VideoDownloadManager.deleteFilesAndUpdateSettings import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { - private val _headerCards = - MutableLiveData>().apply { listOf() } - val headerCards: LiveData> = _headerCards - private val _usedBytes = MutableLiveData() - private val _availableBytes = MutableLiveData() - private val _downloadBytes = MutableLiveData() + private val _headerCards = MutableLiveData>() + val headerCards: LiveData> = _headerCards + + private val _childCards = MutableLiveData>() + val childCards: LiveData> = _childCards + private val _usedBytes = MutableLiveData() val usedBytes: LiveData = _usedBytes + + private val _availableBytes = MutableLiveData() val availableBytes: LiveData = _availableBytes + + private val _downloadBytes = MutableLiveData() val downloadBytes: LiveData = _downloadBytes - private var previousVisual: List? = null + private val _selectedBytes = MutableLiveData(0) + val selectedBytes: LiveData = _selectedBytes + + private val _isMultiDeleteState = MutableLiveData(false) + val isMultiDeleteState: LiveData = _isMultiDeleteState + + private val _selectedItemIds = MutableLiveData>(mutableSetOf()) + val selectedItemIds: LiveData> = _selectedItemIds + + private var previousVisual: List? = null + + fun setIsMultiDeleteState(value: Boolean) { + _isMultiDeleteState.postValue(value) + } + + fun addSelected(itemId: Int) { + updateSelectedItems { it.add(itemId) } + } + + fun removeSelected(itemId: Int) { + updateSelectedItems { it.remove(itemId) } + } + + fun selectAllItems() { + val items = headerCards.value.orEmpty() + childCards.value.orEmpty() + updateSelectedItems { it.addAll(items.map { item -> item.data.id }) } + } + + fun clearSelectedItems() { + // We need this to be done immediately + // so we can't use postValue + _selectedItemIds.value = mutableSetOf() + updateSelectedItems { it.clear() } + } + + fun isAllSelected(): Boolean { + val currentSelected = selectedItemIds.value ?: return false + val items = headerCards.value.orEmpty() + childCards.value.orEmpty() + return items.count() == currentSelected.count() && items.all { it.data.id in currentSelected } + } + + private fun updateSelectedItems(action: (MutableSet) -> Unit) { + val currentSelected = selectedItemIds.value ?: mutableSetOf() + action(currentSelected) + _selectedItemIds.postValue(currentSelected) + updateSelectedBytes() + updateSelectedCards() + } + + private fun updateSelectedBytes() = viewModelScope.launchSafe { + val selectedItemsList = getSelectedItemsData() ?: return@launchSafe + val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes } + _selectedBytes.postValue(totalSelectedBytes) + } + + private fun updateSelectedCards() = viewModelScope.launchSafe { + val currentSelected = selectedItemIds.value ?: return@launchSafe + + headerCards.value?.let { headers -> + headers.forEach { header -> + header.isSelected = header.data.id in currentSelected + } + _headerCards.postValue(headers) + } - fun updateList(context: Context) = viewModelScope.launchSafe { - val children = withContext(Dispatchers.IO) { - context.getKeys(DOWNLOAD_EPISODE_CACHE) + childCards.value?.let { children -> + children.forEach { child -> + child.isSelected = child.data.id in currentSelected + } + _childCards.postValue(children) + } + } + + fun updateHeaderList(context: Context) = viewModelScope.launchSafe { + val visual = withContext(Dispatchers.IO) { + val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { context.getKey(it) } .distinctBy { it.id } // Remove duplicates + + val (totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) = + calculateDownloadStats(context, children) + + val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) + .mapNotNull { context.getKey(it) } + + createVisualDownloadList( + context, cached, totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads + ) + } + + if (visual != previousVisual) { + previousVisual = visual + updateStorageStats(visual) + _headerCards.postValue(visual) } + } + private fun calculateDownloadStats( + context: Context, + children: List + ): Triple, Map, Map> { // parentId : bytes - val totalBytesUsedByChild = HashMap() + val totalBytesUsedByChild = mutableMapOf() // parentId : bytes - val currentBytesUsedByChild = HashMap() + val currentBytesUsedByChild = mutableMapOf() // parentId : downloadsCount - val totalDownloads = HashMap() + val totalDownloads = mutableMapOf() - // Gets all children downloads - withContext(Dispatchers.IO) { - children.forEach { c -> - val childFile = getDownloadFileInfoAndUpdateSettings(context, c.id) ?: return@forEach + children.forEach { child -> + val childFile = getDownloadFileInfoAndUpdateSettings(context, child.id) ?: return@forEach + if (childFile.fileLength <= 1) return@forEach - if (childFile.fileLength <= 1) return@forEach - val len = childFile.totalBytes - val flen = childFile.fileLength + val len = childFile.totalBytes + val flen = childFile.fileLength - totalBytesUsedByChild[c.parentId] = totalBytesUsedByChild[c.parentId]?.plus(len) ?: len - currentBytesUsedByChild[c.parentId] = currentBytesUsedByChild[c.parentId]?.plus(flen) ?: flen - totalDownloads[c.parentId] = totalDownloads[c.parentId]?.plus(1) ?: 1 - } + totalBytesUsedByChild.merge(child.parentId, len, Long::plus) + currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) + totalDownloads.merge(child.parentId, 1, Int::plus) } + return Triple(totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads) + } + + private fun createVisualDownloadList( + context: Context, + cached: List, + totalBytesUsedByChild: Map, + currentBytesUsedByChild: Map, + totalDownloads: Map + ): List { + return cached.mapNotNull { + val downloads = totalDownloads[it.id] ?: 0 + val bytes = totalBytesUsedByChild[it.id] ?: 0 + val currentBytes = currentBytesUsedByChild[it.id] ?: 0 + if (bytes <= 0 || downloads <= 0) return@mapNotNull null + + val isSelected = selectedItemIds.value?.contains(it.id) ?: false + val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey( + DOWNLOAD_EPISODE_CACHE, + getFolderName(it.id.toString(), it.id.toString()) + ) - val cached = withContext(Dispatchers.IO) { // Won't fetch useless keys - totalDownloads.entries.filter { it.value > 0 }.mapNotNull { - context.getKey( - DOWNLOAD_HEADER_CACHE, - it.key.toString() + VisualDownloadCached.Header( + currentBytes = currentBytes, + totalBytes = bytes, + data = it, + child = movieEpisode, + currentOngoingDownloads = 0, + totalDownloads = downloads, + isSelected = isSelected, + ) + // Prevent order being almost completely random, + // making things difficult to find. + }.sortedWith(compareBy { + // Sort by isEpisodeBased() ascending. We put those that + // are episode based at the bottom for UI purposes and to + // make it easier to find by grouping them together. + it.data.type.isEpisodeBased() + }.thenBy { + // Then we sort alphabetically by name (case-insensitive). + // Again, we do this to make things easier to find. + it.data.name.lowercase() + }) + } + + fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { + val visual = withContext(Dispatchers.IO) { + context.getKeys(folder).mapNotNull { key -> + context.getKey(key) + }.mapNotNull { + val isSelected = selectedItemIds.value?.contains(it.id) ?: false + val info = getDownloadFileInfoAndUpdateSettings(context, it.id) ?: return@mapNotNull null + VisualDownloadCached.Child( + currentBytes = info.fileLength, + totalBytes = info.totalBytes, + isSelected = isSelected, + data = it, ) } + }.sortedWith(compareBy( + // Sort by season first, and then by episode number, + // to ensure sorting is consistent. + { it.data.season ?: 0 }, + { it.data.episode } + )) + + if (previousVisual != visual) { + previousVisual = visual + _childCards.postValue(visual) } + } - val visual = withContext(Dispatchers.IO) { - cached.mapNotNull { - val downloads = totalDownloads[it.id] ?: 0 - val bytes = totalBytesUsedByChild[it.id] ?: 0 - val currentBytes = currentBytesUsedByChild[it.id] ?: 0 - if (bytes <= 0 || downloads <= 0) return@mapNotNull null - val movieEpisode = - if (!it.type.isMovieType()) null - else context.getKey( - DOWNLOAD_EPISODE_CACHE, - getFolderName(it.id.toString(), it.id.toString()) + private fun removeItems(idsToRemove: Set) = viewModelScope.launchSafe { + val updatedHeaders = headerCards.value.orEmpty().filter { it.data.id !in idsToRemove } + val updatedChildren = childCards.value.orEmpty().filter { it.data.id !in idsToRemove } + _headerCards.postValue(updatedHeaders) + _childCards.postValue(updatedChildren) + } + + private fun updateStorageStats(visual: List) { + try { + val stat = StatFs(Environment.getExternalStorageDirectory().path) + val localBytesAvailable = stat.availableBytes + val localTotalBytes = stat.blockSizeLong * stat.blockCountLong + val localDownloadedBytes = visual.sumOf { it.totalBytes } + val localUsedBytes = localTotalBytes - localBytesAvailable + _usedBytes.postValue(localUsedBytes) + _availableBytes.postValue(localBytesAvailable) + _downloadBytes.postValue(localDownloadedBytes) + } catch (t: Throwable) { + _downloadBytes.postValue(0) + logError(t) + } + } + + fun handleMultiDelete(context: Context) = viewModelScope.launchSafe { + val selectedItemsList = getSelectedItemsData().orEmpty() + val deleteData = processSelectedItems(context, selectedItemsList) + val message = buildDeleteMessage(context, deleteData) + showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) + } + + fun handleSingleDelete( + context: Context, + itemId: Int + ) = viewModelScope.launchSafe { + val itemData = getItemDataFromId(itemId) + val deleteData = processSelectedItems(context, itemData) + val message = buildDeleteMessage(context, deleteData) + showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) + } + + private fun processSelectedItems( + context: Context, + selectedItemsList: List + ): DeleteData { + val names = mutableListOf() + val seriesNames = mutableListOf() + + val ids = mutableSetOf() + val parentIds = mutableSetOf() + + var parentName: String? = null + + selectedItemsList.forEach { item -> + when (item) { + is VisualDownloadCached.Header -> { + if (item.data.type.isEpisodeBased()) { + val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) + .mapNotNull { + context.getKey( + it + ) + } + .filter { it.parentId == item.data.id } + .map { it.id } + ids.addAll(episodes) + parentIds.add(item.data.id) + + val episodeInfo = "${item.data.name} (${item.totalDownloads} ${ + context.resources.getQuantityString( + R.plurals.episodes, + item.totalDownloads + ).lowercase() + })" + seriesNames.add(episodeInfo) + } else { + ids.add(item.data.id) + names.add(item.data.name) + } + } + + is VisualDownloadCached.Child -> { + ids.add(item.data.id) + val parent = context.getKey( + DOWNLOAD_HEADER_CACHE, + item.data.parentId.toString() ) - VisualDownloadHeaderCached( - currentBytes = currentBytes, - totalBytes = bytes, - data = it, - child = movieEpisode, - currentOngoingDownloads = 0, - totalDownloads = downloads, + parentName = parent?.name + names.add( + context.getNameFull( + item.data.name, + item.data.episode, + item.data.season + ) + ) + } + } + } + + return DeleteData(ids, parentIds, seriesNames, names, parentName) + } + + private fun buildDeleteMessage( + context: Context, + data: DeleteData + ): String { + val formattedNames = data.names.sortedBy { it.lowercase() } + .joinToString(separator = "\n") { "• $it" } + val formattedSeriesNames = data.seriesNames.sortedBy { it.lowercase() } + .joinToString(separator = "\n") { "• $it" } + + return when { + data.ids.count() == 1 -> { + context.getString(R.string.delete_message).format( + data.names.firstOrNull() ) - }.sortedBy { - (it.child?.episode ?: 0) + (it.child?.season?.times(10000) ?: 0) - } // Episode sorting by episode, lowest to highest + } + + data.seriesNames.isNotEmpty() && data.names.isEmpty() -> { + context.getString(R.string.delete_message_series_only).format(formattedSeriesNames) + } + + data.parentName != null && data.names.isNotEmpty() -> { + context.getString(R.string.delete_message_series_episodes) + .format(data.parentName, formattedNames) + } + + data.seriesNames.isNotEmpty() -> { + val seriesSection = context.getString(R.string.delete_message_series_section) + .format(formattedSeriesNames) + context.getString(R.string.delete_message_multiple) + .format(formattedNames) + "\n\n" + seriesSection + } + + else -> context.getString(R.string.delete_message_multiple).format(formattedNames) } + } - // Only update list if different from the previous one to prevent duplicate initialization - if (visual != previousVisual) { - previousVisual = visual + private fun showDeleteConfirmationDialog( + context: Context, + message: String, + ids: Set, + parentIds: Set + ) { + val builder = AlertDialog.Builder(context) + val dialogClickListener = + DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + viewModelScope.launchSafe { + setIsMultiDeleteState(false) + deleteFilesAndUpdateSettings(context, ids, this) { successfulIds -> + // We always remove parent because if we are deleting from here + // and we have it as non-empty, it was triggered on + // parent header card + removeItems(successfulIds + parentIds) + } + } + } - try { - val stat = StatFs(Environment.getExternalStorageDirectory().path) - val localBytesAvailable = stat.availableBytes - val localTotalBytes = stat.blockSizeLong * stat.blockCountLong - val localDownloadedBytes = visual.sumOf { it.totalBytes } - - _usedBytes.postValue(localTotalBytes - localBytesAvailable - localDownloadedBytes) - _availableBytes.postValue(localBytesAvailable) - _downloadBytes.postValue(localDownloadedBytes) - } catch (t: Throwable) { - _downloadBytes.postValue(0) - logError(t) + DialogInterface.BUTTON_NEGATIVE -> { + // Do nothing on cancel + } + } } - _headerCards.postValue(visual) + try { + val title = if (ids.count() == 1) { + R.string.delete_file + } else R.string.delete_files + builder.setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.delete, dialogClickListener) + .setNegativeButton(R.string.cancel, dialogClickListener) + .show().setDefaultFocus() + } catch (e: Exception) { + logError(e) + } + } + + private fun getSelectedItemsData(): List? { + val headers = headerCards.value.orEmpty() + val children = childCards.value.orEmpty() + + return selectedItemIds.value?.mapNotNull { id -> + headers.find { it.data.id == id } ?: children.find { it.data.id == id } } } + + private fun getItemDataFromId(itemId: Int): List { + val headers = headerCards.value.orEmpty() + val children = childCards.value.orEmpty() + + return (headers + children).filter { it.data.id == itemId } + } + + private data class DeleteData( + val ids: Set, + val parentIds: Set, + val seriesNames: List, + val names: List, + val parentName: String? + ) } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt index f10e103e753..908e3a80abc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt @@ -54,6 +54,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : } init { + @Suppress("LeakingThis") resetViewData() } @@ -92,7 +93,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : abstract fun setStatus(status: VideoDownloadManager.DownloadType?) - fun getStatus(id:Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell { + fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell { // some extra padding for just in case return VideoDownloadManager.downloadStatus[id] ?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) { @@ -100,7 +101,7 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : } else DownloadStatusTell.IsPaused } - fun applyMetaData(id:Int, downloadedBytes: Long, totalBytes: Long) { + fun applyMetaData(id: Int, downloadedBytes: Long, totalBytes: Long) { val status = getStatus(id, downloadedBytes, totalBytes) currentMetaData.apply { @@ -139,7 +140,8 @@ abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : } else { if (doSetProgress) { progressText?.apply { - val currentFormattedSizeString = formatShortFileSize(context, downloadedBytes) + val currentFormattedSizeString = + formatShortFileSize(context, downloadedBytes) val totalFormattedSizeString = formatShortFileSize(context, totalBytes) text = // if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt index d97a4b88798..20a4446114b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt @@ -13,7 +13,7 @@ import com.lagradost.cloudstream3.utils.VideoDownloadHelper class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { - var mainText: TextView? = null + private var mainText: TextView? = null override fun onAttachedToWindow() { super.onAttachedToWindow() progressText = findViewById(R.id.result_movie_download_text_precentage) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt index abc159d09ab..29c2daa2cf4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt @@ -58,7 +58,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } private var progressBarBackground: View - private var statusView: ImageView + var statusView: ImageView open fun onInflate() {} @@ -248,7 +248,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : } */ @MainThread - private fun setStatusInternal(status : DownloadStatusTell?) { + private fun setStatusInternal(status: DownloadStatusTell?) { val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) { val animation = AnimationUtils.loadAnimation(context, waitingAnimation) @@ -286,7 +286,7 @@ open class PieFetchButton(context: Context, attributeSet: AttributeSet) : if (Looper.myLooper() == Looper.getMainLooper()) { try { setStatusInternal(status) - } catch (t : Throwable) { + } catch (t: Throwable) { logError(t) // Just in case setStatusInternal throws because thread progressBarBackground.post { setStatusInternal(status) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt index ebed901f928..b25486eb174 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt @@ -15,7 +15,7 @@ import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.utils.UIHelper.IsBottomLayout +import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout import com.lagradost.cloudstream3.utils.UIHelper.toPx class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState(view) { @@ -54,7 +54,7 @@ class HomeChildItemAdapter( var hasNext: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { - val expanded = parent.context.IsBottomLayout() + 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) @@ -133,7 +133,6 @@ class HomeChildItemAdapter( item, position, holder.itemView, - null, // nextFocusBehavior, nextFocusUp, nextFocusDown ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 82a92d80a45..49de25034aa 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -17,7 +17,6 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.* import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -234,7 +233,7 @@ class HomeFragment : Fragment() { return bottomSheetDialogBuilder } - fun getPairList( + private fun getPairList( anime: Chip?, cartoons: Chip?, tvs: Chip?, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt index 916cb9aeb80..8bc0aa287ea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt @@ -1,6 +1,8 @@ package com.lagradost.cloudstream3.ui.home +import android.os.Build import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -53,12 +55,12 @@ open class ParentItemAdapter( "value", recyclerView?.layoutManager?.onSaveInstanceState() ) - (recyclerView?.adapter as? BaseAdapter<*,*>)?.save(recyclerView) + (recyclerView?.adapter as? BaseAdapter<*, *>)?.save(recyclerView) } override fun restore(state: Bundle) { (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( - state.getParcelable("value") + state.getSafeParcelable("value") ) } } @@ -169,4 +171,9 @@ open class ParentItemAdapter( submitList(newList.map { HomeViewModel.ExpandableHomepageList(it, 1, false) } .toMutableList()) } -} \ No newline at end of file +} + +@Suppress("DEPRECATION") +inline fun Bundle.getSafeParcelable(key: String): T? = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key) + else getParcelable(key, T::class.java) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt index 2e98dd1f9f7..339ef1e1254 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt @@ -117,15 +117,12 @@ class HomeParentItemAdapterPreview( } override fun restore(state: Bundle) { - state.getParcelable("resumeRecyclerView")?.let { recycle -> + state.getSafeParcelable("resumeRecyclerView")?.let { recycle -> resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle) } - state.getParcelable("bookmarkRecyclerView")?.let { recycle -> + state.getSafeParcelable("bookmarkRecyclerView")?.let { recycle -> bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle) } - //state.getInt("previewViewpager").let { recycle -> - // previewViewpager.setCurrentItem(recycle,true) - //} } val previewAdapter = HomeScrollAdapter(fragment = fragment) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 9e70d088e68..1e3e492a0b9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AcraApplication.Companion.context @@ -152,7 +153,7 @@ class HomeViewModel : ViewModel() { } }?.distinctBy { it.first } ?: return@launchSafe - val length = WatchType.values().size + val length = WatchType.entries.size val currentWatchTypes = mutableSetOf() for (watch in watchStatusIds) { @@ -387,7 +388,9 @@ class HomeViewModel : ViewModel() { } is Resource.Failure -> { + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") _page.postValue(data!!) + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") _preview.postValue(data!!) } @@ -397,9 +400,7 @@ class HomeViewModel : ViewModel() { } fun click(callback: SearchClickCallback) { - if (callback.action == SEARCH_ACTION_FOCUSED) { - //focusCallback(callback.card) - } else { + if (callback.action != SEARCH_ACTION_FOCUSED) { SearchHelper.handleSearchClickCallback(callback) } } @@ -492,7 +493,20 @@ class HomeViewModel : ViewModel() { return@ioSafe } - val api = getApiFromNameNull(preferredApiName) + var api = getApiFromNameNull(preferredApiName) + // So if we have PREBUILD - try to hack default selection :) + if (preferredApiName == null && allProviders.isEmpty() == false && fromUI == false) { + val littleDefault = allProviders.firstOrNull { it.name == "UASerial" } // ["UASerial", "UASerial.club", "UASerialsPro", "UAserialsVip"] + if (littleDefault != null) { + api = getApiFromNameNull(littleDefault.name) + if (api != null) { + DataStoreHelper.currentHomePage = api.name + loadAndCancel(api) + return@ioSafe + } + } + } + if (preferredApiName == noneApi.name) { // just set to random if (fromUI) DataStoreHelper.currentHomePage = noneApi.name @@ -516,7 +530,7 @@ class HomeViewModel : ViewModel() { } else { _page.postValue(Resource.Loading()) if (preferredApiName != null) - _apiName.postValue(preferredApiName) + _apiName.postValue(preferredApiName!!) } } else { // if the api is found, then set it to it and save key diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt index 7144de09709..5b240693bc9 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt @@ -600,8 +600,4 @@ class LibraryFragment : Fragment() { } } -class MenuSearchView(context: Context) : SearchView(context) { - override fun onActionViewCollapsed() { - super.onActionViewCollapsed() - } -} \ No newline at end of file +class MenuSearchView(context: Context) : SearchView(context) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt index 1bd01c86449..6c602e6c527 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt @@ -23,6 +23,8 @@ enum class ListSorting(@StringRes val stringRes: Int) { UpdatedOld(R.string.sort_updated_old), AlphabeticalA(R.string.sort_alphabetical_a), AlphabeticalZ(R.string.sort_alphabetical_z), + ReleaseDateNew(R.string.sort_release_date_new), + ReleaseDateOld(R.string.sort_release_date_old), } const val LAST_SYNC_API_KEY = "last_sync_api" @@ -132,4 +134,4 @@ class LibraryViewModel : ViewModel() { MainActivity.reloadLibraryEvent -= ::reloadPages super.onCleared() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt index cfd22220c5b..0110187f603 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt @@ -15,6 +15,7 @@ import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState +import com.lagradost.cloudstream3.ui.home.getSafeParcelable import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV @@ -32,7 +33,7 @@ class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) } override fun restore(state: Bundle) { - state.getParcelable("pageRecyclerview")?.let { recycle -> + state.getSafeParcelable("pageRecyclerview")?.let { recycle -> binding.pageRecyclerview.layoutManager?.onRestoreInstanceState(recycle) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 9d838c97451..ee987f444e5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -27,7 +27,11 @@ import androidx.fragment.app.Fragment import androidx.media3.common.PlaybackException import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession -import androidx.media3.ui.* +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 androidx.preference.PreferenceManager import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import com.github.rubensousa.previewseekbar.PreviewBar @@ -216,7 +220,7 @@ abstract class AbstractPlayerFragment( return } player.handleEvent( - CSPlayerEvent.values()[intent.getIntExtra( + CSPlayerEvent.entries[intent.getIntExtra( EXTRA_CONTROL_TYPE, 0 )], source = PlayerEventSource.UI @@ -603,12 +607,12 @@ abstract class AbstractPlayerFragment( } fun nextResize() { - resizeMode = (resizeMode + 1) % PlayerResize.values().size + resizeMode = (resizeMode + 1) % PlayerResize.entries.size resize(resizeMode, true) } fun resize(resize: Int, showToast: Boolean) { - resize(PlayerResize.values()[resize], showToast) + resize(PlayerResize.entries[resize], showToast) } @SuppressLint("UnsafeOptInUsageError") diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 8e322f73bb3..86d67b28a4c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -9,7 +9,11 @@ import android.os.Looper import android.util.Log import android.util.Rational import android.widget.FrameLayout -import androidx.media3.common.C.* +import androidx.annotation.OptIn +import androidx.media3.common.C.TIME_UNSET +import androidx.media3.common.C.TRACK_TYPE_AUDIO +import androidx.media3.common.C.TRACK_TYPE_TEXT +import androidx.media3.common.C.TRACK_TYPE_VIDEO import androidx.media3.common.Format import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes @@ -19,9 +23,10 @@ import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DefaultDataSourceFactory +import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.cache.CacheDataSource @@ -66,7 +71,6 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import java.io.File -import java.lang.IllegalArgumentException import java.util.UUID import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext @@ -84,7 +88,7 @@ const val toleranceBeforeUs = 300_000L * seek position, in microseconds. Must be non-negative. */ const val toleranceAfterUs = 300_000L - +@OptIn(UnstableApi::class) class CS3IPlayer : IPlayer { private var isPlaying = false private var exoPlayer: ExoPlayer? = null @@ -257,7 +261,6 @@ class CS3IPlayer : IPlayer { private var currentSubtitles: SubtitleData? = null - @SuppressLint("UnsafeOptInUsageError") private fun List.getTrack(id: String?): Pair? { if (id == null) return null // This beast of an expression does: @@ -342,7 +345,6 @@ class CS3IPlayer : IPlayer { }.flatten() } - @SuppressLint("UnsafeOptInUsageError") private fun Tracks.Group.getFormats(): List> { return (0 until this.mediaTrackGroup.length).mapNotNull { i -> if (this.isSupported) @@ -371,7 +373,6 @@ class CS3IPlayer : IPlayer { ) } - @SuppressLint("UnsafeOptInUsageError") override fun getVideoTracks(): CurrentTracks { val allTracks = exoPlayer?.currentTracks?.groups ?: emptyList() val videoTracks = allTracks.filter { it.type == TRACK_TYPE_VIDEO } @@ -391,7 +392,6 @@ 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 @@ -451,7 +451,7 @@ class CS3IPlayer : IPlayer { } ?: false } - var currentSubtitleOffset: Long = 0 + private var currentSubtitleOffset: Long = 0 override fun setSubtitleOffset(offset: Long) { currentSubtitleOffset = offset @@ -459,7 +459,7 @@ class CS3IPlayer : IPlayer { } override fun getSubtitleOffset(): Long { - return currentSubtitleOffset //currentTextRenderer?.getRenderOffsetMs() ?: currentSubtitleOffset + return currentSubtitleOffset } override fun getCurrentPreferredSubtitle(): SubtitleData? { @@ -470,7 +470,6 @@ class CS3IPlayer : IPlayer { } } - @SuppressLint("UnsafeOptInUsageError") override fun getAspectRatio(): Rational? { return exoPlayer?.videoFormat?.let { format -> Rational(format.width, format.height) @@ -481,14 +480,13 @@ class CS3IPlayer : IPlayer { subtitleHelper.setSubStyle(style) } - @SuppressLint("UnsafeOptInUsageError") override fun saveData() { Log.i(TAG, "saveData") updatedTime() exoPlayer?.let { exo -> playbackPosition = exo.currentPosition - currentWindow = exo.currentWindowIndex + currentWindow = exo.currentMediaItemIndex isPlaying = exo.isPlaying } } @@ -500,7 +498,7 @@ class CS3IPlayer : IPlayer { updatedTime() exoPlayer?.apply { - setPlayWhenReady(false) + playWhenReady = false stop() release() } @@ -563,7 +561,6 @@ class CS3IPlayer : IPlayer { var requestSubtitleUpdate: (() -> Unit)? = null - @SuppressLint("UnsafeOptInUsageError") private fun createOnlineSource(headers: Map): HttpDataSource.Factory { val source = OkHttpDataSource.Factory(app.baseClient).setUserAgent(USER_AGENT) return source.apply { @@ -571,7 +568,6 @@ class CS3IPlayer : IPlayer { } } - @SuppressLint("UnsafeOptInUsageError") private fun createOnlineSource(link: ExtractorLink): HttpDataSource.Factory { val provider = getApiFromNameNull(link.source) val interceptor = provider?.getVideoInterceptor(link) @@ -604,53 +600,10 @@ class CS3IPlayer : IPlayer { } } - @SuppressLint("UnsafeOptInUsageError") private fun Context.createOfflineSource(): DataSource.Factory { - return DefaultDataSourceFactory(this, USER_AGENT) + return DefaultDataSource.Factory(this, DefaultHttpDataSource.Factory().setUserAgent(USER_AGENT)) } - /*private fun getSubSources( - onlineSourceFactory: DataSource.Factory?, - offlineSourceFactory: DataSource.Factory?, - subHelper: PlayerSubtitleHelper, - ): Pair, List> { - val activeSubtitles = ArrayList() - val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> - val subConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(sub.url)) - .setMimeType(sub.mimeType) - .setLanguage("_${sub.name}") - .setSelectionFlags(C.SELECTION_FLAG_DEFAULT) - .build() - when (sub.origin) { - SubtitleOrigin.DOWNLOADED_FILE -> { - if (offlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(offlineSourceFactory) - .createMediaSource(subConfig, C.TIME_UNSET) - } else { - null - } - } - SubtitleOrigin.URL -> { - if (onlineSourceFactory != null) { - activeSubtitles.add(sub) - SingleSampleMediaSource.Factory(onlineSourceFactory) - .createMediaSource(subConfig, C.TIME_UNSET) - } else { - null - } - } - SubtitleOrigin.OPEN_SUBTITLES -> { - // TODO - throw NotImplementedError() - } - } - } - println("SUBSRC: ${subSources.size} activeSubtitles : ${activeSubtitles.size} of ${subHelper.getAllSubtitles().size} ") - return Pair(subSources, activeSubtitles) - }*/ - - @SuppressLint("UnsafeOptInUsageError") private fun getCache(context: Context, cacheSize: Long): SimpleCache? { return try { val databaseProvider = StandaloneDatabaseProvider(context) @@ -682,7 +635,6 @@ 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 = trackSelector.buildUponParameters() @@ -696,7 +648,6 @@ class CS3IPlayer : IPlayer { var currentTextRenderer: CustomTextRenderer? = null - @SuppressLint("UnsafeOptInUsageError") private fun buildExoPlayer( context: Context, mediaItemSlices: List, @@ -736,7 +687,7 @@ class CS3IPlayer : IPlayer { textRendererOutput, eventHandler.looper, CustomSubtitleDecoderFactory() - ).also { this.currentTextRenderer = it } + ).also { renderer -> this.currentTextRenderer = renderer } currentTextRenderer } else it }.toTypedArray() @@ -912,7 +863,11 @@ class CS3IPlayer : IPlayer { } CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source) + CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source) + + CSPlayerEvent.Restart -> seekTo(0, source) + CSPlayerEvent.NextEpisode -> event( EpisodeSeekEvent( offset = 1, @@ -1029,7 +984,7 @@ class CS3IPlayer : IPlayer { } } - @SuppressLint("UnsafeOptInUsageError") + //fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> event( @@ -1165,7 +1120,6 @@ class CS3IPlayer : IPlayer { private var lastTimeStamps: List = emptyList() - @SuppressLint("UnsafeOptInUsageError") override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> @@ -1183,7 +1137,6 @@ class CS3IPlayer : IPlayer { updatedTime(source = PlayerEventSource.Player) } - @SuppressLint("UnsafeOptInUsageError") fun onRenderFirst() { if (hasUsedFirstRender) { // this insures that we only call this once per player load return @@ -1250,7 +1203,6 @@ class CS3IPlayer : IPlayer { } } - @SuppressLint("UnsafeOptInUsageError") private fun getSubSources( onlineSourceFactory: HttpDataSource.Factory?, offlineSourceFactory: DataSource.Factory?, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt index 20d093a636c..07ce413ea5a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.util.Log +import androidx.annotation.OptIn import androidx.preference.PreferenceManager import androidx.media3.common.Format import androidx.media3.common.MimeTypes @@ -31,7 +32,7 @@ import java.nio.charset.Charset * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not * enough to identify the subtitle format. **/ -@UnstableApi +@OptIn(UnstableApi::class) class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { companion object { fun updateForcedEncoding(context: Context) { @@ -72,7 +73,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { RegexOption.IGNORE_CASE ), ) - val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\d\s]*?[])}]\s*""")) + val captionRegex = listOf(Regex("""(-\s?|)[\[({][\w\s]*?[])}]\s*""")) //https://emptycharacter.com/ //https://www.fileformat.info/info/unicode/char/200b/index.htm @@ -262,7 +263,7 @@ class CustomDecoder(private val fallbackFormat: Format?) : SubtitleDecoder { } /** See https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java */ -@UnstableApi +@OptIn(UnstableApi::class) class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { override fun supportsFormat(format: Format): Boolean { // return SubtitleDecoderFactory.DEFAULT.supportsFormat(format) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt index d6b0735d0f9..f2b863fb4eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomTextRenderer.kt @@ -1,11 +1,12 @@ package com.lagradost.cloudstream3.ui.player import android.os.Looper +import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.text.SubtitleDecoderFactory import androidx.media3.exoplayer.text.TextOutput -@UnstableApi +@OptIn(UnstableApi::class) class CustomTextRenderer( offset: Long, output: TextOutput?, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt index 3b242172f0b..c7db7d0458f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt @@ -1,10 +1,15 @@ package com.lagradost.cloudstream3.ui.player +import android.net.Uri import com.lagradost.cloudstream3.AcraApplication.Companion.context +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.utils.VideoDownloadManager +import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName +import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadFileInfoAndUpdateSettings +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder import kotlin.math.max import kotlin.math.min @@ -13,6 +18,7 @@ class DownloadFileGenerator( private var currentIndex: Int = 0 ) : IGenerator { override val hasCache = false + override val canSkipLoading = false override fun hasNext(): Boolean { return currentIndex < episodes.size - 1 @@ -49,10 +55,6 @@ class DownloadFileGenerator( return null } - fun cleanDisplayName(name: String): String { - return name.substringBeforeLast('.').trim() - } - override suspend fun generateLinks( clearCache: Boolean, type: LoadType, @@ -61,7 +63,21 @@ class DownloadFileGenerator( offset: Int ): Boolean { val meta = episodes[currentIndex + offset] - callback(null to meta) + + if (meta.uri == Uri.EMPTY) { + // We do this here so that we only load it when + // we actually need it as it can be more expensive. + val info = meta.id?.let { id -> + activity?.let { act -> + getDownloadFileInfoAndUpdateSettings(act, id) + } + } + + if (info != null) { + val newMeta = meta.copy(uri = info.path) + callback(null to newMeta) + } else callback(null to meta) + } else callback(null to meta) val ctx = context ?: return true val relative = meta.relativePath ?: return true @@ -69,28 +85,9 @@ class DownloadFileGenerator( 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 - + getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) -> + if (isMatchingSubtitle(name, display, cleanDisplay)) { 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( @@ -104,6 +101,7 @@ class DownloadFileGenerator( ) ) } + } return true } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt index 92ef279d442..c38160c2df5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt @@ -4,17 +4,13 @@ import android.content.Intent import android.os.Bundle import android.util.Log import android.view.KeyEvent -import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.utils.UIHelper.navigate -import com.lagradost.safefile.SafeFile import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri - -const val DTAG = "PlayerActivity" +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback class DownloadedPlayerActivity : AppCompatActivity() { private val dTAG = "DownloadedPlayerAct" @@ -74,14 +70,7 @@ class DownloadedPlayerActivity : AppCompatActivity() { return } - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - finish() - } - } - ) + attachBackPressedCallback { finish() } } override fun onResume() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt index 8255360c640..ec485f1c81c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt @@ -7,6 +7,7 @@ class ExtractorLinkGenerator( private val subtitles: List, ) : IGenerator { override val hasCache = false + override val canSkipLoading = true override fun getCurrentId(): Int? { return null diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index 75a861c0957..b2e807496ca 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -14,20 +14,31 @@ import android.os.Bundle import android.provider.Settings import android.text.Editable import android.text.format.DateUtils -import android.view.* +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.Surface +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.AnimationUtils +import androidx.annotation.OptIn +import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red +import androidx.core.view.children import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged +import androidx.media3.common.util.UnstableApi import androidx.preference.PreferenceManager +import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.playerEventListener import com.lagradost.cloudstream3.CommonActivity.screenHeight @@ -41,7 +52,6 @@ import com.lagradost.cloudstream3.ui.player.GeneratorPlayer.Companion.subsProvid import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.result.setText import com.lagradost.cloudstream3.ui.result.txt -import com.lagradost.cloudstream3.ui.settings.Globals import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout @@ -58,7 +68,11 @@ import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UserPreferenceDelegate import com.lagradost.cloudstream3.utils.Vector2 -import kotlin.math.* +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage @@ -77,7 +91,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null - private var durationMode : Boolean by UserPreferenceDelegate("duration_mode", false) + private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) + // state of player UI protected var isShowing = false protected var isLocked = false @@ -109,6 +124,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { protected var doubleTapPauseEnabled = true protected var playerRotateEnabled = false protected var autoPlayerRotateEnabled = false + private var hideControlsNames = false protected var subtitleDelay set(value) = try { @@ -230,6 +246,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { fadeAnimation.duration = 100 fadeAnimation.fillAfter = true + @OptIn(UnstableApi::class) val sView = subView val sStyle = subStyle if (sView != null && sStyle != null) { @@ -243,7 +260,6 @@ open class FullScreenPlayer : AbstractPlayerFragment() { val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat() - playerBinding?.apply { playerOpenSource.let { ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { @@ -284,44 +300,42 @@ open class FullScreenPlayer : AbstractPlayerFragment() { player.getCurrentPreferredSubtitle() == null } - private fun restoreOrientationWithSensor(activity: Activity){ + private fun restoreOrientationWithSensor(activity: Activity) { val currentOrientation = activity.resources.configuration.orientation - var orientation = 0 - when (currentOrientation) { + val orientation = when (currentOrientation) { Configuration.ORIENTATION_LANDSCAPE -> - orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - - Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED -> - orientation = dynamicOrientation() + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE Configuration.ORIENTATION_PORTRAIT -> - orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + else -> dynamicOrientation() } activity.requestedOrientation = orientation } - private fun toggleOrientationWithSensor(activity: Activity){ + private fun toggleOrientationWithSensor(activity: Activity) { val currentOrientation = activity.resources.configuration.orientation - var orientation = 0 - when (currentOrientation) { + val orientation: Int = when (currentOrientation) { Configuration.ORIENTATION_LANDSCAPE -> - orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - - Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED -> - orientation = dynamicOrientation() + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT Configuration.ORIENTATION_PORTRAIT -> - orientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + else -> dynamicOrientation() } activity.requestedOrientation = orientation } open fun lockOrientation(activity: Activity) { - val display = + @Suppress("DEPRECATION") + val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay + else activity.display!! val rotation = display.rotation val currentOrientation = activity.resources.configuration.orientation - var orientation = 0 + val orientation: Int when (currentOrientation) { Configuration.ORIENTATION_LANDSCAPE -> orientation = @@ -330,27 +344,25 @@ open class FullScreenPlayer : AbstractPlayerFragment() { else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE - Configuration.ORIENTATION_SQUARE, Configuration.ORIENTATION_UNDEFINED -> - orientation = dynamicOrientation() - Configuration.ORIENTATION_PORTRAIT -> orientation = if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + + else -> orientation = dynamicOrientation() } activity.requestedOrientation = orientation } private fun updateOrientation(ignoreDynamicOrientation: Boolean = false) { activity?.apply { - if(lockRotation) { - if(isLocked) { + if (lockRotation) { + if (isLocked) { lockOrientation(this) - } - else { - if(ignoreDynamicOrientation){ + } else { + if (ignoreDynamicOrientation) { // restore when lock is disabled restoreOrientationWithSensor(this) } else { @@ -728,6 +740,15 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private var currentTapIndex = 0 protected fun autoHide() { currentTapIndex++ + delayHide() + } + + override fun playerStatusChanged() { + super.playerStatusChanged() + delayHide() + } + + private fun delayHide() { val index = currentTapIndex playerBinding?.playerHolder?.postDelayed({ if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { @@ -949,7 +970,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } else -> { - player.handleEvent(CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI) + player.handleEvent( + CSPlayerEvent.PlayPauseToggle, + PlayerEventSource.UI + ) } } } else if (doubleTapEnabled && isFullScreenPlayer) { @@ -1142,6 +1166,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { return true } + @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() @@ -1225,6 +1250,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { // if nothing has loaded these buttons should not be visible playerBinding?.apply { playerSkipEpisode.isVisible = false + playerGoForward.isVisible = false playerTracksBtt.isVisible = false playerSkipOp.isVisible = false shadowOverlay.isVisible = false @@ -1298,6 +1324,10 @@ open class FullScreenPlayer : AbstractPlayerFragment() { player.handleEvent(CSPlayerEvent.SeekBack) } + PlayerEventType.Restart -> { + player.handleEvent(CSPlayerEvent.Restart) + } + PlayerEventType.ToggleMute -> { player.handleEvent(CSPlayerEvent.ToggleMute) } @@ -1393,6 +1423,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() { false ) + hideControlsNames = settingsManager.getBoolean(ctx.getString(R.string.hide_player_control_names_key), false) + val profiles = QualityDataHelper.getProfiles() val type = if (ctx.isUsingMobileData()) QualityDataHelper.QualityProfileType.Data @@ -1413,12 +1445,34 @@ open class FullScreenPlayer : AbstractPlayerFragment() { playerSpeedBtt.isVisible = playBackSpeedEnabled playerResizeBtt.isVisible = playerResizeEnabled playerRotateBtt.isVisible = playerRotateEnabled + if (hideControlsNames) { + hideControlsNames() + } } } catch (e: Exception) { logError(e) } playerBinding?.apply { + + if (isLayout(TV or EMULATOR)) { + mapOf( + playerGoBack to playerGoBackText, + playerRestart to playerRestartText, + playerGoForward to playerGoForwardText + ).forEach { (button, text) -> + button.setOnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + text.isSelected = false + text.isVisible = false + return@setOnFocusChangeListener + } + text.isSelected = true + text.isVisible = true + } + } + } + playerPausePlay.setOnClickListener { autoHide() player.handleEvent(CSPlayerEvent.PlayPauseToggle) @@ -1462,6 +1516,16 @@ open class FullScreenPlayer : AbstractPlayerFragment() { player.handleEvent(CSPlayerEvent.NextEpisode) } + playerGoForward.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.NextEpisode) + } + + playerRestart.setOnClickListener { + autoHide() + player.handleEvent(CSPlayerEvent.Restart) + } + playerLock.setOnClickListener { autoHide() toggleLock() @@ -1517,7 +1581,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } // cs3 is peak media center - setRemainingTimeCounter(durationMode || Globals.isLayout(Globals.TV)) + setRemainingTimeCounter(durationMode || isLayout(TV)) playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } @@ -1536,6 +1600,22 @@ open class FullScreenPlayer : AbstractPlayerFragment() { } } + private fun PlayerCustomLayoutBinding.hideControlsNames() { + fun iterate(layout: LinearLayout) { + layout.children.forEach { + if (it is MaterialButton) { + it.textSize = 0f + it.iconPadding = 0 + it.iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START + it.setPadding(0,0,0,0) + } else if (it is LinearLayout) { + iterate(it) + } + } + } + iterate(playerLockHolder.parent as LinearLayout) + } + override fun playerDimensionsLoaded(width: Int, height: Int) { isVerticalOrientation = height > width updateOrientation() @@ -1555,7 +1635,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { private fun setRemainingTimeCounter(showRemaining: Boolean) { durationMode = showRemaining - playerBinding?.exoDuration?.isInvisible= showRemaining + playerBinding?.exoDuration?.isInvisible = showRemaining playerBinding?.timeLeft?.isVisible = showRemaining } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index d827d31e98f..d4fd047cc34 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -6,6 +6,7 @@ import android.app.Dialog import android.content.Context import android.content.Intent import android.content.res.ColorStateList +import android.os.Build import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -13,6 +14,7 @@ import android.view.View import android.view.ViewGroup import android.widget.* import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn import androidx.core.animation.addListener import androidx.core.content.ContextCompat import androidx.core.view.isGone @@ -21,6 +23,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AcraApplication.Companion.setKey @@ -44,6 +47,7 @@ import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.result.* import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR +import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY @@ -62,6 +66,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job +import java.io.Serializable import java.util.* import kotlin.math.abs @@ -158,6 +163,7 @@ class GeneratorPlayer : FullScreenPlayer() { } override fun playerStatusChanged() { + super.playerStatusChanged() if (player.getIsPlaying()) { viewModel.forceClearCache = false } @@ -232,7 +238,7 @@ class GeneratorPlayer : FullScreenPlayer() { private fun closestQuality(target: Int?): Qualities { if (target == null) return Qualities.Unknown - return Qualities.values().minBy { abs(it.value - target) } + return Qualities.entries.minBy { abs(it.value - target) } } private fun getLinkPriority( @@ -365,8 +371,6 @@ class GeneratorPlayer : FullScreenPlayer() { binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE binding.subtitleAdapter.adapter = arrayAdapter - val adapter = - binding.subtitleAdapter.adapter as? ArrayAdapter binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ -> currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener @@ -377,8 +381,8 @@ class GeneratorPlayer : FullScreenPlayer() { fun setSubtitlesList(list: List) { currentSubtitles = list - adapter?.clear() - adapter?.addAll(currentSubtitles) + arrayAdapter.clear() + arrayAdapter.addAll(currentSubtitles) } val currentTempMeta = getMetaData() @@ -520,7 +524,7 @@ class GeneratorPlayer : FullScreenPlayer() { //TODO: Set year text from currently loaded movie on Player //dialog.subtitles_search_year?.setText(currentTempMeta.year) } - + @OptIn(UnstableApi::class) private fun openSubPicker() { try { subsPathPicker.launch( @@ -793,7 +797,6 @@ class GeneratorPlayer : FullScreenPlayer() { settingsManager.edit().putString( ctx.getString(R.string.subtitles_encoding_key), prefValues[it] ).apply() - updateForcedEncoding(ctx) dismiss() player.seekTime(-1) // to update subtitles, a dirty trick @@ -1096,8 +1099,15 @@ class GeneratorPlayer : FullScreenPlayer() { } playerBinding?.playerSkipOp?.isVisible = isOpVisible - playerBinding?.playerSkipEpisode?.isVisible = - !isOpVisible && viewModel.hasNextEpisode() == true + + when { + isLayout(PHONE) -> + playerBinding?.playerSkipEpisode?.isVisible = + !isOpVisible && viewModel.hasNextEpisode() == true + + else -> + playerBinding?.playerGoForward?.isVisible = viewModel.hasNextEpisode() == true + } if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { viewModel.preLoadNextLinks() @@ -1253,7 +1263,7 @@ class GeneratorPlayer : FullScreenPlayer() { fun setPlayerDimen(widthHeight: Pair?) { val extra = if (widthHeight != null) { val (width, height) = widthHeight - "${width}x${height}" + "- ${width}x${height}" } else { "" } @@ -1264,7 +1274,7 @@ class GeneratorPlayer : FullScreenPlayer() { 0 -> "" 1 -> extra 2 -> source - 3 -> "$source - $extra" + 3 -> "$source $extra" else -> "" } playerBinding?.playerVideoTitleRez?.apply { @@ -1281,7 +1291,7 @@ class GeneratorPlayer : FullScreenPlayer() { private fun unwrapBundle(savedInstanceState: Bundle?) { Log.i(TAG, "unwrapBundle = $savedInstanceState") savedInstanceState?.let { bundle -> - sync.addSyncs(bundle.getSerializable("syncData") as? HashMap?) + sync.addSyncs(bundle.getSafeSerializable>("syncData")) } } @@ -1289,7 +1299,8 @@ class GeneratorPlayer : FullScreenPlayer() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason - layout = if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player + layout = + if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] sync = ViewModelProvider(this)[SyncViewModel::class.java] @@ -1451,7 +1462,7 @@ class GeneratorPlayer : FullScreenPlayer() { observe(viewModel.currentLinks) { currentLinks = it - val turnVisible = it.isNotEmpty() + val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true val wasGone = binding?.overlayLoadingSkipButton?.isGone == true binding?.overlayLoadingSkipButton?.isVisible = turnVisible @@ -1497,3 +1508,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } } + +@Suppress("DEPRECATION") +inline fun Bundle.getSafeSerializable(key: String) : T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable(key, T::class.java) \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt index 1e2cf4f508f..6b8e6ea888a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt @@ -45,6 +45,7 @@ fun LoadType.toSet() : Set { interface IGenerator { val hasCache: Boolean + val canSkipLoading: Boolean fun hasNext(): Boolean fun hasPrev(): Boolean diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index 4bd5c7699e2..89c6f73b2e8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -8,7 +8,6 @@ import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink enum class PlayerEventType(val value: Int) { - //Stop(-1), Pause(0), Play(1), SeekForward(2), @@ -26,6 +25,7 @@ enum class PlayerEventType(val value: Int) { Resize(13), SearchSubtitlesOnline(14), SkipOp(15), + Restart(16), } enum class CSPlayerEvent(val value: Int) { @@ -39,6 +39,7 @@ enum class CSPlayerEvent(val value: Int) { PrevEpisode(6), PlayPauseToggle(7), ToggleMute(8), + Restart(9), } enum class CSPlayerLoading { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt index 89e3c8de987..20feae413eb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt @@ -4,7 +4,6 @@ import android.net.Uri import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.utils.ExtractorLink -import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.loadExtractor @@ -40,6 +39,7 @@ class LinkGenerator( private val isM3u8: Boolean? = null ) : IGenerator { override val hasCache = false + override val canSkipLoading = true override fun getCurrentId(): Int? { return null diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java b/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java index 3482f21c7ce..232440ccaff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/NonFinalTextRenderer.java @@ -29,6 +29,7 @@ import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.OptIn; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.text.Cue; @@ -66,7 +67,7 @@ * obtained from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s * is delegated to a {@link TextOutput}. */ -@UnstableApi +@OptIn(markerClass = UnstableApi.class) public class NonFinalTextRenderer extends BaseRenderer implements Callback { private static final String TAG = "TextRenderer"; @@ -74,7 +75,7 @@ public class NonFinalTextRenderer extends BaseRenderer implements Callback { /** * @param trackType The track type that the renderer handles. One of the {@link C} {@code * TRACK_TYPE_*} constants. - * @param outputHandler + * @param outputHandler todo description */ public NonFinalTextRenderer(int trackType, @Nullable Handler outputHandler) { super(trackType); @@ -416,13 +417,11 @@ private void clearOutput() { @SuppressWarnings("unchecked") @Override public boolean handleMessage(Message msg) { - switch (msg.what) { - case MSG_UPDATE_OUTPUT: - invokeUpdateOutputInternal((List) msg.obj); - return true; - default: - throw new IllegalStateException(); + if (msg.what == MSG_UPDATE_OUTPUT) { + invokeUpdateOutputInternal((List) msg.obj); + return true; } + throw new IllegalStateException(); } private void invokeUpdateOutputInternal(List cues) { @@ -441,7 +440,6 @@ private void invokeUpdateOutputInternal(List cues) { } ).collect(Collectors.toList()); - output.onCues(fixedCues); output.onCues(new CueGroup(fixedCues, 0L)); } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt index e6de1266b34..f00f8a61b09 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt @@ -4,8 +4,8 @@ import android.app.Activity import android.content.ContentUris import android.net.Uri import androidx.core.content.ContextCompat.getString +import androidx.media3.common.util.UnstableApi import com.lagradost.cloudstream3.R -import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.safefile.SafeFile diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt index 1ba5a29f1d7..122eaa975a5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.launch class PlayerGeneratorViewModel : ViewModel() { companion object { - val TAG = "PlayViewGen" + const val TAG = "PlayViewGen" } private var generator: IGenerator? = null diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt index 25d7e3dddad..02a7ee03d9c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt @@ -4,7 +4,9 @@ import android.util.Log import android.util.TypedValue import android.view.ViewGroup import android.widget.FrameLayout +import androidx.annotation.OptIn import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.regexSubtitlesToRemoveBloat @@ -47,6 +49,7 @@ data class SubtitleData( } } +@OptIn(UnstableApi::class) class PlayerSubtitleHelper { private var activeSubtitles: Set = emptySet() private var allSubtitles: Set = emptySet() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt index 7c78ce63896..ae800dbd124 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt @@ -8,6 +8,9 @@ import android.os.Build import android.util.Log import androidx.annotation.WorkerThread import androidx.core.graphics.scale +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.AcraApplication +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout @@ -62,8 +65,12 @@ interface IPreviewGenerator { companion object { fun new(): IPreviewGenerator { + val userDisabled = AcraApplication.context?.let { ctx -> + PreferenceManager.getDefaultSharedPreferences(ctx)?.getBoolean( + ctx.getString(R.string.preview_seekbar_key), true) == false + } ?: false /** because TV has low ram + not show we disable this for now */ - return if (isLayout(TV)) { + return if (isLayout(TV) || userDisabled) { empty() } else { PreviewGenerator() @@ -239,7 +246,11 @@ private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewG // generated images 1:1 to idx of hsl private var images: Array = arrayOf() - private val TAG = "PreviewImgM3u8" + companion object { + private const val TAG = "PreviewImgM3u8" + } + + // prefixSum[i] = sum(hsl.ts[0..i].time) // where [0] = 0, [1] = hsl.ts[0].time aka time at start of segment, do [b] - [a] for range a,b @@ -388,13 +399,6 @@ private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewG logError(t) continue } - - /* - val buffer = hsl.resolveLinkSafe(index) ?: continue - tmpFile?.writeBytes(buffer) - val buff = FileOutputStream(tmpFile) - retriever.setDataSource(buff.fd) - val frame = retriever.getFrameAtTime(0L)*/ } } @@ -412,14 +416,16 @@ private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGe null } + companion object { + private const val TAG = "PreviewImgMp4" + } + override fun hasPreview(): Boolean { synchronized(images) { return loadedLod >= MIN_LOD } } - val TAG = "PreviewImgMp4" - override fun getPreviewImage(fraction: Float): Bitmap? { synchronized(images) { if (loadedLod < MIN_LOD) { @@ -524,7 +530,7 @@ private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGe val fraction = (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) Log.i(TAG, "Generating preview for ${fraction * 100}%") val frame = durationUs * fraction - val img = retriever.image(frame.toLong(), params); + val img = retriever.image(frame.toLong(), params) if (!scope.isActive) return if (img == null || img.width <= 1 || img.height <= 1) continue synchronized(images) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt index 90bd1ca7303..588afbb50ce 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui.player import android.util.Log +import androidx.media3.common.util.UnstableApi import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.LoadResponse @@ -28,6 +29,7 @@ class RepoLinkGenerator( } override val hasCache = true + override val canSkipLoading = true override fun hasNext(): Boolean { return currentIndex < episodes.size - 1 diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt index 1e2c9f67338..ce457740d34 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt @@ -17,7 +17,6 @@ class PriorityAdapter(override val items: MutableList>) : override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return PriorityViewHolder( PlayerPrioritizeItemBinding.inflate(LayoutInflater.from(parent.context),parent,false), - //LayoutInflater.from(parent.context).inflate(R.layout.player_prioritize_item, parent, false) ) } @@ -31,10 +30,6 @@ class PriorityAdapter(override val items: MutableList>) : val binding: PlayerPrioritizeItemBinding, ) : RecyclerView.ViewHolder(binding.root) { fun bind(item: SourcePriority) { - /* val plusButton: ImageView = itemView.add_button - val subtractButton: ImageView = itemView.subtract_button - val priorityText: TextView = itemView.priority_text - val priorityNumber: TextView = itemView.priority_number*/ binding.priorityText.text = item.name fun updatePriority() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt index b587276fa66..45f6aa66088 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt @@ -29,8 +29,6 @@ class ProfilesAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return ProfilesViewHolder( PlayerQualityProfileItemBinding.inflate(LayoutInflater.from(parent.context),parent,false) - //LayoutInflater.from(parent.context) - // .inflate(R.layout.player_quality_profile_item, parent, false) ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt index 96249db406f..3267efd73ce 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt @@ -1,6 +1,5 @@ package com.lagradost.cloudstream3.ui.player.source_priority -import android.content.Context import androidx.annotation.StringRes import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey @@ -104,7 +103,7 @@ object QualityDataHelper { * Must under all circumstances at least return one profile **/ fun getProfiles(): List { - val availableTypes = QualityProfileType.values().toMutableList() + val availableTypes = QualityProfileType.entries.toMutableList() val profiles = (1..PROFILE_COUNT).map { profileNumber -> // Get the real type val type = getQualityProfileType(profileNumber) @@ -140,12 +139,12 @@ object QualityDataHelper { } } - QualityProfileType.values().forEach { + QualityProfileType.entries.forEach { if (it.unique) insertType(profiles, it) } debugAssert({ - !QualityProfileType.values().all { type -> + !QualityProfileType.entries.all { type -> !type.unique || profiles.any { it.type == type } } }, { "All unique quality types do not exist" }) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt index e3629158487..0537092c18b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt @@ -65,7 +65,7 @@ class QualityProfileDialog( setDefaultBtt.setOnClickListener { val currentProfile = getCurrentProfile() ?: return@setOnClickListener - val choices = QualityDataHelper.QualityProfileType.values() + val choices = QualityDataHelper.QualityProfileType.entries .filter { it != QualityDataHelper.QualityProfileType.None } val choiceNames = choices.map { txt(it.stringRes).asString(context) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt index 1b59882e282..bc6282af2ca 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt @@ -47,7 +47,7 @@ class SourcePriorityDialog( ) qualitiesRecyclerView.adapter = PriorityAdapter( - Qualities.values().mapNotNull { + Qualities.entries.mapNotNull { SourcePriority( it, Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt index 611889057dc..0ca326ddf82 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt @@ -3,8 +3,6 @@ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.annotation.IdRes -import androidx.annotation.LayoutRes import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView @@ -70,8 +68,7 @@ class ActorAdaptor( } } - private inner class CardViewHolder - constructor( + private inner class CardViewHolder( val binding: CastItemBinding, private val focusCallback: (View?) -> Unit = {} ) : diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt index ed5e51f12b4..d12521b39e6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt @@ -27,7 +27,8 @@ import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.VideoDownloadHelper import java.text.DateFormat import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 const val ACTION_PLAY_EPISODE_IN_VLC_PLAYER = 2 @@ -58,6 +59,7 @@ const val ACTION_MARK_AS_WATCHED = 18 const val ACTION_FCAST = 19 const val TV_EP_SIZE = 400 + data class EpisodeClickEvent(val action: Int, val data: ResultEpisode) class EpisodeAdapter( @@ -167,8 +169,7 @@ class EpisodeAdapter( return cardList.size } - class EpisodeCardViewHolderLarge - constructor( + class EpisodeCardViewHolderLarge( val binding: ResultEpisodeLargeBinding, private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, @@ -274,7 +275,10 @@ class EpisodeAdapter( episodeDate.setText( txt( R.string.episode_upcoming_format, - secondsToReadable(card.airDate.minus(unixTimeMS).div(1000).toInt(), "") + secondsToReadable( + card.airDate.minus(unixTimeMS).div(1000).toInt(), + "" + ) ) ) } else { @@ -292,6 +296,12 @@ class EpisodeAdapter( episodeDate.isVisible = false } + episodeRuntime.setText( + txt( + card.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } + ) + ) + if (isLayout(EMULATOR or PHONE)) { episodePoster.setOnClickListener { clickCallback.invoke(EpisodeClickEvent(ACTION_CLICK_DEFAULT, card)) @@ -324,8 +334,7 @@ class EpisodeAdapter( } } - class EpisodeCardViewHolderSmall - constructor( + class EpisodeCardViewHolderSmall( val binding: ResultEpisodeBinding, private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt index 7b7bae43dfb..eecd6262f0a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt @@ -8,18 +8,6 @@ import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -/* -class ImageAdapter(context: Context, val resource: Int) : ArrayAdapter(context, resource) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val newConvertView = convertView ?: run { - val mInflater = context - .getSystemService(Activity.LAYOUT_INFLATER_SERVICE) as LayoutInflater - mInflater.inflate(resource, null) - } - getItem(position)?.let { (newConvertView as? ImageView?)?.setImageResource(it) } - return newConvertView - } -}*/ const val IMAGE_CLICK = 0 const val IMAGE_LONG_CLICK = 1 @@ -66,8 +54,7 @@ class ImageAdapter( diffResult.dispatchUpdatesTo(this) } - class ImageViewHolder - constructor(val binding: ResultMiniImageBinding) : + class ImageViewHolder(val binding: ResultMiniImageBinding) : RecyclerView.ViewHolder(binding.root) { fun bind( img: Int, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt index c687eaa064d..3eab0c71476 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt @@ -51,6 +51,7 @@ data class ResultEpisode( /** Sum of all previous season episode counts + episode */ val totalEpisodeIndex: Int? = null, val airDate: Long? = null, + val runTime: Int? = null, ) fun ResultEpisode.getRealPosition(): Long { @@ -87,6 +88,7 @@ fun buildResultEpisode( parentId: Int, totalEpisodeIndex: Int? = null, airDate: Long? = null, + runTime: Int? = null, ): ResultEpisode { val posDur = getViewPos(id) val videoWatchState = getVideoWatchState(id) ?: VideoWatchState.None @@ -111,6 +113,7 @@ fun buildResultEpisode( videoWatchState, totalEpisodeIndex, airDate, + runTime, ) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 2f2970986b1..97bc49eaeee 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -23,6 +23,7 @@ import androidx.core.widget.NestedScrollView import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider import com.discord.panels.OverlappingPanelsLayout +import com.discord.panels.PanelState import com.discord.panels.PanelsChildGestureRegionObserver import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext @@ -78,11 +79,12 @@ import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.VideoDownloadHelper open class ResultFragmentPhone : FullScreenPlayer() { - private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { - override fun onGestureRegionsUpdate(gestureRegions: List) { - binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions) + private val gestureRegionsListener = + object : PanelsChildGestureRegionObserver.GestureRegionsListener { + override fun onGestureRegionsUpdate(gestureRegions: List) { + binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions) + } } - } protected lateinit var viewModel: ResultViewModel2 protected lateinit var syncModel: SyncViewModel @@ -117,6 +119,14 @@ open class ResultFragmentPhone : FullScreenPlayer() { return root } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + PanelsChildGestureRegionObserver.Provider.get().apply { + resultBinding?.resultCastItems?.let { register(it) } + } + } + var currentTrailers: List = emptyList() var currentTrailerIndex = 0 @@ -209,9 +219,6 @@ open class ResultFragmentPhone : FullScreenPlayer() { } override fun onDestroyView() { - - //somehow this still leaks and I dont know why???? - // todo look at https://github.com/discord/OverlappingPanels/blob/70b4a7cf43c6771873b1e091029d332896d41a1a/sample_app/src/main/java/com/discord/sampleapp/MainActivity.kt PanelsChildGestureRegionObserver.Provider.get().let { obs -> resultBinding?.resultCastItems?.let { obs.unregister(it) @@ -328,14 +335,18 @@ open class ResultFragmentPhone : FullScreenPlayer() { syncModel.addFromUrl(storedData.url) val api = APIHolder.getApiFromNameNull(storedData.apiName) - PanelsChildGestureRegionObserver.Provider.get().apply { - resultBinding?.resultCastItems?.let { - register(it) + // This may not be 100% reliable, and may delay for small period + // before resultCastItems will be scrollable again, but this does work + // most of the time. + binding?.resultOverlappingPanels?.registerEndPanelStateListeners( + object : OverlappingPanelsLayout.PanelStateListener { + override fun onPanelStateChange(panelState: PanelState) { + PanelsChildGestureRegionObserver.Provider.get().apply { + resultBinding?.resultCastItems?.let { register(it) } + } + } } - addGestureRegionsUpdateListener(gestureRegionsListener) - } - - + ) // ===== ===== ===== @@ -430,16 +441,16 @@ open class ResultFragmentPhone : FullScreenPlayer() { if (newStatus == null) return@toggleSubscriptionStatus val message = if (newStatus) { - // Kinda icky to have this here, but it works. - SubscriptionWorkManager.enqueuePeriodicWork(context) - R.string.subscription_new - } else { - R.string.subscription_deleted - } + // Kinda icky to have this here, but it works. + SubscriptionWorkManager.enqueuePeriodicWork(context) + R.string.subscription_new + } else { + R.string.subscription_deleted + } - val name = (viewModel.page.value as? Resource.Success)?.value?.title - ?: txt(R.string.no_data).asStringNull(context) ?: "" - showToast(txt(message, name), Toast.LENGTH_SHORT) + val name = (viewModel.page.value as? Resource.Success)?.value?.title + ?: txt(R.string.no_data).asStringNull(context) ?: "" + showToast(txt(message, name), Toast.LENGTH_SHORT) } context?.let { openBatteryOptimizationSettings(it) } } @@ -473,8 +484,16 @@ open class ResultFragmentPhone : FullScreenPlayer() { if (act.isCastApiAvailable()) { try { CastButtonFactory.setUpMediaRouteButton(act, this) - val castContext = CastContext.getSharedInstance(act.applicationContext) - isGone = castContext.castState == CastState.NO_DEVICES_AVAILABLE + CastContext.getSharedInstance(act.applicationContext) { + it.run() + }.addOnCompleteListener { + isGone = if (it.isSuccessful) { + it.result.castState == CastState.NO_DEVICES_AVAILABLE + } else { + true + } + + } // this shit leaks for some reason //castContext.addCastStateListener { state -> // media_route_button?.isGone = state == CastState.NO_DEVICES_AVAILABLE @@ -666,6 +685,9 @@ open class ResultFragmentPhone : FullScreenPlayer() { observe(viewModel.page) { data -> if (data == null) return@observe resultBinding?.apply { + PanelsChildGestureRegionObserver.Provider.get().apply { + register(resultCastItems) + } (data as? Resource.Success)?.value?.let { d -> resultVpn.setText(d.vpnText) resultInfo.setText(d.metaText) @@ -961,12 +983,12 @@ open class ResultFragmentPhone : FullScreenPlayer() { setOnClickListener { fab -> activity?.showBottomDialog( - WatchType.values().map { fab.context.getString(it.stringRes) }.toList(), + WatchType.entries.map { fab.context.getString(it.stringRes) }.toList(), watchType.ordinal, fab.context.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { - viewModel.updateWatchStatus(WatchType.values()[it], context) + viewModel.updateWatchStatus(WatchType.entries[it], context) } } } @@ -1046,7 +1068,7 @@ open class ResultFragmentPhone : FullScreenPlayer() { text?.asStringNull(ctx) ?: return@mapNotNull null ) }) { - viewModel.changeDubStatus(DubStatus.values()[itemId]) + viewModel.changeDubStatus(DubStatus.entries[itemId]) } } } @@ -1103,7 +1125,8 @@ open class ResultFragmentPhone : FullScreenPlayer() { override fun onPause() { super.onPause() - PanelsChildGestureRegionObserver.Provider.get().addGestureRegionsUpdateListener(gestureRegionsListener) + PanelsChildGestureRegionObserver.Provider.get() + .addGestureRegionsUpdateListener(gestureRegionsListener) } private fun setRecommendations(rec: List?, validApiName: String?) { @@ -1158,4 +1181,4 @@ open class ResultFragmentPhone : FullScreenPlayer() { } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index a0207060a85..1878f0b8f4b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -56,7 +56,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setImage class ResultFragmentTv : Fragment() { - protected lateinit var viewModel: ResultViewModel2 + private lateinit var viewModel: ResultViewModel2 private var binding: FragmentResultTvBinding? = null override fun onDestroyView() { @@ -418,10 +418,6 @@ class ResultFragmentTv : Fragment() { resultCastItems.layoutManager = object : LinearListLayout(view.context) { - override fun onInterceptFocusSearch(focused: View, direction: Int): View? { - return super.onInterceptFocusSearch(focused, direction) - } - override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, @@ -649,7 +645,7 @@ class ResultFragmentTv : Fragment() { binding?.apply { - (data as? Resource.Success)?.value?.let { (text, ep) -> + (data as? Resource.Success)?.value?.let { (_, ep) -> resultPlayMovieButton.setOnClickListener { viewModel.handleAction( @@ -817,45 +813,8 @@ class ResultFragmentTv : Fragment() { } } - /* - * Okay so what is this fuckery? - * Basically Android TV will crash if you request a new focus while - * the adapter gets updated. - * - * This means that if you load thumbnails and request a next focus at the same time - * the app will crash without any way to catch it! - * - * How to bypass this? - * This code basically steals the focus for 500ms and puts it in an inescapable view - * then lets out the focus by requesting focus to result_episodes - */ - - val hasEpisodes = - !(resultEpisodes.adapter as? EpisodeAdapter?)?.cardList.isNullOrEmpty() - /*val focus = activity?.currentFocus - - if (hasEpisodes) { - // Make it impossible to focus anywhere else! - temporaryNoFocus.isFocusable = true - temporaryNoFocus.requestFocus() - }*/ (resultEpisodes.adapter as? EpisodeAdapter)?.updateList(episodes.value) - - /* if (hasEpisodes) main { - - delay(500) - // This might make some people sad as it changes the focus when leaving an episode :( - if(focus?.requestFocus() == true) { - temporaryNoFocus.isFocusable = false - return@main - } - temporaryNoFocus.isFocusable = false - temporaryNoFocus.requestFocus() - } - - if (hasNoFocus()) - binding?.resultEpisodes?.requestFocus()*/ } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt index 135dc5306d2..2ab60c2f884 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt @@ -7,7 +7,6 @@ import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import androidx.activity.OnBackPressedCallback import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.CommonActivity.screenHeight @@ -17,6 +16,8 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.CSPlayerEvent import com.lagradost.cloudstream3.ui.player.PlayerEventSource import com.lagradost.cloudstream3.ui.player.SubtitleData +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback +import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback open class ResultTrailerPlayer : ResultFragmentPhone() { @@ -156,7 +157,9 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { uiReset() if (isFullScreenPlayer) { - attachBackPressedCallback() + activity?.attachBackPressedCallback { + updateFullscreen(false) + } } else detachBackPressedCallback() } @@ -175,27 +178,4 @@ open class ResultTrailerPlayer : ResultFragmentPhone() { fixPlayerSize() } } - - private var backPressedCallback: OnBackPressedCallback? = null - - private fun attachBackPressedCallback() { - if (backPressedCallback == null) { - backPressedCallback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - updateFullscreen(false) - } - } - } - - backPressedCallback?.isEnabled = true - - activity?.onBackPressedDispatcher?.addCallback( - activity ?: return, - backPressedCallback ?: return - ) - } - - private fun detachBackPressedCallback() { - backPressedCallback?.isEnabled = false - } } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 8e8dfe30a29..6443a9239e3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -2163,7 +2163,7 @@ class ResultViewModel2 : ViewModel() { // lets say that we have subscribed, then we must be able to unsubscribe no matter what else if (data != null) { _subscribeStatus.postValue(true) - } + } else _subscribeStatus.postValue(null) } private fun postFavorites(loadResponse: LoadResponse) { @@ -2371,7 +2371,8 @@ class ResultViewModel2 : ViewModel() { loadResponse.type, mainId, totalIndex, - airDate = i.date + airDate = i.date, + runTime = i.runTime, ) val season = eps.seasonIndex ?: 0 @@ -2426,7 +2427,8 @@ class ResultViewModel2 : ViewModel() { loadResponse.type, mainId, totalIndex, - airDate = episode.date + airDate = episode.date, + runTime = episode.runTime, ) val season = ep.seasonIndex ?: 0 @@ -2721,7 +2723,7 @@ class ResultViewModel2 : ViewModel() { val id: Int?, ) : LoadResponse - fun loadSmall(activity: Activity?, searchResponse: SearchResponse) = ioSafe { + fun loadSmall(searchResponse: SearchResponse) = ioSafe { val url = searchResponse.url _page.postValue(Resource.Loading(url)) _episodes.postValue(Resource.Loading()) @@ -2859,4 +2861,4 @@ class ResultViewModel2 : ViewModel() { } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt index 5a23bfc165b..8752e275c7f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt @@ -63,8 +63,7 @@ class SelectAdaptor(val callback: (Any) -> Unit) : RecyclerView.Adapter Unit, resView: AutofitRecyclerView diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt index 0a2ecb81e1d..4ef5fa698b2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt @@ -1,16 +1,11 @@ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.databinding.AccountSingleBinding import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding data class SearchHistoryItem( @@ -63,8 +58,7 @@ class SearchHistoryAdaptor( diffResult.dispatchUpdatesTo(this) } - class CardViewHolder - constructor( + class CardViewHolder( val binding: SearchHistoryItemBinding, private val clickCallback: (SearchHistoryCallback) -> Unit, ) : diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt index f597132bd4b..92575e58fba 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.search +import android.annotation.SuppressLint import android.content.Context import android.view.View import android.widget.ImageView @@ -37,16 +38,12 @@ object SearchResultBuilder { } } - /** - * @param nextFocusBehavior True if first, False if last, Null if between. - * Used to prevent escaping the adapter horizontally (focus wise). - */ + @SuppressLint("StringFormatInvalid") fun bind( clickCallback: (SearchClickCallback) -> Unit, card: SearchResponse, position: Int, itemView: View, - nextFocusBehavior: Boolean? = null, nextFocusUp: Int? = null, nextFocusDown: Int? = null, colorCallback : ((Palette) -> Unit)? = null diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt index 9e03079f633..71077e91f7c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt @@ -3,11 +3,9 @@ package com.lagradost.cloudstream3.ui.search import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.SyncApis +//TODO Relevance of this class since it's not used class SyncSearchViewModel { - private val repos = SyncApis - data class SyncSearchResultSearchResponse( override val name: String, override val url: String, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt index 1dc79dc0539..d7bd69f115f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.settings +import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -13,7 +14,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.setImage class AccountClickCallback(val action: Int, val view: View, val card: AuthAPI.LoginInfo) class AccountAdapter( - val cardList: List, + private val cardList: List, private val clickCallback: (AccountClickCallback) -> Unit ) : RecyclerView.Adapter() { @@ -42,12 +43,12 @@ class AccountAdapter( return cardList[position].accountIndex.toLong() } - class CardViewHolder - constructor(val binding: AccountSingleBinding, private val clickCallback: (AccountClickCallback) -> Unit) : + class CardViewHolder(val binding: AccountSingleBinding, private val clickCallback: (AccountClickCallback) -> Unit) : RecyclerView.ViewHolder(binding.root) { // private val pfp: ImageView = itemView.findViewById(R.id.account_profile_picture)!! // private val accountName: TextView = itemView.findViewById(R.id.account_name)!! + @SuppressLint("StringFormatInvalid") fun bind(card: AuthAPI.LoginInfo) { // just in case name is null account index will show, should never happened binding.accountName.text = card.name ?: "%s %d".format( diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index 6ba93c0fa46..88335eeafd5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -26,7 +26,6 @@ import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index fd61962c36e..9154375faa3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -28,10 +28,8 @@ import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.ui.EasterEggMonke import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR -import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.beneneCount -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom @@ -69,63 +67,8 @@ fun getCurrentLocale(context: Context): String { // https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes leave blank for auto val appLanguages = arrayListOf( /* begin language list */ - Triple("", "Afrikaans", "af"), - Triple("", "عربي شامي", "ajp"), - Triple("", "አማርኛ", "am"), - Triple("", "العربية", "ar"), - Triple("", "اللهجة النجدية", "ars"), - Triple("", "অসমীয়া", "as"), - Triple("", "български", "bg"), - Triple("", "বাংলা", "bn"), - Triple("\uD83C\uDDE7\uD83C\uDDF7", "português brasileiro", "bp"), - Triple("", "čeština", "cs"), - Triple("", "Deutsch", "de"), - Triple("", "Ελληνικά", "el"), Triple("", "English", "en"), - Triple("", "Esperanto", "eo"), - Triple("", "español", "es"), - Triple("", "فارسی", "fa"), - Triple("", "fil", "fil"), - Triple("", "français", "fr"), - Triple("", "galego", "gl"), - Triple("", "हिन्दी", "hi"), - Triple("", "hrvatski", "hr"), - Triple("", "magyar", "hu"), - Triple("\uD83C\uDDEE\uD83C\uDDE9", "Bahasa Indonesia", "in"), - Triple("", "italiano", "it"), - Triple("\uD83C\uDDEE\uD83C\uDDF1", "עברית", "iw"), - Triple("", "日本語 (にほんご)", "ja"), - Triple("", "ಕನ್ನಡ", "kn"), - Triple("", "한국어", "ko"), - Triple("", "lietuvių kalba", "lt"), - Triple("", "latviešu valoda", "lv"), - Triple("", "македонски", "mk"), - Triple("", "മലയാളം", "ml"), - Triple("", "bahasa Melayu", "ms"), - Triple("", "Malti", "mt"), - Triple("", "ဗမာစာ", "my"), - Triple("", "नेपाली", "ne"), - Triple("", "Nederlands", "nl"), - Triple("", "norsk nynorsk", "nn"), - Triple("", "norsk bokmål", "no"), - Triple("", "ଓଡ଼ିଆ", "or"), - Triple("", "polski", "pl"), - Triple("\uD83C\uDDF5\uD83C\uDDF9", "português", "pt"), - Triple("\uD83E\uDD8D", "mmmm... monke", "qt"), - Triple("", "română", "ro"), - Triple("", "русский", "ru"), - Triple("", "slovenčina", "sk"), - Triple("", "Soomaaliga", "so"), - Triple("", "svenska", "sv"), - Triple("", "தமிழ்", "ta"), - Triple("", "ትግርኛ", "ti"), - Triple("", "Tagalog", "tl"), - Triple("", "Türkçe", "tr"), Triple("", "українська", "uk"), - Triple("", "اردو", "ur"), - Triple("", "Tiếng Việt", "vi"), - Triple("", "中文", "zh"), - Triple("\uD83C\uDDF9\uD83C\uDDFC", "正體中文(臺灣)", "zh-rTW"), /* end language list */ ).sortedBy { it.second.lowercase() } //ye, we go alphabetical, so ppl don't put their lang on top diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt index 20279cd17ac..1753032acc2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt @@ -10,7 +10,6 @@ import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV -import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn @@ -46,7 +45,8 @@ class SettingsPlayer : PreferenceFragmentCompat() { ), TV or EMULATOR ) - + + getPref(R.string.preview_seekbar_key)?.hideOn(TV) getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener { @@ -87,10 +87,6 @@ class SettingsPlayer : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - /*(getPref(R.string.double_tap_seek_time_key) as? SeekBarPreference?)?.let { - - }*/ - getPref(R.string.prefer_limit_title_rez_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.limit_title_rez_pref_names) val prefValues = resources.getIntArray(R.array.limit_title_rez_pref_values) @@ -109,8 +105,10 @@ class SettingsPlayer : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + getPref(R.string.hide_player_control_names_key)?.hideOn(TV) + getPref(R.string.quality_pref_key)?.setOnPreferenceClickListener { - val prefValues = Qualities.values().map { it.value }.reversed().toMutableList() + val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() prefValues.remove(Qualities.Unknown.value) val prefNames = prefValues.map { Qualities.getStringByInt(it) } @@ -118,7 +116,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { val currentQuality = settingsManager.getInt( getString(R.string.quality_pref_key), - Qualities.values().last().value + Qualities.entries.last().value ) activity?.showBottomDialog( @@ -134,7 +132,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { } getPref(R.string.quality_pref_mobile_data_key)?.setOnPreferenceClickListener { - val prefValues = Qualities.values().map { it.value }.reversed().toMutableList() + val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() prefValues.remove(Qualities.Unknown.value) val prefNames = prefValues.map { Qualities.getStringByInt(it) } @@ -142,7 +140,7 @@ class SettingsPlayer : PreferenceFragmentCompat() { val currentQuality = settingsManager.getInt( getString(R.string.quality_pref_mobile_data_key), - Qualities.values().last().value + Qualities.entries.last().value ) activity?.showBottomDialog( @@ -246,4 +244,4 @@ class SettingsPlayer : PreferenceFragmentCompat() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt index cfb46c3955f..cb7d25fd7d5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt @@ -34,7 +34,7 @@ class SettingsProviders : PreferenceFragmentCompat() { getPref(R.string.display_sub_key)?.setOnPreferenceClickListener { activity?.getApiDubstatusSettings()?.let { current -> - val dublist = DubStatus.values() + val dublist = DubStatus.entries val names = dublist.map { it.name } val currentList = ArrayList() diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt index cc14e7617da..8c3ad0adeb6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt @@ -88,10 +88,9 @@ class SettingsUI : PreferenceFragmentCompat() { getPref(R.string.app_theme_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_names).toMutableList() val prefValues = resources.getStringArray(R.array.themes_names_values).toMutableList() - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + val removeIncompatible = { text: String -> val toRemove = prefValues - .mapIndexed { idx, s -> if (s.startsWith("Monet")) idx else null } + .mapIndexed { idx, s -> if (s.startsWith(text)) idx else null } .filterNotNull() var offset = 0 toRemove.forEach { idx -> @@ -100,6 +99,12 @@ class SettingsUI : PreferenceFragmentCompat() { offset += 1 } } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + removeIncompatible("Monet") + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { // Remove system on android 9 and less + removeIncompatible("System") + } val currentLayout = settingsManager.getString(getString(R.string.app_theme_key), prefValues.first()) @@ -123,7 +128,8 @@ class SettingsUI : PreferenceFragmentCompat() { } getPref(R.string.primary_color_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_overlay_names).toMutableList() - val prefValues = resources.getStringArray(R.array.themes_overlay_names_values).toMutableList() + val prefValues = + resources.getStringArray(R.array.themes_overlay_names_values).toMutableList() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less val toRemove = prefValues diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index 4aaa5e12f11..260c667474b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -128,7 +128,7 @@ class SettingsUpdates : PreferenceFragmentCompat() { } binding.saveBtt.setOnClickListener { - val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) + val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) var fileStream: OutputStream? = null try { fileStream = VideoDownloadManager.setupStream( @@ -169,10 +169,10 @@ class SettingsUpdates : PreferenceFragmentCompat() { prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, - {}) { + {}) { num -> try { settingsManager.edit() - .putInt(getString(R.string.apk_installer_key), prefValues[it]) + .putInt(getString(R.string.apk_installer_key), prefValues[num]) .apply() } catch (e: Exception) { logError(e) @@ -209,9 +209,9 @@ class SettingsUpdates : PreferenceFragmentCompat() { prefValues.indexOf(current), getString(R.string.automatic_plugin_download_mode_title), true, - {}) { + {}) { num -> settingsManager.edit() - .putInt(getString(R.string.auto_download_plugins_key), prefValues[it]).apply() + .putInt(getString(R.string.auto_download_plugins_key), prefValues[num]).apply() (context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt index 909c30be1f3..d159539d669 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt @@ -1,9 +1,11 @@ package com.lagradost.cloudstream3.ui.settings.extensions +import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible @@ -27,12 +29,12 @@ import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.UIHelper.setImage import com.lagradost.cloudstream3.utils.UIHelper.toPx -import org.junit.Assert -import org.junit.Test import java.text.DecimalFormat import kotlin.math.floor import kotlin.math.log10 - +import kotlin.math.pow +import org.junit.Test +import org.junit.Assert data class PluginViewData( val plugin: Plugin, @@ -101,6 +103,8 @@ class PluginAdapter( return findClosestBase2(target, current * 2, max) } + // DO NOT MOVE, as running this test will result in ExceptionInInitializerError on prerelease due to static variables using Resources.getSystem() + // this test function is only to show how the function works @Test fun testFindClosestBase2() { Assert.assertEquals(16, findClosestBase2(0)) @@ -122,10 +126,7 @@ class PluginAdapter( val base = value / 3 return if (value >= 3 && base < suffix.size) { DecimalFormat("#0.00").format( - numValue / Math.pow( - 10.0, - (base * 3).toDouble() - ) + numValue / 10.0.pow((base * 3).toDouble()) ) + suffix[base] } else { DecimalFormat().format(numValue) @@ -136,6 +137,7 @@ class PluginAdapter( inner class PluginViewHolder(val binding: RepositoryItemBinding) : RecyclerView.ViewHolder(binding.root) { + @SuppressLint("SetTextI18n") fun bind( data: PluginViewData, ) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt index c5319c37b53..4878049b4cf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt @@ -190,7 +190,7 @@ class PluginsFragment : Fragment() { bindChips( binding?.tvtypesChipsScroll?.tvtypesChips, emptyList(), - TvType.values().toList(), + TvType.entries.toList(), callback = { list -> pluginViewModel.tvTypes.clear() pluginViewModel.tvTypes.addAll(list.map { it.name }) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt index 56014eb4dcc..fd5422b2d8c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt @@ -10,7 +10,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.showToast -import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt index f919721391d..49a93608c10 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt @@ -10,7 +10,6 @@ import androidx.core.util.forEach import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager -import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt index bb9558b82bc..c76a218e547 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt @@ -15,8 +15,11 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.media3.common.text.Cue import com.fasterxml.jackson.annotation.JsonProperty -import com.google.android.gms.cast.TextTrackStyle -import com.google.android.gms.cast.TextTrackStyle.* +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DEPRESSED +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DROP_SHADOW +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_NONE +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_OUTLINE +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_RAISED import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent @@ -42,7 +45,7 @@ data class SaveChromeCaptionStyle( @JsonProperty("fontGenericFamily") var fontGenericFamily: Int? = null, @JsonProperty("backgroundColor") var backgroundColor: Int = 0x00FFFFFF, // transparent @JsonProperty("edgeColor") var edgeColor: Int = Color.BLACK, // BLACK - @JsonProperty("edgeType") var edgeType: Int = TextTrackStyle.EDGE_TYPE_OUTLINE, + @JsonProperty("edgeType") var edgeType: Int = EDGE_TYPE_OUTLINE, @JsonProperty("foregroundColor") var foregroundColor: Int = Color.WHITE, @JsonProperty("fontScale") var fontScale: Float = 1.05f, @JsonProperty("windowColor") var windowColor: Int = Color.TRANSPARENT, @@ -99,7 +102,7 @@ class ChromecastSubtitlesFragment : Fragment() { } private fun onColorSelected(stuff: Pair) { - context?.setColor(stuff.first, stuff.second) + setColor(stuff.first, stuff.second) if (hide) activity?.hideSystemUI() } @@ -122,7 +125,7 @@ class ChromecastSubtitlesFragment : Fragment() { return if (color == Color.TRANSPARENT) Color.BLACK else color } - private fun Context.setColor(id: Int, color: Int?) { + private fun setColor(id: Int, color: Int?) { val realColor = color ?: getDefColor(id) when (id) { 0 -> state.foregroundColor = realColor @@ -135,7 +138,7 @@ class ChromecastSubtitlesFragment : Fragment() { updateState() } - private fun Context.updateState() { + private fun updateState() { //subtitle_text?.setStyle(fromSaveToStyle(state)) } @@ -173,7 +176,7 @@ class ChromecastSubtitlesFragment : Fragment() { fixPaddingStatusbar(binding?.subsRoot) state = getCurrentSavedStyle() - context?.updateState() + updateState() val isTvSettings = isLayout(TV or EMULATOR) @@ -195,7 +198,7 @@ class ChromecastSubtitlesFragment : Fragment() { } this.setOnLongClickListener { - it.context.setColor(id, null) + setColor(id, null) showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } @@ -247,13 +250,13 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.edgeType = edgeTypes.map { it.first }[index] - textView.context.updateState() + updateState() } } binding?.subsEdgeType?.setOnLongClickListener { state.edgeType = defaultState.edgeType - it.context.updateState() + updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } @@ -323,12 +326,12 @@ class ChromecastSubtitlesFragment : Fragment() { dismissCallback ) { index -> state.fontFamily = fontTypes.map { it.first }[index] - textView.context.updateState() + updateState() } } - binding?.subsFont?.setOnLongClickListener { textView -> + binding?.subsFont?.setOnLongClickListener { _ -> state.fontFamily = defaultState.fontFamily - textView.context.updateState() + updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index 1466afed132..8821905e3d1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -14,11 +14,13 @@ import android.view.ViewGroup import android.widget.TextView import android.widget.Toast import androidx.annotation.FontRes +import androidx.annotation.OptIn import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import androidx.media3.common.text.Cue +import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.lagradost.cloudstream3.AcraApplication.Companion.getKey @@ -28,7 +30,6 @@ import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding -import com.lagradost.cloudstream3.ui.settings.Globals import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey @@ -46,7 +47,7 @@ const val SUBTITLE_KEY = "subtitle_settings" const val SUBTITLE_AUTO_SELECT_KEY = "subs_auto_select" const val SUBTITLE_DOWNLOAD_KEY = "subs_auto_download" -data class SaveCaptionStyle( +data class SaveCaptionStyle @OptIn(UnstableApi::class) constructor( @JsonProperty("foregroundColor") var foregroundColor: Int, @JsonProperty("backgroundColor") var backgroundColor: Int, @JsonProperty("windowColor") var windowColor: Int, @@ -67,7 +68,7 @@ data class SaveCaptionStyle( const val DEF_SUBS_ELEVATION = 20 -@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@OptIn(androidx.media3.common.util.UnstableApi::class) class SubtitlesFragment : Fragment() { companion object { val applyStyleEvent = Event() @@ -167,7 +168,7 @@ class SubtitlesFragment : Fragment() { activity?.hideSystemUI() } - private fun onDialogDismissed(id: Int) { + private fun onDialogDismissed(@Suppress("UNUSED_PARAMETER") id: Int) { if (hide) activity?.hideSystemUI() } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt index e9b69c5b013..f0c948a4a34 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt @@ -83,7 +83,7 @@ object EpisodeSkip { startMs = start, endMs = end ) - }?.let { list -> + }.let { list -> out.addAll(list) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt index f0aae7bc035..8d65acf7eae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt @@ -43,7 +43,6 @@ import androidx.recyclerview.widget.RecyclerView import androidx.tvprovider.media.tv.* import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor import androidx.viewpager2.widget.ViewPager2 -import com.fasterxml.jackson.module.kotlin.readValue import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.gms.common.ConnectionResult @@ -58,7 +57,7 @@ import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEv import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.plugins.RepositoryManager -import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.appStringResumeWatching +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.WebviewFragment import com.lagradost.cloudstream3.ui.player.SubtitleData @@ -161,7 +160,7 @@ object AppContextUtils { .setTitle(title) .setPosterArtUri(Uri.parse(card.posterUrl)) .setIntentUri(Uri.parse(card.id?.let { - "$appStringResumeWatching://$it" + "$APP_STRING_RESUME_WATCHING://$it" } ?: card.url)) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( @@ -678,9 +677,15 @@ object AppContextUtils { } fun Context.isNetworkAvailable(): Boolean { - val manager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val activeNetworkInfo = manager.activeNetworkInfo - return activeNetworkInfo != null && activeNetworkInfo.isConnected || manager.allNetworkInfo?.any { it.isConnected } ?: false + val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return false + val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } else { + @Suppress("DEPRECATION") + connectivityManager.activeNetworkInfo?.isConnected == true + } } fun splitQuery(url: URL): Map { @@ -1019,4 +1024,4 @@ object AppContextUtils { } return currentAudioFocusRequest } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt new file mode 100644 index 00000000000..1326ab27443 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.utils + +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback + +object BackPressedCallbackHelper { + private var backPressedCallback: OnBackPressedCallback? = null + + fun ComponentActivity.attachBackPressedCallback(callback: () -> Unit) { + if (backPressedCallback == null) { + backPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + callback.invoke() + } + } + } + + backPressedCallback?.isEnabled = true + + onBackPressedDispatcher.addCallback( + this@attachBackPressedCallback, + backPressedCallback ?: return + ) + } + + fun detachBackPressedCallback() { + backPressedCallback?.isEnabled = false + backPressedCallback = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt index 1d23e5037cb..b25be59f98a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt @@ -67,8 +67,6 @@ object BackupUtils { OPEN_SUBTITLES_USER_KEY, SUBDL_SUBTITLES_USER_KEY, - DOWNLOAD_EPISODE_CACHE, - "biometric_key", // can lock down users if backup is shared on a incompatible device "nginx_user", // Nginx user key "download_path_key" // No access rights after restore data from backup @@ -83,12 +81,12 @@ object BackupUtils { // Kinda hack, but I couldn't think of a better way data class BackupVars( - @JsonProperty("_Bool") val _Bool: Map?, - @JsonProperty("_Int") val _Int: Map?, - @JsonProperty("_String") val _String: Map?, - @JsonProperty("_Float") val _Float: Map?, - @JsonProperty("_Long") val _Long: Map?, - @JsonProperty("_StringSet") val _StringSet: Map?>?, + @JsonProperty("_Bool") val bool: Map?, + @JsonProperty("_Int") val int: Map?, + @JsonProperty("_String") val string: Map?, + @JsonProperty("_Float") val float: Map?, + @JsonProperty("_Long") val long: Map?, + @JsonProperty("_StringSet") val stringSet: Map?>?, ) data class BackupFile( @@ -136,21 +134,21 @@ object BackupUtils { ) { if (context == null) return if (restoreSettings) { - context.restoreMap(backupFile.settings._Bool, true) - context.restoreMap(backupFile.settings._Int, true) - context.restoreMap(backupFile.settings._String, true) - context.restoreMap(backupFile.settings._Float, true) - context.restoreMap(backupFile.settings._Long, true) - context.restoreMap(backupFile.settings._StringSet, true) + context.restoreMap(backupFile.settings.bool, true) + context.restoreMap(backupFile.settings.int, true) + context.restoreMap(backupFile.settings.string, true) + context.restoreMap(backupFile.settings.float, true) + context.restoreMap(backupFile.settings.long, true) + context.restoreMap(backupFile.settings.stringSet, true) } if (restoreDataStore) { - context.restoreMap(backupFile.datastore._Bool) - context.restoreMap(backupFile.datastore._Int) - context.restoreMap(backupFile.datastore._String) - context.restoreMap(backupFile.datastore._Float) - context.restoreMap(backupFile.datastore._Long) - context.restoreMap(backupFile.datastore._StringSet) + context.restoreMap(backupFile.datastore.bool) + context.restoreMap(backupFile.datastore.int) + context.restoreMap(backupFile.datastore.string) + context.restoreMap(backupFile.datastore.float) + context.restoreMap(backupFile.datastore.long) + context.restoreMap(backupFile.datastore.stringSet) } } @@ -266,4 +264,4 @@ object BackupUtils { } editor.apply() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt index 19c817b91b5..b5192aae287 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt @@ -56,14 +56,25 @@ data class Editor( ) { /** Always remember to call apply after */ fun setKeyRaw(path: String, value: T) { - when (value) { - is Boolean -> editor.putBoolean(path, value) - is Int -> editor.putInt(path, value) - is String -> editor.putString(path, value) - is Float -> editor.putFloat(path, value) - is Long -> editor.putLong(path, value) - (value as? Set != null) -> editor.putStringSet(path, value as Set) + @Suppress("UNCHECKED_CAST") + if (isStringSet(value)) { + editor.putStringSet(path, value as Set) + } else { + when (value) { + is Boolean -> editor.putBoolean(path, value) + is Int -> editor.putInt(path, value) + is String -> editor.putString(path, value) + is Float -> editor.putFloat(path, value) + is Long -> editor.putLong(path, value) + } + } + } + + private fun isStringSet(value: Any?) : Boolean { + if (value is Set<*>) { + return value.filterIsInstance().size == value.size } + return false } fun apply() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt index 43124a531b1..2fa5f6a3a52 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt @@ -2,8 +2,8 @@ package com.lagradost.cloudstream3.utils import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty -import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.unixTimeMS +import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AcraApplication.Companion.context import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKeys @@ -11,6 +11,13 @@ import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKeys import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType @@ -18,6 +25,9 @@ import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.result.UiImage import com.lagradost.cloudstream3.ui.result.VideoWatchState import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar import kotlin.reflect.KClass import kotlin.reflect.KProperty @@ -195,6 +205,8 @@ object DataStoreHelper { return this } + fun Int.toYear() : Date = GregorianCalendar.getInstance().also { it.set(Calendar.YEAR, this) }.time + /** * Used to display notifications on new episodes and posters in library. **/ @@ -242,7 +254,7 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, type, posterUrl, posterHeaders, quality, this.id, plot = this.plot, rating = this.rating, tags = this.tags + apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags ) } } @@ -273,7 +285,7 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, type, posterUrl, posterHeaders, quality, this.id, plot = this.plot, rating = this.rating, tags = this.tags + apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags ) } } @@ -304,7 +316,7 @@ object DataStoreHelper { null, null, latestUpdatedTime, - apiName, type, posterUrl, posterHeaders, quality, this.id, plot = this.plot, rating = this.rating, tags = this.tags + apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, rating = this.rating, tags = this.tags ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt index 421e4420499..c92da214d05 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -7,7 +7,6 @@ import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.VideoDownloadManager.WORK_KEY_INFO diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt index 89bb0031acc..59f534ff266 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt @@ -32,26 +32,26 @@ import java.io.InputStreamReader class InAppUpdater { companion object { - const val GITHUB_USER_NAME = "recloudstream" - const val GITHUB_REPO = "cloudstream" + private const val GITHUB_USER_NAME = "recloudstream" + private const val GITHUB_REPO = "cloudstream" - const val LOG_TAG = "InAppUpdater" + private const val LOG_TAG = "InAppUpdater" // === IN APP UPDATER === data class GithubAsset( @JsonProperty("name") val name: String, @JsonProperty("size") val size: Int, // Size bytes - @JsonProperty("browser_download_url") val browser_download_url: String, // download link - @JsonProperty("content_type") val content_type: String, // application/vnd.android.package-archive + @JsonProperty("browser_download_url") val browserDownloadUrl: String, // download link + @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive ) data class GithubRelease( - @JsonProperty("tag_name") val tag_name: String, // Version code + @JsonProperty("tag_name") val tagName: String, // Version code @JsonProperty("body") val body: String, // Desc @JsonProperty("assets") val assets: List, - @JsonProperty("target_commitish") val target_commitish: String, // branch + @JsonProperty("target_commitish") val targetCommitish: String, // branch @JsonProperty("prerelease") val prerelease: Boolean, - @JsonProperty("node_id") val node_id: String //Node Id + @JsonProperty("node_id") val nodeId: String //Node Id ) data class GithubObject( @@ -61,7 +61,7 @@ class InAppUpdater { ) data class GithubTag( - @JsonProperty("object") val github_object: GithubObject, + @JsonProperty("object") val githubObject: GithubObject, ) data class Update( @@ -114,7 +114,7 @@ class InAppUpdater { response.filter { rel -> !rel.prerelease }.sortedWith(compareBy { release -> - release.assets.firstOrNull { it.content_type == "application/vnd.android.package-archive" }?.name?.let { it1 -> + release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> versionRegex.find( it1 )?.groupValues?.let { @@ -134,7 +134,7 @@ class InAppUpdater { foundAsset?.name?.let { assetName -> val foundVersion = versionRegex.find(assetName) val shouldUpdate = - if (foundAsset.browser_download_url != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> + if (foundAsset.browserDownloadUrl != "" && foundVersion != null) currentVersion?.versionName?.let { versionName -> versionRegexLocal.find(versionName)?.groupValues?.let { it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } @@ -146,10 +146,10 @@ class InAppUpdater { return if (foundVersion != null) { Update( shouldUpdate, - foundAsset.browser_download_url, + foundAsset.browserDownloadUrl, foundVersion.groupValues[2], found.body, - found.node_id + found.nodeId ) } else { Update(false, null, null, null, null) @@ -168,33 +168,33 @@ class InAppUpdater { val found = response.lastOrNull { rel -> - rel.prerelease || rel.tag_name == "pre-release" + rel.prerelease || rel.tagName == "pre-release" } val foundAsset = found?.assets?.filter { it -> - it.content_type == "application/vnd.android.package-archive" + it.contentType == "application/vnd.android.package-archive" }?.getOrNull(0) val tagResponse = parseJson(app.get(tagUrl, headers = headers).text) - Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.github_object.sha.take(7)}") + Log.d(LOG_TAG, "Fetched GitHub tag: ${tagResponse.githubObject.sha.take(7)}") val shouldUpdate = (getString(R.string.commit_hash) .trim { c -> c.isWhitespace() } .take(7) != - tagResponse.github_object.sha + tagResponse.githubObject.sha .trim { c -> c.isWhitespace() } .take(7)) return if (foundAsset != null) { Update( shouldUpdate, - foundAsset.browser_download_url, - tagResponse.github_object.sha.take(10), + foundAsset.browserDownloadUrl, + tagResponse.githubObject.sha.take(10), found.body, - found.node_id + found.nodeId ) } else { Update(false, null, null, null, null) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt index bc81a5b9f22..4b3f02f11bd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt @@ -11,7 +11,6 @@ import android.os.Build import android.widget.Toast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError -import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.utils.Coroutines.main import java.io.InputStream @@ -57,7 +56,7 @@ class ApkInstaller(private val service: PackageInstallerService) { PackageInstaller.STATUS_FAILURE )) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { - val userAction = intent.getParcelableExtra(Intent.EXTRA_INTENT) + val userAction = intent.getSafeParcelableExtra(Intent.EXTRA_INTENT) userAction?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(userAction) } @@ -146,3 +145,5 @@ class ApkInstaller(private val service: PackageInstallerService) { } } +@Suppress("DEPRECATION") +inline fun Intent.getSafeParcelableExtra(key: String): T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelableExtra(key) else getParcelableExtra(key, T::class.java) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt index 27609730c28..0d3da8e75c8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -17,8 +17,8 @@ import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout -const val packageName = BuildConfig.APPLICATION_ID -const val TAG = "PowerManagerAPI" +private const val PACKAGE_NAME = BuildConfig.APPLICATION_ID +private const val TAG = "PowerManagerAPI" object BatteryOptimizationChecker { @@ -72,7 +72,7 @@ object BatteryOptimizationChecker { val intent = Intent() try { intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(Uri.fromParts("package", packageName, null)) + .setData(Uri.fromParts("package", PACKAGE_NAME, null)) context.startActivity(intent, Bundle()) } catch (t: Throwable) { Log.e(TAG, "Unable to invoke any intent", t) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt new file mode 100644 index 00000000000..e6a77795e32 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt @@ -0,0 +1,84 @@ +package com.lagradost.cloudstream3.utils + +import android.app.Activity +import android.view.View +import androidx.annotation.MainThread +import androidx.annotation.StringRes +import com.google.android.material.snackbar.Snackbar +import com.lagradost.api.Log +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.ui.result.UiText +import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute + +object SnackbarHelper { + + private const val TAG = "COMPACT" + private var currentSnackbar: Snackbar? = null + + @MainThread + fun showSnackbar( + act: Activity?, + message: UiText, + duration: Int = Snackbar.LENGTH_SHORT, + actionText: UiText? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null) return + showSnackbar(act, message.asString(act), duration, + actionText?.asString(act), actionCallback) + } + + @MainThread + fun showSnackbar( + act: Activity?, + @StringRes message: Int, + duration: Int = Snackbar.LENGTH_SHORT, + @StringRes actionText: Int? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null) return + showSnackbar(act, act.getString(message), duration, + actionText?.let { act.getString(it) }, actionCallback) + } + + @MainThread + fun showSnackbar( + act: Activity?, + message: String?, + duration: Int = Snackbar.LENGTH_SHORT, + actionText: String? = null, + actionCallback: (() -> Unit)? = null + ) { + if (act == null || message == null) { + Log.w(TAG, "Invalid showSnackbar: act = $act, message = $message") + return + } + Log.i(TAG, "showSnackbar: $message") + + try { + currentSnackbar?.dismiss() + } catch (e: Exception) { + logError(e) + } + + try { + val parentView = act.findViewById(android.R.id.content) + val snackbar = Snackbar.make(parentView, message, duration) + + actionCallback?.let { + snackbar.setAction(actionText) { actionCallback.invoke() } + } + + snackbar.show() + currentSnackbar = snackbar + + snackbar.setBackgroundTint(act.colorFromAttribute(R.attr.primaryBlackBackground)) + snackbar.setTextColor(act.colorFromAttribute(R.attr.textColor)) + snackbar.setActionTextColor(act.colorFromAttribute(R.attr.colorPrimary)) + + } catch (e: Exception) { + logError(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt new file mode 100644 index 00000000000..93a53395408 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt @@ -0,0 +1,56 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import com.lagradost.api.Log +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getFolder +import com.lagradost.safefile.SafeFile + +object SubtitleUtils { + + // Only these files are allowed, so no videos as subtitles + private val allowedExtensions = listOf( + ".vtt", ".srt", ".txt", ".ass", + ".ttml", ".sbv", ".dfxp" + ) + + fun deleteMatchingSubtitles(context: Context, info: VideoDownloadManager.DownloadedFileInfo) { + val relative = info.relativePath + val display = info.displayName + val cleanDisplay = cleanDisplayName(display) + + getFolder(context, relative, info.basePath)?.forEach { (name, uri) -> + if (isMatchingSubtitle(name, display, cleanDisplay)) { + val subtitleFile = SafeFile.fromUri(context, uri) + if (subtitleFile == null || !subtitleFile.delete()) { + Log.e("SubtitleDeletion", "Failed to delete subtitle file: ${subtitleFile?.name()}") + } + } + } + } + + /** + * @param name the file name of the subtitle + * @param display the file name of the video + * @param cleanDisplay the cleanDisplayName of the video file name + */ + fun isMatchingSubtitle( + name: String, + display: String, + cleanDisplay: String + ): Boolean { + // Check if the file has a valid subtitle extension + val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) } + + // We can't have the exact same file as a subtitle + val isNotDisplayName = !name.equals(display, ignoreCase = true) + + // Check if the file name starts with a cleaned version of the display name + val startsWithCleanDisplay = cleanDisplayName(name).startsWith(cleanDisplay, ignoreCase = true) + + return hasValidExtension && isNotDisplayName && startsWithCleanDisplay + } + + fun cleanDisplayName(name: String): String { + return name.substringBeforeLast('.').trim() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt index 71d3a1eff01..351e77c8d72 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt @@ -73,8 +73,8 @@ object SyncUtil { val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text val mapped = parseJson(response) - val overrideMal = mapped?.malId ?: mapped?.Mal?.id ?: mapped?.Anilist?.malId - val overrideAnilist = mapped?.aniId ?: mapped?.Anilist?.id + val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId + val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id if (overrideMal != null) { return overrideMal.toString() to overrideAnilist?.toString() @@ -135,8 +135,8 @@ object SyncUtil { @JsonProperty("createdAt") val createdAt: String?, @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String?, - @JsonProperty("Mal") val Mal: Mal?, - @JsonProperty("Anilist") val Anilist: Anilist?, + @JsonProperty("Mal") val mal: Mal?, + @JsonProperty("Anilist") val anilist: Anilist?, @JsonProperty("malUrl") val malUrl: String? ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt index 5e2b2bc1d99..049f92fb4cc 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt @@ -4,6 +4,7 @@ import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.mvvm.logError import kotlinx.coroutines.* import org.junit.Assert +import kotlin.random.Random object TestingUtils { open class TestResult(val success: Boolean) { @@ -280,8 +281,8 @@ object TestingUtils { // Test Search Results val searchQueries = - // Use the first 3 home page results as queries since they are guaranteed to exist - (homePageList.take(3).map { it.name } + + // Use the random 3 home page results as queries since they are guaranteed to exist + (homePageList.shuffled(Random).take(3).map { it.name.split(" ").first() } + // If home page is sparse then use generic search queries listOf("over", "iron", "guy")).take(3) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt index 8670de53feb..ad1b6502d6a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt @@ -553,7 +553,7 @@ object UIHelper { return result } - fun Context?.IsBottomLayout(): Boolean { + fun Context?.isBottomLayout(): Boolean { if (this == null) return true val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) return settingsManager.getBoolean(getString(R.string.bottom_title_key), true) diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 197bacc6629..2190e03f647 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -20,6 +20,7 @@ import androidx.work.WorkManager import com.bumptech.glide.Glide import com.bumptech.glide.load.model.GlideUrl import com.fasterxml.jackson.annotation.JsonProperty +import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey @@ -29,12 +30,14 @@ import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.services.VideoDownloadService import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey +import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile @@ -42,6 +45,8 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -293,6 +298,7 @@ object VideoDownloadManager { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) } else { + //fixme Specify a better flag PendingIntent.getActivity(context, 0, intent, 0) } builder.setContentIntent(pendingIntent) @@ -475,10 +481,10 @@ object VideoDownloadManager { } } - private const val reservedChars = "|\\?*<\":>+[]/\'" + private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { var tempName = name - for (c in reservedChars) { + for (c in RESERVED_CHARS) { tempName = tempName.replace(c, ' ') } if (removeSpaces) tempName = tempName.replace(" ", "") @@ -1699,7 +1705,7 @@ object VideoDownloadManager { } */ fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? = - getDownloadFileInfo(context, id, removeKeys = true) + getDownloadFileInfo(context, id) private fun DownloadedFileInfo.toFile(context: Context): SafeFile? { return basePathToFile(context, this.basePath)?.gotoDirectory(relativePath) @@ -1709,7 +1715,6 @@ object VideoDownloadManager { private fun getDownloadFileInfo( context: Context, id: Int, - removeKeys: Boolean = false ): DownloadedFileInfoResult? { try { val info = @@ -1733,7 +1738,37 @@ object VideoDownloadManager { } } - fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { + fun deleteFilesAndUpdateSettings( + context: Context, + ids: Set, + scope: CoroutineScope, + onComplete: (Set) -> Unit = {} + ) { + scope.launchSafe(Dispatchers.IO) { + val deleteJobs = ids.map { id -> + async { + id to deleteFileAndUpdateSettings(context, id) + } + } + val results = deleteJobs.awaitAll() + + val (successfulResults, failedResults) = results.partition { it.second } + val successfulIds = successfulResults.map { it.first }.toSet() + + if (failedResults.isNotEmpty()) { + failedResults.forEach { (id, _) -> + // TODO show a toast if some failed? + Log.e("FileDeletion", "Failed to delete file with ID: $id") + } + } else { + Log.i("FileDeletion", "All files deleted successfully") + } + + onComplete.invoke(successfulIds) + } + } + + private fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { val success = deleteFile(context, id) if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) return success @@ -1759,11 +1794,17 @@ object VideoDownloadManager { private fun deleteFile(context: Context, id: Int): Boolean { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false + val file = info.toFile(context) + downloadEvent.invoke(id to DownloadActionType.Stop) downloadProgressEvent.invoke(Triple(id, 0, 0)) downloadStatusEvent.invoke(id to DownloadType.IsStopped) downloadDeleteEvent.invoke(id) - return info.toFile(context)?.delete() ?: false + + val isFileDeleted = file?.delete() == true || file?.exists() == false + if (isFileDeleted) deleteMatchingSubtitles(context, info) + + return isFileDeleted } fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastManager.kt index 9ff5cc0896f..e7c36a87256 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/fcast/FcastManager.kt @@ -79,7 +79,9 @@ class FcastManager { override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { if (serviceInfo == null) return - currentDevices.add(PublicDeviceInfo(serviceInfo)) + synchronized(_currentDevices) { + _currentDevices.add(PublicDeviceInfo(serviceInfo)) + } Log.d( tag, @@ -93,8 +95,10 @@ class FcastManager { if (serviceInfo == null) return // May remove duplicates, but net and port is null here, preventing device specific identification - currentDevices.removeAll { - it.rawName == serviceInfo.serviceName + synchronized(_currentDevices) { + _currentDevices.removeAll { + it.rawName == serviceInfo.serviceName + } } Log.d(tag, "Service lost: ${serviceInfo.serviceName}") @@ -103,7 +107,8 @@ class FcastManager { companion object { const val APP_PREFIX = "CloudStream" - val currentDevices: MutableList = mutableListOf() + private val _currentDevices: MutableList = mutableListOf() + val currentDevices: List = _currentDevices class DefaultRegistrationListener : NsdManager.RegistrationListener { val tag = "DiscoveryService" @@ -132,4 +137,4 @@ class PublicDeviceInfo(serviceInfo: NsdServiceInfo) { val rawName: String = serviceInfo.serviceName val host: String? = serviceInfo.host.hostAddress val name = rawName.replace("-", " ") + host?.let { " $it" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt index d4725d53e64..2aea0b8df68 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt @@ -19,7 +19,7 @@ class FlowLayout : ViewGroup { @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { val t = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout) - itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0); + itemSpacing = t.getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) t.recycle() } diff --git a/app/src/main/res/drawable/ic_baseline_equalizer_24.xml b/app/src/main/res/drawable/ic_baseline_equalizer_24.xml new file mode 100644 index 00000000000..cd20ad156ac --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_equalizer_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_replay_24.xml b/app/src/main/res/drawable/ic_baseline_replay_24.xml new file mode 100644 index 00000000000..e247aa9243a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_replay_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_restart_24.xml b/app/src/main/res/drawable/ic_baseline_restart_24.xml new file mode 100644 index 00000000000..aed3a562c44 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_restart_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml b/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml new file mode 100644 index 00000000000..a8c43bbdb34 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml b/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml new file mode 100644 index 00000000000..452c4dd9969 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/preview_seekbar_24.xml b/app/src/main/res/drawable/preview_seekbar_24.xml new file mode 100644 index 00000000000..657f6247002 --- /dev/null +++ b/app/src/main/res/drawable/preview_seekbar_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/add_repo_input.xml b/app/src/main/res/layout/add_repo_input.xml index 6f6b4d5bdfa..cb4224d10b5 100644 --- a/app/src/main/res/layout/add_repo_input.xml +++ b/app/src/main/res/layout/add_repo_input.xml @@ -72,7 +72,7 @@ android:inputType="text" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" - android:nextFocusDown="@id/site_url_input" + android:nextFocusDown="@id/repo_url_input" android:requiresFadingEdge="vertical" android:textColorHint="?attr/grayTextColor" tools:ignore="LabelFor" /> @@ -85,9 +85,8 @@ android:inputType="textUri" android:nextFocusLeft="@id/apply_btt" android:nextFocusRight="@id/cancel_btt" - - android:nextFocusUp="@id/site_name_input" - android:nextFocusDown="@id/site_lang_input" + android:nextFocusUp="@id/repo_name_input" + android:nextFocusDown="@id/apply_btt" android:requiresFadingEdge="vertical" android:textColorHint="?attr/grayTextColor" tools:ignore="LabelFor" /> diff --git a/app/src/main/res/layout/download_child_episode.xml b/app/src/main/res/layout/download_child_episode.xml index 4974a0276b1..e53e63d312f 100644 --- a/app/src/main/res/layout/download_child_episode.xml +++ b/app/src/main/res/layout/download_child_episode.xml @@ -2,10 +2,8 @@ @@ -78,7 +73,6 @@ tools:text="128MB / 237MB" /> - + + \ No newline at end of file diff --git a/app/src/main/res/layout/download_header_episode.xml b/app/src/main/res/layout/download_header_episode.xml index a0b64ce3c23..385fb2e0bb9 100644 --- a/app/src/main/res/layout/download_header_episode.xml +++ b/app/src/main/res/layout/download_header_episode.xml @@ -14,6 +14,17 @@ app:cardBackgroundColor="?attr/boxItemBackground" app:cardCornerRadius="@dimen/rounded_image_radius"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_child_downloads.xml b/app/src/main/res/layout/fragment_child_downloads.xml index 9afaea0bac3..64ed1d7006d 100644 --- a/app/src/main/res/layout/fragment_child_downloads.xml +++ b/app/src/main/res/layout/fragment_child_downloads.xml @@ -7,13 +7,69 @@ android:layout_height="match_parent" android:background="?attr/primaryGrayBackground" android:orientation="vertical" - tools:context=".ui.download.DownloadFragment"> + tools:context=".ui.download.DownloadChildFragment"> + + + + +