diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ad8451a352..bac0098d68b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,8 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) @@ -288,7 +290,11 @@ dependencies { implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib + implementation(libs.bundles.compose) + implementation(libs.activity.compose) + implementation(project(":library")) + implementation(project(":shared")) } tasks.register("androidSourcesJar") { diff --git a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt index 466ef5a007c..6ff3ab54def 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt @@ -170,11 +170,11 @@ class CloudStreamApp : Application(), SingletonImageLoader.Factory { } /** Will fall back to WebView if in TV or emulator layout. */ - fun openBrowser(url: String, activity: FragmentActivity?) { + fun openBrowser(url: String, activity: Activity?) { openBrowser( url, isLayout(TV or EMULATOR), - activity?.supportFragmentManager?.fragments?.lastOrNull() + (activity as? FragmentActivity)?.supportFragmentManager?.fragments?.lastOrNull() ) } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt index f4e8768d88f..0fdccb47660 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -98,7 +98,7 @@ object VideoClickActionHolder { ?.second } - fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShowSafe(activity, null) } + fun getPlayers(context: Context? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShowSafe(context, null) } } abstract class VideoClickAction { diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt index 203a503e903..e4924590458 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt @@ -37,7 +37,7 @@ fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClie safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0) + val dns = settingsManager.getInt(context.getString(R.string.dns_key), 0) val baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) 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 6a0f12e9a4e..e1b8ad479c1 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 @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.player.source_priority +import android.app.Activity import android.app.Dialog import androidx.annotation.StyleRes import androidx.core.view.isVisible @@ -26,7 +27,7 @@ data class LinkSource( class QualityProfileDialog private constructor( - val activity: FragmentActivity, + val activity: Activity, @StyleRes val themeRes: Int, private val links: List, private val usedProfile: Int?, @@ -34,7 +35,7 @@ class QualityProfileDialog private constructor( private val useProfileSelection: Boolean ) : Dialog(activity, themeRes) { constructor( - activity: FragmentActivity, + activity: Activity, @StyleRes themeRes: Int, links: List, usedProfile: Int, @@ -42,7 +43,7 @@ class QualityProfileDialog private constructor( ) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true) constructor( - activity: FragmentActivity, + activity: Activity, @StyleRes themeRes: Int, links: List ) : this(activity, themeRes, links, null, null, false) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt index 75ba55674a6..f6d335b2f96 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui.settings import android.annotation.SuppressLint +import android.app.Activity import android.graphics.Bitmap import android.os.Bundle import android.os.CountDownTimer @@ -76,7 +77,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { /** Used by nginx plugin too */ @SuppressLint("StringFormatInvalid") fun showLoginInfo( - activity: FragmentActivity?, + activity: Activity?, api: AuthRepo, info: AuthUser?, index: Int, @@ -121,7 +122,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { } } - private fun showAccountSwitch(activity: FragmentActivity, api: AuthRepo) { + private fun showAccountSwitch(activity: Activity, api: AuthRepo) { val accounts = api.accounts val binding: AccountSwitchBinding = AccountSwitchBinding.inflate(activity.layoutInflater, null, false) @@ -153,7 +154,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { @UiThread - fun showPin(activity: FragmentActivity, api: AuthRepo) { + fun showPin(activity: Activity, api: AuthRepo) { val binding: DeviceAuthBinding = DeviceAuthBinding.inflate(activity.layoutInflater, null, false) @@ -273,8 +274,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { } - fun showAppLogin(activity: FragmentActivity, api: AuthRepo) { - + fun showAppLogin(activity: Activity, api: AuthRepo) { val binding: AddAccountInputBinding = AddAccountInputBinding.inflate(activity.layoutInflater, null, false) val builder = @@ -315,7 +315,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { binding.createAccount.setOnClickListener { openBrowser( api.createAccountUrl ?: return@setOnClickListener, - activity + activity, ) dialog.dismissSafe() } @@ -391,7 +391,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { } @UiThread - fun addAccount(activity: FragmentActivity, api: AuthRepo) { + fun addAccount(activity: Activity, api: AuthRepo) { try { if (api.hasPin && !isLayout(PHONE)) { showPin(activity, api) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount2.kt new file mode 100644 index 00000000000..3727bdb5cd2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount2.kt @@ -0,0 +1,18 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.lagradost.cloudstream4.compose.createComposeView +import com.mihon.presentation.settings.SearchableSettings + +/** Empty glue code to connect the old navigation graph to Compose */ +class SettingsAccount2 : Fragment(), SearchableSettings by SettingsAccountScreen() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createComposeView(inflater, container, savedInstanceState) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccountScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccountScreen.kt new file mode 100644 index 00000000000..15faf99c956 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccountScreen.kt @@ -0,0 +1,141 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.content.Context +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.FragmentActivity +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.animeSkipApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi +import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi +import com.lagradost.cloudstream3.syncproviders.PlainAuthRepo +import com.lagradost.cloudstream3.syncproviders.SubtitleRepo +import com.lagradost.cloudstream3.syncproviders.SyncRepo +import com.lagradost.cloudstream3.ui.settings.SettingsAccount.Companion.addAccount +import com.lagradost.cloudstream3.ui.settings.SettingsAccount.Companion.showLoginInfo +import com.lagradost.cloudstream3.utils.AppContextUtils.html +import com.lagradost.cloudstream3.utils.BackupUtils +import com.lagradost.cloudstream3.utils.BiometricAuthenticator +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.authCallback +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo +import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication +import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogText +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.rememberAppSettings +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import kotlinx.collections.immutable.persistentListOf + +class SettingsAccountScreen : SearchableSettings, BiometricAuthenticator.BiometricCallback { + companion object { + val syncApis = persistentListOf( + SyncRepo(malApi), + SyncRepo(kitsuApi), + SyncRepo(aniListApi), + SyncRepo(simklApi), + SubtitleRepo(openSubtitlesApi), + SubtitleRepo(subDlApi), + PlainAuthRepo(animeSkipApi), + ) + } + + private fun updateAuthPreference(context: Context, enabled: Boolean) { + val settings = AppSettings(context) + settings.security.biometrics.set(enabled) + } + + override fun onAuthenticationError() { + val context = activity ?: return + updateAuthPreference(context, !isAuthEnabled(context)) + } + + override fun onAuthenticationSuccess() { + val context = activity ?: return + + if (isAuthEnabled(context)) { + updateAuthPreference(context, true) + BackupUtils.backup(context) + context.showBottomDialogText( + context.getString(R.string.biometric_setting), + context.getString(R.string.biometric_warning).html() + ) { onDialogDismissedEvent } + } else { + updateAuthPreference(context, false) + } + } + + @Composable + override fun getTitleRes(): String = stringResource(R.string.category_account) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + val activity = LocalActivity.current + val context = LocalContext.current + val hasSecurity = remember(context) { + deviceHasPasswordPinLock(context) + } + + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_accounts), + preferenceItems = syncApis.map { api -> + Preference.PreferenceItem.TextPreference( + title = api.name, + icon = api.icon?.let { painterResource(it) }, + onClick = { + val activity = activity ?: return@TextPreference + val info = api.authUser() + val index = + api.accounts.indexOfFirst { account -> account.user.id == info?.id } + if (api.accounts.isNotEmpty()) { + showLoginInfo(activity, api, info, index) + } else { + addAccount(activity, api) + } + }) + } + Preference.PreferenceItem.SwitchPreference( + preference = settings.security.skipAccountSelection, + title = stringResource(R.string.skip_startup_account_select_pref), + icon = painterResource(R.drawable.ic_outline_account_circle_24) + ), + ), Preference.PreferenceGroup( + enabled = hasSecurity, + title = stringResource(R.string.pref_category_security), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = settings.security.biometrics, + title = stringResource(R.string.biometric_setting), + subtitle = stringResource(R.string.biometric_setting_summary), + icon = painterResource(R.drawable.ic_fingerprint), + onValueChanged = { _ -> + val activity = + activity as? FragmentActivity ?: return@SwitchPreference false + + if (deviceHasPasswordPinLock(activity)) { + startBiometricAuthentication( + activity, R.string.biometric_authentication_title, false + ) + promptInfo?.let { + authCallback = this + biometricPrompt?.authenticate(it) + } + } + + return@SwitchPreference true + })))) + } +} \ No newline at end of file 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 b04f9a4a2ab..3b406ef0aff 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 @@ -3,6 +3,7 @@ package com.lagradost.cloudstream3.ui.settings import android.content.Context import android.net.Uri import android.os.Bundle +import android.view.LayoutInflater import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog @@ -17,6 +18,7 @@ import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R @@ -175,68 +177,25 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { putString(context.getString(R.string.download_path_key_visual), visual) } } - } - - private val pathPicker = getChooseFolderLauncher { uri, path -> - pickDownloadPath(uri, path) - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - hideKeyboard() - setPreferencesFromResource(R.xml.settings_general, rootKey) - val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) fun getCurrent(): MutableList { return getKey>(USER_PROVIDER_API)?.toMutableList() ?: mutableListOf() } - - getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> - val current = getCurrentLocale(pref.context) - val languageTagsIETF = appLanguages.map { it.second } - val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } - val currentIndex = languageTagsIETF.indexOf(current) - - activity?.showDialog( - languageNames, currentIndex, getString(R.string.app_language), true, { } - ) { selectedLangIndex -> - try { - val langTagIETF = languageTagsIETF[selectedLangIndex] - CommonActivity.setLocale(activity, langTagIETF) - settingsManager.edit { - putString(getString(R.string.locale_key), langTagIETF) - } - activity?.recreate() - } catch (e: Exception) { - logError(e) - } - } - return@setOnPreferenceClickListener true - } - - getPref(R.string.battery_optimisation_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { - val ctx = context ?: return@setOnPreferenceClickListener false - - if (isAppRestricted(ctx)) { - ctx.showBatteryOptimizationDialog() - } else { - showToast(R.string.app_unrestricted_toast) - } - - true - } - fun showAdd() { val providers = allProviders.distinctBy { it::class }.sortedBy { it.name } - activity?.showDialog( + val context = activity + context?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, - context?.getString(R.string.add_site_pref) ?: return, + context.getString(R.string.add_site_pref) ?: return, true, {}) { selection -> val provider = providers.getOrNull(selection) ?: return@showDialog - val binding : AddSiteInputBinding = AddSiteInputBinding.inflate(layoutInflater,null,false) + val binding : AddSiteInputBinding = AddSiteInputBinding.inflate(LayoutInflater.from( + context + ),null,false) val builder = AlertDialog.Builder(context ?: return@showDialog, R.style.AlertDialogCustom) @@ -274,11 +233,11 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { fun showDelete() { val current = getCurrent() - - activity?.showMultiDialog( + val context = activity + context?.showMultiDialog( current.map { it.name }, listOf(), - context?.getString(R.string.remove_site_pref) ?: return, + context.getString(R.string.remove_site_pref), {}) { indexes -> current.removeAll(indexes.map { current[it] }) setKey(USER_PROVIDER_API, current.toTypedArray()) @@ -286,7 +245,12 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { } fun showAddOrDelete() { - val binding : AddRemoveSitesBinding = AddRemoveSitesBinding.inflate(layoutInflater,null,false) + val context = activity + + val binding : AddRemoveSitesBinding = AddRemoveSitesBinding.inflate( + LayoutInflater.from( + context + ),null,false) val builder = AlertDialog.Builder(context ?: return, R.style.AlertDialogCustom) .setView(binding.root) @@ -303,6 +267,54 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { dialog.dismissSafe(activity) } } + } + + private val pathPicker = getChooseFolderLauncher { uri, path -> + pickDownloadPath(uri, path) + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + hideKeyboard() + setPreferencesFromResource(R.xml.settings_general, rootKey) + val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) + + + getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> + val current = getCurrentLocale(pref.context) + val languageTagsIETF = appLanguages.map { it.second } + val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } + val currentIndex = languageTagsIETF.indexOf(current) + + activity?.showDialog( + languageNames, currentIndex, getString(R.string.app_language), true, { } + ) { selectedLangIndex -> + try { + val langTagIETF = languageTagsIETF[selectedLangIndex] + CommonActivity.setLocale(activity, langTagIETF) + settingsManager.edit { + putString(getString(R.string.locale_key), langTagIETF) + } + activity?.recreate() + } catch (e: Exception) { + logError(e) + } + } + return@setOnPreferenceClickListener true + } + + getPref(R.string.battery_optimisation_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { + val ctx = context ?: return@setOnPreferenceClickListener false + + if (isAppRestricted(ctx)) { + ctx.showBatteryOptimizationDialog() + } else { + showToast(R.string.app_unrestricted_toast) + } + + true + } + + getPref(R.string.override_site_key)?.setOnPreferenceClickListener { _ -> @@ -329,7 +341,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { val prefValues = resources.getIntArray(R.array.dns_pref_values) val currentDns = - settingsManager.getInt(getString(R.string.dns_pref), 0) + settingsManager.getInt(getString(R.string.dns_key), 0) activity?.showBottomDialog( prefNames.toList(), @@ -337,7 +349,7 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { getString(R.string.dns_pref), true, {}) { - settingsManager.edit { putInt(getString(R.string.dns_pref), prefValues[it]) } + settingsManager.edit { putInt(getString(R.string.dns_key), prefValues[it]) } (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral2.kt new file mode 100644 index 00000000000..808338f222f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral2.kt @@ -0,0 +1,18 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.lagradost.cloudstream4.compose.createComposeView +import com.mihon.presentation.settings.SearchableSettings + +/** Empty glue code to connect the old navigation graph to Compose */ +class SettingsGeneral2 : Fragment(), SearchableSettings by SettingsGeneralScreen() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createComposeView(inflater, container, savedInstanceState) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt new file mode 100644 index 00000000000..160b4f86389 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt @@ -0,0 +1,271 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.UnsafeSSL +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.insecureApp +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.network.initClient +import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.isAppRestricted +import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.showRequestIgnoreBatteryOptDialog +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.compose.ActionDialog +import com.lagradost.cloudstream4.rememberAppSettings +import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme +import com.lagradost.safefile.SafeFile +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import com.mihon.presentation.settings.collectAsState +import com.mihon.presentation.settings.widget.TextPreferenceWidget +import kotlinx.collections.immutable.persistentListOf + +class SettingsGeneralScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(R.string.category_general) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + + // TODO Refactor entirely to use a different file path selector ect like QuickNovel + val selectFileSelector = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + // It lies, it can be null if file manager quits. + if (uri == null) return@rememberLauncherForActivityResult + val context = CloudStreamApp.context ?: return@rememberLauncherForActivityResult + + try { + val settings = AppSettings(context) + // RW perms for the path + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, flags) + + val filePath = SafeFile.fromUri(context, uri)?.filePath() + println("Selected URI path: $uri - Full path: $filePath") + + // store the actual URI instead of the path due to permissions. + // filePath should only be used for cosmetic purposes. + val visual = filePath ?: uri.toString() + settings.general.downloadPath.set(uri.toString()) + settings.general.downloadPathVisual.set(visual) + } catch (t: Throwable) { + logError(t) + } + } + + val bananas by settings.general.bananas.collectAsState() + val parallelDownloads by settings.general.parallelDownloads.collectAsState() + val concurrentConnections by settings.general.concurrentConnections.collectAsState() + val locale by settings.general.locale.collectAsState() + val downloadPathVisual by settings.general.downloadPathVisual.collectAsState() + //val downloadPath by settings.general.downloadPath.collectAsState() + + return persistentListOf( + Preference.PreferenceItem.BasicListPreference( + value = locale, + entries = appLanguages.associate { (name, code) -> (code to (name to code).nameNextToFlagEmoji()) }, + title = stringResource(R.string.app_language), + icon = painterResource(R.drawable.language_korean_latin_24px), + onValueChanged = { value -> + settings.general.locale.set(value) + activity?.recreate() + }, + subtitleProvider = { v, e -> e[v] ?: getCurrentLocale(LocalContext.current) } + ), + Preference.PreferenceGroup( + title = stringResource(R.string.title_downloads), + preferenceItems = persistentListOf( + Preference.PreferenceItem.TextPreference( + icon = painterResource(R.drawable.netflix_download), + subtitle = downloadPathVisual, + title = stringResource(R.string.download_path_pref), + onClick = { + // This is not a ListPreference because the old selection system is + // broken af. This needs to be refactored to QuickNovels download path + // system. + selectFileSelector.launch(Uri.EMPTY) + }, + ), + Preference.PreferenceItem.SliderPreference( + icon = painterResource(R.drawable.arrow_or_edge_24px), + value = parallelDownloads, + valueRange = 1..10, + title = stringResource(R.string.parallel_downloads), + subtitle = stringResource(R.string.download_parallel_settings_des), + onValueChanged = settings.general.parallelDownloads::set, + ), + Preference.PreferenceItem.SliderPreference( + icon = painterResource(R.drawable.arrow_and_edge_24px), + value = concurrentConnections, + valueRange = 1..10, + title = stringResource(R.string.concurrent_connections), + subtitle = stringResource(R.string.concurrent_connections_settings_des), + onValueChanged = settings.general.concurrentConnections::set, + ), + Preference.PreferenceItem.CustomPreference( + title = stringResource(R.string.battery_dialog_title), + content = { + var isBatteryShown by remember { mutableStateOf(false) } + val context = LocalContext.current + + TextPreferenceWidget( + title = stringResource(R.string.battery_dialog_title), + icon = painterResource(R.drawable.ic_battery), + onPreferenceClick = { + if (isAppRestricted(context)) { + isBatteryShown = true + } else { + showToast(R.string.app_unrestricted_toast) + } + }) + + if (isBatteryShown) { + ActionDialog( + icon = painterResource(R.drawable.ic_battery), + title = stringResource(R.string.battery_dialog_title), + text = stringResource(R.string.battery_dialog_message), + confirmText = stringResource(R.string.ok), + dismissText = stringResource(R.string.cancel), + dismiss = { + isBatteryShown = false + settings.general.batterOptimization.set(false) + }, + confirm = { + isBatteryShown = false + // The og impl never modified it to true? + // settings.general.batterOptimization.set(true) + context.showRequestIgnoreBatteryOptDialog() + } + ) + } + } + ), + ) + ), + + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_bypass), + preferenceItems = persistentListOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.add_site_pref), + subtitle = stringResource(R.string.add_site_summary), + icon = painterResource(R.drawable.ic_baseline_add_24), + onClick = { + // TODO refactor into compose + if (SettingsGeneral.getCurrent().isEmpty()) { + SettingsGeneral.showAdd() + } else { + SettingsGeneral.showAddOrDelete() + } + } + ), + Preference.PreferenceItem.ListPreference( + title = stringResource(R.string.dns_pref), + subtitle = stringResource(R.string.dns_pref_summary), + icon = painterResource(R.drawable.ic_baseline_dns_24), + preference = settings.general.dns, + entries = integerArrayResource(R.array.dns_pref_values).zip( + stringArrayResource(R.array.dns_pref) + ).toMap(), + onValueChanged = { + (CloudStreamApp.context)?.let { ctx -> + app.initClient(ctx, ignoreSSL = false) + @OptIn(UnsafeSSL::class) + insecureApp.initClient(ctx, ignoreSSL = true) + } + return@ListPreference true + }, + ), + Preference.PreferenceItem.SwitchPreference( + title = stringResource(R.string.jsdelivr_proxy), + subtitle = stringResource(R.string.jsdelivr_proxy_summary), + icon = painterResource(R.drawable.ic_github_logo), + preference = settings.general.jsdelivrProxy + ) + ) + ), + + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_links), + preferenceItems = persistentListOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.github), + subtitle = "https://github.com/recloudstream/cloudstream", + icon = painterResource(R.drawable.ic_github_logo), + onClick = { + CloudStreamApp.openBrowser("https://github.com/recloudstream/cloudstream") + } + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.lightnovel), + subtitle = "https://github.com/LagradOst/QuickNovel", + icon = painterResource(R.drawable.quick_novel_icon), + onClick = { + CloudStreamApp.openBrowser("https://github.com/LagradOst/QuickNovel") + } + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.discord), + subtitle = "https://discord.gg/5Hus6fM", + icon = painterResource(R.drawable.ic_baseline_discord_24), + onClick = { + CloudStreamApp.openBrowser("https://discord.gg/5Hus6fM") + } + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.cs3wiki), + subtitle = "https://cloudstream.miraheze.org/", + icon = painterResource(R.drawable.baseline_description_24), + onClick = { + CloudStreamApp.openBrowser("https://cloudstream.miraheze.org/") + } + ), + ) + ), + + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.benene), + subtitle = if (bananas == 0) { + stringResource(R.string.benene_count_text_none) + } else { + stringResource(R.string.benene_count_text, bananas) + }, + onClick = { + settings.general.bananas.set(bananas + 1) + }, + icon = painterResource(R.drawable.benene), + ), + Preference.PreferenceItem.InfoPreference(title = stringResource(R.string.legal_notice_text)), + ) + } +} + + +@PreviewLightDark +@Composable +private fun SettingGeneralPreview() { + val screen = SettingsGeneralScreen() + CloudStreamPreviewTheme { + screen.Content() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer2.kt new file mode 100644 index 00000000000..b178f26a33a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer2.kt @@ -0,0 +1,18 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.lagradost.cloudstream4.compose.createComposeView +import com.mihon.presentation.settings.SearchableSettings + +/** Empty glue code to connect the old navigation graph to Compose */ +class SettingsPlayer2 : Fragment(), SearchableSettings by SettingsPlayerScreen() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createComposeView(inflater, container, savedInstanceState) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt new file mode 100644 index 00000000000..d4a0bff9b64 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt @@ -0,0 +1,347 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.text.format.Formatter.formatShortFileSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.actions.VideoClickActionHolder +import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog +import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize +import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment +import com.lagradost.cloudstream3.utils.Coroutines.ioSafe +import com.lagradost.cloudstream4.rememberAppSettings +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import com.mihon.presentation.settings.collectAsState +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.reflect.jvm.jvmName + +class SettingsPlayerScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(R.string.category_player) + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + + val context = LocalContext.current + val defaultPlayerName = stringResource(R.string.player_settings_play_in_app) + val players = remember(context) { + mapOf("" to defaultPlayerName) + VideoClickActionHolder.getPlayers() + .associate { player -> + player.uniqueId() to (player.name.asStringNull(context) + ?: player::class.simpleName ?: player::class.jvmName) + } + } + + val playerSeekTime by settings.player.doubleTapTime.collectAsState() + val tvSeekOnTime by settings.player.tvSeekOnTime.collectAsState() + val tvSeekOffTime by settings.player.tvSeekOffTime.collectAsState() + + var cacheSize by remember { mutableLongStateOf(0L) } + var cacheCleared by remember { mutableIntStateOf(0) } + val cacheDir = LocalContext.current.cacheDir + LaunchedEffect(cacheCleared) { + withContext(Dispatchers.IO) { + cacheSize = getFolderSize(cacheDir) + } + } + + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_subtitles), + preferenceItems = persistentListOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.player_subtitles_settings), + subtitle = stringResource(R.string.player_subtitles_settings_des), + icon = painterResource(R.drawable.ic_outline_subtitles_24), + onClick = { + SubtitlesFragment.push(activity, false) + }), + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.chromecast_subtitles_settings), + subtitle = stringResource(R.string.chromecast_subtitles_settings_des), + icon = painterResource(R.drawable.cast), + onClick = { + ChromecastSubtitlesFragment.push(activity, false) + }), + ), + ), + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_player_features), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + title = stringResource(R.string.player_pref), + icon = painterResource(R.drawable.netflix_play), + preference = settings.player.defaultPlayer, + entries = players, + ), + + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.source_priority), + subtitle = stringResource(R.string.source_priority_help), + icon = painterResource(R.drawable.ic_baseline_people_24), + onClick = { + ioSafe { + val defaultSources = QualityProfileDialog.getAllDefaultSources() + val activity = activity ?: return@ioSafe + activity.runOnUiThread { + QualityProfileDialog( + activity, + R.style.DialogFullscreenPlayer, + defaultSources, + ).show() + } + } + }), + + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.pipEnabled, + title = stringResource(R.string.picture_in_picture), + subtitle = stringResource(R.string.picture_in_picture_des), + icon = painterResource(R.drawable.ic_baseline_picture_in_picture_alt_24), + ), + + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.resizeEnabled, + title = stringResource(R.string.player_size_settings), + subtitle = stringResource(R.string.player_size_settings_des), + icon = painterResource(R.drawable.ic_baseline_aspect_ratio_24), + ), + + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.speedEnabled, + title = stringResource(R.string.eigengraumode_settings), + subtitle = stringResource(R.string.speed_setting_summary), + icon = painterResource(R.drawable.ic_baseline_speed_24), + ), + + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.tiktokEnabled, + title = stringResource(R.string.speedup_title), + subtitle = stringResource(R.string.speedup_summary), + icon = painterResource(R.drawable.speedup), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.autoPlayEnabled, + title = stringResource(R.string.autoplay_next_settings), + subtitle = stringResource(R.string.autoplay_next_settings_des), + icon = painterResource(R.drawable.skip_next_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.skipOpEnabled, + title = stringResource(R.string.video_skip_op), + subtitle = stringResource(R.string.enable_skip_op_from_database_des), + icon = painterResource(R.drawable.keyboard_double_arrow_right_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.autoRotateEnabled, + title = stringResource(R.string.auto_rotate_video), + subtitle = stringResource(R.string.auto_rotate_video_desc), + icon = painterResource(R.drawable.screen_rotation_alt_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.rotateButtonEnabled, + title = stringResource(R.string.rotate_video), + subtitle = stringResource(R.string.rotate_video_desc), + icon = painterResource(R.drawable.screen_rotation), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.previewBarEnabled, + title = stringResource(R.string.preview_seekbar), + subtitle = stringResource(R.string.preview_seekbar_desc), + icon = painterResource(R.drawable.picture_in_picture_center_24px), + ), + Preference.PreferenceItem.ListPreference( + preference = settings.player.softwareDecoding, + title = stringResource(R.string.software_decoding), + subtitle = stringResource(R.string.software_decoding_desc), + icon = painterResource(R.drawable.memory_24px), + entries = integerArrayResource(R.array.software_decoding_switch_values).zip( + stringArrayResource(R.array.software_decoding_switch) + ).toMap() + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.extraBrightnessEnabled, + title = stringResource(R.string.extra_brightness_settings), + subtitle = stringResource(R.string.extra_brightness_settings_des), + icon = painterResource(R.drawable.sun_7_24), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.episodeSync, + title = stringResource(R.string.episode_sync_settings), + subtitle = stringResource(R.string.episode_sync_settings_des), + icon = painterResource(R.drawable.baseline_sync_24) + ), + ), + ), + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_gestures), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.swipeHorizontalEnabled, + title = stringResource(R.string.swipe_to_seek_settings), + subtitle = stringResource(R.string.swipe_to_seek_settings_des), + icon = painterResource(R.drawable.swipe_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.swipeVerticalEnabled, + title = stringResource(R.string.swipe_to_change_settings), + subtitle = stringResource(R.string.swipe_to_change_settings_des), + icon = painterResource(R.drawable.swipe_vertical_24px), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.doubleTapToSeekEnabled, + title = stringResource(R.string.double_tap_to_seek_settings), + subtitle = stringResource(R.string.double_tap_to_seek_settings_des), + icon = painterResource(R.drawable.ic_baseline_touch_app_24), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.doubleTapToPauseEnabled, + title = stringResource(R.string.double_tap_to_pause_settings), + subtitle = stringResource(R.string.double_tap_to_pause_settings_des), + icon = painterResource(R.drawable.ic_baseline_touch_app_24), + ), + + Preference.PreferenceItem.SliderPreference( + value = playerSeekTime, + title = stringResource(R.string.double_tap_to_seek_amount_settings), + icon = painterResource(R.drawable.go_forward_30), + valueRange = 5..60, + steps = 10, + onValueChanged = settings.player.doubleTapTime::set + ), + ) + ), + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_android_tv), + // TODO enabled by TV + preferenceItems = persistentListOf( + Preference.PreferenceItem.SliderPreference( + value = tvSeekOnTime, + title = stringResource(R.string.android_tv_interface_on_seek_settings), + subtitle = stringResource(R.string.android_tv_interface_on_seek_settings_summary), + icon = painterResource(R.drawable.go_forward_30), + valueRange = 5..60, + steps = 10, + onValueChanged = settings.player.tvSeekOnTime::set + ), + Preference.PreferenceItem.SliderPreference( + value = tvSeekOffTime, + title = stringResource(R.string.android_tv_interface_off_seek_settings), + subtitle = stringResource(R.string.android_tv_interface_off_seek_settings_summary), + icon = painterResource(R.drawable.go_forward_30), + valueRange = 5..60, + steps = 10, + onValueChanged = settings.player.tvSeekOffTime::set + ), + ) + ), + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_player_layout), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + preference = settings.player.limitPlayerTitle, + title = stringResource(R.string.limit_title), + icon = painterResource(R.drawable.match_word_24px), + entries = integerArrayResource(R.array.limit_title_pref_values).zip( + stringArrayResource(R.array.limit_title_pref_names) + ).toMap() + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.hidePlayerControlNames, + icon = painterResource(R.drawable.visibility_off_24px), + title = stringResource(R.string.hide_player_control_names), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.showName, + icon = painterResource(R.drawable.label_24px), + title = stringResource(R.string.source_name), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.showMediaInfo, + icon = painterResource(R.drawable.movie_info_24px), + title = stringResource(R.string.video_info), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.player.showResolution, + title = stringResource(R.string.resolution), + icon = painterResource(R.drawable.high_res_24px), + ), + + // Unsure if we want to have MultiSelectListPreference or boolean + /*Preference.PreferenceItem.MultiSelectListPreference( + preference = settings.player.showPlayerInfo, + title = stringResource(R.string.limit_title_rez), + icon = painterResource(R.drawable.ic_baseline_text_format_24), + entries = persistentMapOf( + ShowPlayerInfo.Name to stringResource(R.string.source_name), + ShowPlayerInfo.Resolution to stringResource(R.string.resolution), + ShowPlayerInfo.VideoInfo to stringResource(R.string.video_info), + ) + ), */ + ) + ), + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_cache), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + preference = settings.player.bufferDiskMB, + title = stringResource(R.string.video_buffer_disk_settings), + subtitle = "%s\n" + stringResource(R.string.video_disk_description), + icon = painterResource(R.drawable.ic_baseline_storage_24), + entries = integerArrayResource(R.array.video_buffer_size_values).zip( + stringArrayResource(R.array.video_buffer_size_names) + ).toMap() + ), + + Preference.PreferenceItem.ListPreference( + preference = settings.player.bufferRamMB, + title = stringResource(R.string.video_buffer_size_settings), + subtitle = "%s\n" + stringResource(R.string.video_ram_description), + icon = painterResource(R.drawable.memory_alt_24px), + entries = integerArrayResource(R.array.video_buffer_size_values).zip( + stringArrayResource(R.array.video_buffer_size_names) + ).toMap() + ), + + Preference.PreferenceItem.ListPreference( + preference = settings.player.bufferTimeSec, + title = stringResource(R.string.video_buffer_length_settings), + subtitle = "%s\n" + stringResource(R.string.video_ram_description), + icon = painterResource(R.drawable.history_toggle_off_24px), + entries = integerArrayResource(R.array.video_buffer_length_values).zip( + stringArrayResource(R.array.video_buffer_length_names) + ).toMap() + ), + + Preference.PreferenceItem.TextPreference( + icon = painterResource(R.drawable.ic_baseline_delete_outline_24), + title = stringResource(R.string.video_buffer_clear_settings), + subtitle = formatShortFileSize(LocalContext.current, cacheSize), + onClick = { + ioSafe { + cacheDir.deleteRecursively() + cacheCleared += 1 + } + }) + ) + ), + + ) + } +} \ 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 c8478a84003..08b147f52bf 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 @@ -109,7 +109,8 @@ class SettingsProviders : BasePreferenceFragmentCompat() { return@setOnPreferenceClickListener true } - getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener { + getPref(R.string. + provider_lang_key)?.setOnPreferenceClickListener { activity?.getApiProviderLangSettings()?.let { currentLangTags -> val languagesTagName = APIHolder.apis.withLock { listOf(Pair(AllLanguagesName, getString(R.string.all_languages_preference))) + diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders2.kt new file mode 100644 index 00000000000..02b609c69bf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders2.kt @@ -0,0 +1,17 @@ +package com.lagradost.cloudstream3.ui.settings +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.lagradost.cloudstream4.compose.createComposeView +import com.mihon.presentation.settings.SearchableSettings + +/** Empty glue code to connect the old navigation graph to Compose */ +class SettingsProviders2 : Fragment(), SearchableSettings by SettingsProvidersScreen() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createComposeView(inflater, container, savedInstanceState) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProvidersScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProvidersScreen.kt new file mode 100644 index 00000000000..4d8e83d1bd1 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProvidersScreen.kt @@ -0,0 +1,87 @@ +package com.lagradost.cloudstream3.ui.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.UIHelper.navigate +import com.lagradost.cloudstream4.rememberAppSettings +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import kotlinx.collections.immutable.persistentListOf + +class SettingsProvidersScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(R.string.category_providers) + + fun TvType.toStringRes() = when (this) { + TvType.TvSeries -> R.string.tv_series_singular + TvType.Anime -> R.string.anime_singular + TvType.OVA -> R.string.ova_singular + TvType.AnimeMovie -> R.string.movies_singular + TvType.Cartoon -> R.string.cartoons_singular + TvType.Documentary -> R.string.documentaries_singular + TvType.Movie -> R.string.movies_singular + TvType.Torrent -> R.string.torrent_singular + TvType.AsianDrama -> R.string.asian_drama_singular + TvType.Live -> R.string.live_singular + TvType.Others -> R.string.other_singular + TvType.NSFW -> R.string.nsfw_singular + TvType.Music -> R.string.music_singular + TvType.AudioBook -> R.string.audio_book_singular + TvType.CustomMedia -> R.string.custom_media_singular + TvType.Audio -> R.string.audio_singular + TvType.Podcast -> R.string.podcast_singular + TvType.Video -> R.string.video_singular + } + + // TODO Move this into general and layout? + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + + val default = AllLanguagesName to stringResource(R.string.all_languages_preference) + val languages = APIHolder.apis.withLock { + APIHolder.apis.map { api -> api.lang }.distinct() + } + + return persistentListOf( + Preference.PreferenceItem.MultiSelectListPreference( + title = stringResource(R.string.provider_lang_settings), + icon = painterResource(R.drawable.plugin_lang), + entries = mapOf(default) + languages.associateWith { lang -> + (getNameNextToFlagEmoji( + lang + ) ?: lang) + }, + preference = settings.provider.extentionLanguages + ), Preference.PreferenceItem.MultiSelectListPreference( + title = stringResource(R.string.preferred_media_settings), + icon = painterResource(R.drawable.movie_edit_24px), + preference = settings.provider.preferredMedia, + entries = TvType.entries.associate { + it.ordinal.toString() to stringResource(it.toStringRes()) + }), Preference.PreferenceItem.MultiSelectListPreference( + title = stringResource(R.string.display_subbed_dubbed_settings), + icon = painterResource(R.drawable.ic_outline_voice_over_off_24), + preference = settings.provider.displayDubSub, + entries = mapOf( + DubStatus.None.name to stringResource(R.string.none), + DubStatus.Dubbed.name to stringResource(R.string.app_dubbed_text), + DubStatus.Subbed.name to stringResource(R.string.app_subbed_text), + ) + ), Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.test_extensions), + subtitle = stringResource(R.string.test_extensions_summary), + icon = painterResource(R.drawable.baseline_network_ping_24), + onClick = { + activity?.navigate(R.id.navigation_test_providers) + })) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI2.kt new file mode 100644 index 00000000000..1dc13af0980 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI2.kt @@ -0,0 +1,17 @@ +package com.lagradost.cloudstream3.ui.settings +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.lagradost.cloudstream4.compose.createComposeView +import com.mihon.presentation.settings.SearchableSettings + +/** Empty glue code to connect the old navigation graph to Compose */ +class SettingsUI2 : Fragment(), SearchableSettings by SettingsUIScreen() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createComposeView(inflater, container, savedInstanceState) +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUIScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUIScreen.kt new file mode 100644 index 00000000000..f90f59298f8 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUIScreen.kt @@ -0,0 +1,247 @@ +package com.lagradost.cloudstream3.ui.settings + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.integerArrayResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import com.lagradost.cloudstream3.CommonActivity.activity +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.ui.clear +import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter +import com.lagradost.cloudstream3.ui.home.ParentItemAdapter +import com.lagradost.cloudstream3.ui.search.SearchAdapter +import com.lagradost.cloudstream3.ui.settings.Globals.updateTv +import com.lagradost.cloudstream3.utils.UIHelper.toPx +import com.lagradost.cloudstream4.rememberAppSettings +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import com.mihon.presentation.settings.collectAsState +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentMap + +class SettingsUIScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(R.string.category_ui) + + fun SearchQuality.toStringRes() = when (this) { + SearchQuality.BlueRay -> R.string.quality_blueray + SearchQuality.Cam -> R.string.quality_cam + SearchQuality.CamRip -> R.string.quality_cam_rip + SearchQuality.DVD -> R.string.quality_dvd + SearchQuality.HD -> R.string.quality_hd + SearchQuality.HQ -> R.string.quality_hq + SearchQuality.HdCam -> R.string.quality_cam_hd + SearchQuality.Telecine -> R.string.quality_tc + SearchQuality.Telesync -> R.string.quality_ts + SearchQuality.WorkPrint -> R.string.quality_workprint + SearchQuality.SD -> R.string.quality_sd + SearchQuality.FourK -> R.string.quality_4k + SearchQuality.UHD -> R.string.quality_uhd + SearchQuality.SDR -> R.string.quality_sdr + SearchQuality.HDR -> R.string.quality_hdr + SearchQuality.WebRip -> R.string.quality_webrip + } + + @Composable + override fun getPreferences(): List { + val settings = rememberAppSettings() + + val overscanDp by settings.ui.overscanDp.collectAsState() + val posterSize by settings.ui.posterSize.collectAsState() + + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_looks), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + preference = settings.ui.primaryColor, + icon = painterResource(R.drawable.colors_24px), + title = stringResource(R.string.primary_color_settings), + entries = stringArrayResource(R.array.themes_overlay_names_values).zip( + stringArrayResource(R.array.themes_overlay_names) + ).toMap().toPersistentMap().mutate { map -> + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + map.remove("Monet") + map.remove("Monet2") + } + }, + onValueChanged = { newValue -> + settings.ui.primaryColor.set(newValue) // We need to set before we recreate + activity?.recreate() + return@ListPreference true + }), + Preference.PreferenceItem.ListPreference( + preference = settings.ui.theme, + icon = painterResource(R.drawable.palette_24px), + title = stringResource(R.string.app_theme_settings), + entries = stringArrayResource(R.array.themes_names_values).zip( + stringArrayResource(R.array.themes_names) + ).toMap().toPersistentMap().mutate { map -> + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less + map.remove("Monet") + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { // Remove system on android 9 and less + map.remove("System") + } + }, + onValueChanged = { newValue -> + settings.ui.theme.set(newValue) // We need to set before we recreate + activity?.recreate() + return@ListPreference true + }), + Preference.PreferenceItem.ListPreference( + preference = settings.ui.layout, + icon = painterResource(R.drawable.responsive_layout_24px), + title = stringResource(R.string.app_layout), + entries = integerArrayResource(R.array.app_layout_values).zip( + stringArrayResource(R.array.app_layout) + ).toMap().toPersistentMap(), + onValueChanged = { newValue -> + settings.ui.layout.set(newValue) // We need to set before we recreate + activity?.updateTv() + activity?.recreate() + return@ListPreference true + }), + ) + ), + + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_ui_features), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SliderPreference( + value = overscanDp, + title = stringResource(R.string.overscan_settings), + subtitle = stringResource(R.string.overscan_settings_des), + valueRange = 0..100, + icon = painterResource(R.drawable.arrows_input_24px), + onValueChanged = { newValue -> + settings.ui.overscanDp.set(newValue) + val padding = newValue.toPx + (activity as? MainActivity)?.binding?.homeRoot?.setPadding( + padding, padding, padding, padding + ) + }), + + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.advancedSearch, + title = stringResource(R.string.advanced_search), + subtitle = stringResource(R.string.advanced_search_des), + icon = painterResource(R.drawable.search_icon) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.searchSuggestions, + title = stringResource(R.string.search_suggestions), + subtitle = stringResource(R.string.search_suggestions_des), + icon = painterResource(R.drawable.tooltip_24px) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.trailersEnabled, + title = stringResource(R.string.show_trailers_settings), + icon = painterResource(R.drawable.baseline_theaters_24) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.kitsuPostersEnabled, + title = stringResource(R.string.kitsu_settings), + icon = painterResource(R.drawable.kitsu_icon) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.castEnabled, + title = stringResource(R.string.show_cast_in_details), + icon = painterResource(R.drawable.face_24px) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.fillersEnabled, + title = stringResource(R.string.show_fillers_settings), + icon = painterResource(R.drawable.skip_next_24px) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.showMetadataOverlay, + title = stringResource(R.string.show_player_metadata_overlay), + icon = painterResource(R.drawable.metadata_overlay_icon) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.showClock, + title = stringResource(R.string.tv_layout_clock_settings), + subtitle = stringResource(R.string.tv_layout_clock_settings_des), + icon = painterResource(R.drawable.ic_baseline_clock_24) + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.randomButtonEnabled, + title = stringResource(R.string.random_button_settings), + subtitle = stringResource(R.string.random_button_settings_desc), + icon = painterResource(R.drawable.shuffle_24px) + ), + Preference.PreferenceItem.ListPreference( + preference = settings.ui.confirmExit, + title = stringResource(R.string.confirm_before_exiting_title), + subtitle = stringResource(R.string.confirm_before_exiting_desc), + icon = painterResource(R.drawable.ic_baseline_exit_24), + entries = integerArrayResource(R.array.confirm_exit_values).zip( + stringArrayResource(R.array.confirm_exit) + ).toMap() + ), + ) + ), + + Preference.PreferenceGroup( + title = stringResource(R.string.search_poster_img_des), + preferenceItems = persistentListOf( + Preference.PreferenceItem.MultiSelectListPreference( + preference = settings.ui.filterQuality, + title = stringResource(R.string.pref_filter_search_quality), + icon = painterResource(R.drawable.filter_alt_24px), + entries = SearchQuality.entries.associateWith { stringResource(it.toStringRes()) }), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.bottomTitle, + title = stringResource(R.string.bottom_title_settings), + subtitle = stringResource(R.string.bottom_title_settings_des), + icon = painterResource(R.drawable.title_24px) + ), + Preference.PreferenceItem.SliderPreference( + value = posterSize, + title = stringResource(R.string.poster_size_settings), + subtitle = stringResource(R.string.poster_size_settings_des), + valueRange = 0..15, + icon = painterResource(R.drawable.baseline_grid_view_24), + onValueChanged = { newValue -> + HomeChildItemAdapter.sharedPool.clear() + ParentItemAdapter.sharedPool.clear() + SearchAdapter.sharedPool.clear() + activity?.let { HomeChildItemAdapter.updatePosterSize(it, newValue) } + settings.ui.posterSize.set(newValue) + }), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowRating, + title = stringResource(R.string.show_rating), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowEpisode, + title = stringResource(R.string.show_episode_text), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowTitle, + title = stringResource(R.string.show_title), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowHd, + title = stringResource(R.string.show_hd), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowDub, + title = stringResource(R.string.show_dub), + ), + Preference.PreferenceItem.SwitchPreference( + preference = settings.ui.posterShowSub, + title = stringResource(R.string.show_sub), + ), + ) + ) + ) + } +} \ No newline at end of file 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 e3c7d68dffd..af3795834be 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt @@ -63,7 +63,7 @@ object BatteryOptimizationChecker { return isRestricted && isOptimizedNotShown && isLayout(PHONE) } - private fun Context.showRequestIgnoreBatteryOptDialog() { + fun Context.showRequestIgnoreBatteryOptDialog() { try { val intent = Intent().apply { action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt index df9d56217fd..687f746e3a2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/videoskip/AnimeSkip.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.AnimeLoadResponse import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvSeriesLoadResponse import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app @@ -25,6 +26,7 @@ import java.security.MessageDigest class AnimeSkipAuth : AuthAPI() { override val name = "AnimeSkip" + override val icon: Int = R.drawable.animeskip override val inAppLoginRequirement: AuthLoginRequirement = AuthLoginRequirement(password = true, username = true) override val idPrefix = "anime-skip" diff --git a/app/src/main/res/drawable/cast.xml b/app/src/main/res/drawable/cast.xml new file mode 100644 index 00000000000..7600cb70692 --- /dev/null +++ b/app/src/main/res/drawable/cast.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/colors_24px.xml b/app/src/main/res/drawable/colors_24px.xml new file mode 100644 index 00000000000..d86304cf993 --- /dev/null +++ b/app/src/main/res/drawable/colors_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/face_24px.xml b/app/src/main/res/drawable/face_24px.xml new file mode 100644 index 00000000000..152c278e79b --- /dev/null +++ b/app/src/main/res/drawable/face_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/filter_alt_24px.xml b/app/src/main/res/drawable/filter_alt_24px.xml new file mode 100644 index 00000000000..0afa9b51dc0 --- /dev/null +++ b/app/src/main/res/drawable/filter_alt_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/high_res_24px.xml b/app/src/main/res/drawable/high_res_24px.xml new file mode 100644 index 00000000000..183f2ae6941 --- /dev/null +++ b/app/src/main/res/drawable/high_res_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/history_toggle_off_24px.xml b/app/src/main/res/drawable/history_toggle_off_24px.xml new file mode 100644 index 00000000000..30186016ca7 --- /dev/null +++ b/app/src/main/res/drawable/history_toggle_off_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/keyboard_double_arrow_right_24px.xml b/app/src/main/res/drawable/keyboard_double_arrow_right_24px.xml new file mode 100644 index 00000000000..2e564cea409 --- /dev/null +++ b/app/src/main/res/drawable/keyboard_double_arrow_right_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/label_24px.xml b/app/src/main/res/drawable/label_24px.xml new file mode 100644 index 00000000000..fb1d1b50603 --- /dev/null +++ b/app/src/main/res/drawable/label_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/language_korean_latin_24px.xml b/app/src/main/res/drawable/language_korean_latin_24px.xml new file mode 100644 index 00000000000..55bf0cf2165 --- /dev/null +++ b/app/src/main/res/drawable/language_korean_latin_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/match_word_24px.xml b/app/src/main/res/drawable/match_word_24px.xml new file mode 100644 index 00000000000..d2a7334f837 --- /dev/null +++ b/app/src/main/res/drawable/match_word_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/memory_24px.xml b/app/src/main/res/drawable/memory_24px.xml new file mode 100644 index 00000000000..7e4dd5e9b53 --- /dev/null +++ b/app/src/main/res/drawable/memory_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/memory_alt_24px.xml b/app/src/main/res/drawable/memory_alt_24px.xml new file mode 100644 index 00000000000..21807a9e9fc --- /dev/null +++ b/app/src/main/res/drawable/memory_alt_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/movie_edit_24px.xml b/app/src/main/res/drawable/movie_edit_24px.xml new file mode 100644 index 00000000000..41df964e657 --- /dev/null +++ b/app/src/main/res/drawable/movie_edit_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/movie_info_24px.xml b/app/src/main/res/drawable/movie_info_24px.xml new file mode 100644 index 00000000000..7d7dfe9e908 --- /dev/null +++ b/app/src/main/res/drawable/movie_info_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/palette_24px.xml b/app/src/main/res/drawable/palette_24px.xml new file mode 100644 index 00000000000..f701027ccf9 --- /dev/null +++ b/app/src/main/res/drawable/palette_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/picture_in_picture_center_24px.xml b/app/src/main/res/drawable/picture_in_picture_center_24px.xml new file mode 100644 index 00000000000..60d7e133ba2 --- /dev/null +++ b/app/src/main/res/drawable/picture_in_picture_center_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/plugin_lang.xml b/app/src/main/res/drawable/plugin_lang.xml new file mode 100644 index 00000000000..ac33489ac11 --- /dev/null +++ b/app/src/main/res/drawable/plugin_lang.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/responsive_layout_24px.xml b/app/src/main/res/drawable/responsive_layout_24px.xml new file mode 100644 index 00000000000..4d3ea085f26 --- /dev/null +++ b/app/src/main/res/drawable/responsive_layout_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/screen_rotation_alt_24px.xml b/app/src/main/res/drawable/screen_rotation_alt_24px.xml new file mode 100644 index 00000000000..a1d01a5c693 --- /dev/null +++ b/app/src/main/res/drawable/screen_rotation_alt_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/shuffle_24px.xml b/app/src/main/res/drawable/shuffle_24px.xml new file mode 100644 index 00000000000..d25985f8bc5 --- /dev/null +++ b/app/src/main/res/drawable/shuffle_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/skip_next_24px.xml b/app/src/main/res/drawable/skip_next_24px.xml new file mode 100644 index 00000000000..3beb9c7604e --- /dev/null +++ b/app/src/main/res/drawable/skip_next_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/swipe_24px.xml b/app/src/main/res/drawable/swipe_24px.xml new file mode 100644 index 00000000000..9b2183713c3 --- /dev/null +++ b/app/src/main/res/drawable/swipe_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/swipe_vertical_24px.xml b/app/src/main/res/drawable/swipe_vertical_24px.xml new file mode 100644 index 00000000000..48048f3341e --- /dev/null +++ b/app/src/main/res/drawable/swipe_vertical_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/tooltip_24px.xml b/app/src/main/res/drawable/tooltip_24px.xml new file mode 100644 index 00000000000..5770d07b46b --- /dev/null +++ b/app/src/main/res/drawable/tooltip_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/touch_double_24px.xml b/app/src/main/res/drawable/touch_double_24px.xml new file mode 100644 index 00000000000..f008bd37474 --- /dev/null +++ b/app/src/main/res/drawable/touch_double_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/visibility_off_24px.xml b/app/src/main/res/drawable/visibility_off_24px.xml new file mode 100644 index 00000000000..50ba19a57a1 --- /dev/null +++ b/app/src/main/res/drawable/visibility_off_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index 27f186a0074..bc75c46f8e0 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -137,7 +137,7 @@ Any legal issues regarding the content on this application should be taken up with the actual file hosts and providers themselves as we are not affiliated with them. - In case of copyright infringement, please directly contact the responsible parties or the streaming websites. + \n\nIn case of copyright infringement, please directly contact the responsible parties or the streaming websites. - The app is purely for educational and personal use. + \n\nThe app is purely for educational and personal use. - CloudStream does not host any content on the app, and has no control over what media is put up or taken down. + \n\nCloudStream does not host any content on the app, and has no control over what media is put up or taken down. CloudStream functions like any other search engine, such as Google. CloudStream does not host, upload or - manage any videos, films or content. It simply crawls, aggregates and displayes links in a convenient, + manage any videos, films or content. It simply crawls, aggregates and displays links in a convenient, user-friendly interface. - It merely scrapes 3rd-party websites that are publicly accessible via any regular web browser. It is the + \n\nIt merely scrapes 3rd-party websites that are publicly accessible via any regular web browser. It is the responsibility of user to avoid any actions that might violate the laws governing his/her locality. Use CloudStream at your own risk. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d530b59f6e..872d0a343d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -458,8 +458,8 @@ Random Coming soon… Cam - Cam - Cam + CamRip + CamHD HQ HD TS diff --git a/build.gradle.kts b/build.gradle.kts index f58fb584fc8..dc98269b556 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,4 +7,6 @@ plugins { alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.compose.multiplatform) apply false } diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 00000000000..0008a854d86 --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,55 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + jvm {} + sourceSets { + jvmMain.dependencies { + implementation(libs.bundles.compose) + implementation(compose.desktop.currentOs) { + // compose.desktop.currentOs imports the wrong material 2, so we exclude it + exclude(group = "org.jetbrains.compose.material", module = "material") + } + implementation(project(":shared")) + } + } +} + +// java.lang.System::load has been called by org.jetbrains.skiko.LibraryLoader in an unnamed module +tasks.withType { + jvmArgs("--enable-native-access=ALL-UNNAMED") +} + +compose.desktop { + application { + mainClass = "com.lagradost.cloudstream4.MainKt" + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "CloudStream" + packageVersion = "1.0.0" + + val iconsRoot = project.file("desktop-icons") + macOS { + // iconFile.set(iconsRoot.resolve("icon-mac.icns")) + } + windows { + iconFile.set(iconsRoot.resolve("icon-windows.ico")) + // menuGroup = "Compose Examples" + // see https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html + // upgradeUuid = "" + } + linux { + iconFile.set(iconsRoot.resolve("icon-linux.png")) + } + } + + //buildTypes.release.proguard { + // configurationFiles.from(project.file("rules.pro")) + //} + } +} \ No newline at end of file diff --git a/desktopApp/src/desktop-icons/icon-linux.png b/desktopApp/src/desktop-icons/icon-linux.png new file mode 100644 index 00000000000..394459001fd Binary files /dev/null and b/desktopApp/src/desktop-icons/icon-linux.png differ diff --git a/desktopApp/src/desktop-icons/icon-windows.ico b/desktopApp/src/desktop-icons/icon-windows.ico new file mode 100644 index 00000000000..defddc2e910 Binary files /dev/null and b/desktopApp/src/desktop-icons/icon-windows.ico differ diff --git a/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt new file mode 100644 index 00000000000..f40cc9f8bb1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/lagradost/cloudstream4/Main.kt @@ -0,0 +1,59 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import com.lagradost.cloudstream4.compose.Colors +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.app_name +import com.lagradost.cloudstream4.generated.resources.default_icon +import com.lagradost.cloudstream4.generated.resources.preview +import com.lagradost.cloudstream4.theme.CloudStreamTheme +import com.lagradost.cloudstream4.theme.CloudStreamTheme.colors +import com.lagradost.cloudstream4.theme.CloudStreamThemeMode +import com.mihon.presentation.settings.widget.SwitchPreferenceWidget +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = stringResource(Res.string.app_name), + icon = painterResource(Res.drawable.default_icon) + ) { + CloudStreamTheme(mode = CloudStreamThemeMode.Dark) { + Scaffold( + containerColor = colors.background, + contentColor = colors.onBackground + ) { + Column { + Text("Hello, World!") + Row { + Button(onClick = {}, colors = Colors.whiteButton) { + Text("Hello in White") + } + Button(onClick = {}, colors = Colors.blackButton) { + Text("Hello in Black") + } + } + var checked by remember { mutableStateOf(false) } + SwitchPreferenceWidget( + title = "hello", subtitle = "world", icon = painterResource( + Res.drawable.preview + ), + checked = checked, + onCheckedChanged = { checked = !checked } + ) + } + } + } + } +} \ No newline at end of file diff --git a/desktopApp/src/readme.md b/desktopApp/src/readme.md new file mode 100644 index 00000000000..0bebb86381d --- /dev/null +++ b/desktopApp/src/readme.md @@ -0,0 +1 @@ +Based on https://github.com/JetBrains/compose-multiplatform/tree/master/examples/imageviewer/desktopApp \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e388954512d..4652cf938c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ # https://docs.gradle.org/current/userguide/plugins.html#sec:version_catalog_plugin_application # https://docs.gradle.org/current/userguide/dependency_versions.html#sec:strict-version [versions] -activityKtx = "1.13.0" +activity = "1.13.0" androidGradlePlugin = "9.1.1" animeDb = "1.0.2" annotation = "1.10.0" @@ -55,8 +55,14 @@ tvprovider = "1.1.0" video = "1.0.0" workRuntimeKtx = "2.11.2" zipline = "1.27.0" +composeMaterial3 = "1.12.0-alpha03" +composeMultiplatform = "1.12.0" +lifecycleViewmodelCompose = "2.11.0" -jvmTarget = "1.8" +# jvmTarget = "11" is needed for compose, otherwise you will get +# "Cannot inline bytecode built with JVM target 11 into bytecode" or +# "Error was captured in composition. java.lang.AbstractMethodError" +jvmTarget = "11" jdkToolchain = "17" minSdk = "23" compileSdk = "37" @@ -66,7 +72,8 @@ versionCode = "68" versionName = "4.8.0" [libraries] -activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" } +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } +activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity" } anime-db = { module = "com.github.recloudstream:anime-db", version.ref = "animeDb" } annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -136,6 +143,16 @@ video = { module = "com.google.android.mediahome:video", version.ref = "video" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } zipline = { module = "app.cash.zipline:zipline-android", version.ref = "zipline" } +compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } +compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" } +compose-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } +compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } +compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-lint = { id = "com.android.lint", version.ref = "androidGradlePlugin" } @@ -146,6 +163,9 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlinGradlePlug kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlinGradlePlugin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinGradlePlugin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinGradlePlugin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } + [bundles] coil = ["coil", "coil-network-okhttp"] cryptography = ["cryptography-core", "cryptography-provider-optimal"] @@ -153,3 +173,4 @@ lifecycle = ["lifecycle-livedata-ktx", "lifecycle-viewmodel-ktx"] media3 = ["media3-cast", "media3-common", "media3-container", "media3-datasource-cronet", "media3-datasource-okhttp", "media3-exoplayer", "media3-exoplayer-dash", "media3-exoplayer-hls", "media3-session", "media3-ui"] navigation = ["navigation-fragment-ktx", "navigation-ui-ktx"] nextlib = ["nextlib-media3ext", "nextlib-mediainfo"] +compose = ["compose-foundation", "compose-material3", "compose-resources", "compose-runtime", "compose-ui", "compose-ui-tooling", "compose-ui-tooling-preview", "lifecycle-viewmodel-compose", "coil-compose"] \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 73bf5a1958b..dd92448b069 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,4 +18,4 @@ dependencyResolutionManagement { } rootProject.name = "CloudStream" -include(":app", ":library", ":docs") +include(":app", ":shared", ":library", ":docs", ":desktopApp") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts new file mode 100644 index 00000000000..1b7f0f29e0b --- /dev/null +++ b/shared/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.lint) + alias(libs.plugins.android.multiplatform.library) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + android { + // Must be unique + namespace = "com.lagradost.cloudstream4" + compileSdk = libs.versions.compileSdk.get().toInt() + minSdk = libs.versions.minSdk.get().toInt() + + androidResources { + enable = true + } + } + + jvm() + + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + sourceSets { + all { + languageSettings { + optIn("com.lagradost.cloudstream3.InternalAPI") + optIn("com.lagradost.cloudstream3.Prerelease") + } + } + + commonMain.dependencies { + implementation(libs.bundles.compose) + implementation(project(":library")) + } + + androidMain.dependencies { + implementation(libs.activity.compose) + implementation(libs.preference.ktx) + } + } +} + +dependencies { + androidRuntimeClasspath(libs.compose.ui.tooling) +} + +compose.resources { + publicResClass = true + packageOfResClass = "com.lagradost.cloudstream4.generated.resources" + generateResClass = auto +} \ No newline at end of file diff --git a/shared/readme.md b/shared/readme.md new file mode 100644 index 00000000000..084244cf3c1 --- /dev/null +++ b/shared/readme.md @@ -0,0 +1,112 @@ +## Motivation + +This readme is documentation for humans to understand ***why***, and ***not how*** the core architecture +for CloudStream will be designed going forward. + +## Design decisions + +The following design decisions has been tried in the sister project QuickNovel with great success, and a +seamless porting process. For inspiration and code in a similar codebase, take a look at QuickNovel. + +### File structure + +The `shared` directory is supposed to be for shared compose UI between platforms, and can act as a glue for business logic. +This is separate from the `library` that includes the primitives for a headless, cross-platform plugin system. +Note that we do not want a shared screen layout, because each platform has its own design language and quirks. +Instead, the ViewModels should be entirely detached from the rendering process, and each UI component should be shared +between every platform. This ensures ViewModels cant cheat with dirty escape hatches, a leaky api or +with UI elements that hijack business logic. + +**Include**: +*Viewmodels*, *UI components* and *Theming* + +**Avoid**: +*Entire Screens*, *Plugin APIs* and *Android Layout XML* + +Please also note that while it would be nice with an e.g. cross-platform downloader or video player. +It is simply infeasible and undesirable to write such a system. Therefore, each platform should have +a seperate implemenation of platform specific behavior that closly interop with native systems. + +### MVI + +MVI (Model View Intent) is the core structure for building cross-platform UI we will use. +The reason for this decision is that current MVVM architecture created a lot of boilerplate, +and is very unwieldy when working with compose. We will instead use a single immutable UI state, using +kotlin immutable collections, as that creates thread-safe and performant UI. Additionally, because the viewmodel +is shared between platforms, the Viewmodel must be provided with an implementation specific +database/preference/downloaded by using dependency injection and composition. + +**Use**: +*PersistentList*, *val*, *dependency injection* and *composition* + +**Avoid**: +*List*, *var*, *globals* and *inheritance* + +Please note that this process aims to replace the current UI in a backwards compatible way. Only +the navigation graph should change to point to the new fragments. Then we can roll back if some +critical issue is found with the compose rewrite, as well as compare functionality without a checkout. + +### Resources and assets + +**Images**: For this project the accepted image format is `XML Drawable resources` instead of +`ImageVector` or `.svg`. This is primarily due to make the review, cross-platform, and edit +processing easier. ImageVector is undesirable because file must be reviewed to not contain malicious +code, and does not have a built-in preview. SVG files on the other hand are currently not natively +supported on Android. + +**Translation**: CloudStream already has a huge translation, however to make the porting process easier, +we should limit any copy-paste from the android project until everything is converted to compose. +This is in part because weblate is not set up target the cross-platform compose yet. + +**Styling/Themes**: In compose there is no `styles.xml`/`colors.xml`/`dimens.xml`. Therefore, +styling is done entierly in code, but should use the exact same colors to avoid a styling mismatch +when porting. + +**UI/UX**: The intended experience when using CloudStream should be the same, but the UI should be +refreshed to a more Material 3 style, such as rounded corners and a greater focus on light theme +support. However, no glassmorphic, blurred or gradient UI is accepted. + +### Settings and Mihon + +The settings system has been entirely forked from Tachiyomi, now Mihon project. This is because +Mihon has one of the best FOSS codebases for Android, and has a great declarative preference system +unmatched by any other tested Compose settings system. Unfortunately, this is not provided as a +library and is therefore instead forked by importing the exact files used. + +### Why compose? + +During the lifetime of CloudStream, the app has been redesigned and rewritten several times. + +**C# Downloader → Xamarin Native → Xamarin Cross platform → Android Native XML → ViewBinding → Compose Multiplatform** + +We have experimented with different UI technologies such as **Blazor**, **Electron**, **PWA**, +**WPF**, **UWP**, **Windows Forms**, and **CLI/Console** for CloudStream. +We have also investigated projectes written in **Flutter** and **React Native**. + +However, none of these frameworks provided a good developer experience, that was also performant and +cross-platform. Compose was the only real option, but did not exist until after we started on +CloudStream 3. Android Native XML was simply the least bad option when building an Android app. + +### CloudStream 4? + +The current android app is named CloudStream 3, and this Compose rewrite will be named +CloudStream 4. However, the android app will be upgraded, not replaced with Compose. It aims to be a +seamless and gradual rollout of updates for everyone using the Android app. Therefore, CloudStream 4 +is used to refer to the new compose UI, features, and cross-platform support. + +## AI Usage + +Due to the prevalence and popularity of LLMs, CloudStream 3 has been rewritten entirely 4+ times by +independent actors using Compose to target desktop with different LLM models. These have not been merged, due to the +absolute mess of the AI generated codebase, both in code quality and performance. + +Therefore, I (LagradOst) will not accept or allow any pull requests concering the Compose rewrite +before I put the core structures are in place. The compose rewrite is supposed to be a refactor and +quality improvement for everyone involved, to solve the massive tech debt we already have. XML and +old plans forced bad design decissions, which we can improve greatly upon. However, in no part do I +want AI to lay the groundwork for something this important. + +The idea is to provide a solid staring point so it is easy for first time contributors and LLMs to +understand and work with the code, not ship a product as fast as possible. This is not a project +with only one person involved. A messy AI generated codebase makes reviews and contributions very hard +for everyone involved. diff --git a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.android.kt b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.android.kt new file mode 100644 index 00000000000..04ba9dd3679 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.android.kt @@ -0,0 +1,19 @@ +package com.lagradost.cloudstream4 + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.mihon.common.preference.AndroidPreferenceStore + + +@Composable +actual fun rememberAppSettings() : AppSettings { + val context = LocalContext.current + val value = remember(context) { AppSettings(context) } + return value +} + +fun AppSettings(context: Context) : AppSettings { + return AppSettings(preferences = AndroidPreferenceStore(context)) +} diff --git a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/BackHandler.kt b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/BackHandler.kt new file mode 100644 index 00000000000..8f9d096c4a8 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/BackHandler.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream4.compose + +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.compose.LocalLifecycleOwner + +/** https://dev.to/pawegio/handling-back-presses-in-jetpack-compose-50d5 */ +@Composable +fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) { + // Safely update the current `onBack` lambda when a new one is provided + val currentOnBack by rememberUpdatedState(onBack) + // Remember in Composition a back callback that calls the `onBack` lambda + val backCallback = remember { + object : OnBackPressedCallback(enabled) { + override fun handleOnBackPressed() { + currentOnBack() + } + } + } + // On every successful composition, update the callback with the `enabled` value + SideEffect { + backCallback.isEnabled = enabled + } + val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) { + "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner" + }.onBackPressedDispatcher + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, backDispatcher) { + // Add callback to the backDispatcher + backDispatcher.addCallback(lifecycleOwner, backCallback) + // When the effect leaves the Composition, remove the callback + onDispose { + backCallback.remove() + } + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/ComposeFragment.kt b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/ComposeFragment.kt new file mode 100644 index 00000000000..b9b3e7d7dc0 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/compose/ComposeFragment.kt @@ -0,0 +1,45 @@ +package com.lagradost.cloudstream4.compose + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import com.lagradost.cloudstream4.rememberAppSettings +import com.lagradost.cloudstream4.theme.CloudStreamTheme +import com.lagradost.cloudstream4.theme.perfToColor +import com.lagradost.cloudstream4.theme.perfToMode +import com.mihon.presentation.LocalBackPress +import com.mihon.presentation.settings.collectAsState + +/** Backwards compatible fragment for compose, before we switch entirely to compose navigation */ +fun Screen.createComposeView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, +): View = ComposeView(inflater.context).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + setContent { + val settings = rememberAppSettings() + val mode by settings.ui.theme.collectAsState() + val primaryColor by settings.ui.primaryColor.collectAsState() + + CloudStreamTheme( + mode = perfToMode(mode), + primaryColor = perfToColor(primaryColor), + ) { + val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) { + "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner" + }.onBackPressedDispatcher + + CompositionLocalProvider(LocalBackPress provides backDispatcher::onBackPressed) { + this@createComposeView.Content() + } + } + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/theme/ColorPreference.kt b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/theme/ColorPreference.kt new file mode 100644 index 00000000000..17d715372b7 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/theme/ColorPreference.kt @@ -0,0 +1,72 @@ +package com.lagradost.cloudstream4.theme + +import android.content.Context +import android.os.Build +import androidx.preference.PreferenceManager + +/** TODO: This should be replaced with a better system */ + +fun perfToMode(perf: String?) = + when (perf) { + "System" -> CloudStreamThemeMode.FollowSystem + "Black" -> CloudStreamThemeMode.Dark + "Light" -> CloudStreamThemeMode.Light + "Amoled" -> CloudStreamThemeMode.Amoled + "AmoledLight" -> CloudStreamThemeMode.AmoledLight + "Dracula" -> CloudStreamThemeMode.Dracula + "Lavender" -> CloudStreamThemeMode.Lavender + "SilentBlue" -> CloudStreamThemeMode.SilentBlue + "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + CloudStreamThemeMode.Dynamic + } else { + CloudStreamThemeMode.Dark + } + + else -> CloudStreamThemeMode.Dark + } + +fun perfToColor(perf : String?) = when (perf) { + "Normal" -> CloudStreamPrimaryColor.NORMAL + "Blue" -> CloudStreamPrimaryColor.BLUE + "Purple" -> CloudStreamPrimaryColor.PURPLE + "Green" -> CloudStreamPrimaryColor.GREEN + "GreenApple" -> CloudStreamPrimaryColor.GREEN_APPLE + "Red" -> CloudStreamPrimaryColor.RED + "Banana" -> CloudStreamPrimaryColor.BANANA + "Party" -> CloudStreamPrimaryColor.PARTY + "Pink" -> CloudStreamPrimaryColor.PINK + "CarnationPink" -> CloudStreamPrimaryColor.CARNATION_PINK + "Maroon" -> CloudStreamPrimaryColor.MAROON + "DarkGreen" -> CloudStreamPrimaryColor.DARK_GREEN + "NavyBlue" -> CloudStreamPrimaryColor.NAVY_BLUE + "Grey" -> CloudStreamPrimaryColor.GREY + "White" -> CloudStreamPrimaryColor.WHITE + "Brown" -> CloudStreamPrimaryColor.BROWN + "Orange" -> CloudStreamPrimaryColor.ORANGE + "DandelionYellow" -> CloudStreamPrimaryColor.DANDELION_YELLOW + "CoolBlue" -> CloudStreamPrimaryColor.COOL_BLUE + "Lavender" -> CloudStreamPrimaryColor.LAVENDER + "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + CloudStreamPrimaryColor.DYNAMIC + } else { + CloudStreamPrimaryColor.NORMAL + } + + "Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + CloudStreamPrimaryColor.DYNAMIC_TWO + } else { + CloudStreamPrimaryColor.NORMAL + } + + else -> CloudStreamPrimaryColor.NORMAL +} + +fun Context.loadThemeMode(): CloudStreamThemeMode { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + return perfToMode(prefs.getString("theme_key", "AmoledLight")) +} + +fun Context.loadPrimaryColor(): CloudStreamPrimaryColor { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + return perfToColor(prefs.getString("primary_color_key", "Normal")) +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/theme/DeviceTheme.android.kt b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/theme/DeviceTheme.android.kt new file mode 100644 index 00000000000..73b188c25c7 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/lagradost/cloudstream4/theme/DeviceTheme.android.kt @@ -0,0 +1,73 @@ +package com.lagradost.cloudstream4.theme + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource + +internal actual object DeviceTheme { + @Composable + @ReadOnlyComposable + @RequiresApi(Build.VERSION_CODES.S) + private fun buildMonetScheme(): CloudStreamColorScheme { + return if (true) { // isSystemInDarkTheme() disabled until everything is Compose, as otherwise you get bugs + CloudStreamColorScheme( + background = colorResource(android.R.color.system_neutral1_900), + surfaceVariant = colorResource(android.R.color.system_neutral1_800), + surface = colorResource(android.R.color.system_neutral1_800), + surfaceContainer = colorResource(android.R.color.system_neutral1_800), + onBackground = colorResource(android.R.color.system_neutral1_100), + onSurfaceVariant = colorResource(android.R.color.system_neutral2_400), + icon = colorResource(android.R.color.system_neutral1_100), + primary = colorResource(android.R.color.system_accent1_200), + ongoing = CloudStreamPalette.Ongoing, + isLight = false, + ) + } else { + CloudStreamColorScheme( + background = colorResource(android.R.color.system_neutral1_10), + surfaceVariant = colorResource(android.R.color.system_neutral1_100), + surface = colorResource(android.R.color.system_neutral1_100), + surfaceContainer = colorResource(android.R.color.system_neutral1_100), + onBackground = colorResource(android.R.color.system_neutral1_900), + onSurfaceVariant = colorResource(android.R.color.system_neutral2_600), + icon = colorResource(android.R.color.system_neutral1_900), + primary = colorResource(android.R.color.system_accent1_600), + ongoing = CloudStreamPalette.Ongoing, + isLight = true, + ) + } + } + + @Composable + @ReadOnlyComposable + actual fun resolveDynamicTheme(): CloudStreamColorScheme { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + buildMonetScheme() + } else { + darkScheme() + } + } + + @Composable + @ReadOnlyComposable + actual fun resolveDynamicPrimaryColor(): Color { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + colorResource(android.R.color.system_accent1_200) + } else { + CloudStreamPrimaryColor.NORMAL.color + } + } + + @Composable + @ReadOnlyComposable + actual fun resolveDynamicSecondaryColor(): Color { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + colorResource(android.R.color.system_accent2_200) + } else { + CloudStreamPrimaryColor.NORMAL.color + } + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/mihon/common/preference/AndroidPreference.kt b/shared/src/androidMain/kotlin/com/mihon/common/preference/AndroidPreference.kt new file mode 100644 index 00000000000..b766dd4b3aa --- /dev/null +++ b/shared/src/androidMain/kotlin/com/mihon/common/preference/AndroidPreference.kt @@ -0,0 +1,235 @@ +package com.mihon.common.preference + +import android.content.SharedPreferences +import android.content.SharedPreferences.Editor +import androidx.core.content.edit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +sealed class AndroidPreference( + private val preferences: SharedPreferences, + private val keyFlow: Flow, + private val key: String, + private val defaultValue: T, +) : PreferenceData { + + abstract fun read(preferences: SharedPreferences, key: String, defaultValue: T): T + + abstract fun write(key: String, value: T): Editor.() -> Unit + + override fun key(): String { + return key + } + + override fun get(): T { + return try { + read(preferences, key, defaultValue) + } catch (e: ClassCastException) { + //logcat { "Invalid value for $key; deleting" } + delete() + defaultValue + } + } + + override fun set(value: T) { + preferences.edit(action = write(key, value)) + } + + override fun isSet(): Boolean { + return preferences.contains(key) + } + + override fun delete() { + preferences.edit { + remove(key) + } + } + + override fun defaultValue(): T { + return defaultValue + } + + override fun changes(): Flow { + return keyFlow + .filter { it == key || it == null } + .onStart { emit("ignition") } + .map { get() } + .conflate() + } + + override fun stateIn(scope: CoroutineScope): StateFlow { + return changes().stateIn(scope, SharingStarted.Eagerly, get()) + } + + class StringPrimitive( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: String, + ) : AndroidPreference(preferences, keyFlow, key, defaultValue) { + override fun read( + preferences: SharedPreferences, + key: String, + defaultValue: String, + ): String { + return preferences.getString(key, defaultValue) ?: defaultValue + } + + override fun write(key: String, value: String): Editor.() -> Unit = { + putString(key, value) + } + } + + class LongPrimitive( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: Long, + ) : AndroidPreference(preferences, keyFlow, key, defaultValue) { + override fun read(preferences: SharedPreferences, key: String, defaultValue: Long): Long { + return preferences.getLong(key, defaultValue) + } + + override fun write(key: String, value: Long): Editor.() -> Unit = { + putLong(key, value) + } + } + + class IntPrimitive( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: Int, + ) : AndroidPreference(preferences, keyFlow, key, defaultValue) { + override fun read(preferences: SharedPreferences, key: String, defaultValue: Int): Int { + return preferences.getInt(key, defaultValue) + } + + override fun write(key: String, value: Int): Editor.() -> Unit = { + putInt(key, value) + } + } + + class FloatPrimitive( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: Float, + ) : AndroidPreference(preferences, keyFlow, key, defaultValue) { + override fun read(preferences: SharedPreferences, key: String, defaultValue: Float): Float { + return preferences.getFloat(key, defaultValue) + } + + override fun write(key: String, value: Float): Editor.() -> Unit = { + putFloat(key, value) + } + } + + class BooleanPrimitive( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: Boolean, + ) : AndroidPreference(preferences, keyFlow, key, defaultValue) { + override fun read( + preferences: SharedPreferences, + key: String, + defaultValue: Boolean, + ): Boolean { + return preferences.getBoolean(key, defaultValue) + } + + override fun write(key: String, value: Boolean): Editor.() -> Unit = { + putBoolean(key, value) + } + } + + class StringSetPrimitive( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: Set, + ) : AndroidPreference>(preferences, keyFlow, key, defaultValue) { + override fun read( + preferences: SharedPreferences, + key: String, + defaultValue: Set, + ): Set { + return preferences.getStringSet(key, defaultValue) ?: defaultValue + } + + override fun write(key: String, value: Set): Editor.() -> Unit = { + putStringSet(key, value) + } + } + + class ObjectAsString( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: T, + private val serializer: (T) -> String, + private val deserializer: (String) -> T, + ) : AndroidPreference(preferences, keyFlow, key, defaultValue) { + override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T { + return try { + preferences.getString(key, null)?.let(deserializer) ?: defaultValue + } catch (e: Exception) { + defaultValue + } + } + + override fun write(key: String, value: T): Editor.() -> Unit = { + putString(key, serializer(value)) + } + } + + class ObjectAsInt( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: T, + private val serializer: (T) -> Int, + private val deserializer: (Int) -> T, + ) : AndroidPreference(preferences, keyFlow, key, defaultValue) { + override fun read(preferences: SharedPreferences, key: String, defaultValue: T): T { + return try { + if (preferences.contains(key)) preferences.getInt(key, 0).let(deserializer) else defaultValue + } catch (e: Exception) { + defaultValue + } + } + + override fun write(key: String, value: T): Editor.() -> Unit = { + putInt(key, serializer(value)) + } + } + + class ObjectSetAsStringSet( + preferences: SharedPreferences, + keyFlow: Flow, + key: String, + defaultValue: Set, + private val serializer: (T) -> String, + private val deserializer: (String) -> T?, + ) : AndroidPreference>(preferences, keyFlow, key, defaultValue) { + override fun read(preferences: SharedPreferences, key: String, defaultValue: Set): Set { + return try { + preferences.getStringSet(key, null)?.mapNotNull(deserializer)?.toSet() ?: defaultValue + } catch (_: Exception) { + defaultValue + } + } + + override fun write(key: String, value: Set): Editor.() -> Unit = { + putStringSet(key, value.map(serializer).toSet()) + } + } +} diff --git a/shared/src/androidMain/kotlin/com/mihon/common/preference/AndroidPreferenceStore.kt b/shared/src/androidMain/kotlin/com/mihon/common/preference/AndroidPreferenceStore.kt new file mode 100644 index 00000000000..192bc77edf7 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/mihon/common/preference/AndroidPreferenceStore.kt @@ -0,0 +1,102 @@ +package com.mihon.common.preference + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow + +class AndroidPreferenceStore(private val sharedPreferences: SharedPreferences) : PreferenceStore { + constructor(context: Context) : this(PreferenceManager.getDefaultSharedPreferences(context)) + + private val keyFlow = sharedPreferences.keyFlow + + override fun getString(key: String, defaultValue: String): PreferenceData { + return AndroidPreference.StringPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getLong(key: String, defaultValue: Long): PreferenceData { + return AndroidPreference.LongPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getInt(key: String, defaultValue: Int): PreferenceData { + return AndroidPreference.IntPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getFloat(key: String, defaultValue: Float): PreferenceData { + return AndroidPreference.FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getBoolean(key: String, defaultValue: Boolean): PreferenceData { + return AndroidPreference.BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getStringSet(key: String, defaultValue: Set): PreferenceData> { + return AndroidPreference.StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getObjectFromString( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): PreferenceData { + return AndroidPreference.ObjectAsString( + preferences = sharedPreferences, + keyFlow = keyFlow, + key = key, + defaultValue = defaultValue, + serializer = serializer, + deserializer = deserializer, + ) + } + + override fun getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): PreferenceData { + return AndroidPreference.ObjectAsInt( + preferences = sharedPreferences, + keyFlow = keyFlow, + key = key, + defaultValue = defaultValue, + serializer = serializer, + deserializer = deserializer, + ) + } + + override fun getObjectSetFromStringSet( + key: String, + defaultValue: Set, + serializer: (T) -> String, + deserializer: (String) -> T?, + ): PreferenceData> { + return AndroidPreference.ObjectSetAsStringSet( + preferences = sharedPreferences, + keyFlow = keyFlow, + key = key, + defaultValue = defaultValue, + serializer = serializer, + deserializer = deserializer, + ) + } + + override fun getAll(): Map { + return sharedPreferences.all ?: emptyMap() + } +} + +private val SharedPreferences.keyFlow + get() = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? -> + trySend( + key, + ) + } + registerOnSharedPreferenceChangeListener(listener) + awaitClose { + unregisterOnSharedPreferenceChangeListener(listener) + } + } \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/drawable/arrow_back.xml b/shared/src/commonMain/composeResources/drawable/arrow_back.xml new file mode 100644 index 00000000000..196b4c087f9 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/arrow_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/arrow_downward.xml b/shared/src/commonMain/composeResources/drawable/arrow_downward.xml new file mode 100644 index 00000000000..061a6db1b60 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/arrow_downward.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/arrow_upward.xml b/shared/src/commonMain/composeResources/drawable/arrow_upward.xml new file mode 100644 index 00000000000..fbf89a7967c --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/arrow_upward.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/cancel.xml b/shared/src/commonMain/composeResources/drawable/cancel.xml new file mode 100644 index 00000000000..57098699003 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/cancel.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/check.xml b/shared/src/commonMain/composeResources/drawable/check.xml new file mode 100644 index 00000000000..6976b0df21f --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/check.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/close.xml b/shared/src/commonMain/composeResources/drawable/close.xml new file mode 100644 index 00000000000..14beedf9b52 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/close.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/default_icon.png b/shared/src/commonMain/composeResources/drawable/default_icon.png new file mode 100644 index 00000000000..394459001fd Binary files /dev/null and b/shared/src/commonMain/composeResources/drawable/default_icon.png differ diff --git a/shared/src/commonMain/composeResources/drawable/error.xml b/shared/src/commonMain/composeResources/drawable/error.xml new file mode 100644 index 00000000000..b77c10cfa07 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/error.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/info.xml b/shared/src/commonMain/composeResources/drawable/info.xml new file mode 100644 index 00000000000..32d30b88aa9 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/info.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/preview.xml b/shared/src/commonMain/composeResources/drawable/preview.xml new file mode 100644 index 00000000000..d56fbde6e85 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/preview.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/font/productsans_black.ttf b/shared/src/commonMain/composeResources/font/productsans_black.ttf new file mode 100644 index 00000000000..5864e269083 Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_black.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_blackitalic.ttf b/shared/src/commonMain/composeResources/font/productsans_blackitalic.ttf new file mode 100644 index 00000000000..869037b785d Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_blackitalic.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_bold.ttf b/shared/src/commonMain/composeResources/font/productsans_bold.ttf new file mode 100644 index 00000000000..96619df92e3 Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_bold.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_bolditalic.ttf b/shared/src/commonMain/composeResources/font/productsans_bolditalic.ttf new file mode 100644 index 00000000000..bdd22b046b8 Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_bolditalic.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_italic.ttf b/shared/src/commonMain/composeResources/font/productsans_italic.ttf new file mode 100644 index 00000000000..5b44037be59 Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_italic.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_light.ttf b/shared/src/commonMain/composeResources/font/productsans_light.ttf new file mode 100644 index 00000000000..33de1c39f2d Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_light.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_lightitalic.ttf b/shared/src/commonMain/composeResources/font/productsans_lightitalic.ttf new file mode 100644 index 00000000000..00c76b8ec8b Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_lightitalic.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_medium.ttf b/shared/src/commonMain/composeResources/font/productsans_medium.ttf new file mode 100644 index 00000000000..fd818d6f5b2 Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_medium.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_mediumitalic.ttf b/shared/src/commonMain/composeResources/font/productsans_mediumitalic.ttf new file mode 100644 index 00000000000..ec70cd38411 Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_mediumitalic.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_regular.ttf b/shared/src/commonMain/composeResources/font/productsans_regular.ttf new file mode 100644 index 00000000000..e2c69c3fb94 Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_regular.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_thin.ttf b/shared/src/commonMain/composeResources/font/productsans_thin.ttf new file mode 100644 index 00000000000..5e046b53295 Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_thin.ttf differ diff --git a/shared/src/commonMain/composeResources/font/productsans_thinitalic.ttf b/shared/src/commonMain/composeResources/font/productsans_thinitalic.ttf new file mode 100644 index 00000000000..7b049551887 Binary files /dev/null and b/shared/src/commonMain/composeResources/font/productsans_thinitalic.ttf differ diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 00000000000..3e6f4f768eb --- /dev/null +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,9 @@ + + CloudStream + None + Ok + Cancel + Navigate up + Lorem ipsum + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt new file mode 100644 index 00000000000..e2b1bd0db45 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -0,0 +1,184 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.runtime.Composable +import com.lagradost.cloudstream3.AllLanguagesName +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.SearchQuality +import com.lagradost.cloudstream3.TvType +import com.mihon.common.preference.PreferenceStore +import com.mihon.common.preference.getEnumSet + +@Composable +expect fun rememberAppSettings(): AppSettings + +/** + * App settings for every setting we have, that way we can just inject AppSettings into a viewmodel, + * or similar. + * + * We use an internal constructor to force the user to construct it using the plantform specific + * initalizer + * */ +class AppSettings internal constructor( + preferences: PreferenceStore, + // Todo add database key-value interface here, possibly using the same PreferenceStore interface +) { + val general = GeneralPreferences(preferences) + val player = PlayerPreferences(preferences) + val provider = ProviderPreferences(preferences) + val ui = UIPreferences(preferences) + val security = SecurityPreferences(preferences) +} + +class SecurityPreferences(preferences: PreferenceStore) { + val biometrics = preferences.getBoolean( + "biometric_key", false + ) + val skipAccountSelection = preferences.getBoolean( + "skip_startup_account_select_key", false + ) +} + +class UIPreferences(preferences: PreferenceStore) { + val primaryColor = preferences.getString( + "primary_color_key", "Normal" + ) + val theme = preferences.getString( + "app_theme_key", "AmoledLight" + ) + val layout = preferences.getInt( + "app_layout_key", -1 + ) + val bottomTitle = preferences.getBoolean( + "bottom_title_key", true + ) + val advancedSearch = preferences.getBoolean( + "advanced_search", true + ) + val searchSuggestions = preferences.getBoolean( + "search_suggestions_enabled", true + ) + val kitsuPostersEnabled = preferences.getBoolean( + "show_kitsu_posters_key", true + ) + val trailersEnabled = preferences.getBoolean( + "show_trailers_key", true + ) + val castEnabled = preferences.getBoolean( + "show_cast_in_details_key", true + ) + val fillersEnabled = preferences.getBoolean( + "show_fillers_key", false + ) + val showMetadataOverlay = preferences.getBoolean( + "show_player_metadata_key", true + ) + val overscanDp = preferences.getInt( + "overscan_key", 0 + ) + val posterSize = preferences.getInt( + "poster_size_key", 0 + ) + val posterShowHd = preferences.getBoolean( + "show_hd_key", true + ) + val posterShowDub = preferences.getBoolean( + "show_dub_key", true + ) + val posterShowSub = preferences.getBoolean( + "show_sub_key", true + ) + val posterShowRating = preferences.getBoolean( + "show_rating_key", true + ) + val posterShowTitle = preferences.getBoolean( + "show_title_key", true + ) + val posterShowEpisode = preferences.getBoolean( + "show_episode_text_key", true + ) + val showClock = preferences.getBoolean( + "tv_layout_clock_key", false + ) + + val randomButtonEnabled = preferences.getBoolean( + "random_button_key", false + ) + + val confirmExit = preferences.getInt( + "confirm_exit_key", -1 + ) + + // TODO use this instead of the old key for the UI + /** This had to be refactored to use enumSet because the old system is prone to bugs */ + val filterQuality = preferences.getEnumSet( + "pref_filter_search_quality_key2", emptySet() + ) +} + +class ProviderPreferences(preferences: PreferenceStore) { + companion object { + private val defaultPreferredMedia = + TvType.entries.filter { it != TvType.NSFW }.map { it.ordinal.toString() }.toSet() + private val defaultDub = DubStatus.entries.map { it.name }.toSet() + } + + val preferredMedia = preferences.getStringSet( + "prefer_media_type_key_2", defaultPreferredMedia + ) + + val extentionLanguages = preferences.getStringSet( + "provider_lang_key", setOf(AllLanguagesName) + ) + + val displayDubSub = preferences.getStringSet( + "display_sub_key", defaultDub + ) +} + +class PlayerPreferences(preferences: PreferenceStore) { + val episodeSync = preferences.getBoolean("episode_sync_enabled_key", true) + val defaultPlayer = preferences.getString("player_default_key", "") + val limitPlayerTitle = preferences.getInt("prefer_limit_title_key", 0) + val hidePlayerControlNames = preferences.getBoolean("hide_player_control_names_key", false) + val showName = preferences.getBoolean("show_name", true) + val showResolution = preferences.getBoolean("show_resolution", true) + val showMediaInfo = preferences.getBoolean("show_media_info", false) + val pipEnabled = preferences.getBoolean("pip_enabled_key", true) + val resizeEnabled = preferences.getBoolean("player_resize_enabled_key", true) + val speedEnabled = preferences.getBoolean("playback_speed_enabled_key", false) + val tiktokEnabled = preferences.getBoolean("speedup_key", false) + val autoPlayEnabled = preferences.getBoolean("autoplay_next_key", true) + val skipOpEnabled = preferences.getBoolean("enable_skip_op_from_database", true) + val autoRotateEnabled = preferences.getBoolean("auto_rotate_video_key", true) + val rotateButtonEnabled = preferences.getBoolean("rotate_video_key", false) + val previewBarEnabled = preferences.getBoolean("preview_seekbar_key", true) + val softwareDecoding = preferences.getInt("software_decoding_key2", -1) + val extraBrightnessEnabled = preferences.getBoolean("extra_brightness_key", false) + val swipeHorizontalEnabled = preferences.getBoolean("swipe_enabled_key", true) + val swipeVerticalEnabled = preferences.getBoolean("swipe_vertical_enabled_key", true) + val doubleTapToSeekEnabled = preferences.getBoolean("double_tap_enabled_key", false) + val doubleTapToPauseEnabled = preferences.getBoolean("double_tap_pause_enabled_key", false) + val doubleTapTime = preferences.getInt("double_tap_seek_time_key2", 10) + val bufferDiskMB = preferences.getInt("video_buffer_disk_key", 0) + val bufferRamMB = preferences.getInt("video_buffer_size_key", 0) + val bufferTimeSec = preferences.getInt("video_buffer_length_key", 0) + val tvSeekOnTime = preferences.getInt("android_tv_interface_on_seek_key", 10) + val tvSeekOffTime = preferences.getInt("android_tv_interface_off_seek_key", 10) +} + +class GeneralPreferences(preferences: PreferenceStore) { + val locale = preferences.getString("app_locale", "") + val bananas = preferences.getInt("benene_count", 0) + val parallelDownloads = preferences.getInt("download_parallel_key", 3) + val concurrentConnections = preferences.getInt("download_concurrent_key", 3) + val batterOptimization = preferences.getBoolean("battery_optimisation_key", false) + + /** Please note that it used R.array.dns_pref before, this was a bug and caused this setting to be language sensitive **/ + val dns = preferences.getInt("dns_key", 0) + + val jsdelivrProxy = preferences.getBoolean("jsdelivr_proxy_key", false) + + /** This should honesty be refactored to a single setting */ + val downloadPath = preferences.getString("download_path_key", "") + val downloadPathVisual = preferences.getString("download_path_key_visual", "") +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt new file mode 100644 index 00000000000..d39bffa6650 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Colors.kt @@ -0,0 +1,24 @@ +package com.lagradost.cloudstream4.compose + +import androidx.compose.material3.ButtonColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import com.lagradost.cloudstream4.theme.CloudStreamTheme.colors + +object Colors { + val blackButton + @Composable @ReadOnlyComposable get() = ButtonColors( + containerColor = colors.surfaceVariant, + contentColor = colors.onBackground, + disabledContainerColor = colors.surface.copy(alpha = 0.9f), + disabledContentColor = colors.onBackground.copy(alpha = 0.9f) + ) + + val whiteButton + @Composable @ReadOnlyComposable get() = ButtonColors( + containerColor = colors.onBackground, + contentColor = colors.surfaceVariant, + disabledContainerColor = colors.onBackground.copy(alpha = 0.9f), + disabledContentColor = colors.surface.copy(alpha = 0.9f) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt new file mode 100644 index 00000000000..56750f61ceb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Dialog.kt @@ -0,0 +1,409 @@ +package com.lagradost.cloudstream4.compose + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.check +import com.lagradost.cloudstream4.theme.CloudStreamTheme.colors +import com.mihon.material.padding +import org.jetbrains.compose.resources.painterResource + +@Composable +fun ActionDialog( + title: String, + text: String, + icon: Painter? = null, + + confirmText: String, + dismissText: String, + + dismiss: () -> Unit, + confirm: () -> Unit +) { + AlertDialog( + containerColor = colors.background, + onDismissRequest = dismiss, + title = { + if (icon == null) { + Text(text = title) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(painter = icon, modifier = Modifier.size(36.dp), contentDescription = null) + Spacer(modifier = Modifier.size(MaterialTheme.padding.medium)) + Text(text = title) + } + } + }, + text = { Text(text = text) }, + confirmButton = { + Button( + onClick = confirm, + colors = Colors.whiteButton + ) { Text(text = confirmText) } + }, + dismissButton = { + Button( + onClick = dismiss, colors = Colors.blackButton + ) { Text(text = dismissText) } + } + ) +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SingleSelectDialog( + modifier: Modifier = Modifier, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = colors.background, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + icon: @Composable (() -> Unit)? = null, + title: String? = null, + properties: DialogProperties = DialogProperties(), + /* visual */ + entries: List, + /* Selected keys used in entries */ + selectedIndex: Int, + confirmText: String? = null, + dismissText: String? = null, + dismiss: () -> Unit, + confirm: (Int) -> Unit +) { + SingleSelectDialog( + modifier = modifier, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + tonalElevation = tonalElevation, + icon = icon, + title = title, + properties = properties, + entries = entries.mapIndexed { index, string -> index to string }.associate { it }, + selectedKey = selectedIndex, + confirmText = confirmText, + dismissText = dismissText, + dismiss = dismiss, + confirm = confirm, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SingleSelectDialog( + modifier: Modifier = Modifier, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = colors.background, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + icon: @Composable (() -> Unit)? = null, + title: String? = null, + properties: DialogProperties = DialogProperties(), + /* key - visual */ + entries: Map, + /* Selected keys used in entries */ + selectedKey: T, + confirmText: String? = null, + dismissText: String? = null, + dismiss: () -> Unit, + confirm: (T) -> Unit, + iconProvider: (@Composable (key: T, value: String) -> Unit)? = null +) { + var selected by remember { mutableStateOf(selectedKey) } + val selectedPainter = painterResource(Res.drawable.check) + + AlertDialog( + properties = properties, + tonalElevation = tonalElevation, + modifier = modifier, + icon = icon, + shape = shape, + iconContentColor = iconContentColor, + containerColor = containerColor, + onDismissRequest = dismiss, + title = title?.let { { Text(text = it) } }, + text = { + LazyColumn { + entries.forEach { (key, value) -> + item(key = key) { + val isSelected = selected == key + SingleSelectionItem(isSelected, key, value, iconProvider, selectedPainter) { + if (confirmText == null) { + confirm(key) + } else { + selected = key + } + } + } + } + } + }, + confirmButton = { + if (confirmText != null) { + Button( + onClick = { + confirm(selected) + }, + colors = Colors.whiteButton + ) { Text(text = confirmText) } + } + }, + dismissButton = { + if (dismissText != null) { + Button( + onClick = dismiss, colors = Colors.blackButton + ) { Text(text = dismissText) } + } + } + ) +} + +/* +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SinglePairSelectDialog( + modifier: Modifier = Modifier, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = colors.background, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + icon: @Composable (() -> Unit)? = null, + title: String? = null, + properties: DialogProperties = DialogProperties(), + /* key - visual */ + entries: Map, String>, + /* Selected keys used in entries */ + selectedKey: T, + confirmText: String? = null, + dismissText: String? = null, + dismiss: () -> Unit, + confirm: (T) -> Unit, + iconProvider: (@Composable (key: T, value: String) -> Unit)? = null +) { + val selected = remember { mutableStateOf(selectedKey) } + + AlertDialog( + properties = properties, + tonalElevation = tonalElevation, + modifier = modifier, + icon = icon, + shape = shape, + iconContentColor = iconContentColor, + containerColor = containerColor, + onDismissRequest = dismiss, + title = title?.let { { Text(text = it) } }, + text = { + LazyColumn { + entries.forEach { (keyPair, value) -> + item(key = keyPair) { + val (a, b) = keyPair + val isSelected = selected.value == a || selected.value == b + + val painter = if (a == b) { + R.drawable.ic_baseline_check_24_listview + } else if(selected.value == a) { + R.drawable.ic_baseline_arrow_downward_24 + } else { + R.drawable.ic_baseline_arrow_upward_24 + } + + val nextKey = if (selected.value != a) a else b + SingleSelectionItem( + isSelected = isSelected, + key = nextKey, + text = value, + iconProvider = iconProvider, + selectIcon = painter + ) { + if (confirmText == null) { + confirm(nextKey) + } else { + selected.value = nextKey + } + } + } + } + } + }, + confirmButton = { + if (confirmText != null) { + Button( + onClick = { + confirm(selected.value) + }, + colors = Colors.whiteButton + ) { Text(text = confirmText) } + } + }, + dismissButton = { + if (dismissText != null) { + Button( + onClick = dismiss, colors = Colors.blackButton + ) { Text(text = dismissText) } + } + } + ) +}*/ + +@Composable +fun SingleSelectionItem( + isSelected: Boolean, + key: T, + text: String, + iconProvider: (@Composable (key: T, value: String) -> Unit)? = null, + selectedPainter: Painter, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + onLongClick = { + } + ) + .rounded() + .ripple(interactionSource), + verticalAlignment = Alignment.CenterVertically + ) { + if (isSelected) { + Icon( + painter = selectedPainter, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = colors.onBackground + ) + iconProvider?.invoke(key, text) + Text( + text, + color = colors.onBackground, + modifier = Modifier.padding(15.dp) + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + iconProvider?.invoke(key, text) + Text( + text, + color = colors.onSurfaceVariant, + modifier = Modifier.padding(15.dp) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MultiSelectDialog( + modifier: Modifier = Modifier, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = colors.background, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + icon: @Composable (() -> Unit)? = null, + title: String? = null, + properties: DialogProperties = DialogProperties(), + /* key - visual */ + entries: Map, + /* Selected keys used in entries */ + selectedKeys: Set, + confirmText: String? = null, + dismissText: String? = null, + dismiss: () -> Unit, + confirm: (Set) -> Unit, + iconProvider: (@Composable (key: T, value: String) -> Unit)? = null +) { + val selected = remember { + entries.keys + .filter { selectedKeys.contains(it) } + .toMutableStateList() + } + + val selectedPainter = painterResource(Res.drawable.check) + + AlertDialog( + properties = properties, + tonalElevation = tonalElevation, + modifier = modifier, + icon = icon, + shape = shape, + iconContentColor = iconContentColor, + containerColor = containerColor, + onDismissRequest = { + if (confirmText == null) { + confirm(selected.toSet()) + } else { + dismiss() + } + }, + title = title?.let { { Text(text = it) } }, + text = { + LazyColumn { + entries.forEach { (key, value) -> + item(key = key) { + val isSelected = selected.contains(key) + SingleSelectionItem( + isSelected, + key, + value, + iconProvider, + selectedPainter = selectedPainter + ) { + if (isSelected) { + selected.remove(key) + } else { + selected.add(key) + } + } + } + } + } + }, + confirmButton = { + if (confirmText != null) { + Button( + onClick = { + confirm(selected.toSet()) + }, + colors = Colors.whiteButton + ) { Text(text = confirmText) } + } + }, + dismissButton = { + if (dismissText != null) { + Button( + onClick = dismiss, colors = Colors.blackButton + ) { Text(text = dismissText) } + } + } + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Modifiers.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Modifiers.kt new file mode 100644 index 00000000000..273af89db5a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Modifiers.kt @@ -0,0 +1,33 @@ +package com.lagradost.cloudstream4.compose + +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.theme.CloudStreamTheme.colors + +@Composable +fun Modifier.ripple( + interactionSource: MutableInteractionSource, + bounded: Boolean = true, +): Modifier = indication( + interactionSource = interactionSource, + indication = ripple(bounded = bounded, color = colors.onBackground), +) + +// no dimens.xml allowed in compose :/ +fun RoundedShape() = RoundedCornerShape(10.dp) + +@Composable +fun Modifier.rounded(): Modifier = + clip(RoundedShape()) + + +@Composable +fun Modifier.circle(): Modifier = + clip(CircleShape) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Screen.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Screen.kt new file mode 100644 index 00000000000..a99b623e828 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Screen.kt @@ -0,0 +1,8 @@ +package com.lagradost.cloudstream4.compose +import androidx.compose.runtime.Composable + +// https://github.com/adrielcafe/voyager +interface Screen { + @Composable + fun Content() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt new file mode 100644 index 00000000000..8255b426023 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Viewmodel.kt @@ -0,0 +1,145 @@ +package com.lagradost.cloudstream4.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +/** + * Use with the DefaultStateContainer + * + * `X : ViewModel(), StateContainer by DefaultStateContainer(XState)` + */ +interface StateContainer { + val state: StateFlow + fun updateState(reducer: State.() -> State) +} + +/** + * Use with the DefaultEffectContainer + * + * `X : ViewModel(), EffectContainer by DefaultEffectContainer()` + */ +interface EffectContainer { + val effect: Flow + suspend fun postEffect(builder: () -> Effect) +} + +/** + * Use an interface on the viewmodel directly + * + * `X : ViewModel(), ActionHandler` + */ +interface ActionHandler { + fun onAction(action: Action) +} + +/** + * The default implementation of a state container to allow by delegate. + * + * All state should be updated with updateState + copy to allow atomic operations + * */ +class DefaultStateContainer(initialState: State) : StateContainer { + private val _state = MutableStateFlow(initialState) + override val state: StateFlow = _state.asStateFlow() + + /** Do not do expensive operations in updateState, as this can get rerun twice or more if we have concurrent writers */ + override fun updateState(reducer: State.() -> State) { + _state.update(reducer) + } +} + +/** + * The default implementation of an effect container to allow by delegate + * */ +class DefaultEffectContainer : EffectContainer { + private val _effect = Channel() // To ensure events are sent + override val effect: Flow = _effect.receiveAsFlow() + + override suspend fun postEffect(builder: () -> Effect) { + _effect.send(builder()) + } +} + +/** + * Observe the viewmodel EffectContainer in the UI on the Main thread for e.g. toasts + * */ +@Composable +fun ObserveEffect(flow: Flow, onEvent: (T) -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner.lifecycle) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { // To ensure events are sent + flow.collect(onEvent) + } + } + } +} + +/** Single active job, replacing the old job by cancellation if launched again */ +data class SingleActiveQuery( + val dispatcher: CoroutineDispatcher, + private var job: Job? = null, + private val mutex: Mutex = Mutex(), +) { + suspend fun launch(block: suspend CoroutineScope.() -> Unit) { + val currentScope = CoroutineScope(currentCoroutineContext()) + val obj: suspend CoroutineScope.() -> Unit = { + try { + withContext(dispatcher) { + block() + } + } catch (_: Throwable) { + //logError(t) + } + } + mutex.withLock { + job?.cancel() + job?.join() + job = currentScope.launch(block = obj) + } + } +} + +/** Debounce Query to handle e.g. user search without spamming the endpoint */ +data class DebounceQuery( + private val pipe: MutableSharedFlow = MutableSharedFlow( + extraBufferCapacity = 64, + replay = 64 + ) +) { + @OptIn(FlowPreview::class) + suspend fun launch(collector: FlowCollector) { + pipe.debounce(200L.milliseconds).distinctUntilChanged() + .flowOn(Dispatchers.Default) + .collect(collector) + } + + suspend fun emit(query: String) { + this.pipe.emit(query) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/DeviceTheme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/DeviceTheme.kt new file mode 100644 index 00000000000..72da2f7890e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/DeviceTheme.kt @@ -0,0 +1,17 @@ +package com.lagradost.cloudstream4.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + +internal expect object DeviceTheme { + @Composable + @ReadOnlyComposable + fun resolveDynamicTheme(): CloudStreamColorScheme + @Composable + @ReadOnlyComposable + fun resolveDynamicPrimaryColor(): Color + @Composable + @ReadOnlyComposable + fun resolveDynamicSecondaryColor(): Color +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Font.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Font.kt new file mode 100644 index 00000000000..b6b665511c0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Font.kt @@ -0,0 +1,69 @@ +package com.lagradost.cloudstream4.theme + +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.productsans_black +import com.lagradost.cloudstream4.generated.resources.productsans_blackitalic +import com.lagradost.cloudstream4.generated.resources.productsans_bold +import com.lagradost.cloudstream4.generated.resources.productsans_bolditalic +import com.lagradost.cloudstream4.generated.resources.productsans_italic +import com.lagradost.cloudstream4.generated.resources.productsans_light +import com.lagradost.cloudstream4.generated.resources.productsans_lightitalic +import com.lagradost.cloudstream4.generated.resources.productsans_medium +import com.lagradost.cloudstream4.generated.resources.productsans_mediumitalic +import com.lagradost.cloudstream4.generated.resources.productsans_regular +import com.lagradost.cloudstream4.generated.resources.productsans_thin +import com.lagradost.cloudstream4.generated.resources.productsans_thinitalic +import org.jetbrains.compose.resources.Font + +object AppFont { + val googleSans @Composable get() = FontFamily( + Font(Res.font.productsans_thin, weight = FontWeight.W100, style = FontStyle.Normal), + Font(Res.font.productsans_thinitalic, weight = FontWeight.W100, style = FontStyle.Italic), + + Font(Res.font.productsans_light, weight = FontWeight.W300, style = FontStyle.Normal), + Font(Res.font.productsans_lightitalic, weight = FontWeight.W300, style = FontStyle.Italic), + + Font(Res.font.productsans_regular, weight = FontWeight.W400, style = FontStyle.Normal), + Font(Res.font.productsans_italic, weight = FontWeight.W400, style = FontStyle.Italic), + + Font(Res.font.productsans_medium, weight = FontWeight.W500, style = FontStyle.Normal), + Font(Res.font.productsans_mediumitalic, weight = FontWeight.W500, style = FontStyle.Italic), + + Font(Res.font.productsans_bold, weight = FontWeight.W700, style = FontStyle.Normal), + Font(Res.font.productsans_bolditalic, weight = FontWeight.W700, style = FontStyle.Italic), + + Font(Res.font.productsans_black, weight = FontWeight.W900, style = FontStyle.Normal), + Font(Res.font.productsans_blackitalic, weight = FontWeight.W900, style = FontStyle.Italic)) + + private val defaultTypography = androidx.compose.material3.Typography() + val typography @Composable get() = + googleSans.let { fontFamily -> + Typography( + displayLarge = defaultTypography.displayLarge.copy(fontFamily = fontFamily), + displayMedium = defaultTypography.displayMedium.copy(fontFamily = fontFamily), + displaySmall = defaultTypography.displaySmall.copy(fontFamily = fontFamily), + + headlineLarge = defaultTypography.headlineLarge.copy(fontFamily = fontFamily), + headlineMedium = defaultTypography.headlineMedium.copy(fontFamily = fontFamily), + headlineSmall = defaultTypography.headlineSmall.copy(fontFamily = fontFamily), + + titleLarge = defaultTypography.titleLarge.copy(fontFamily = fontFamily), + titleMedium = defaultTypography.titleMedium.copy(fontFamily = fontFamily), + titleSmall = defaultTypography.titleSmall.copy(fontFamily = fontFamily), + + bodyLarge = defaultTypography.bodyLarge.copy(fontFamily = fontFamily), + bodyMedium = defaultTypography.bodyMedium.copy(fontFamily = fontFamily), + bodySmall = defaultTypography.bodySmall.copy(fontFamily = fontFamily), + + labelLarge = defaultTypography.labelLarge.copy(fontFamily = fontFamily), + labelMedium = defaultTypography.labelMedium.copy(fontFamily = fontFamily), + labelSmall = defaultTypography.labelSmall.copy(fontFamily = fontFamily) + ) + } +} + diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt new file mode 100644 index 00000000000..c6332808f02 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Palette.kt @@ -0,0 +1,84 @@ +package com.lagradost.cloudstream4.theme + +import androidx.compose.ui.graphics.Color + +enum class CloudStreamPrimaryColor(val color: Color) { + NORMAL(Color(0xFF3D50FA)), + BLUE(Color(0xFF5664B7)), + PURPLE(Color(0xFF6200EA)), + GREEN(Color(0xFF00BFA5)), + GREEN_APPLE(Color(0xFF48E484)), + RED(Color(0xFFD50000)), + BANANA(Color(0xFFE4D448)), + PARTY(Color(0xFFEA596E)), + PINK(Color(0xFFFF1493)), + CARNATION_PINK(Color(0xFFBD5DA5)), + MAROON(Color(0xFF451010)), + DARK_GREEN(Color(0xFF004500)), + NAVY_BLUE(Color(0xFF000080)), + GREY(Color(0xFF515151)), + WHITE(Color(0xFFFFFFFF)), + BROWN(Color(0xFF622C00)), + ORANGE(Color(0xFFCE8500)), + DANDELION_YELLOW(Color(0xFFF5BB00)), + COOL_BLUE(Color(0xFF408CAC)), + LAVENDER(Color(0xFF6F55AF)), + DYNAMIC(Color(0xFF3D50FA)), + DYNAMIC_TWO(Color(0xFF3D50FA)), +} + + +internal object CloudStreamPalette { + // Default dark (AppTheme / Black) + val Primary = Color(0xFF3D50FA) + val PrimaryDark = Color(0xFF3700B3) + val Ongoing = Color(0xFFF53B66) + + val DarkPrimaryGrayBg = Color(0xFF2B2C30) + val DarkBlackBg = Color(0xFF111111) + val DarkIconGrayBg = Color(0xFF1C1C20) + val DarkBoxItemBg = Color(0xFF161616) + val DarkText = Color(0xFFE9EAEE) + val DarkGrayText = Color(0xFF9BA0A4) + val DarkIcon = Color(0xFF9BA0A6) + + // Amoled + val AmoledBlack = Color(0xFF000000) + val AmoledNearBlack = Color(0xFF121212) + + // Light + val LightPrimaryGrayBg = Color(0xFFF1F1F1) + val LightBlackBg = Color(0xFFFFFFFF) + val LightIconGrayBg = Color(0xFFEEEEEE) + val LightBoxItemBg = Color(0xFFEEEEEE) + val LightText = Color(0xFF202125) + val LightGrayText = Color(0xFF5F6267) + val LightIcon = Color(0xFF5F6267) + + // Dracula + val DraculaPrimaryGrayBg = Color(0xFF414450) + val DraculaBlackBg = Color(0xFF282A36) + val DraculaIconGrayBg = Color(0xFF44475A) + val DraculaBoxItemBg = Color(0xFF373844) + val DraculaText = Color(0xFFF8F8F2) + val DraculaGrayText = Color(0xFF6272A4) + val DraculaIcon = Color(0xFF6272A4) + + // Lavender Dreams + val LavenderPrimaryGrayBg = Color(0xFFF7EEFC) + val LavenderBlackBg = Color(0xFFFDF0FB) + val LavenderIconGrayBg = Color(0xFFB794F6) + val LavenderBoxItemBg = Color(0xFFF8F5FF) + val LavenderText = Color(0xFF2D1B47) + val LavenderGrayText = Color(0xFF9AB3FF) + val LavenderIcon = Color(0xFF7C3AED) + + // Silent Blue + val SilentBluePrimaryGrayBg = Color(0xFF282F49) + val SilentBlueBlackBg = Color(0xFF151A30) + val SilentBlueIconGrayBg = Color(0xFF3A446A) + val SilentBlueBoxItemBg = Color(0xFF3A446A) + val SilentBlueText = Color(0xFFE0E1F3) + val SilentBlueGrayText = Color(0xFF7B83B0) + val SilentBlueIcon = Color(0xFF7B83B0) +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt new file mode 100644 index 00000000000..d51c75d0822 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Scheme.kt @@ -0,0 +1,162 @@ +package com.lagradost.cloudstream4.theme + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color + +/** + * Maps to the XML custom attrs declared in attrs.xml: + * TODO: Remove this comment when we migrate fully + * and attrs.xml will no longer be used at all. + * + * | XML ?attr | Here | + * |--------------------------|--------------------| + * | primaryBlackBackground | [background] | + * | primaryGrayBackground | [surfaceVariant] | + * | iconGrayBackground | [surface] | + * | boxItemBackground | [surfaceContainer] | + * | textColor | [onBackground] | + * | grayTextColor | [onSurfaceVariant] | + * | iconColor | [icon] | + * | colorPrimary | [primary] | + * | colorOngoing | [ongoing] | + * + * All fields are [MutableState] so Compose recomposes automatically + * if the scheme is swapped at runtime (e.g. user changes theme without restart). + */ +@Stable +class CloudStreamColorScheme( + background: Color, + surfaceVariant: Color, + surface: Color, + surfaceContainer: Color, + onBackground: Color, + onSurfaceVariant: Color, + icon: Color, + primary: Color, + ongoing: Color, + isLight: Boolean, +) { + /** primaryBlackBackground */ + var background by mutableStateOf(background) + + /** primaryGrayBackground */ + var surfaceVariant by mutableStateOf(surfaceVariant) + + /** iconGrayBackground */ + var surface by mutableStateOf(surface) + + /** boxItemBackground */ + var surfaceContainer by mutableStateOf(surfaceContainer) + + /** textColor */ + var onBackground by mutableStateOf(onBackground) + + /** grayTextColor */ + var onSurfaceVariant by mutableStateOf(onSurfaceVariant) + + /** iconColor */ + var icon by mutableStateOf(icon) + + /** colorPrimary */ + var primary by mutableStateOf(primary) + + /** colorOngoing */ + var ongoing by mutableStateOf(ongoing) + var isLight by mutableStateOf(isLight) + + fun copy( + background: Color = this.background, + surfaceVariant: Color = this.surfaceVariant, + surface: Color = this.surface, + surfaceContainer: Color = this.surfaceContainer, + onBackground: Color = this.onBackground, + onSurfaceVariant: Color = this.onSurfaceVariant, + icon: Color = this.icon, + primary: Color = this.primary, + ongoing: Color = this.ongoing, + isLight: Boolean = this.isLight, + ) = CloudStreamColorScheme( + background, surfaceVariant, surface, surfaceContainer, + onBackground, onSurfaceVariant, icon, primary, ongoing, isLight, + ) +} + +internal fun darkScheme() = CloudStreamColorScheme( + background = CloudStreamPalette.DarkBlackBg, + surfaceVariant = CloudStreamPalette.DarkPrimaryGrayBg, + surface = CloudStreamPalette.DarkIconGrayBg, + surfaceContainer = CloudStreamPalette.DarkBoxItemBg, + onBackground = CloudStreamPalette.DarkText, + onSurfaceVariant = CloudStreamPalette.DarkGrayText, + icon = CloudStreamPalette.DarkIcon, + primary = CloudStreamPalette.Primary, + ongoing = CloudStreamPalette.Ongoing, + isLight = false, +) + + +internal fun amoledScheme() = darkScheme().copy( + background = CloudStreamPalette.AmoledBlack, + surface = CloudStreamPalette.AmoledBlack, + surfaceVariant = CloudStreamPalette.AmoledBlack, + surfaceContainer = CloudStreamPalette.AmoledBlack, +) + +internal fun amoledLightScheme() = amoledScheme().copy( + surfaceVariant = CloudStreamPalette.AmoledNearBlack, +) + +internal fun lightScheme() = CloudStreamColorScheme( + background = CloudStreamPalette.LightBlackBg, + surfaceVariant = CloudStreamPalette.LightPrimaryGrayBg, + surface = CloudStreamPalette.LightIconGrayBg, + surfaceContainer = CloudStreamPalette.LightBoxItemBg, + onBackground = CloudStreamPalette.LightText, + onSurfaceVariant = CloudStreamPalette.LightGrayText, + icon = CloudStreamPalette.LightIcon, + primary = CloudStreamPalette.Primary, + ongoing = CloudStreamPalette.Ongoing, + isLight = true, +) + +internal fun draculaScheme() = CloudStreamColorScheme( + background = CloudStreamPalette.DraculaBlackBg, + surfaceVariant = CloudStreamPalette.DraculaPrimaryGrayBg, + surface = CloudStreamPalette.DraculaIconGrayBg, + surfaceContainer = CloudStreamPalette.DraculaBoxItemBg, + onBackground = CloudStreamPalette.DraculaText, + onSurfaceVariant = CloudStreamPalette.DraculaGrayText, + icon = CloudStreamPalette.DraculaIcon, + primary = CloudStreamPalette.Primary, + ongoing = CloudStreamPalette.Ongoing, + isLight = false, +) + +internal fun lavenderScheme() = CloudStreamColorScheme( + background = CloudStreamPalette.LavenderBlackBg, + surfaceVariant = CloudStreamPalette.LavenderPrimaryGrayBg, + surface = CloudStreamPalette.LavenderIconGrayBg, + surfaceContainer = CloudStreamPalette.LavenderBoxItemBg, + onBackground = CloudStreamPalette.LavenderText, + onSurfaceVariant = CloudStreamPalette.LavenderGrayText, + icon = CloudStreamPalette.LavenderIcon, + primary = CloudStreamPalette.Primary, + ongoing = CloudStreamPalette.Ongoing, + isLight = true, +) + +internal fun silentBlueScheme() = CloudStreamColorScheme( + background = CloudStreamPalette.SilentBlueBlackBg, + surfaceVariant = CloudStreamPalette.SilentBluePrimaryGrayBg, + surface = CloudStreamPalette.SilentBlueIconGrayBg, + surfaceContainer = CloudStreamPalette.SilentBlueBoxItemBg, + onBackground = CloudStreamPalette.SilentBlueText, + onSurfaceVariant = CloudStreamPalette.SilentBlueGrayText, + icon = CloudStreamPalette.SilentBlueIcon, + primary = CloudStreamPalette.Primary, + ongoing = CloudStreamPalette.Ongoing, + isLight = false, +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt new file mode 100644 index 00000000000..ab1ea1e95fd --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/theme/Theme.kt @@ -0,0 +1,132 @@ +package com.lagradost.cloudstream4.theme + +import androidx.compose.animation.core.InfiniteTransition +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +enum class CloudStreamThemeMode { + /** "Black" standard dark, #111111 backgrounds */ + Dark, + + /** "Amoled" / "AmoledLight" pure black (#000000) */ + Amoled, + + /** "AmoledLight" pure black (#000000) */ + AmoledLight, + + /** "Light" white/gray backgrounds, dark text */ + Light, + + /** "Dracula" */ + Dracula, + + /** "Lavender" */ + Lavender, + + /** "SilentBlue" */ + SilentBlue, + + /** "System" resolved on each platform via [isSystemInDarkTheme] */ + FollowSystem, + + /** + * Uses platform dynamic color system, Material You on Android 12+, + * falls back to [Dark] on unsupported platforms. + */ + Dynamic, +} + +@Composable +fun modeToTheme(mode : CloudStreamThemeMode, primaryColor: CloudStreamPrimaryColor) : CloudStreamColorScheme { + val dynamicTheme = DeviceTheme.resolveDynamicTheme() + val dynamicPrimary = DeviceTheme.resolveDynamicPrimaryColor() + val dynamicSecondary = DeviceTheme.resolveDynamicSecondaryColor() + val systemDark = isSystemInDarkTheme() + val color = remember(mode, primaryColor, systemDark, dynamicTheme, dynamicPrimary, dynamicSecondary) { + val base = when (mode) { + CloudStreamThemeMode.Dark -> darkScheme() + CloudStreamThemeMode.Amoled -> amoledScheme() + CloudStreamThemeMode.AmoledLight -> amoledLightScheme() + CloudStreamThemeMode.Light -> lightScheme() + CloudStreamThemeMode.Dracula -> draculaScheme() + CloudStreamThemeMode.Lavender -> lavenderScheme() + CloudStreamThemeMode.SilentBlue -> silentBlueScheme() + CloudStreamThemeMode.FollowSystem -> if (systemDark) darkScheme() else lightScheme() + CloudStreamThemeMode.Dynamic -> dynamicTheme + } + when { + mode == CloudStreamThemeMode.Dynamic -> base + primaryColor == CloudStreamPrimaryColor.DYNAMIC -> base.copy(primary = dynamicPrimary) + primaryColor == CloudStreamPrimaryColor.DYNAMIC_TWO -> base.copy(primary = dynamicSecondary) + else -> base.copy(primary = primaryColor.color) + } + } + return color +} + +private fun CloudStreamColorScheme.toMaterial3ColorScheme() = if (isLight) { + lightColorScheme( + primary = primary, + background = background, + surface = surface, + surfaceVariant = surfaceVariant, + surfaceContainer = surfaceContainer, + onBackground = onBackground, + onSurface = onBackground, + onSurfaceVariant = onSurfaceVariant, + onPrimary = Color.White, + ) +} else { + darkColorScheme( + primary = primary, + background = background, + surface = surface, + surfaceVariant = surfaceVariant, + surfaceContainer = surfaceContainer, + onBackground = onBackground, + onSurface = onBackground, + onSurfaceVariant = onSurfaceVariant, + onPrimary = Color.White, + ) +} + +val LocalSharedInfiniteTransition = staticCompositionLocalOf { throw NotImplementedError() } +val LocalCloudStreamColors = staticCompositionLocalOf { darkScheme() } + +object CloudStreamTheme { + val colors: CloudStreamColorScheme @Composable @ReadOnlyComposable get() = LocalCloudStreamColors.current + /** Global synchronized animation, so many items in e.g. a LazyList can animate at the same time, + * even if they appeared at different times */ + val transition: InfiniteTransition @Composable @ReadOnlyComposable get() = LocalSharedInfiniteTransition.current +} + +@Composable +fun CloudStreamPreviewTheme(content: @Composable () -> Unit) { + CloudStreamTheme(content = content) +} + +@Composable +fun CloudStreamTheme( + mode: CloudStreamThemeMode = CloudStreamThemeMode.FollowSystem, + primaryColor: CloudStreamPrimaryColor = CloudStreamPrimaryColor.NORMAL, + content: @Composable () -> Unit, +) { + val csColors = modeToTheme(mode, primaryColor) + val globalTransition = rememberInfiniteTransition(label = "GlobalSharedTransition") + CompositionLocalProvider(LocalCloudStreamColors provides csColors, LocalSharedInfiniteTransition provides globalTransition) { + MaterialTheme( + colorScheme = csColors.toMaterial3ColorScheme(), + content = content, + typography = AppFont.typography + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/mihon/LICENSE b/shared/src/commonMain/kotlin/com/mihon/LICENSE new file mode 100644 index 00000000000..d9a10c0d8e8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/shared/src/commonMain/kotlin/com/mihon/common/preference/InMemoryPreferenceStore.kt b/shared/src/commonMain/kotlin/com/mihon/common/preference/InMemoryPreferenceStore.kt new file mode 100644 index 00000000000..5c604e5401b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/common/preference/InMemoryPreferenceStore.kt @@ -0,0 +1,125 @@ +package com.mihon.common.preference + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn + +/** + * Local-copy implementation of PreferenceStore mostly for test and preview purposes + */ +class InMemoryPreferenceStore( + initialPreferences: Sequence> = sequenceOf(), +) : PreferenceStore { + + private val preferences: Map> = + initialPreferences.toList().associateBy { it.key() } + + override fun getString(key: String, defaultValue: String): PreferenceData { + val default = InMemoryPreference(key, null, defaultValue) + val data: String? = preferences[key]?.get() as? String + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getLong(key: String, defaultValue: Long): PreferenceData { + val default = InMemoryPreference(key, null, defaultValue) + val data: Long? = preferences[key]?.get() as? Long + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getInt(key: String, defaultValue: Int): PreferenceData { + val default = InMemoryPreference(key, null, defaultValue) + val data: Int? = preferences[key]?.get() as? Int + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getFloat(key: String, defaultValue: Float): PreferenceData { + val default = InMemoryPreference(key, null, defaultValue) + val data: Float? = preferences[key]?.get() as? Float + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getBoolean(key: String, defaultValue: Boolean): PreferenceData { + val default = InMemoryPreference(key, null, defaultValue) + val data: Boolean? = preferences[key]?.get() as? Boolean + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getStringSet(key: String, defaultValue: Set): PreferenceData> { + val default = InMemoryPreference(key, null, defaultValue) + + @Suppress("UNCHECKED_CAST") + val data: Set? = preferences[key]?.get() as? Set + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + @Suppress("UNCHECKED_CAST") + override fun getObjectFromString( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): PreferenceData { + val default = InMemoryPreference(key, null, defaultValue) + val data: T? = preferences[key]?.get() as? T + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + @Suppress("UNCHECKED_CAST") + override fun getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): PreferenceData { + val default = InMemoryPreference(key, null, defaultValue) + val data: T? = preferences[key]?.get() as? T + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + @Suppress("UNCHECKED_CAST") + override fun getObjectSetFromStringSet( + key: String, + defaultValue: Set, + serializer: (T) -> String, + deserializer: (String) -> T?, + ): PreferenceData> { + val default = InMemoryPreference(key, null, defaultValue) + val data: Set? = preferences[key]?.get() as? Set + return if (data == null) default else InMemoryPreference(key, data, defaultValue) + } + + override fun getAll(): Map { + return preferences + } + + class InMemoryPreference( + private val key: String, + private var data: T?, + private val defaultValue: T, + ) : PreferenceData { + override fun key(): String = key + + override fun get(): T = data ?: defaultValue() + + override fun isSet(): Boolean = data != null + + override fun delete() { + data = null + } + + override fun defaultValue(): T = defaultValue + + override fun changes(): Flow = flow { data } + + override fun stateIn(scope: CoroutineScope): StateFlow { + return changes().stateIn(scope, SharingStarted.Eagerly, get()) + } + + override fun set(value: T) { + data = value + } + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/common/preference/PreferenceData.kt b/shared/src/commonMain/kotlin/com/mihon/common/preference/PreferenceData.kt new file mode 100644 index 00000000000..16aeb41bc36 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/common/preference/PreferenceData.kt @@ -0,0 +1,71 @@ +package com.mihon.common.preference + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface PreferenceData { + + fun key(): String + + fun get(): T + + fun set(value: T) + + fun isSet(): Boolean + + fun delete() + + fun defaultValue(): T + + fun changes(): Flow + + fun stateIn(scope: CoroutineScope): StateFlow + + companion object { + /** + * A preference that should not be exposed in places like backups without user consent. + */ + fun isPrivate(key: String): Boolean { + return key.startsWith(PRIVATE_PREFIX) + } + fun privateKey(key: String): String { + return "$PRIVATE_PREFIX$key" + } + + /** + * A preference used for internal app state that isn't really a user preference + * and therefore should not be in places like backups. + */ + fun isAppState(key: String): Boolean { + return key.startsWith(APP_STATE_PREFIX) + } + fun appStateKey(key: String): String { + return "$APP_STATE_PREFIX$key" + } + + private const val APP_STATE_PREFIX = "__APP_STATE_" + private const val PRIVATE_PREFIX = "__PRIVATE_" + } +} + +inline fun PreferenceData.getAndSet(crossinline block: (T) -> R) = set( + block(get()), +) + +operator fun PreferenceData>.plusAssign(item: T) { + set(get() + item) +} + +operator fun PreferenceData>.plusAssign(items: Iterable) { + set(get() + items) +} + +operator fun PreferenceData>.minusAssign(item: T) { + set(get() - item) +} + +fun PreferenceData.toggle(): Boolean { + set(!get()) + return get() +} diff --git a/shared/src/commonMain/kotlin/com/mihon/common/preference/PreferenceStore.kt b/shared/src/commonMain/kotlin/com/mihon/common/preference/PreferenceStore.kt new file mode 100644 index 00000000000..6075fb45cbc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/common/preference/PreferenceStore.kt @@ -0,0 +1,87 @@ +package com.mihon.common.preference + +interface PreferenceStore { + + fun getString(key: String, defaultValue: String = ""): PreferenceData + + fun getLong(key: String, defaultValue: Long = 0): PreferenceData + + fun getInt(key: String, defaultValue: Int = 0): PreferenceData + + fun getFloat(key: String, defaultValue: Float = 0f): PreferenceData + + fun getBoolean(key: String, defaultValue: Boolean = false): PreferenceData + + fun getStringSet(key: String, defaultValue: Set = emptySet()): PreferenceData> + + fun getObjectFromString( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): PreferenceData + + fun getObjectFromInt( + key: String, + defaultValue: T, + serializer: (T) -> Int, + deserializer: (Int) -> T, + ): PreferenceData + + fun getObjectSetFromStringSet( + key: String, + defaultValue: Set, + serializer: (T) -> String, + deserializer: (String) -> T?, + ): PreferenceData> + + fun getAll(): Map +} + +fun PreferenceStore.getLongArray( + key: String, + defaultValue: List, +): PreferenceData> { + return getObjectFromString( + key = key, + defaultValue = defaultValue, + serializer = { it.joinToString(",") }, + deserializer = { it.split(",").mapNotNull { l -> l.toLongOrNull() } }, + ) +} + +inline fun > PreferenceStore.getEnum( + key: String, + defaultValue: T, +): PreferenceData { + return getObjectFromString( + key = key, + defaultValue = defaultValue, + serializer = { it.name }, + deserializer = { + try { + enumValueOf(it) + } catch (e: IllegalArgumentException) { + defaultValue + } + }, + ) +} + +inline fun > PreferenceStore.getEnumSet( + key: String, + defaultValue: Set, +): PreferenceData> { + return getObjectSetFromStringSet( + key = key, + defaultValue = defaultValue, + serializer = { it.name }, + deserializer = { + try { + enumValueOf(it) + } catch (_: IllegalArgumentException) { + null + } + }, + ) +} diff --git a/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt new file mode 100644 index 00000000000..52fe5af751b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/material/AppBar.kt @@ -0,0 +1,403 @@ +package com.mihon.material + +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.action_bar_up_description +import com.lagradost.cloudstream4.generated.resources.arrow_back +import com.lagradost.cloudstream4.generated.resources.cancel +import com.lagradost.cloudstream4.generated.resources.close +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AppBar( + title: String?, + + modifier: Modifier = Modifier, + backgroundColor: Color? = null, + // Text + subtitle: String? = null, + // Up button + navigateUp: (() -> Unit)? = null, + navigationIcon: Painter? = null, + // Menu + actions: @Composable RowScope.() -> Unit = {}, + // Action mode + actionModeCounter: Int = 0, + onCancelActionMode: () -> Unit = {}, + actionModeActions: @Composable RowScope.() -> Unit = {}, + + scrollBehavior: TopAppBarScrollBehavior? = null, +) { + val isActionMode by remember(actionModeCounter) { + derivedStateOf { actionModeCounter > 0 } + } + + AppBar( + modifier = modifier, + backgroundColor = backgroundColor, + titleContent = { + if (isActionMode) { + AppBarTitle(actionModeCounter.toString()) + } else { + AppBarTitle(title, subtitle = subtitle) + } + }, + navigateUp = navigateUp, + navigationIcon = navigationIcon, + actions = { + if (isActionMode) { + actionModeActions() + } else { + actions() + } + }, + isActionMode = isActionMode, + onCancelActionMode = onCancelActionMode, + scrollBehavior = scrollBehavior, + ) +} + +@Composable +fun AppBar( + // Title + titleContent: @Composable () -> Unit, + + modifier: Modifier = Modifier, + backgroundColor: Color? = null, + // Up button + navigateUp: (() -> Unit)? = null, + navigationIcon: Painter? = null, + // Menu + actions: @Composable RowScope.() -> Unit = {}, + // Action mode + isActionMode: Boolean = false, + onCancelActionMode: () -> Unit = {}, + + scrollBehavior: TopAppBarScrollBehavior? = null, +) { + Column( + modifier = modifier, + ) { + TopAppBar( + navigationIcon = { + if (isActionMode) { + IconButton(onClick = onCancelActionMode) { + Icon( + painter = painterResource(Res.drawable.close), + contentDescription = stringResource(Res.string.cancel), + ) + } + } else { + navigateUp?.let { + IconButton(onClick = it) { + UpIcon(navigationIcon = navigationIcon) + } + } + } + }, + title = titleContent, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = backgroundColor ?: MaterialTheme.colorScheme.surfaceColorAtElevation( + elevation = if (isActionMode) 3.dp else 0.dp, + ), + ), + scrollBehavior = scrollBehavior, + ) + } +} + +@Composable +fun AppBarTitle( + title: String?, + modifier: Modifier = Modifier, + subtitle: String? = null, +) { + Column(modifier = modifier) { + title?.let { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee( + repeatDelayMillis = 2_000, + ), + ) + } + } +} + +/* +@Composable +fun AppBarActions( + actions: List, +) { + var showMenu by remember { mutableStateOf(false) } + + actions.filterIsInstance().map { + TooltipBox( + positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + PlainTooltip { + Text(it.title) + } + }, + state = rememberTooltipState(), + focusable = false, + ) { + IconButton( + onClick = it.onClick, + enabled = it.enabled, + ) { + Icon( + imageVector = it.icon, + tint = it.iconTint ?: LocalContentColor.current, + contentDescription = it.title, + ) + } + } + } + + val overflowActions = actions.filterIsInstance() + if (overflowActions.isNotEmpty()) { + TooltipBox( + positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + PlainTooltip { + Text(stringResource(MR.strings.action_menu_overflow_description)) + } + }, + state = rememberTooltipState(), + focusable = false, + ) { + IconButton( + onClick = { showMenu = !showMenu }, + ) { + Icon( + MaterialSymbols.Rounded.MoreVert, + contentDescription = stringResource(MR.strings.action_menu_overflow_description), + ) + } + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + overflowActions.map { + DropdownMenuItem( + onClick = { + it.onClick() + showMenu = false + }, + text = { Text(it.title, fontWeight = FontWeight.Normal) }, + ) + } + } + } +}*/ + +/* +/** + * @param searchEnabled Set to false if you don't want to show search action. + * @param searchQuery If null, use normal toolbar. + * @param placeholderText If null, [MR.strings.action_search_hint] is used. + */ +@Composable +fun SearchToolbar( + searchQuery: String?, + onChangeSearchQuery: (String?) -> Unit, + modifier: Modifier = Modifier, + titleContent: @Composable () -> Unit = {}, + navigateUp: (() -> Unit)? = null, + searchEnabled: Boolean = true, + placeholderText: String? = null, + onSearch: (String) -> Unit = {}, + onClickCloseSearch: () -> Unit = { onChangeSearchQuery(null) }, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focusRequester = remember { FocusRequester() } + + AppBar( + modifier = modifier, + titleContent = { + if (searchQuery == null) return@AppBar titleContent() + + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + val searchAndClearFocus: () -> Unit = f@{ + if (searchQuery.isBlank()) return@f + onSearch(searchQuery) + focusManager.clearFocus() + keyboardController?.hide() + focusManager.moveFocus(FocusDirection.Next) + } + + BasicTextField( + value = searchQuery, + onValueChange = onChangeSearchQuery, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .runOnEnterKeyPressed(action = searchAndClearFocus) + .showSoftKeyboard(remember { searchQuery.isEmpty() }) + .clearFocusOnSoftKeyboardHide(), + textStyle = MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Normal, + fontSize = 18.sp, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { searchAndClearFocus() }), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground), + visualTransformation = visualTransformation, + interactionSource = interactionSource, + decorationBox = { innerTextField -> + TextFieldDefaults.DecorationBox( + value = searchQuery, + innerTextField = innerTextField, + enabled = true, + singleLine = true, + visualTransformation = visualTransformation, + interactionSource = interactionSource, + placeholder = { + Text( + modifier = Modifier.secondaryItemAlpha(), + text = (placeholderText ?: stringResource(MR.strings.action_search_hint)), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Normal, + ), + ) + }, + container = {}, + ) + }, + ) + }, + navigateUp = if (searchQuery == null) navigateUp else onClickCloseSearch, + actions = { + key("search") { + val onClick = { onChangeSearchQuery("") } + + if (!searchEnabled) { + // Don't show search action + } else if (searchQuery == null) { + TooltipBox( + positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + PlainTooltip { + Text(stringResource(MR.strings.action_search)) + } + }, + state = rememberTooltipState(), + focusable = false, + ) { + IconButton( + onClick = onClick, + ) { + Icon( + MaterialSymbols.Rounded.Search, + contentDescription = stringResource(MR.strings.action_search), + ) + } + } + } else if (searchQuery.isNotEmpty()) { + TooltipBox( + positionProvider = rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { + PlainTooltip { + Text(stringResource(MR.strings.action_reset)) + } + }, + state = rememberTooltipState(), + focusable = false, + ) { + IconButton( + onClick = { + onClick() + focusRequester.requestFocus() + }, + ) { + Icon( + MaterialSymbols.Rounded.Close, + contentDescription = stringResource(MR.strings.action_reset), + ) + } + } + } + } + + key("actions") { actions() } + }, + isActionMode = false, + scrollBehavior = scrollBehavior, + ) +}*/ + +@Composable +fun UpIcon( + modifier: Modifier = Modifier, + navigationIcon: Painter? = null, +) { + val icon = navigationIcon + ?: painterResource(Res.drawable.arrow_back) + Icon( + painter = icon, + contentDescription = stringResource(Res.string.action_bar_up_description), + modifier = modifier, + ) +} +/* +sealed interface AppBar { + sealed interface AppBarAction + + data class Action( + val title: String, + val icon: ImageVector, + val iconTint: Color? = null, + val onClick: () -> Unit, + val enabled: Boolean = true, + ) : AppBarAction + + data class OverflowAction( + val title: String, + val onClick: () -> Unit, + ) : AppBarAction +} +*/ \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/mihon/material/Constants.kt b/shared/src/commonMain/kotlin/com/mihon/material/Constants.kt new file mode 100644 index 00000000000..2b38b44851f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/material/Constants.kt @@ -0,0 +1,26 @@ +package com.mihon.material + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.unit.dp + +val topSmallPaddingValues = PaddingValues(top = MaterialTheme.padding.small) + +const val DISABLED_ALPHA = .38f +const val SECONDARY_ALPHA = .78f + +class Padding { + + val extraLarge = 32.dp + + val large = 24.dp + + val medium = 16.dp + + val small = 8.dp + + val extraSmall = 4.dp +} + +val MaterialTheme.padding: Padding + get() = Padding() diff --git a/shared/src/commonMain/kotlin/com/mihon/material/LabeledCheckbox.kt b/shared/src/commonMain/kotlin/com/mihon/material/LabeledCheckbox.kt new file mode 100644 index 00000000000..123bb34b727 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/material/LabeledCheckbox.kt @@ -0,0 +1,50 @@ +package com.mihon.material + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp + +@Composable +fun LabeledCheckbox( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + modifier = modifier + .clip(MaterialTheme.shapes.small) + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable( + role = Role.Checkbox, + onClick = { + if (enabled) { + onCheckedChange(!checked) + } + }, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + enabled = enabled, + ) + + Text(text = label) + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/material/Pill.kt b/shared/src/commonMain/kotlin/com/mihon/material/Pill.kt new file mode 100644 index 00000000000..44d6c2931d6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/material/Pill.kt @@ -0,0 +1,61 @@ +package com.mihon.material + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp + +@Composable +fun Pill( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + style: TextStyle = LocalTextStyle.current, +) { + Surface( + modifier = modifier + .padding(start = 4.dp), + shape = MaterialTheme.shapes.extraLarge, + color = color, + contentColor = contentColor, + ) { + Box( + modifier = Modifier + .padding(6.dp, 1.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + maxLines = 1, + style = style, + ) + } + } +} + +@Composable +fun Pill( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + fontSize: TextUnit = LocalTextStyle.current.fontSize, +) { + Pill( + text = text, + modifier = modifier, + color = color, + contentColor = contentColor, + style = LocalTextStyle.current.merge(fontSize = fontSize), + ) +} diff --git a/shared/src/commonMain/kotlin/com/mihon/material/Scaffold.kt b/shared/src/commonMain/kotlin/com/mihon/material/Scaffold.kt new file mode 100644 index 00000000000..65c11f193b2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/material/Scaffold.kt @@ -0,0 +1,85 @@ +package com.mihon.material + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.FabPosition +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +@Composable +fun Scaffold( + modifier: Modifier = Modifier, + topBarScrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( + rememberTopAppBarState(), + ), + topBar: @Composable (TopAppBarScrollBehavior) -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + containerColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = contentColorFor(containerColor), + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + content: @Composable (PaddingValues) -> Unit, +) { + var fabHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + + Scaffold( + modifier = modifier.nestedScroll(topBarScrollBehavior.nestedScrollConnection), + topBar = { topBar(topBarScrollBehavior) }, + bottomBar = bottomBar, + snackbarHost = snackbarHost, + floatingActionButton = { + Box(Modifier.onSizeChanged { fabHeight = it.height }) { + floatingActionButton() + } + }, + floatingActionButtonPosition = floatingActionButtonPosition, + containerColor = containerColor, + contentColor = contentColor, + contentWindowInsets = contentWindowInsets, + ) { contentPadding -> + val paddingValues = remember(contentPadding) { + FabAwarePaddingValues(contentPadding) { + if (fabHeight == 0) 0.dp else with(density) { fabHeight.toDp() } + FabSpacing + } + } + content(paddingValues) + } +} + +private class FabAwarePaddingValues( + private val base: PaddingValues, + private val extraBottom: () -> Dp, +) : PaddingValues { + override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = + base.calculateLeftPadding(layoutDirection) + + override fun calculateTopPadding(): Dp = base.calculateTopPadding() + + override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = + base.calculateRightPadding(layoutDirection) + + override fun calculateBottomPadding(): Dp = base.calculateBottomPadding() + extraBottom() +} +private val FabSpacing = 16.dp diff --git a/shared/src/commonMain/kotlin/com/mihon/material/SettingsItems.kt b/shared/src/commonMain/kotlin/com/mihon/material/SettingsItems.kt new file mode 100644 index 00000000000..0b5970f2f30 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/material/SettingsItems.kt @@ -0,0 +1,468 @@ +package com.mihon.material + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.arrow_downward +import com.lagradost.cloudstream4.generated.resources.arrow_upward +import com.lagradost.cloudstream4.generated.resources.preview +import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme +import com.mihon.common.preference.PreferenceData +import com.mihon.common.preference.toggle +import com.mihon.presentation.secondaryItemAlpha +import com.mihon.presentation.settings.collectAsState +import com.mihon.presentation.settings.widget.PrefsHorizontalPadding +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +object SettingsItemsPaddings { + val Horizontal = 24.dp + val Vertical = 10.dp +} + +@Composable +fun HeadingItem(labelRes: StringResource) { + HeadingItem(stringResource(labelRes)) +} + +@Composable +fun HeadingItem(text: String) { + Text( + text = text, + style = MaterialTheme.typography.header, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = SettingsItemsPaddings.Horizontal, + vertical = SettingsItemsPaddings.Vertical, + ), + ) +} + +@Composable +fun IconItem(label: String, icon: ImageVector, onClick: () -> Unit) { + BaseSettingsItem( + label = label, + widget = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + onClick = onClick, + ) +} + +@Composable +fun SortItem(label: String, sortDescending: Boolean?, onClick: () -> Unit) { + val arrowIcon = when (sortDescending) { + true -> painterResource(Res.drawable.arrow_downward) + false -> painterResource(Res.drawable.arrow_upward) + null -> null + } + + BaseSortItem( + label = label, + icon = arrowIcon, + onClick = onClick, + ) +} + +@Composable +fun BaseSortItem(label: String, icon: Painter?, onClick: () -> Unit) { + BaseSettingsItem( + label = label, + widget = { + if (icon != null) { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + }, + onClick = onClick, + ) +} + +@Composable +fun CheckboxItem(label: String, pref: PreferenceData) { + val checked by pref.collectAsState() + CheckboxItem( + label = label, + checked = checked, + onClick = { pref.toggle() }, + ) +} + +@Composable +fun CheckboxItem(label: String, checked: Boolean, onClick: () -> Unit) { + BaseSettingsItem( + label = label, + widget = { + Checkbox( + checked = checked, + onCheckedChange = null, + ) + }, + onClick = onClick, + ) +} + +@Composable +fun RadioItem(label: String, selected: Boolean, onClick: () -> Unit) { + BaseSettingsItem( + label = label, + widget = { + RadioButton( + selected = selected, + onClick = null, + ) + }, + onClick = onClick, + ) +} + +@Composable +fun SliderItem( + value: Int, + valueRange: IntProgression, + label: String, + onChange: (Int) -> Unit, + steps: Int = with(valueRange) { (last - first) - 1 }, + valueString: String = value.toString(), + labelStyle: TextStyle = MaterialTheme.typography.bodyMedium, + pillColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, +) { + BaseSliderItem( + value = value, + valueRange = valueRange, + steps = steps, + title = label, + valueString = valueString, + onChange = onChange, + titleStyle = labelStyle, + pillColor = pillColor, + modifier = Modifier.padding( + horizontal = SettingsItemsPaddings.Horizontal, + vertical = SettingsItemsPaddings.Vertical, + ), + ) +} + +@Composable +fun BaseSliderItem( + value: Int, + valueRange: IntProgression, + title: String, + onChange: (Int) -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + steps: Int = with(valueRange) { (last - first) - 1 }, + valueString: String = value.toString(), + titleStyle: TextStyle = MaterialTheme.typography.titleLarge, + subtitleStyle: TextStyle = MaterialTheme.typography.bodySmall, + pillColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + icon: Painter? = null, +) { + val haptic = LocalHapticFeedback.current + Column( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + ) { + if(icon != null) { + Box( + modifier = Modifier.padding(end = PrefsHorizontalPadding) + ) { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = titleStyle, + ) + if (subtitle != null) { + Text( + text = subtitle, + style = subtitleStyle, + modifier = Modifier.secondaryItemAlpha(), + ) + } + } + Pill( + text = valueString, + style = MaterialTheme.typography.bodyMedium, + color = pillColor, + ) + } + Slider( + value = value, + onValueChange = f@{ + if (it == value) return@f + onChange(it) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + valueRange = valueRange, + steps = steps, + ) + } +} + +@Composable +@PreviewLightDark +fun SliderItemPreview() { + CloudStreamPreviewTheme { + var value by remember { mutableIntStateOf(0) } + Surface { + BaseSliderItem( + value = value, + valueRange = 0..10, + title = "Item per row", + valueString = if (value == 0) "Auto" else value.toString(), + onChange = { value = it }, + modifier = Modifier.padding( + horizontal = SettingsItemsPaddings.Horizontal, + vertical = SettingsItemsPaddings.Vertical, + ), + icon = painterResource(Res.drawable.preview) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectItem( + label: String, + options: Array, + selectedIndex: Int, + onSelect: (Int) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + OutlinedTextField( + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + .padding( + horizontal = SettingsItemsPaddings.Horizontal, + vertical = SettingsItemsPaddings.Vertical, + ), + label = { Text(text = label) }, + value = options[selectedIndex].toString(), + onValueChange = {}, + readOnly = true, + singleLine = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded, + ) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + ) + + ExposedDropdownMenu( + modifier = Modifier.exposedDropdownSize(), + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEachIndexed { index, text -> + DropdownMenuItem( + text = { Text(text.toString()) }, + onClick = { + onSelect(index) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} + +/* +@Composable +fun TriStateItem( + label: String, + state: TriState, + enabled: Boolean = true, + onClick: ((TriState) -> Unit)?, +) { + Row( + modifier = Modifier + .clickable( + enabled = enabled && onClick != null, + onClick = { + when (state) { + TriState.DISABLED -> onClick?.invoke(TriState.ENABLED_IS) + TriState.ENABLED_IS -> onClick?.invoke(TriState.ENABLED_NOT) + TriState.ENABLED_NOT -> onClick?.invoke(TriState.DISABLED) + } + }, + ) + .fillMaxWidth() + .padding( + horizontal = SettingsItemsPaddings.Horizontal, + vertical = SettingsItemsPaddings.Vertical, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.large), + ) { + val stateAlpha = if (enabled && onClick != null) 1f else DISABLED_ALPHA + + Icon( + imageVector = when (state) { + TriState.DISABLED -> MaterialSymbols.Rounded.CheckBoxOutlineBlank + TriState.ENABLED_IS -> MaterialSymbols.RoundedFilled.CheckBox + TriState.ENABLED_NOT -> MaterialSymbols.RoundedFilled.DisabledByDefault + }, + contentDescription = null, + tint = if (!enabled || state == TriState.DISABLED) { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = stateAlpha) + } else { + when (onClick) { + null -> MaterialTheme.colorScheme.onSurface.copy(alpha = DISABLED_ALPHA) + else -> MaterialTheme.colorScheme.primary + } + }, + ) + Text( + text = label, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = stateAlpha), + style = MaterialTheme.typography.bodyMedium, + ) + } +}*/ + +@Composable +fun TextItem(label: String, value: String, onChange: (String) -> Unit) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsItemsPaddings.Horizontal, vertical = 4.dp), + label = { Text(text = label) }, + value = value, + onValueChange = onChange, + singleLine = true, + ) +} + +@Composable +fun SettingsChipRow(labelRes: StringResource, content: @Composable FlowRowScope.() -> Unit) { + Column { + HeadingItem(labelRes) + FlowRow( + modifier = Modifier.padding( + start = SettingsItemsPaddings.Horizontal, + top = 0.dp, + end = SettingsItemsPaddings.Horizontal, + bottom = SettingsItemsPaddings.Vertical, + ), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + content = content, + ) + } +} + +@Composable +fun SettingsIconGrid(labelRes: StringResource, content: LazyGridScope.() -> Unit) { + Column { + HeadingItem(labelRes) + LazyVerticalGrid( + columns = GridCells.Adaptive(128.dp), + modifier = Modifier.padding( + start = SettingsItemsPaddings.Horizontal, + end = SettingsItemsPaddings.Horizontal, + bottom = SettingsItemsPaddings.Vertical, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + content = content, + ) + } +} + +@Composable +private fun BaseSettingsItem( + label: String, + widget: @Composable RowScope.() -> Unit, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .fillMaxWidth() + .padding( + horizontal = SettingsItemsPaddings.Horizontal, + vertical = SettingsItemsPaddings.Vertical, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + widget(this) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt b/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt new file mode 100644 index 00000000000..f0c6d0e4e6d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/material/Slider.kt @@ -0,0 +1,49 @@ +package com.mihon.material + +import androidx.annotation.IntRange +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SliderState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import kotlin.math.roundToInt + +@Composable +fun Slider( + value: Int, + onValueChange: (Int) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: IntProgression = 0..1, + @IntRange(from = 0) steps: Int = with(valueRange) { (last - first) - 1 }, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + thumb: @Composable (SliderState) -> Unit = { + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = colors, + enabled = enabled, + ) + }, + track: @Composable (SliderState) -> Unit = { sliderState -> + SliderDefaults.Track(colors = colors, enabled = enabled, sliderState = sliderState) + }, +) { + Slider( + value = value.toFloat(), + onValueChange = { onValueChange(it.roundToInt()) }, + modifier = modifier, + enabled = enabled, + valueRange = with(valueRange) { first.toFloat()..last.toFloat() }, + steps = steps, + onValueChangeFinished = onValueChangeFinished, + colors = colors, + interactionSource = interactionSource, + thumb = thumb, + track = track, + ) +} diff --git a/shared/src/commonMain/kotlin/com/mihon/material/Typography.kt b/shared/src/commonMain/kotlin/com/mihon/material/Typography.kt new file mode 100644 index 00000000000..789f815397a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/material/Typography.kt @@ -0,0 +1,14 @@ +package com.mihon.material + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight + +val Typography.header: TextStyle + @Composable + get() = bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/Modifier.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/Modifier.kt new file mode 100644 index 00000000000..7a60342e9a1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/Modifier.kt @@ -0,0 +1,120 @@ +package com.mihon.presentation + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import com.mihon.material.SECONDARY_ALPHA + +@Composable +fun Modifier.selectedBackground(isSelected: Boolean): Modifier { + if (!isSelected) return this + val alpha = if (isSystemInDarkTheme()) 0.16f else 0.22f + val color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha) + return this.drawBehind { drawRect(color) } +} + +fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SECONDARY_ALPHA) + +fun Modifier.clickableNoIndication( + onLongClick: (() -> Unit)? = null, + onClick: () -> Unit, +) = this.combinedClickable( + interactionSource = null, + indication = null, + onLongClick = onLongClick, + onClick = onClick, +) + +/** + * For TextField, the provided [action] will be invoked when + * physical enter key is pressed. + * + * Naturally, the TextField should be set to single line only. + */ +fun Modifier.runOnEnterKeyPressed(action: () -> Unit): Modifier = this.onPreviewKeyEvent { + when (it.key) { + Key.Enter, Key.NumPadEnter -> { + // Physical keyboards generate two event types: + // - KeyDown when the key is pressed + // - KeyUp when the key is released + if (it.type == KeyEventType.KeyDown) { + action() + true + } else { + false + } + } + + else -> false + } +} + +/** + * For TextField on AppBar, this modifier will request focus + * to the element the first time it's composed. + */ +@Composable +fun Modifier.showSoftKeyboard(show: Boolean): Modifier { + if (!show) return this + val focusRequester = remember { FocusRequester() } + var openKeyboard by rememberSaveable { mutableStateOf(show) } + LaunchedEffect(focusRequester) { + if (openKeyboard) { + focusRequester.requestFocus() + openKeyboard = false + } + } + return this.focusRequester(focusRequester) +} + +/* +/** + * For TextField, this modifier will clear focus when soft + * keyboard is hidden. + */ +@Composable +fun Modifier.clearFocusOnSoftKeyboardHide( + onFocusCleared: (() -> Unit)? = null, +): Modifier { + var isFocused by remember { mutableStateOf(false) } + var keyboardShowedSinceFocused by remember { mutableStateOf(false) } + if (isFocused) { + val imeVisible = WindowInsets.isImeVisible + val focusManager = LocalFocusManager.current + LaunchedEffect(imeVisible) { + if (imeVisible) { + keyboardShowedSinceFocused = true + } else if (keyboardShowedSinceFocused) { + focusManager.clearFocus() + onFocusCleared?.invoke() + } + } + } + + return this.onFocusChanged { + if (isFocused != it.isFocused) { + if (isFocused) { + keyboardShowedSinceFocused = false + } + isFocused = it.isFocused + } + } +} +*/ \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/Navigation.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/Navigation.kt new file mode 100644 index 00000000000..5cdb8aadae5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/Navigation.kt @@ -0,0 +1,9 @@ +package com.mihon.presentation + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * For invoking back press to the parent activity + */ +val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/Preference.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/Preference.kt new file mode 100644 index 00000000000..fb227ac967e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/Preference.kt @@ -0,0 +1,194 @@ +package com.mihon.presentation.settings + +import androidx.annotation.IntRange +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.painter.Painter +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.none +import com.mihon.common.preference.PreferenceData +import org.jetbrains.compose.resources.stringResource + +sealed class Preference { + abstract val title: String + abstract val enabled: Boolean + + sealed class PreferenceItem : Preference() { + abstract val subtitle: String? + abstract val icon: Painter? + abstract val onValueChanged: suspend (value: T) -> R + + /** + * A basic [PreferenceItem] that only displays texts. + */ + data class TextPreference( + override val title: String, + override val subtitle: String? = null, + override val enabled: Boolean = true, + val widget: @Composable (() -> Unit)? = null, + val onClick: (() -> Unit)? = null, + override val icon: Painter? = null + ) : PreferenceItem() { + override val onValueChanged: suspend (value: String) -> Unit = {} + } + + /** + * A [PreferenceItem] that provides a two-state toggleable option. + */ + data class SwitchPreference( + val preference: PreferenceData, + override val title: String, + override val subtitle: String? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (value: Boolean) -> Boolean = { true }, + override val icon: Painter? = null + ) : PreferenceItem() + + /** + * A [PreferenceItem] that provides a slider to select an integer number. + */ + data class SliderPreference( + val value: Int, + override val title: String, + override val subtitle: String? = null, + val valueString: String? = null, + val valueRange: IntProgression = 0..1, + @IntRange(from = 0) val steps: Int = with(valueRange) { (last - first) - 1 }, + override val enabled: Boolean = true, + override val onValueChanged: suspend (value: Int) -> Unit = {}, + override val icon: Painter? = null + ) : PreferenceItem() + + /** + * A [PreferenceItem] that displays a list of entries as a dialog. + */ + @Suppress("UNCHECKED_CAST") + data class ListPreference( + val preference: PreferenceData, + val entries: Map, + override val title: String, + override val subtitle: String? = "%s", + val subtitleProvider: @Composable (value: T, entries: Map) -> String? = + { v, e -> subtitle?.format(e[v]) }, + override val icon: Painter? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (value: T) -> Boolean = { true }, + ) : PreferenceItem() { + internal fun internalSet(value: Any) = preference.set(value as T) + internal suspend fun internalOnValueChanged(value: Any) = onValueChanged(value as T) + + @Composable + internal fun internalSubtitleProvider(value: Any?, entries: Map) = + subtitleProvider(value as T, entries as Map) + } + + /** + * [ListPreference] but with no connection to a [PreferenceData] + */ + data class BasicListPreference( + val value: String, + val entries: Map, + override val title: String, + override val subtitle: String? = "%s", + val subtitleProvider: @Composable (value: String, entries: Map) -> String? = + { v, e -> subtitle?.format(e[v]) }, + override val icon: Painter? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (value: String) -> Unit = {}, + ) : PreferenceItem() + + /** + * A [PreferenceItem] that displays a list of entries as a dialog. + * Multiple entries can be selected at the same time. + */ + @Suppress("UNCHECKED_CAST") + data class MultiSelectListPreference( + val preference: PreferenceData>, + val entries: Map, + override val title: String, + override val subtitle: String? = "%s", + val subtitleProvider: @Composable (value: Set, entries: Map) -> String? = + { v, e -> + val combined = remember(v, e) { + v.mapNotNull { e[it] } + .joinToString() + .takeUnless { it.isBlank() } + } + ?: stringResource(Res.string.none) + subtitle?.format(combined) + }, + override val icon: Painter? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (value: Set) -> Boolean = { true }, + ) : PreferenceItem, Boolean>() { + internal fun internalSet(value: Set) = preference.set(value as Set) + internal suspend fun internalOnValueChanged(value: Set) = onValueChanged(value as Set) + + @Composable + internal fun internalSubtitleProvider(value: Set, entries: Map) = + subtitleProvider(value as Set, entries as Map) + } + + /** + * A [PreferenceItem] that shows a EditText in the dialog. + */ + data class EditTextPreference( + val preference: PreferenceData, + override val title: String, + override val subtitle: String? = "%s", + override val enabled: Boolean = true, + override val onValueChanged: suspend (value: String) -> Boolean = { true }, + ) : PreferenceItem() { + override val icon: Painter? = null + } + + /** + * A [PreferenceItem] for individual tracker. + */ + /*data class TrackerPreference( + val tracker: Tracker, + val login: () -> Unit, + val logout: () -> Unit, + ) : PreferenceItem() { + override val title: String = "" + override val enabled: Boolean = true + override val subtitle: String? = null + override val icon: ImageVector? = null + override val onValueChanged: suspend (value: String) -> Unit = {} + }*/ + + data class InfoPreference( + override val title: String, + ) : PreferenceItem() { + override val enabled: Boolean = true + override val subtitle: String? = null + override val icon: Painter? = null + override val onValueChanged: suspend (value: String) -> Unit = {} + } + + data class CustomPreference( + override val title: String, + val content: @Composable () -> Unit, + ) : PreferenceItem() { + override val enabled: Boolean = true + override val subtitle: String? = null + override val icon: Painter? = null + override val onValueChanged: suspend (value: Unit) -> Unit = {} + } + } + + data class PreferenceGroup( + override val title: String, + override val enabled: Boolean = true, + + val preferenceItems: List>, + ) : Preference() +} + +@Composable +fun PreferenceData.collectAsState(): State { + val flow = remember(this) { changes() } + return flow.collectAsState(initial = get()) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceItem.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceItem.kt new file mode 100644 index 00000000000..6c06c086c7b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceItem.kt @@ -0,0 +1,187 @@ +package com.mihon.presentation.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mihon.presentation.settings.widget.EditTextPreferenceWidget +import com.mihon.presentation.settings.widget.InfoWidget +import com.mihon.presentation.settings.widget.ListPreferenceWidget +import com.mihon.presentation.settings.widget.MultiSelectListPreferenceWidget +import com.mihon.presentation.settings.widget.PrefsHorizontalPadding +import com.mihon.presentation.settings.widget.PrefsVerticalPadding +import com.mihon.presentation.settings.widget.SwitchPreferenceWidget +import com.mihon.presentation.settings.widget.TextPreferenceWidget +import com.mihon.presentation.settings.widget.TitleFontSize +import kotlinx.coroutines.launch +import com.mihon.material.BaseSliderItem + +val LocalPreferenceHighlighted = compositionLocalOf(structuralEqualityPolicy()) { false } +val LocalPreferenceMinHeight = compositionLocalOf(structuralEqualityPolicy()) { 56.dp } + +@Composable +fun StatusWrapper( + item: Preference.PreferenceItem<*, *>, + highlightKey: String?, + content: @Composable () -> Unit, +) { + val enabled = item.enabled + val highlighted = item.title == highlightKey + AnimatedVisibility( + visible = enabled, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + content = { + CompositionLocalProvider( + LocalPreferenceHighlighted provides highlighted, + content = content, + ) + }, + ) +} + +@Composable +internal fun PreferenceItem( + item: Preference.PreferenceItem<*, *>, + highlightKey: String?, +) { + val scope = rememberCoroutineScope() + StatusWrapper( + item = item, + highlightKey = highlightKey, + ) { + when (item) { + is Preference.PreferenceItem.SwitchPreference -> { + val value by item.preference.collectAsState() + SwitchPreferenceWidget( + title = item.title, + subtitle = item.subtitle, + icon = item.icon, + checked = value, + onCheckedChanged = { newValue -> + scope.launch { + if (item.onValueChanged(newValue)) { + item.preference.set(newValue) + } + } + }, + ) + } + is Preference.PreferenceItem.SliderPreference -> { + BaseSliderItem( + value = item.value, + valueRange = item.valueRange, + steps = item.steps, + title = item.title, + subtitle = item.subtitle, + valueString = item.valueString.takeUnless { it.isNullOrEmpty() } ?: item.value.toString(), + onChange = { + scope.launch { + item.onValueChanged(it) + } + }, + titleStyle = MaterialTheme.typography.titleLarge.copy(fontSize = TitleFontSize), + modifier = Modifier.padding( + horizontal = PrefsHorizontalPadding, + vertical = PrefsVerticalPadding, + ), + icon = item.icon + ) + } + is Preference.PreferenceItem.ListPreference<*> -> { + val value by item.preference.collectAsState() + ListPreferenceWidget( + value = value, + title = item.title, + subtitle = item.internalSubtitleProvider(value, item.entries), + icon = item.icon, + entries = item.entries, + onValueChange = { newValue -> + scope.launch { + if (item.internalOnValueChanged(newValue!!)) { + item.internalSet(newValue) + } + } + }, + ) + } + is Preference.PreferenceItem.BasicListPreference -> { + ListPreferenceWidget( + value = item.value, + title = item.title, + subtitle = item.subtitleProvider(item.value, item.entries), + icon = item.icon, + entries = item.entries, + onValueChange = { scope.launch { item.onValueChanged(it) } }, + ) + } + is Preference.PreferenceItem.MultiSelectListPreference<*> -> { + val values by item.preference.collectAsState() + MultiSelectListPreferenceWidget( + values = values, + title = item.title, + subtitle = item.internalSubtitleProvider(values, item.entries), + icon = item.icon, + entries = item.entries, + onValuesChange = { newValues -> + scope.launch { + if (item.internalOnValueChanged(newValues)) { + item.internalSet(newValues) + } + } + }, + ) + } + is Preference.PreferenceItem.TextPreference -> { + TextPreferenceWidget( + title = item.title, + subtitle = item.subtitle, + icon = item.icon, + widget = item.widget, + onPreferenceClick = item.onClick, + ) + } + is Preference.PreferenceItem.EditTextPreference -> { + val values by item.preference.collectAsState() + EditTextPreferenceWidget( + title = item.title, + subtitle = item.subtitle, + icon = item.icon, + value = values, + onConfirm = { + val accepted = item.onValueChanged(it) + if (accepted) item.preference.set(it) + accepted + }, + ) + } + /*is Preference.PreferenceItem.TrackerPreference -> { + val isLoggedIn by item.tracker.let { tracker -> + tracker.isLoggedInFlow.collectAsState(tracker.isLoggedIn) + } + TrackingPreferenceWidget( + tracker = item.tracker, + isLoggedIn = isLoggedIn, + onClick = { if (isLoggedIn) item.logout() else item.login() }, + ) + }*/ + is Preference.PreferenceItem.InfoPreference -> { + InfoWidget(text = item.title) + } + is Preference.PreferenceItem.CustomPreference -> { + item.content() + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt new file mode 100644 index 00000000000..46c7b6a3edc --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScaffold.kt @@ -0,0 +1,31 @@ +package com.mihon.presentation.settings + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import com.mihon.material.AppBar +import com.mihon.material.Scaffold + +@Composable +fun PreferenceScaffold( + title: String, + actions: @Composable RowScope.() -> Unit = {}, + onBackPressed: (() -> Unit)? = null, + itemsProvider: @Composable () -> List, +) { + Scaffold( + topBar = { + AppBar( + title = title, + navigateUp = onBackPressed, + actions = actions, + scrollBehavior = it, + ) + }, + content = { contentPadding -> + PreferenceScreen( + items = itemsProvider(), + contentPadding = contentPadding, + ) + }, + ) +} diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt new file mode 100644 index 00000000000..0e99aa9a3c0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceScreen.kt @@ -0,0 +1,96 @@ +package com.mihon.presentation.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.mihon.presentation.settings.widget.PreferenceGroupHeader +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +/** + * Preference Screen composable which contains a list of [Preference] items + * @param items [Preference] items which should be displayed on the preference screen. An item can be a single [PreferenceItem] or a group ([Preference.PreferenceGroup]) + * @param modifier [Modifier] to be applied to the preferenceScreen layout + */ +@Composable +fun PreferenceScreen( + items: List, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + val state = rememberLazyListState() + val highlightKey = SearchableSettings.highlightKey + if (highlightKey != null) { + LaunchedEffect(Unit) { + val i = items.findHighlightedIndex(highlightKey) + if (i >= 0) { + delay(0.5.seconds) + state.animateScrollToItem(i) + } + SearchableSettings.highlightKey = null + } + } + + LazyColumn( + modifier = modifier, + state = state, + contentPadding = contentPadding, + ) { + items.fastForEachIndexed { i, preference -> + when (preference) { + // Create Preference Group + is Preference.PreferenceGroup -> { + if (!preference.enabled) return@fastForEachIndexed + + item { + Column { + PreferenceGroupHeader(title = preference.title) + } + } + items(preference.preferenceItems) { item -> + PreferenceItem( + item = item, + highlightKey = highlightKey, + ) + } + item { + if (i < items.lastIndex) { + Spacer(modifier = Modifier.height(12.dp)) + } + } + } + + // Create Preference Item + is Preference.PreferenceItem<*, *> -> item { + PreferenceItem( + item = preference, + highlightKey = highlightKey, + ) + } + } + } + } +} + +private fun List.findHighlightedIndex(highlightKey: String): Int { + return flatMap { + if (it is Preference.PreferenceGroup) { + buildList { + add(null) // Header + addAll(it.preferenceItems.map { groupItem -> groupItem.title }) + add(null) // Spacer + } + } else { + listOf(it.title) + } + }.indexOfFirst { it == highlightKey } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/SearchableSettings.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/SearchableSettings.kt new file mode 100644 index 00000000000..c114f0dd048 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/SearchableSettings.kt @@ -0,0 +1,39 @@ +package com.mihon.presentation.settings + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import com.lagradost.cloudstream4.compose.Screen +import com.mihon.presentation.LocalBackPress + +interface SearchableSettings : Screen { + @Composable + @ReadOnlyComposable + fun getTitleRes(): String + + @Composable + fun getPreferences(): List + + @Composable + fun RowScope.AppBarAction() { + } + + @Composable + override fun Content() { + val handleBack = LocalBackPress.current + PreferenceScaffold( + title = getTitleRes(), + onBackPressed = if (handleBack != null) handleBack::invoke else null, + actions = { AppBarAction() }, + itemsProvider = { getPreferences() }, + ) + } + + companion object { + // HACK: for the background blipping thingy. + // The title of the target PreferenceItem + // Set before showing the destination screen and reset after + // See BasePreferenceWidget.highlightBackground + var highlightKey: String? = null + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt new file mode 100644 index 00000000000..1c033ff4b9c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/BasePreferenceWidget.kt @@ -0,0 +1,125 @@ +package com.mihon.presentation.settings.widget + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.StartOffsetType +import androidx.compose.animation.core.repeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mihon.presentation.settings.LocalPreferenceHighlighted +import com.mihon.presentation.settings.LocalPreferenceMinHeight +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +@Composable +internal fun BasePreferenceWidget( + modifier: Modifier = Modifier, + title: String? = null, + subcomponent: @Composable (ColumnScope.() -> Unit)? = null, + icon: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null, + widget: @Composable (() -> Unit)? = null, +) { + val highlighted = LocalPreferenceHighlighted.current + val minHeight = LocalPreferenceMinHeight.current + Row( + modifier = modifier + .highlightBackground(highlighted) + .sizeIn(minHeight = minHeight) + .clickable(enabled = onClick != null, onClick = { onClick?.invoke() }) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Box( + modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp), + content = { icon() }, + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = PrefsVerticalPadding), + ) { + if (!title.isNullOrBlank()) { + Text( + modifier = Modifier.padding(horizontal = PrefsHorizontalPadding), + text = title, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = MaterialTheme.typography.titleLarge, + fontSize = TitleFontSize, + ) + } + subcomponent?.invoke(this) + } + if (widget != null) { + Box( + modifier = Modifier.padding(end = PrefsHorizontalPadding), + content = { widget() }, + ) + } + } +} + +@Composable +internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier { + var highlightFlag by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (highlighted) { + highlightFlag = true + delay(3.seconds) + highlightFlag = false + } + } + val highlight by animateColorAsState( + targetValue = if (highlightFlag) { + MaterialTheme.colorScheme.surfaceTint.copy(alpha = .12f) + } else { + Color.Transparent + }, + animationSpec = if (highlightFlag) { + repeatable( + iterations = 5, + animation = tween(durationMillis = 200), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset( + offsetMillis = 600, + offsetType = StartOffsetType.Delay, + ), + ) + } else { + tween(200) + }, + label = "highlight", + ) + return this.background(color = highlight) +} + +internal val TrailingWidgetBuffer = 16.dp +internal val PrefsHorizontalPadding = 16.dp +internal val PrefsVerticalPadding = 16.dp +internal val TitleFontSize = 16.sp diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/EditTextPreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/EditTextPreferenceWidget.kt new file mode 100644 index 00000000000..137318928f1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/EditTextPreferenceWidget.kt @@ -0,0 +1,97 @@ +package com.mihon.presentation.settings.widget + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.window.DialogProperties +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.cancel +import com.lagradost.cloudstream4.generated.resources.error +import com.lagradost.cloudstream4.generated.resources.ok +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun EditTextPreferenceWidget( + title: String, + subtitle: String?, + icon: Painter?, + value: String, + onConfirm: suspend (String) -> Boolean, +) { + var isDialogShown by remember { mutableStateOf(false) } + + TextPreferenceWidget( + title = title, + subtitle = subtitle?.format(value), + icon = icon, + onPreferenceClick = { isDialogShown = true }, + ) + + if (isDialogShown) { + val scope = rememberCoroutineScope() + val onDismissRequest = { isDialogShown = false } + var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(value)) + } + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = title) }, + text = { + OutlinedTextField( + value = textFieldValue, + onValueChange = { textFieldValue = it }, + trailingIcon = { + if (textFieldValue.text.isBlank()) { + Icon(painter = painterResource(Res.drawable.error), contentDescription = null) + } else { + IconButton(onClick = { textFieldValue = TextFieldValue("") }) { + Icon(painter = painterResource(Res.drawable.cancel), contentDescription = null) + } + } + }, + isError = textFieldValue.text.isBlank(), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmButton = { + TextButton( + enabled = textFieldValue.text != value && textFieldValue.text.isNotBlank(), + onClick = { + scope.launch { + if (onConfirm(textFieldValue.text)) { + onDismissRequest() + } + } + }, + ) { + Text(text = stringResource(Res.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(Res.string.cancel)) + } + }, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt new file mode 100644 index 00000000000..3382ebda5c8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/InfoWidget.kt @@ -0,0 +1,53 @@ +package com.mihon.presentation.settings.widget + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.info +import com.lagradost.cloudstream4.generated.resources.lorem_content +import com.lagradost.cloudstream4.generated.resources.lorem_title +import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import com.mihon.presentation.secondaryItemAlpha +import com.mihon.material.padding + +@Composable +internal fun InfoWidget(text: String) { + Column( + modifier = Modifier + .padding( + horizontal = PrefsHorizontalPadding, + vertical = MaterialTheme.padding.medium, + ) + .secondaryItemAlpha(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium), + ) { + Icon( + painter = painterResource(Res.drawable.info), + contentDescription = null, + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@PreviewLightDark +@Composable +private fun InfoWidgetPreview() { + CloudStreamPreviewTheme { + Surface { + InfoWidget(text = stringResource(Res.string.lorem_content)) + } + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/ListPreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/ListPreferenceWidget.kt new file mode 100644 index 00000000000..23dae9b0fd3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/ListPreferenceWidget.kt @@ -0,0 +1,109 @@ +package com.mihon.presentation.settings.widget + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.painter.Painter +import com.lagradost.cloudstream4.compose.SingleSelectDialog +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.cancel +import com.lagradost.cloudstream4.generated.resources.ok +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ListPreferenceWidget( + value: T, + title: String, + subtitle: String?, + icon: Painter?, + entries: Map, + onValueChange: (T) -> Unit, +) { + var isDialogShown by remember { mutableStateOf(false) } + + TextPreferenceWidget( + title = title, + subtitle = subtitle, + icon = icon, + onPreferenceClick = { isDialogShown = true }, + ) + + if (isDialogShown) { + SingleSelectDialog( + dismiss = { isDialogShown = false }, + title = title, + entries = entries, + selectedKey = value, + confirm = { key -> + onValueChange(key) + isDialogShown = false + }, + confirmText = stringResource(Res.string.ok), + dismissText = stringResource(Res.string.cancel), + iconProvider = null + ) + /*AlertDialog( + onDismissRequest = { isDialogShown = false }, + title = { Text(text = title) }, + text = { + Box { + val state = rememberLazyListState() + LazyColumn(state = state) { + entries.forEach { current -> + val isSelected = value == current.key + item { + DialogRow( + label = current.value, + isSelected = isSelected, + onSelected = { + onValueChange(current.key!!) + isDialogShown = false + }, + ) + } + } + } + if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) + if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) + } + }, + confirmButton = { + TextButton(onClick = { isDialogShown = false }) { + Text(text = stringResource(Res.string.cancel)) + } + }, + )*/ + } +} +/* +@Composable +private fun DialogRow( + label: String, + isSelected: Boolean, + onSelected: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .selectable( + selected = isSelected, + onClick = { if (!isSelected) onSelected() }, + ) + .fillMaxWidth() + .minimumInteractiveComponentSize(), + ) { + RadioButton( + selected = isSelected, + onClick = null, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge.merge(), + modifier = Modifier.padding(start = 24.dp), + ) + } +} +*/ \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/MultiSelectListPreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/MultiSelectListPreferenceWidget.kt new file mode 100644 index 00000000000..c0a2d735fa4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/MultiSelectListPreferenceWidget.kt @@ -0,0 +1,97 @@ +package com.mihon.presentation.settings.widget + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.window.DialogProperties +import com.lagradost.cloudstream4.compose.MultiSelectDialog +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.cancel +import com.lagradost.cloudstream4.generated.resources.ok +import org.jetbrains.compose.resources.stringResource + +@Composable +fun MultiSelectListPreferenceWidget( + values: Set, + title: String, + subtitle: String?, + icon: Painter?, + entries: Map, + onValuesChange: (Set) -> Unit, +) { + var isDialogShown by remember { mutableStateOf(false) } + + TextPreferenceWidget( + title = title, + subtitle = subtitle, + icon = icon, + onPreferenceClick = { isDialogShown = true }, + ) + + if (isDialogShown) { + MultiSelectDialog( + title = title, + entries = entries, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmText = stringResource(Res.string.ok), + confirm = { selection -> + onValuesChange(selection) + isDialogShown = false + }, + dismissText = stringResource(Res.string.cancel), + dismiss = { + isDialogShown = false + }, + selectedKeys = values, + ) + + /* + AlertDialog( + onDismissRequest = { isDialogShown = false }, + title = { Text(text = title) }, + text = { + LazyColumn { + entries.forEach { current -> + item { + val isSelected = selected.contains(current.key) + LabeledCheckbox( + label = current.value, + checked = isSelected, + onCheckedChange = { + if (it) { + selected.add(current.key) + } else { + selected.remove(current.key) + } + }, + ) + } + } + } + }, + properties = DialogProperties( + usePlatformDefaultWidth = true, + ), + confirmButton = { + TextButton( + onClick = { + onValuesChange(selected.toMutableSet()) + isDialogShown = false + }, + ) { + Text(text = stringResource(Res.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { isDialogShown = false }) { + Text(text = stringResource(Res.string.cancel)) + } + }, + )*/ + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt new file mode 100644 index 00000000000..b7d1197a939 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/PreferenceGroupHeader.kt @@ -0,0 +1,28 @@ +package com.mihon.presentation.settings.widget + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PreferenceGroupHeader(title: String) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp, top = 14.dp), + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = PrefsHorizontalPadding), + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt new file mode 100644 index 00000000000..2744cdcdc9b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/SwitchPreferenceWidget.kt @@ -0,0 +1,73 @@ +package com.mihon.presentation.settings.widget + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.preview +import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme +import org.jetbrains.compose.resources.painterResource + +@Composable +fun SwitchPreferenceWidget( + modifier: Modifier = Modifier, + title: String, + subtitle: String? = null, + icon: Painter? = null, + checked: Boolean = false, + onCheckedChanged: (Boolean) -> Unit, +) { + TextPreferenceWidget( + modifier = modifier, + title = title, + subtitle = subtitle, + icon = icon, + widget = { + Switch( + checked = checked, + onCheckedChange = null, + modifier = Modifier.padding(start = TrailingWidgetBuffer), + ) + }, + onPreferenceClick = { onCheckedChanged(!checked) }, + ) +} + +@PreviewLightDark +@Composable +private fun SwitchPreferenceWidgetPreview() { + CloudStreamPreviewTheme { + Surface { + Column { + SwitchPreferenceWidget( + title = "Text preference with icon", + subtitle = "Text preference summary", + icon = painterResource(Res.drawable.preview) , + checked = true, + onCheckedChanged = {}, + ) + SwitchPreferenceWidget( + title = "Text preference", + subtitle = "Text preference summary", + checked = false, + onCheckedChanged = {}, + ) + SwitchPreferenceWidget( + title = "Text preference no summary", + checked = false, + onCheckedChanged = {}, + ) + SwitchPreferenceWidget( + title = "Another text preference no summary", + checked = false, + onCheckedChanged = {}, + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/TextPreferenceWidget.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/TextPreferenceWidget.kt new file mode 100644 index 00000000000..6765114e174 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/widget/TextPreferenceWidget.kt @@ -0,0 +1,86 @@ +package com.mihon.presentation.settings.widget + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.generated.resources.Res +import com.lagradost.cloudstream4.generated.resources.preview +import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme +import org.jetbrains.compose.resources.painterResource +import com.mihon.presentation.secondaryItemAlpha + +@Composable +fun TextPreferenceWidget( + modifier: Modifier = Modifier, + title: String? = null, + subtitle: String? = null, + icon: Painter? = null, + iconTint: Color = MaterialTheme.colorScheme.onBackground, + widget: @Composable (() -> Unit)? = null, + onPreferenceClick: (() -> Unit)? = null, +) { + BasePreferenceWidget( + modifier = modifier, + title = title, + subcomponent = if (!subtitle.isNullOrBlank()) { + { + Text( + text = subtitle, + modifier = Modifier + .padding(horizontal = PrefsHorizontalPadding) + .secondaryItemAlpha(), + style = MaterialTheme.typography.bodySmall, + maxLines = 10, + ) + } + } else { + null + }, + icon = if (icon != null) { + { + Icon( + painter = icon, + modifier = Modifier.size(24.dp), + tint = iconTint, + contentDescription = null, + ) + } + } else { + null + }, + onClick = onPreferenceClick, + widget = widget, + ) +} + +@PreviewLightDark +@Composable +private fun TextPreferenceWidgetPreview() { + CloudStreamPreviewTheme { + Surface { + Column { + TextPreferenceWidget( + title = "Text preference with icon", + subtitle = "Text preference summary", + icon = painterResource(Res.drawable.preview), + onPreferenceClick = {}, + ) + TextPreferenceWidget( + title = "Text preference", + subtitle = "Text preference summary", + onPreferenceClick = {}, + ) + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/mihon/readme.md b/shared/src/commonMain/kotlin/com/mihon/readme.md new file mode 100644 index 00000000000..2984058d08f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mihon/readme.md @@ -0,0 +1,4 @@ +Mihon (tachiyomi) has a great codebase for working with compose. +Because they do not provide these features as a lib, we are forced to fork each file we want instead. + +https://github.com/mihonapp/mihon \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt new file mode 100644 index 00000000000..cca2d70d79e --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.jvm.kt @@ -0,0 +1,16 @@ +package com.lagradost.cloudstream4 + +import androidx.compose.runtime.Composable +import com.mihon.common.preference.InMemoryPreferenceStore + +/** As this is mostly for testing so far, we can assume we have a shared global for settings */ +internal val settings = AppSettings() + +@Composable +actual fun rememberAppSettings(): AppSettings { + return settings +} + +fun AppSettings() : AppSettings { + return AppSettings(preferences = InMemoryPreferenceStore()) +} diff --git a/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/theme/DeviceTheme.jvm.kt b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/theme/DeviceTheme.jvm.kt new file mode 100644 index 00000000000..dc9f4f1aea2 --- /dev/null +++ b/shared/src/jvmMain/kotlin/com/lagradost/cloudstream4/theme/DeviceTheme.jvm.kt @@ -0,0 +1,19 @@ +package com.lagradost.cloudstream4.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + +internal actual object DeviceTheme { + @Composable + @ReadOnlyComposable + actual fun resolveDynamicTheme(): CloudStreamColorScheme = darkScheme() + + @Composable + @ReadOnlyComposable + actual fun resolveDynamicPrimaryColor(): Color = CloudStreamPrimaryColor.NORMAL.color + + @Composable + @ReadOnlyComposable + actual fun resolveDynamicSecondaryColor(): Color = CloudStreamPrimaryColor.NORMAL.color +} \ No newline at end of file diff --git a/shared/src/readme.md b/shared/src/readme.md new file mode 100644 index 00000000000..8b37a84a043 --- /dev/null +++ b/shared/src/readme.md @@ -0,0 +1,11 @@ +https://kotlinlang.org/docs/multiplatform/compose-multiplatform-create-first-app.html#examine-the-project-structure + +`jvmMain` contains source files for the desktop target, which uses Kotlin/JVM. + +`androidMain` contains Android source files and targets Kotlin/JVM. + +`iosMain` contains Kotlin code for iOS and targets Kotlin/Native. + +`jsMain` contains JavaScript-specific Kotlin code and targets Kotlin/JS. + +`wasmJsMain` contains Wasm-specific Kotlin code and targets Kotlin/Wasm. \ No newline at end of file