diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index 4495a560262..e4616c93bcf 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -101,6 +101,7 @@ import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageTagIETF +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragmentDialog2 import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs @@ -1046,7 +1047,7 @@ class GeneratorPlayer : FullScreenPlayer() { binding.subtitleSettingsBtt.setOnClickListener { safe { - val subtitlesFragment = SubtitlesFragment() + val subtitlesFragment = SubtitlesFragmentDialog2() subtitlesFragment.systemBarsAddPadding = true subtitlesFragment.show(this.parentFragmentManager, "SubtitleSettings") } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt index 269452ff2a9..03c66d9fd55 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragmentScreen.kt @@ -70,6 +70,7 @@ import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream4.compose.Screen import com.lagradost.cloudstream4.compose.TV import com.lagradost.cloudstream4.compose.circle +import com.lagradost.cloudstream4.compose.circleBorder import com.lagradost.cloudstream4.compose.focusOutline import com.lagradost.cloudstream4.compose.isLayout import com.lagradost.cloudstream4.theme.CloudStreamPreviewTheme @@ -229,23 +230,12 @@ object SettingsFragmentScreen : Screen { account.customImage ?: profileImages.getOrNull(account.defaultImageIndex) ?: profileImages.first() - Box( - modifier = Modifier - .size(50.dp) - .border( - 2.dp, - MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f), - CircleShape - ) - .circle(), - ) { - AsyncImage( - contentScale = ContentScale.Crop, - model = image, - modifier = Modifier.fillMaxSize(), - contentDescription = null, - ) - } + AsyncImage( + contentScale = ContentScale.Crop, + model = image, + modifier = Modifier.circleBorder(50.dp), + contentDescription = null, + ) Spacer(modifier = Modifier.width(MaterialTheme.padding.medium)) Column { Text( 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 index 309b65cb8b6..744f1bdb5a7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayerScreen.kt @@ -227,7 +227,7 @@ object SettingsPlayerScreen : SearchableSettings { title = stringResource(R.string.double_tap_to_seek_amount_settings), icon = painterResource(R.drawable.go_forward_30), valueRange = 5..60, - steps = 10, + //steps = 10, onValueChanged = settings.player.doubleTapTime::set ), ) @@ -242,7 +242,7 @@ object SettingsPlayerScreen : SearchableSettings { subtitle = stringResource(R.string.android_tv_interface_on_seek_settings_summary), icon = painterResource(R.drawable.go_forward_30), valueRange = 5..60, - steps = 10, + //steps = 10, onValueChanged = settings.player.tvSeekOnTime::set ), Preference.PreferenceItem.SliderPreference( @@ -251,7 +251,7 @@ object SettingsPlayerScreen : SearchableSettings { subtitle = stringResource(R.string.android_tv_interface_off_seek_settings_summary), icon = painterResource(R.drawable.go_forward_30), valueRange = 5..60, - steps = 10, + //steps = 10, onValueChanged = settings.player.tvSeekOffTime::set ), ) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt index ab64e99936d..0e203989fa8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt @@ -11,6 +11,8 @@ import android.view.View import android.widget.TextView import android.widget.Toast import androidx.annotation.OptIn +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateOf import androidx.media3.common.text.Cue import androidx.media3.common.util.UnstableApi import com.fasterxml.jackson.annotation.JsonProperty @@ -44,15 +46,16 @@ import kotlinx.serialization.Serializable const val CHROME_SUBTITLE_KEY = "chome_subtitle_settings" @Serializable +@Immutable data class SaveChromeCaptionStyle( - @JsonProperty("fontFamily") @SerialName("fontFamily") var fontFamily: String? = null, - @JsonProperty("fontGenericFamily") @SerialName("fontGenericFamily") var fontGenericFamily: Int? = null, - @JsonProperty("backgroundColor") @SerialName("backgroundColor") var backgroundColor: Int = 0x00FFFFFF, // transparent - @JsonProperty("edgeColor") @SerialName("edgeColor") var edgeColor: Int = Color.BLACK, // BLACK - @JsonProperty("edgeType") @SerialName("edgeType") var edgeType: Int = EDGE_TYPE_OUTLINE, - @JsonProperty("foregroundColor") @SerialName("foregroundColor") var foregroundColor: Int = Color.WHITE, - @JsonProperty("fontScale") @SerialName("fontScale") var fontScale: Float = 1.05f, - @JsonProperty("windowColor") @SerialName("windowColor") var windowColor: Int = Color.TRANSPARENT, + @JsonProperty("fontFamily") @SerialName("fontFamily") val fontFamily: String? = null, + @JsonProperty("fontGenericFamily") @SerialName("fontGenericFamily") val fontGenericFamily: Int? = null, + @JsonProperty("backgroundColor") @SerialName("backgroundColor") val backgroundColor: Int = 0x00FFFFFF, // transparent + @JsonProperty("edgeColor") @SerialName("edgeColor") val edgeColor: Int = Color.BLACK, // BLACK + @JsonProperty("edgeType") @SerialName("edgeType") val edgeType: Int = EDGE_TYPE_OUTLINE, + @JsonProperty("foregroundColor") @SerialName("foregroundColor") val foregroundColor: Int = Color.WHITE, + @JsonProperty("fontScale") @SerialName("fontScale") val fontScale: Float = 1.05f, + @JsonProperty("windowColor") @SerialName("windowColor") val windowColor: Int = Color.TRANSPARENT, ) class ChromecastSubtitlesFragment : BaseFragment( @@ -92,6 +95,9 @@ class ChromecastSubtitlesFragment : BaseFragment(CHROME_SUBTITLE_KEY) ?: defaultState + return chromeCastSubtitleState.value } - private val defaultState = SaveChromeCaptionStyle() + val defaultChromeCastSubtitleState = SaveChromeCaptionStyle() + val chromeCastSubtitleState = mutableStateOf( + getKey(CHROME_SUBTITLE_KEY) ?: defaultChromeCastSubtitleState + ) } private fun onColorSelected(stuff: Pair) { @@ -134,10 +143,10 @@ class ChromecastSubtitlesFragment : BaseFragment state.foregroundColor = realColor - 1 -> state.edgeColor = realColor - 2 -> state.backgroundColor = realColor - 3 -> state.windowColor = realColor + 0 -> state = state.copy(foregroundColor = realColor) + 1 -> state = state.copy(edgeColor = realColor) + 2 -> state = state.copy(backgroundColor = realColor) + 3 -> state = state.copy(windowColor = realColor) else -> Unit } @@ -240,13 +249,13 @@ class ChromecastSubtitlesFragment : BaseFragment - state.edgeType = edgeTypes.map { it.first }[index] + state = state.copy(edgeType = edgeTypes.map { it.first }[index]) updateState() } } binding.subsEdgeType.setOnLongClickListener { - state.edgeType = defaultState.edgeType + state = state.copy(edgeType = defaultChromeCastSubtitleState.edgeType) updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true @@ -281,13 +290,13 @@ class ChromecastSubtitlesFragment : BaseFragment - state.fontScale = fontSizes.map { it.first }[index] + state = state.copy(fontScale = fontSizes.map { it.first }[index]) //textView.context.updateState() // font size not changed } } binding.subsFontSize.setOnLongClickListener { _ -> - state.fontScale = defaultState.fontScale + state = state.copy(fontScale = defaultChromeCastSubtitleState.fontScale) //textView.context.updateState() // font size not changed showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true @@ -314,12 +323,12 @@ class ChromecastSubtitlesFragment : BaseFragment - state.fontFamily = fontTypes.map { it.first }[index] + state = state.copy(fontFamily = fontTypes.map { it.first }[index]) updateState() } } binding.subsFont.setOnLongClickListener { _ -> - state.fontFamily = defaultState.fontFamily + state = state.copy(fontFamily = defaultChromeCastSubtitleState.fontFamily) updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment2.kt new file mode 100644 index 00000000000..a25a8aa8864 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment2.kt @@ -0,0 +1,6 @@ +package com.lagradost.cloudstream3.ui.subtitles + +import com.lagradost.cloudstream3.utils.BaseComposeFragment +import com.lagradost.cloudstream4.compose.Screen + +class ChromecastSubtitlesFragment2 : BaseComposeFragment(), Screen by ChromecastSubtitlesScreen \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesScreen.kt new file mode 100644 index 00000000000..da27be05e1a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesScreen.kt @@ -0,0 +1,177 @@ +package com.lagradost.cloudstream3.ui.subtitles + +import androidx.compose.foundation.layout.RowScope +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.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DEPRESSED +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DROP_SHADOW +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_NONE +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_OUTLINE +import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_RAISED +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment.Companion.applyStyleEvent +import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment.Companion.chromeCastSubtitleState +import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment.Companion.defaultChromeCastSubtitleState +import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment.Companion.saveStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesScreen.SetUpUndoBar +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlin.math.roundToInt + +object ChromecastSubtitlesScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(R.string.chromecast_subtitles_settings) + + // For ux purposes we also want to show a diff of what will be reset + private val diff = arrayOf Any?>>( + R.string.subs_font to { style -> style.fontFamily }, + R.string.subs_text_color to { state -> state.foregroundColor }, + R.string.subs_font_size to { state -> state.fontScale }, + R.string.subs_edge_type to { state -> state.edgeType }, + R.string.subs_outline_color to { state -> state.edgeColor }, + R.string.subs_window_color to { state -> state.windowColor }, + R.string.subs_background_color to { state -> state.backgroundColor }, + ) + + @Composable + override fun RowScope.AppBarAction() { + val initialState = remember { chromeCastSubtitleState.value } + SetUpUndoBar( + diff = diff, + initialState = initialState, + currentState = chromeCastSubtitleState, + default = defaultChromeCastSubtitleState + ) + } + + @Composable + override fun getPreferences(): List { + val initialState = remember { chromeCastSubtitleState.value } + var state by remember { chromeCastSubtitleState } + + val context = LocalContext.current + + SideEffect(state) { + // When we modify the state, we modify the stored data + context.saveStyle(state) + } + + DisposableEffect(Unit) { + onDispose { + if (initialState != state) { + applyStyleEvent.invoke(state) + } + } + } + return getPreferences(state) { updater -> + state = updater(state) + } + } + + /** Unfortunately this causes a lot of re-compositions, but what can you do? */ + @Composable + fun getPreferences( + state : SaveChromeCaptionStyle, + update : (SaveChromeCaptionStyle.() -> SaveChromeCaptionStyle) -> Unit + ): List { + return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(R.string.subs_font), + preferenceItems = persistentListOf( + Preference.PreferenceItem.BasicListPreference( + icon = painterResource(R.drawable.font_download_24px), + title = stringResource(R.string.subs_font), + value = state.fontFamily, + entries = persistentMapOf( + null to stringResource(R.string.normal), + "Droid Sans" to "Droid Sans", + "Droid Sans Mono" to "Droid Sans Mono", + "Droid Serif Regular" to "Droid Serif Regular", + "Cutive Mono" to "Cutive Mono", + "Short Stack" to "Short Stack", + "Quintessential" to "Quintessential", + "Alegreya Sans SC" to "Alegreya Sans SC", + ), + onValueChanged = { newValue -> + update { copy(fontFamily = newValue) } + }), + Preference.PreferenceItem.BasicColorPreference( + title = stringResource(R.string.subs_text_color), + value = Color(state.foregroundColor), + icon = painterResource(R.drawable.format_color_text_24px), + onValueChanged = { newValue -> + update { copy(foregroundColor = newValue.toArgb()) } + } + ), + Preference.PreferenceItem.SliderPreference( + value = state.fontScale.times(100.0f).roundToInt(), + icon = painterResource(R.drawable.text_fields_24px), + title = stringResource(R.string.subs_font_size), + valueRange = 75..150, + steps = 75/5 - 1, + onValueChanged = { newValue -> + update { copy(fontScale = newValue.toFloat() * 0.01f) } + }) + ) + ), + Preference.PreferenceGroup( + title = stringResource(R.string.subs_edge), + preferenceItems = persistentListOf( + Preference.PreferenceItem.BasicListPreference( + icon = painterResource(R.drawable.shadow_24px), + title = stringResource(R.string.subs_edge_type), + value = state.edgeType, + entries = persistentMapOf( + EDGE_TYPE_NONE to stringResource(R.string.subtitles_none), + EDGE_TYPE_OUTLINE to stringResource(R.string.subtitles_outline), + EDGE_TYPE_DEPRESSED to stringResource(R.string.subtitles_depressed), + EDGE_TYPE_DROP_SHADOW to stringResource(R.string.subtitles_shadow), + EDGE_TYPE_RAISED to stringResource(R.string.subtitles_raised), + ), + onValueChanged = { newValue -> + update { copy(edgeType = newValue) } + }), + Preference.PreferenceItem.BasicColorPreference( + enabled = state.edgeType != EDGE_TYPE_NONE, + title = stringResource(R.string.subs_outline_color), + value = Color(state.edgeColor), + icon = painterResource(R.drawable.border_color_24px), + onValueChanged = { newValue -> + update { copy(edgeColor = newValue.toArgb()) } + } + ), + )), + Preference.PreferenceGroup( + title = stringResource(R.string.background), + preferenceItems = persistentListOf( + Preference.PreferenceItem.BasicColorPreference( + title = stringResource(R.string.subs_window_color), + value = Color(state.windowColor), + icon = painterResource(R.drawable.imagesearch_roller_24px), + onValueChanged = { newValue -> + update { copy(windowColor = newValue.toArgb()) } + } + ), + Preference.PreferenceItem.BasicColorPreference( + title = stringResource(R.string.subs_background_color), + value = Color(state.backgroundColor), + icon = painterResource(R.drawable.format_color_fill_24px), + onValueChanged = { newValue -> + update { copy(backgroundColor = newValue.toArgb()) } + } + ), + )) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt index bf77ef24767..757d8a94033 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt @@ -9,6 +9,7 @@ import android.os.Bundle import android.text.Layout import android.text.Spannable import android.text.SpannableString +import android.text.SpannableStringBuilder import android.text.style.StyleSpan import android.util.DisplayMetrics import android.util.TypedValue @@ -18,6 +19,8 @@ import android.widget.Toast import androidx.annotation.FontRes import androidx.annotation.OptIn import androidx.annotation.Px +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateOf import androidx.core.content.edit import androidx.core.content.res.ResourcesCompat import androidx.media3.common.text.Cue @@ -63,30 +66,80 @@ const val SUBTITLE_AUTO_SELECT_KEY = "subs_auto_select" const val SUBTITLE_DOWNLOAD_KEY = "subs_auto_download" @Serializable +enum class SubtitleFont( + @FontRes val resource: Int, + val label: String +) { + @JsonProperty("Trebuchet") + @SerialName("Trebuchet") + Trebuchet(R.font.trebuchet_ms, "Trebuchet MS"), + @JsonProperty("Netflix") + @SerialName("Netflix") + Netflix(R.font.netflix_sans, "Netflix Sans"), + @JsonProperty("Google") + @SerialName("Google") + Google(R.font.google_sans, "Google Sans"), + @JsonProperty("Open") + @SerialName("Open") + Open(R.font.open_sans, "Open Sans"), + @JsonProperty("Futura") + @SerialName("Futura") + Futura(R.font.futura, "Futura"), + @JsonProperty("Consola") + @SerialName("Consola") + Consola(R.font.consola, "Consola"), + @JsonProperty("Gotham") + @SerialName("Gotham") + Gotham(R.font.gotham, "Gotham"), + @JsonProperty("Lucida") + @SerialName("Lucida") + Lucida(R.font.lucida_grande, "Lucida Grande"), + @JsonProperty("STIX") + @SerialName("STIX") + STIX(R.font.stix_general, "STIX General"), + @JsonProperty("TimesNewRoman") + @SerialName("TimesNewRoman") + TimesNewRoman(R.font.times_new_roman, "Times New Roman"), + @JsonProperty("Verdana") + @SerialName("Verdana") + Verdana(R.font.verdana, "Verdana"), + @JsonProperty("Ubuntu") + @SerialName("Ubuntu") + Ubuntu(R.font.ubuntu_regular, "Ubuntu"), + @JsonProperty("Comic") + @SerialName("Comic") + Comic(R.font.comic_sans, "Comic Sans"), + @JsonProperty("Poppins") + @SerialName("Poppins") + Poppins(R.font.poppins_regular, "Poppins"), +} + +@Serializable +@Immutable data class SaveCaptionStyle( - @JsonProperty("foregroundColor") @SerialName("foregroundColor") var foregroundColor: Int, - @JsonProperty("backgroundColor") @SerialName("backgroundColor") var backgroundColor: Int, - @JsonProperty("windowColor") @SerialName("windowColor") var windowColor: Int, + @JsonProperty("foregroundColor") @SerialName("foregroundColor") val foregroundColor: Int, + @JsonProperty("backgroundColor") @SerialName("backgroundColor") val backgroundColor: Int, + @JsonProperty("windowColor") @SerialName("windowColor") val windowColor: Int, @OptIn(UnstableApi::class) - @JsonProperty("edgeType") @SerialName("edgeType") var edgeType: @CaptionStyleCompat.EdgeType Int, - @JsonProperty("edgeColor") @SerialName("edgeColor") var edgeColor: Int, - @FontRes @JsonProperty("typeface") @SerialName("typeface") var typeface: Int?, - @JsonProperty("typefaceFilePath") @SerialName("typefaceFilePath") var typefaceFilePath: String?, - @JsonProperty("elevation") @SerialName("elevation") var elevation: Int, // in dp - @JsonProperty("fixedTextSize") @SerialName("fixedTextSize") var fixedTextSize: Float?, // in sp - @Px @JsonProperty("edgeSize") @SerialName("edgeSize") var edgeSize: Float? = null, - @JsonProperty("removeCaptions") @SerialName("removeCaptions") var removeCaptions: Boolean = false, - @JsonProperty("removeBloat") @SerialName("removeBloat") var removeBloat: Boolean = true, + @JsonProperty("edgeType") @SerialName("edgeType") val edgeType: @CaptionStyleCompat.EdgeType Int, + @JsonProperty("edgeColor") @SerialName("edgeColor") val edgeColor: Int, + @JsonProperty("font") @SerialName("font") val font: SubtitleFont? = null, + @JsonProperty("typefaceFilePath") @SerialName("typefaceFilePath") val typefaceFilePath: String?, + @JsonProperty("elevation") @SerialName("elevation") val elevation: Int, // in dp + @JsonProperty("fixedTextSize") @SerialName("fixedTextSize") val fixedTextSize: Float?, // in sp + @Px @JsonProperty("edgeSize") @SerialName("edgeSize") val edgeSize: Float? = null, + @JsonProperty("removeCaptions") @SerialName("removeCaptions") val removeCaptions: Boolean = false, + @JsonProperty("removeBloat") @SerialName("removeBloat") val removeBloat: Boolean = true, /** Apply caps lock to the text */ - @JsonProperty("upperCase") @SerialName("upperCase") var upperCase: Boolean = false, + @JsonProperty("upperCase") @SerialName("upperCase") val upperCase: Boolean = false, /** Apply bold to the text */ - @JsonProperty("bold") @SerialName("bold") var bold: Boolean = false, + @JsonProperty("bold") @SerialName("bold") val bold: Boolean = false, /** Apply italic to the text */ - @JsonProperty("italic") @SerialName("italic") var italic: Boolean = false, + @JsonProperty("italic") @SerialName("italic") val italic: Boolean = false, /** in px, background radius, aka how round the background (backgroundColor) on each row is */ - @JsonProperty("backgroundRadius") @SerialName("backgroundRadius") var backgroundRadius: Float? = null, + @JsonProperty("backgroundRadius") @SerialName("backgroundRadius") val backgroundRadius: Float? = null, /** The SSA_ALIGNMENT */ - @JsonProperty("alignment") @SerialName("alignment") var alignment: Int? = null, + @JsonProperty("alignment") @SerialName("alignment") val alignment: Int? = null, ) const val DEF_SUBS_ELEVATION = 20 @@ -130,7 +183,7 @@ class SubtitlesFragment : BaseDialogFragment( } fun Cue.Builder.applyStyle(style: SaveCaptionStyle): Cue.Builder { - val edgeSize = style.edgeSize + val edgeSize = style.edgeSize ?: 0.0f /* This is old code for only applying on non null @@ -148,22 +201,18 @@ class SubtitlesFragment : BaseDialogFragment( // instead we use the inherit size of the subtitle view setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET) - // 2. apply edge text?.let { text -> - val customSpan = SpannableString.valueOf(text) - if (edgeSize != null) { - customSpan.setSpan( - OutlineSpan(edgeSize), 0, customSpan.length, + val textSpanBuilder = SpannableStringBuilder(text) + + // 2. apply edge + if (edgeSize > 0.0f) { + textSpanBuilder.setSpan( + OutlineSpan(edgeSize), 0, textSpanBuilder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } - setText(customSpan) - } - - // 3. apply bold + italic - text?.let { text -> - val customSpan = SpannableString.valueOf(text) + // 3. apply bold + italic val typeface = when (style.bold to style.italic) { (true to true) -> Typeface.BOLD_ITALIC (true to false) -> Typeface.BOLD @@ -175,39 +224,36 @@ class SubtitlesFragment : BaseDialogFragment( } if (typeface != Typeface.NORMAL) { val styleSpan = StyleSpan(typeface) - customSpan.setSpan( - styleSpan, 0, customSpan.length, + textSpanBuilder.setSpan( + styleSpan, 0, textSpanBuilder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } - setText(customSpan) - } - - // 4. apply radius - text?.let { text -> - val customSpan = SpannableString.valueOf(text) - val radius = style.backgroundRadius - if (radius != null && style.backgroundColor != Color.TRANSPARENT) { + // 4. apply radius + val radius = style.backgroundRadius ?: 0.0f + if (radius > 0.0f && style.backgroundColor != Color.TRANSPARENT) { val styleSpan = RoundedBackgroundColorSpan( style.backgroundColor, this.textAlignment ?: Layout.Alignment.ALIGN_CENTER, 2.0F + radius * 0.5f, radius ) - customSpan.setSpan( - styleSpan, 0, customSpan.length, + textSpanBuilder.setSpan( + styleSpan, 0, textSpanBuilder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } - setText(customSpan) - } - // 5. remove captions - text?.let { text -> + // 5. remove captions but ensure that styling is kept + // Just doing .replace removes all styling! if (style.removeCaptions) { - setText(text.replace(captionRegex, "")) + for (match in captionRegex.findAll(text).toList().asReversed()) { + textSpanBuilder.delete(match.range.first, match.range.last + 1) + } } + + this.setText(textSpanBuilder) } // 6. set alignment @@ -217,22 +263,22 @@ class SubtitlesFragment : BaseDialogFragment( private fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { return CaptionStyleCompat( data.foregroundColor, - // we actually override with a custom span when backgroundRadius != null - if (data.backgroundRadius == null) data.backgroundColor else Color.TRANSPARENT, + data.backgroundColor, data.windowColor, data.edgeType, data.edgeColor, data.typefaceFilePath?.let { try { // RuntimeException: Font asset not found + // Fuck android, they have no good way to load a font from a uri without a copy Typeface.createFromFile(File(it)) - } catch (e: Exception) { + } catch (_: Exception) { null } - } ?: data.typeface?.let { + } ?: data.font?.let { font -> ResourcesCompat.getFont( this, - it + font.resource ) } ?: Typeface.SANS_SERIF @@ -256,25 +302,29 @@ class SubtitlesFragment : BaseDialogFragment( } } - private var cachedSubtitleStyle: SaveCaptionStyle? = null - fun Context.saveStyle(style: SaveCaptionStyle) { - cachedSubtitleStyle = style + if (subtitleStyleState.value !== style) { + subtitleStyleState.value = style + } this.setKey(SUBTITLE_KEY, style) } + val defaultSubtitleStyle = SaveCaptionStyle( + foregroundColor = getDefColor(0), + backgroundColor = getDefColor(2), + windowColor = getDefColor(3), + edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE, + edgeColor = getDefColor(1), + font = null, + typefaceFilePath = null, + elevation = DEF_SUBS_ELEVATION, + fixedTextSize = null, + ) + val subtitleStyleState = + mutableStateOf((getKey(SUBTITLE_KEY) ?: defaultSubtitleStyle)) + fun getCurrentSavedStyle(): SaveCaptionStyle { - return cachedSubtitleStyle ?: (getKey(SUBTITLE_KEY) ?: SaveCaptionStyle( - foregroundColor = getDefColor(0), - backgroundColor = getDefColor(2), - windowColor = getDefColor(3), - edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE, - edgeColor = getDefColor(1), - typeface = null, - typefaceFilePath = null, - elevation = DEF_SUBS_ELEVATION, - fixedTextSize = null, - )).also { cachedSubtitleStyle = it } + return subtitleStyleState.value } private fun Context.getSavedFonts(): List { @@ -318,10 +368,10 @@ class SubtitlesFragment : BaseDialogFragment( private fun Context.setColor(id: Int, color: Int?) { val realColor = color ?: getDefColor(id) when (id) { - 0 -> state.foregroundColor = realColor - 1 -> state.edgeColor = realColor - 2 -> state.backgroundColor = realColor - 3 -> state.windowColor = realColor + 0 -> state = state.copy(foregroundColor = realColor) + 1 -> state = state.copy(edgeColor = realColor) + 2 -> state = state.copy(backgroundColor = realColor) + 3 -> state = state.copy(windowColor = realColor) else -> Unit } @@ -447,7 +497,7 @@ class SubtitlesFragment : BaseDialogFragment( false, dismissCallback ) { index -> - state.elevation = elevationTypes.map { it.first }[index] + state = state.copy(elevation = elevationTypes.map { it.first }[index]) textView.context.updateState() if (hide) activity?.hideSystemUI() @@ -455,7 +505,7 @@ class SubtitlesFragment : BaseDialogFragment( } subsSubtitleElevation.setOnLongClickListener { - state.elevation = DEF_SUBS_ELEVATION + state = state.copy(elevation = DEF_SUBS_ELEVATION) it.context.updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true @@ -478,13 +528,14 @@ class SubtitlesFragment : BaseDialogFragment( false, dismissCallback ) { index -> - state.backgroundRadius = radiusTypes.map { it.first }[index]?.toFloat() + state = + state.copy(backgroundRadius = radiusTypes.map { it.first }[index]?.toFloat()) textView.context.updateState() } } subsBackgroundRadius.setOnLongClickListener { - state.backgroundRadius = null + state = state.copy(backgroundRadius = null) it.context.updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true @@ -512,7 +563,7 @@ class SubtitlesFragment : BaseDialogFragment( false, dismissCallback ) { index -> - state.alignment = alignmentTypes.map { it.first }[index] + state = state.copy(alignment = alignmentTypes.map { it.first }[index]) textView.context.updateState() } } @@ -540,13 +591,13 @@ class SubtitlesFragment : BaseDialogFragment( false, dismissCallback ) { index -> - state.edgeType = edgeTypes.map { it.first }[index] + state = state.copy(edgeType = edgeTypes.map { it.first }[index]) textView.context.updateState() } } subsEdgeType.setOnLongClickListener { - state.edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE + state = state.copy(edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE) it.context.updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true @@ -566,7 +617,7 @@ class SubtitlesFragment : BaseDialogFragment( false, dismissCallback ) { index -> - state.fixedTextSize = fontSizes.map { it.first }[index] + state = state.copy(fixedTextSize = fontSizes.map { it.first }[index]) textView.context.updateState() } } @@ -585,47 +636,47 @@ class SubtitlesFragment : BaseDialogFragment( false, dismissCallback ) { index -> - state.edgeSize = fontSizes.map { it.first }[index] + state = state.copy(edgeSize = fontSizes.map { it.first }[index]) textView.context.updateState() } } subtitlesRemoveBloat.isChecked = state.removeBloat subtitlesRemoveBloat.setOnCheckedChangeListener { _, b -> - state.removeBloat = b + state = state.copy(removeBloat = b) } subtitlesUppercase.isChecked = state.upperCase subtitlesUppercase.setOnCheckedChangeListener { _, b -> - state.upperCase = b + state = state.copy(upperCase = b) context?.updateState() } subtitlesRemoveCaptions.isChecked = state.removeCaptions subtitlesRemoveCaptions.setOnCheckedChangeListener { _, b -> - state.removeCaptions = b + state = state.copy(removeCaptions = b) } subtitlesBold.isChecked = state.bold subtitlesBold.setOnCheckedChangeListener { _, b -> - state.bold = b + state = state.copy(bold = b) context?.updateState() } subtitlesItalic.isChecked = state.italic subtitlesItalic.setOnCheckedChangeListener { _, b -> - state.italic = b + state = state.copy(italic = b) context?.updateState() } subsFontSize.setOnLongClickListener { _ -> - state.fixedTextSize = null + state = state.copy(fixedTextSize = null) context?.updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } subsEdgeSize.setOnLongClickListener { _ -> - state.edgeSize = null + state = state.copy(edgeSize = null) context?.updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true @@ -671,7 +722,7 @@ class SubtitlesFragment : BaseDialogFragment( savedFontTypes.indexOfFirst { it.absolutePath == state.typefaceFilePath } .let { index -> if (index == -1) - fontTypes.indexOfFirst { it.first == state.typeface } + fontTypes.indexOfFirst { it.first == state.font?.resource } else index + fontTypes.size } @@ -683,20 +734,23 @@ class SubtitlesFragment : BaseDialogFragment( false, dismissCallback ) { index -> - if (index < fontTypes.size) { - state.typeface = fontTypes[index].first - state.typefaceFilePath = null + state = if (index < fontTypes.size) { + state.copy( + font = SubtitleFont.entries.firstOrNull { it.resource == fontTypes[index].first }, + typefaceFilePath = null + ) } else { - state.typefaceFilePath = savedFontTypes[index - fontTypes.size].absolutePath - state.typeface = null + state.copy( + typefaceFilePath = savedFontTypes[index - fontTypes.size].absolutePath, + font = null + ) } textView.context.updateState() } } subsFont.setOnLongClickListener { textView -> - state.typeface = null - state.typefaceFilePath = null + state = state.copy(font = null, typefaceFilePath = null) textView.context.updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment2.kt new file mode 100644 index 00000000000..b9fd405567e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment2.kt @@ -0,0 +1,8 @@ +package com.lagradost.cloudstream3.ui.subtitles + +import com.lagradost.cloudstream3.utils.BaseComposeFragment +import com.lagradost.cloudstream3.utils.BaseDialogComposeFragment +import com.lagradost.cloudstream4.compose.Screen + +class SubtitlesFragment2 : BaseComposeFragment(), Screen by SubtitlesScreen +class SubtitlesFragmentDialog2 : BaseDialogComposeFragment(), Screen by SubtitlesScreen \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesScreen.kt new file mode 100644 index 00000000000..4c62d7b821d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesScreen.kt @@ -0,0 +1,431 @@ +package com.lagradost.cloudstream3.ui.subtitles + +import android.text.SpannableString +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.text.Cue +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.SubtitleView +import coil3.compose.AsyncImage +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.player.CustomDecoder +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyleEvent +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.defaultSubtitleStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.saveStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle +import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.subtitleStyleState +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral +import com.lagradost.cloudstream3.utils.SubtitleHelper.languages +import com.lagradost.cloudstream4.compose.ActionDialog +import com.mihon.common.preference.AndroidPreferenceStore +import com.mihon.common.preference.DataPreferenceStore +import com.mihon.presentation.settings.Preference +import com.mihon.presentation.settings.SearchableSettings +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf + +object SubtitlesScreen : SearchableSettings { + @Composable + override fun getTitleRes(): String = stringResource(R.string.subtitles_settings) + + // For ux purposes we also want to show a diff of what will be reset + private val diff = arrayOf Any?>>( + R.string.subs_font to { style -> style.font }, + R.string.subs_text_color to { state -> state.foregroundColor }, + R.string.subs_font_size to { state -> state.fixedTextSize }, + R.string.uppercase_all_subtitles to { state -> state.upperCase }, + R.string.all_subtitles_bold to { state -> state.bold }, + R.string.all_subtitles_italic to { state -> state.italic }, + R.string.subs_edge_type to { state -> state.edgeType }, + R.string.subs_outline_color to { state -> state.edgeColor }, + R.string.subs_edge_size to { state -> state.edgeSize }, + R.string.subs_window_color to { state -> state.windowColor }, + R.string.subs_background_color to { state -> state.backgroundColor }, + R.string.background_radius to { state -> state.backgroundRadius }, + R.string.subs_subtitle_alignment to { state -> state.alignment }, + R.string.subs_subtitle_elevation to { state -> state.elevation }, + R.string.subtitles_remove_captions to { state -> state.removeCaptions }, + R.string.subtitles_remove_bloat to { state -> state.removeBloat }, + ) + + @Composable + fun SetUpUndoBar( + diff: Array Any?>>, + initialState: T, + currentState: MutableState, + default: T + ) { + var currentState by currentState + var dialogShown by remember { mutableStateOf(false) } + if (dialogShown) { + ActionDialog( + title = stringResource(R.string.subs_default_reset_toast), + text = diff.filter { (_, f) -> f(default) != f(currentState) } + .map { (name, _) -> stringResource(name) }.joinToString(separator = "\n"), + confirmText = stringResource(R.string.reset_btn), + dismissText = stringResource(R.string.dismiss), + confirm = { + currentState = default + dialogShown = false + }, + dismiss = { + dialogShown = false + }) + } + + // Undo, current -> initial last saved value + AnimatedVisibility(currentState != initialState) { + IconButton(onClick = { + currentState = initialState + }) { + Icon( + painter = painterResource(R.drawable.undo_24px), + contentDescription = stringResource(R.string.reset_btn), + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + + // Reset, current -> default + AnimatedVisibility(currentState != default) { + IconButton(onClick = { + dialogShown = true + }) { + Icon( + painter = painterResource(R.drawable.reset_colors_24px), + contentDescription = stringResource(R.string.subs_default_reset_toast), + tint = MaterialTheme.colorScheme.onBackground + ) + } + } + } + + @Composable + override fun RowScope.AppBarAction() { + val initialState = remember { subtitleStyleState.value } + SetUpUndoBar( + diff = diff, + initialState = initialState, + currentState = subtitleStyleState, + default = defaultSubtitleStyle + ) + } + + @OptIn(UnstableApi::class) + @Composable + override fun getPreferences(): List { + val initialState = remember { subtitleStyleState.value } + var state by subtitleStyleState + val context = LocalContext.current + + SideEffect(state) { + // When we modify the state, we modify the stored data + context.saveStyle(state) + } + + DisposableEffect(Unit) { + onDispose { + if (initialState != state) { + applyStyleEvent.invoke(state) + } + } + } + + return getPreferences(state) { updater -> + state = updater(state) + } + } + + /** Unfortunately this causes a lot of re-compositions, but what can you do? */ + @OptIn(UnstableApi::class) + @Composable + fun getPreferences( + state: SaveCaptionStyle, + update: (SaveCaptionStyle.() -> SaveCaptionStyle) -> Unit + ): List { + val text = stringResource(R.string.subtitles_example_text) + val fixedText = SpannableString.valueOf(if (state.upperCase) text.uppercase() else text) + + val dataStore = DataPreferenceStore(LocalContext.current) + val androidStore = AndroidPreferenceStore(LocalContext.current) + + val autoSelectSubtitles = dataStore.getString(SUBTITLE_AUTO_SELECT_KEY, "en") + val downloadSubsLanguage = + dataStore.getObjectFromString(SUBTITLE_DOWNLOAD_KEY, setOf("en"), serializer = { obj -> + obj.toList().toJsonLiteral() + }, deserializer = { json -> + parseJson>(json).toSet() + }) + + val subtitlesFilterSubLang = androidStore.getBoolean("filter_sub_lang_key", false) + + return persistentListOf( + Preference.PreferenceItem.CustomPreference( + title = stringResource(R.string.skip_type_preview), content = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(75.dp) + ) { + AsyncImage( + contentDescription = stringResource(R.string.preview_background_img_des), + model = R.drawable.subtitles_preview_background, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> + SubtitleView(context).apply { + setSubtitleViewStyle(this, state, false) + this.setCues( + listOf( + Cue.Builder().setText(fixedText).applyStyle(state).build() + ) + ) + } + }, update = { view -> + setSubtitleViewStyle(view, state, false) + view.setCues( + listOf( + Cue.Builder().setText(fixedText).applyStyle(state).build() + ) + ) + }) + } + }), + Preference.PreferenceGroup( + title = stringResource(R.string.subs_font), + preferenceItems = persistentListOf( + Preference.PreferenceItem.BasicListPreference( + icon = painterResource(R.drawable.font_download_24px), + title = stringResource(R.string.subs_font), + value = state.font, + entries = mapOf(null to stringResource(R.string.normal)) + SubtitleFont.entries.associateWith { it.label }, + onValueChanged = { newValue -> + update { copy(font = newValue) } + }), + Preference.PreferenceItem.BasicColorPreference( + title = stringResource(R.string.subs_text_color), + value = Color(state.foregroundColor), + icon = painterResource(R.drawable.format_color_text_24px), + onValueChanged = { newValue -> + update { copy(foregroundColor = newValue.toArgb()) } + } + ), + Preference.PreferenceItem.SliderPreference( + value = state.fixedTextSize?.toInt() ?: 25, + icon = painterResource(R.drawable.text_fields_24px), + title = stringResource(R.string.subs_font_size), + valueRange = 5..60, + onValueChanged = { newValue -> + update { copy(fixedTextSize = newValue.toFloat()) } + }), + + Preference.PreferenceItem.BasicSwitchPreference( + value = state.upperCase, + icon = painterResource(R.drawable.uppercase_24px), + title = stringResource(R.string.uppercase_all_subtitles), + onValueChanged = { newValue -> + update { copy(upperCase = newValue) } + }, + ), + Preference.PreferenceItem.BasicSwitchPreference( + value = state.bold, + icon = painterResource(R.drawable.format_bold_24px), + title = stringResource(R.string.all_subtitles_bold), + onValueChanged = { newValue -> + update { copy(bold = newValue) } + }, + ), + Preference.PreferenceItem.BasicSwitchPreference( + value = state.italic, + icon = painterResource(R.drawable.format_italic_24px), + title = stringResource(R.string.all_subtitles_italic), + onValueChanged = { newValue -> + update { copy(italic = newValue) } + }, + ), + ) + ), + Preference.PreferenceGroup( + title = stringResource(R.string.subs_edge), + preferenceItems = persistentListOf( + Preference.PreferenceItem.BasicListPreference( + icon = painterResource(R.drawable.shadow_24px), + title = stringResource(R.string.subs_edge_type), + value = state.edgeType, + entries = persistentMapOf( + CaptionStyleCompat.EDGE_TYPE_NONE to stringResource(R.string.subtitles_none), + CaptionStyleCompat.EDGE_TYPE_OUTLINE to stringResource(R.string.subtitles_outline), + CaptionStyleCompat.EDGE_TYPE_DEPRESSED to stringResource(R.string.subtitles_depressed), + CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW to stringResource(R.string.subtitles_shadow), + CaptionStyleCompat.EDGE_TYPE_RAISED to stringResource(R.string.subtitles_raised), + ), + onValueChanged = { newValue -> + update { copy(edgeType = newValue) } + }), + Preference.PreferenceItem.BasicColorPreference( + enabled = state.edgeType != CaptionStyleCompat.EDGE_TYPE_NONE, + title = stringResource(R.string.subs_outline_color), + value = Color(state.edgeColor), + icon = painterResource(R.drawable.border_color_24px), + onValueChanged = { newValue -> + update { copy(edgeColor = newValue.toArgb()) } + } + ), + Preference.PreferenceItem.SliderPreference( + enabled = state.edgeType == CaptionStyleCompat.EDGE_TYPE_OUTLINE, + value = state.edgeSize?.toInt() ?: 0, + icon = painterResource(R.drawable.shadow_add_24px), + title = stringResource(R.string.subs_edge_size), + valueRange = 0..60, + onValueChanged = { newValue -> + update { copy(edgeSize = newValue.toFloat()) } + }), + ) + ), + Preference.PreferenceGroup( + title = stringResource(R.string.background), + preferenceItems = persistentListOf( + Preference.PreferenceItem.BasicColorPreference( + title = stringResource(R.string.subs_window_color), + value = Color(state.windowColor), + icon = painterResource(R.drawable.imagesearch_roller_24px), + onValueChanged = { newValue -> + update { copy(windowColor = newValue.toArgb()) } + } + ), + Preference.PreferenceItem.BasicColorPreference( + title = stringResource(R.string.subs_background_color), + value = Color(state.backgroundColor), + icon = painterResource(R.drawable.format_color_fill_24px), + onValueChanged = { newValue -> + update { copy(backgroundColor = newValue.toArgb()) } + } + ), + Preference.PreferenceItem.SliderPreference( + value = state.backgroundRadius?.toInt() ?: 0, + icon = painterResource(R.drawable.rounded_corner_24px), + title = stringResource(R.string.background_radius), + valueRange = 0..50, + steps = 9, + onValueChanged = { newValue -> + update { copy(backgroundRadius = newValue.toFloat()) } + }), + ) + ), + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_player_layout), + preferenceItems = persistentListOf( + Preference.PreferenceItem.BasicListPreference( + icon = painterResource(R.drawable.format_align_center_24px), + title = stringResource(R.string.subs_subtitle_alignment), + value = state.alignment, + entries = persistentMapOf( + null to stringResource(R.string.automatic), + CustomDecoder.SSA_ALIGNMENT_BOTTOM_LEFT to stringResource(R.string.bottom_left), + CustomDecoder.SSA_ALIGNMENT_BOTTOM_CENTER to stringResource(R.string.bottom_center), + CustomDecoder.SSA_ALIGNMENT_BOTTOM_RIGHT to stringResource(R.string.bottom_right), + CustomDecoder.SSA_ALIGNMENT_MIDDLE_LEFT to stringResource(R.string.middle_left), + CustomDecoder.SSA_ALIGNMENT_MIDDLE_CENTER to stringResource(R.string.middle_center), + CustomDecoder.SSA_ALIGNMENT_MIDDLE_RIGHT to stringResource(R.string.middle_right), + CustomDecoder.SSA_ALIGNMENT_TOP_LEFT to stringResource(R.string.top_left), + CustomDecoder.SSA_ALIGNMENT_TOP_CENTER to stringResource(R.string.top_center), + CustomDecoder.SSA_ALIGNMENT_TOP_RIGHT to stringResource(R.string.top_right), + ), + onValueChanged = { newValue -> + update { copy(alignment = newValue) } + }), + Preference.PreferenceItem.SliderPreference( + value = state.elevation, + title = stringResource(R.string.subs_subtitle_elevation), + icon = painterResource(R.drawable.text_select_move_up_24px), + valueRange = 0..400, + steps = 39, + onValueChanged = { newValue -> + update { copy(elevation = newValue) } + }), + ) + ), + Preference.PreferenceGroup( + title = stringResource(R.string.extension_language), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + icon = painterResource(R.drawable.language_korean_latin_24px), + title = stringResource(R.string.subs_auto_select_language), + preference = autoSelectSubtitles, + entries = mapOf( + "None" to stringResource(R.string.none) + ) + languages.map { it.IETF_tag to it.nameNextToFlagEmoji() }.sortedBy { + it.second.substringAfter("\u00a0").lowercase() + }.toMap(), + ), + Preference.PreferenceItem.MultiSelectListPreference( + icon = painterResource(R.drawable.language_download2), + title = stringResource(R.string.subs_download_languages), + preference = downloadSubsLanguage, + entries = mapOf( + "None" to stringResource(R.string.none) + ) + languages.map { it.IETF_tag to it.nameNextToFlagEmoji() }.sortedBy { + it.second.substringAfter("\u00a0").lowercase() + }.toMap(), + ), + Preference.PreferenceItem.SwitchPreference( + preference = subtitlesFilterSubLang, + icon = painterResource(R.drawable.filter_alt_24px), + title = stringResource(R.string.subtitles_filter_lang), + ), + ) + ), + Preference.PreferenceGroup( + title = stringResource(R.string.pref_category_ui_features), + preferenceItems = persistentListOf( + Preference.PreferenceItem.BasicSwitchPreference( + value = state.removeCaptions, + icon = painterResource(R.drawable.closed_caption_24px), + title = stringResource(R.string.subtitles_remove_captions), + subtitle = "[Knocking on door] Hello → Hello", + onValueChanged = { newValue -> + update { copy(removeCaptions = newValue) } + }, + ), + Preference.PreferenceItem.BasicSwitchPreference( + value = state.removeBloat, + icon = painterResource(R.drawable.text_ad_off_24px), + title = stringResource(R.string.subtitles_remove_bloat), + onValueChanged = { newValue -> + update { copy(removeBloat = newValue) } + }, + ), + ) + ), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/BaseComposeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/BaseComposeFragment.kt index 91a75d0ab02..b11b037546d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/BaseComposeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/BaseComposeFragment.kt @@ -5,7 +5,9 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment +import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape @@ -40,4 +42,45 @@ abstract class BaseComposeFragment : Fragment(), Screen { padBottom = isLandscape() ) } +} + +abstract class BaseDialogComposeFragment : DialogFragment(), Screen { + var systemBarsAddPadding = isLayout(TV or EMULATOR) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = createComposeView(inflater, container, savedInstanceState) + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + dialog?.window?.decorView?.let { view -> + fixSystemBarsPadding( + view, + padLeft = systemBarsAddPadding, + padBottom = systemBarsAddPadding || isLandscape() + ) + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.setWindowAnimations(R.style.DialogFullscreenPlayer) + } + + override fun getTheme(): Int { + return R.style.DialogFullscreenPlayer + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialog?.window?.decorView?.let { view -> + fixSystemBarsPadding( + view, + padLeft = systemBarsAddPadding, + padBottom = systemBarsAddPadding || isLandscape() + ) + } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/border_color_24px.xml b/app/src/main/res/drawable/border_color_24px.xml new file mode 100644 index 00000000000..f4a5bbf3b0d --- /dev/null +++ b/app/src/main/res/drawable/border_color_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/closed_caption_24px.xml b/app/src/main/res/drawable/closed_caption_24px.xml new file mode 100644 index 00000000000..793e1e0611a --- /dev/null +++ b/app/src/main/res/drawable/closed_caption_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/delete_24px.xml b/app/src/main/res/drawable/delete_24px.xml new file mode 100644 index 00000000000..237e75aa6fd --- /dev/null +++ b/app/src/main/res/drawable/delete_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/font_download_24px.xml b/app/src/main/res/drawable/font_download_24px.xml new file mode 100644 index 00000000000..82f04c483d1 --- /dev/null +++ b/app/src/main/res/drawable/font_download_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_align_center_24px.xml b/app/src/main/res/drawable/format_align_center_24px.xml new file mode 100644 index 00000000000..8a425daa4f6 --- /dev/null +++ b/app/src/main/res/drawable/format_align_center_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_bold_24px.xml b/app/src/main/res/drawable/format_bold_24px.xml new file mode 100644 index 00000000000..61aa7f5090e --- /dev/null +++ b/app/src/main/res/drawable/format_bold_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_color_fill_24px.xml b/app/src/main/res/drawable/format_color_fill_24px.xml new file mode 100644 index 00000000000..3dc2047e99d --- /dev/null +++ b/app/src/main/res/drawable/format_color_fill_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_color_text_24px.xml b/app/src/main/res/drawable/format_color_text_24px.xml new file mode 100644 index 00000000000..e1d88be3317 --- /dev/null +++ b/app/src/main/res/drawable/format_color_text_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_italic_24px.xml b/app/src/main/res/drawable/format_italic_24px.xml new file mode 100644 index 00000000000..0f69910e85c --- /dev/null +++ b/app/src/main/res/drawable/format_italic_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/imagesearch_roller_24px.xml b/app/src/main/res/drawable/imagesearch_roller_24px.xml new file mode 100644 index 00000000000..184f6f60a29 --- /dev/null +++ b/app/src/main/res/drawable/imagesearch_roller_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/language_download.xml b/app/src/main/res/drawable/language_download.xml new file mode 100644 index 00000000000..129dd4eead5 --- /dev/null +++ b/app/src/main/res/drawable/language_download.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/language_download2.xml b/app/src/main/res/drawable/language_download2.xml new file mode 100644 index 00000000000..30cb8bf13ab --- /dev/null +++ b/app/src/main/res/drawable/language_download2.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/reset_colors_24px.xml b/app/src/main/res/drawable/reset_colors_24px.xml new file mode 100644 index 00000000000..6cfa81413f9 --- /dev/null +++ b/app/src/main/res/drawable/reset_colors_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/rounded_corner_24px.xml b/app/src/main/res/drawable/rounded_corner_24px.xml new file mode 100644 index 00000000000..6d3180c4287 --- /dev/null +++ b/app/src/main/res/drawable/rounded_corner_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/shadow_24px.xml b/app/src/main/res/drawable/shadow_24px.xml new file mode 100644 index 00000000000..09bf8d62e20 --- /dev/null +++ b/app/src/main/res/drawable/shadow_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/shadow_add_24px.xml b/app/src/main/res/drawable/shadow_add_24px.xml new file mode 100644 index 00000000000..9ff30d54c8c --- /dev/null +++ b/app/src/main/res/drawable/shadow_add_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/text_ad_off_24px.xml b/app/src/main/res/drawable/text_ad_off_24px.xml new file mode 100644 index 00000000000..2a108446b05 --- /dev/null +++ b/app/src/main/res/drawable/text_ad_off_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/text_fields_24px.xml b/app/src/main/res/drawable/text_fields_24px.xml new file mode 100644 index 00000000000..49153d403fa --- /dev/null +++ b/app/src/main/res/drawable/text_fields_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/text_select_move_up_24px.xml b/app/src/main/res/drawable/text_select_move_up_24px.xml new file mode 100644 index 00000000000..7311798a9ea --- /dev/null +++ b/app/src/main/res/drawable/text_select_move_up_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/undo_24px.xml b/app/src/main/res/drawable/undo_24px.xml new file mode 100644 index 00000000000..89a3f5b801d --- /dev/null +++ b/app/src/main/res/drawable/undo_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/uppercase_24px.xml b/app/src/main/res/drawable/uppercase_24px.xml new file mode 100644 index 00000000000..68b9166ae87 --- /dev/null +++ b/app/src/main/res/drawable/uppercase_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 6a1ea6912fd..a65e3706dbd 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -416,7 +416,7 @@ Outline Color Background Color Window Color + Edge Edge Type Subtitle Elevation Font @@ -486,7 +487,7 @@ Invalid URL Error Remove closed captions from subtitles - Remove bloat from subtitles + Remove ads from subtitles Filter by preferred media language Extras Trailer @@ -743,6 +744,7 @@ Make all subtitles bold Make all subtitles italic Background Radius + Background How many different items that can be downloaded in parallel Parallel downloads Concurrent connections diff --git a/shared/src/androidMain/kotlin/com/mihon/common/preference/DataPreference.kt b/shared/src/androidMain/kotlin/com/mihon/common/preference/DataPreference.kt new file mode 100644 index 00000000000..dcf4eedfa81 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/mihon/common/preference/DataPreference.kt @@ -0,0 +1,238 @@ +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.MutableSharedFlow +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 DataPreference( + private val preferences: SharedPreferences, + private val keyFlow: MutableSharedFlow, + 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)) + keyFlow.tryEmit(key) + } + + 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: MutableSharedFlow, + key: String, + defaultValue: String, + ) : DataPreference(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: MutableSharedFlow, + key: String, + defaultValue: Long, + ) : DataPreference(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: MutableSharedFlow, + key: String, + defaultValue: Int, + ) : DataPreference(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: MutableSharedFlow, + key: String, + defaultValue: Float, + ) : DataPreference(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: MutableSharedFlow, + key: String, + defaultValue: Boolean, + ) : DataPreference(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: MutableSharedFlow, + key: String, + defaultValue: Set, + ) : DataPreference>(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: MutableSharedFlow, + key: String, + defaultValue: T, + private val serializer: (T) -> String, + private val deserializer: (String) -> T, + ) : DataPreference(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: MutableSharedFlow, + key: String, + defaultValue: T, + private val serializer: (T) -> Int, + private val deserializer: (Int) -> T, + ) : DataPreference(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: MutableSharedFlow, + key: String, + defaultValue: Set, + private val serializer: (T) -> String, + private val deserializer: (String) -> T?, + ) : DataPreference>(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()) + } + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/mihon/common/preference/DataPreferenceStore.kt b/shared/src/androidMain/kotlin/com/mihon/common/preference/DataPreferenceStore.kt new file mode 100644 index 00000000000..4f197768da5 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/mihon/common/preference/DataPreferenceStore.kt @@ -0,0 +1,109 @@ +package com.mihon.common.preference + +import android.content.Context +import android.content.SharedPreferences +import com.lagradost.cloudstream3.utils.AppUtils.parseJson +import com.lagradost.cloudstream3.utils.AppUtils.toJsonLiteral +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * Same as AndroidPreferenceStore for DataStore but with some optimization to only see changes when values are + * changed by this store. This is done to avoid having a global key listener where we might pollute + * the setKey from other places. + * */ +class DataPreferenceStore(private val sharedPreferences: SharedPreferences) : PreferenceStore { + constructor(context: Context) : this( + context.getSharedPreferences( + "rebuild_preference", + Context.MODE_PRIVATE + ) + ) + + companion object { + private val keyFlow: MutableSharedFlow = + MutableSharedFlow(extraBufferCapacity = 16) + } + + override fun getString(key: String, defaultValue: String): PreferenceData { + return DataPreference.StringPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getLong(key: String, defaultValue: Long): PreferenceData { + return DataPreference.LongPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getInt(key: String, defaultValue: Int): PreferenceData { + return DataPreference.IntPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getFloat(key: String, defaultValue: Float): PreferenceData { + return DataPreference.FloatPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getBoolean(key: String, defaultValue: Boolean): PreferenceData { + return DataPreference.BooleanPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + override fun getStringSet(key: String, defaultValue: Set): PreferenceData> { + return DataPreference.StringSetPrimitive(sharedPreferences, keyFlow, key, defaultValue) + } + + inline fun getJson(key: String, defaultValue: T): PreferenceData = + getObjectFromString(key, defaultValue, serializer = { obj -> + obj.toJsonLiteral() + }, deserializer = { json -> + parseJson(json) + }) + + override fun getObjectFromString( + key: String, + defaultValue: T, + serializer: (T) -> String, + deserializer: (String) -> T, + ): PreferenceData { + return DataPreference.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 DataPreference.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 DataPreference.ObjectSetAsStringSet( + preferences = sharedPreferences, + keyFlow = keyFlow, + key = key, + defaultValue = defaultValue, + serializer = serializer, + deserializer = deserializer, + ) + } + + override fun getAll(): Map { + return sharedPreferences.all ?: emptyMap() + } +} diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index fbd98f6492a..6d81554c1ab 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -4,6 +4,9 @@ No results found Ok Cancel + Transparency + Luminance + Color 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. diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt new file mode 100644 index 00000000000..a89085556bb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/ColorDialog.kt @@ -0,0 +1,345 @@ +package com.lagradost.cloudstream4.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +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.cancel +import com.lagradost.cloudstream4.generated.resources.color +import com.lagradost.cloudstream4.generated.resources.luminance +import com.lagradost.cloudstream4.generated.resources.ok +import com.lagradost.cloudstream4.generated.resources.transparency +import com.mihon.material.Slider +import com.mihon.material.padding +import org.jetbrains.compose.resources.stringResource +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + + +internal val colors = arrayOf( + Color(0xFFF44336), // RED 500 + Color(0xFFE91E63), // PINK 500 + Color(0xFFFF2C93), // LIGHT PINK 500 + Color(0xFF9C27B0), // PURPLE 500 + Color(0xFF673AB7), // DEEP PURPLE 500 + Color(0xFF3F51B5), // INDIGO 500 + Color(0xFF2196F3), // BLUE 500 + Color(0xFF03A9F4), // LIGHT BLUE 500 + Color(0xFF00BCD4), // CYAN 500 + Color(0xFF009688), // TEAL 500 + Color(0xFF4CAF50), // GREEN 500 + Color(0xFF8BC34A), // LIGHT GREEN 500 + Color(0xFFCDDC39), // LIME 500 + Color(0xFFFFEB3B), // YELLOW 500 + Color(0xFFFFC107), // AMBER 500 + Color(0xFFFF9800), // ORANGE 500 + Color(0xFF795548), // BROWN 500 + Color(0xFF607D8B), // BLUE GREY 500 + Color(0xFF9E9E9E), // GREY 500 +) +internal val colors2 = arrayOf( + Color(0xFF795548), // BROWN 500 + Color(0xFF607D8B), // BLUE GREY 500 + Color(0xFF9E9E9E), // GREY 500 +) + +@Composable +fun ColorDialog( + title: String, + color: Color, + dismiss: () -> Unit, + confirm: (Color) -> Unit, +) { + var alpha by remember { mutableFloatStateOf(color.alpha) } + var selectedColor by remember { mutableStateOf(color) } + + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = dismiss, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + ColorCircle( + color = selectedColor.copy(alpha = alpha), + ) + Spacer(modifier = Modifier.size(MaterialTheme.padding.medium)) + Text(text = title) + } + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text( + text = stringResource(Res.string.color), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(MaterialTheme.padding.extraSmall), + style = MaterialTheme.typography.titleMedium + ) + FlowRow { + ColorCircle( + color = Color.White.copy(alpha = alpha), + ) { + selectedColor = Color.White + } + + val hsl = floatArrayOf(0.0f, 1.0f, 0.5f) + (0..18).forEach { hue -> + hsl[0] = hue.toFloat() * 18f + val color = hslToColor(hsl) + ColorCircle( + color = color.copy(alpha = alpha), + ) { + selectedColor = color + } + } + colors2.forEach { color -> + ColorCircle( + color = color.copy(alpha = alpha), + ) { + selectedColor = color + } + } + } + + Text( + text = stringResource(Res.string.luminance), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(MaterialTheme.padding.extraSmall), + style = MaterialTheme.typography.titleMedium + ) + + // https://github.com/mhssn95/compose-color-picker/blob/main/colorPicker/src/main/java/io/mhssn/colorpicker/ext/drawExt.kt + Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { + val hsl = floatArrayOf(0.0f, 0.0f, 0.0f) + (0..10).forEach { luminance -> + rbgToHSL(selectedColor.red, selectedColor.green, selectedColor.blue, hsl) + hsl[2] = luminance.toFloat() * 0.1f + val color = hslToColor(hsl) + ColorCircle( + color = color.copy(alpha = alpha), + ) { + selectedColor = color + } + } + } + + Text( + text = stringResource(Res.string.transparency), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(MaterialTheme.padding.extraSmall), + style = MaterialTheme.typography.titleMedium + ) + val haptic = LocalHapticFeedback.current + Slider( + modifier = Modifier.focusOutline().padding(MaterialTheme.padding.extraSmall), + value = (alpha * 10.0f).toInt(), + onValueChange = f@{ newValue -> + alpha = newValue.toFloat() * 0.1f + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + valueRange = 0..10, + ) + } + }, + confirmButton = { + WhiteButton( + text = stringResource(Res.string.ok), + onClick = { confirm(selectedColor.copy(alpha = alpha)) }) + }, + dismissButton = { + BlackButton(text = stringResource(Res.string.cancel), onClick = dismiss) + }) +} + +@Composable +fun ColorCircle( + color: Color, + modifier: Modifier = Modifier, + size: Dp = 50.dp, + clickable: (() -> Unit)? = null +) { + Canvas( + modifier = modifier + .focusOutline(shape = CircleShape) + .padding(MaterialTheme.padding.extraSmall) + .size(size) + .border( + 1.dp, + if (color.alpha > 0.5f) MaterialTheme.colorScheme.onBackground else color.copy( + alpha = 1.0f + ), + CircleShape + ) + .circle() + .clickable(enabled = clickable != null) { + clickable?.invoke() + } + ) { + drawTransparentBackground(10) + drawRect(color) + } +} + +internal fun DrawScope.drawTransparentBackground(verticalBoxesSize: Int = 10) { + val boxSize = size.height / verticalBoxesSize + repeat((size.width / boxSize).roundToInt() + 1) { x -> + repeat(verticalBoxesSize) { y -> + drawRect( + if ((y + x) % 2 == 0) { + Color.LightGray + } else { + Color.White + }, topLeft = Offset(x * boxSize, y * boxSize), size = Size(boxSize, boxSize) + ) + } + } +} + +/** + * Convert RGB components to HSL (hue-saturation-lightness). + * + * * outHsl[0] is Hue [0, 360) + * * outHsl[1] is Saturation [0, 1] + * * outHsl[2] is Lightness [0, 1] + * + * + * @param rf red component value [0, 1] + * @param gf green component value [0, 1] + * @param bf blue component value [0, 1] + * @param outHsl 3-element array which holds the resulting HSL components + */ +fun rbgToHSL( + rf: Float, + gf: Float, + bf: Float, + outHsl: FloatArray +) { + val max = max(rf, max(gf, bf)) + val min = min(rf, min(gf, bf)) + val deltaMaxMin = max - min + + var h: Float + val s: Float + val l = (max + min) / 2f + + if (max == min) { + // Monochromatic + s = 0f + h = s + } else { + if (max == rf) { + h = ((gf - bf) / deltaMaxMin) % 6f + } else if (max == gf) { + h = ((bf - rf) / deltaMaxMin) + 2f + } else { + h = ((rf - gf) / deltaMaxMin) + 4f + } + + s = deltaMaxMin / (1f - abs(2f * l - 1f)) + } + + h = (h * 60f) % 360f + if (h < 0) { + h += 360f + } + + outHsl[0] = h.coerceIn(0f, 360f) + outHsl[1] = s.coerceIn(0f, 1f) + outHsl[2] = l.coerceIn(0f, 1f) +} + +fun hslToColor(hsl: FloatArray): Color { + val h = hsl[0] + val s = hsl[1] + val l = hsl[2] + + val c = (1f - abs(2 * l - 1f)) * s + val m = l - 0.5f * c + val x = c * (1f - abs((h / 60f % 2f) - 1f)) + + val hueSegment = h.toInt() / 60 + + var r = 0 + var g = 0 + var b = 0 + + when (hueSegment) { + 0 -> { + r = (255 * (c + m)).roundToInt() + g = (255 * (x + m)).roundToInt() + b = (255 * m).roundToInt() + } + + 1 -> { + r = (255 * (x + m)).roundToInt() + g = (255 * (c + m)).roundToInt() + b = (255 * m).roundToInt() + } + + 2 -> { + r = (255 * m).roundToInt() + g = (255 * (c + m)).roundToInt() + b = (255 * (x + m)).roundToInt() + } + + 3 -> { + r = (255 * m).roundToInt() + g = (255 * (x + m)).roundToInt() + b = (255 * (c + m)).roundToInt() + } + + 4 -> { + r = (255 * (x + m)).roundToInt() + g = (255 * m).roundToInt() + b = (255 * (c + m)).roundToInt() + } + + 5, 6 -> { + r = (255 * (c + m)).roundToInt() + g = (255 * m).roundToInt() + b = (255 * (x + m)).roundToInt() + } + } + + r = r.coerceIn(0, 255) + g = g.coerceIn(0, 255) + b = b.coerceIn(0, 255) + + return Color(r, g, b) +} \ 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 index ee910dd3642..140c89b7655 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Modifiers.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/compose/Modifiers.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream4.compose import androidx.compose.foundation.border +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -15,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /* @@ -39,18 +41,30 @@ fun Modifier.rounded(): Modifier = fun Modifier.circle(): Modifier = clip(CircleShape) +@Composable +fun Modifier.circleBorder(size: Dp): Modifier = + this.size(size) + .border( + 2.dp, + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.2f), + CircleShape + ) + .circle() + + /** * If we should have outlines on items when focused by default, this should be false for desktop * as we do not use a dpad for that * */ -val LocalFocusOutlineDefault: ProvidableCompositionLocal = staticCompositionLocalOf { true } +val LocalFocusOutlineDefault: ProvidableCompositionLocal = + staticCompositionLocalOf { true } @Composable fun Modifier.focusOutline( - enabled : Boolean = LocalFocusOutlineDefault.current, + enabled: Boolean = LocalFocusOutlineDefault.current, shape: Shape = RoundedShape() ): Modifier { - if(!enabled) return this + if (!enabled) return this var hasFocus by remember { mutableStateOf(false) } diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/Preference.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/Preference.kt index 11ad8b9846f..e857cd9281b 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/Preference.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/Preference.kt @@ -5,6 +5,7 @@ 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.Color import androidx.compose.ui.graphics.painter.Painter import com.lagradost.cloudstream4.generated.resources.Res import com.lagradost.cloudstream4.generated.resources.none @@ -46,6 +47,30 @@ sealed class Preference { override val icon: Painter? = null ) : PreferenceItem() + /** + * A [PreferenceItem] that provides a two-state toggleable option. + */ + data class BasicSwitchPreference( + val value: Boolean, + override val title: String, + override val subtitle: String? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (value: Boolean) -> Unit = {}, + override val icon: Painter? = null + ) : PreferenceItem() + + /** + * A [PreferenceItem] that provides a color + */ + data class BasicColorPreference( + val value: Color, + override val title: String, + override val subtitle: String? = null, + override val enabled: Boolean = true, + override val onValueChanged: suspend (value: Color) -> Unit = {}, + override val icon: Painter? = null + ) : PreferenceItem() + /** * A [PreferenceItem] that provides a slider to select an integer number. */ @@ -91,17 +116,20 @@ sealed class Preference { /** * [ListPreference] but with no connection to a [PreferenceData] */ - data class BasicListPreference( - val value: String, - val entries: Map, + data class BasicListPreference( + val value: T, + val entries: Map, override val title: String, override val subtitle: String? = "%s", - val subtitleProvider: @Composable (value: String, entries: Map) -> String? = + 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: String) -> Unit = {}, - ) : PreferenceItem() + override val onValueChanged: suspend (value: T) -> Unit = {}, + ) : PreferenceItem() { + @Suppress("UNCHECKED_CAST") + internal suspend fun internalOnValueChanged(value: Any?) = onValueChanged(value as T) + } /** * A [PreferenceItem] that displays a list of entries as a dialog. @@ -187,7 +215,7 @@ sealed class Preference { override val title: String, override val enabled: Boolean = true, - val preferenceItems: List>, + val preferenceItems: List>, ) : Preference() } diff --git a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceItem.kt b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceItem.kt index 151f3cb69bd..6dc0f8fdf18 100644 --- a/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceItem.kt +++ b/shared/src/commonMain/kotlin/com/mihon/presentation/settings/PreferenceItem.kt @@ -11,10 +11,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream4.compose.ColorCircle +import com.lagradost.cloudstream4.compose.ColorDialog +import com.mihon.material.BaseSliderItem import com.mihon.presentation.settings.widget.EditTextPreferenceWidget import com.mihon.presentation.settings.widget.InfoWidget import com.mihon.presentation.settings.widget.ListPreferenceWidget @@ -25,7 +31,6 @@ 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 } @@ -78,6 +83,51 @@ internal fun PreferenceItem( }, ) } + + is Preference.PreferenceItem.BasicSwitchPreference -> { + SwitchPreferenceWidget( + title = item.title, + subtitle = item.subtitle, + icon = item.icon, + checked = item.value, + onCheckedChanged = { newValue -> + scope.launch { + item.onValueChanged(newValue) + } + }, + ) + } + + is Preference.PreferenceItem.BasicColorPreference -> { + var isDialogShown by remember { mutableStateOf(false) } + + TextPreferenceWidget( + title = item.title, + subtitle = item.subtitle, + icon = item.icon, + onPreferenceClick = { + isDialogShown = true + }, + widget = { + ColorCircle(item.value, size = 30.dp) + } + ) + + if (isDialogShown) { + ColorDialog( + title = item.title, + color = item.value, + dismiss = { isDialogShown = false }, + confirm = { color -> + isDialogShown = false + scope.launch { + item.onValueChanged(color) + } + } + ) + } + } + is Preference.PreferenceItem.SliderPreference -> { BaseSliderItem( value = item.value, @@ -85,7 +135,8 @@ internal fun PreferenceItem( steps = item.steps, title = item.title, subtitle = item.subtitle, - valueString = item.valueString.takeUnless { it.isNullOrEmpty() } ?: item.value.toString(), + valueString = item.valueString.takeUnless { it.isNullOrEmpty() } + ?: item.value.toString(), onChange = { scope.launch { item.onValueChanged(it) @@ -99,6 +150,7 @@ internal fun PreferenceItem( icon = item.icon ) } + is Preference.PreferenceItem.ListPreference<*> -> { val value by item.preference.collectAsState() ListPreferenceWidget( @@ -117,6 +169,7 @@ internal fun PreferenceItem( }, ) } + is Preference.PreferenceItem.BasicListPreference -> { ListPreferenceWidget( value = item.value, @@ -124,9 +177,16 @@ internal fun PreferenceItem( subtitle = item.subtitleProvider(item.value, item.entries), icon = item.icon, entries = item.entries, - onValueChange = { scope.launch { item.onValueChanged(it) } }, + onValueChange = { newValue -> + scope.launch { + item.internalOnValueChanged( + newValue + ) + } + }, ) } + is Preference.PreferenceItem.MultiSelectListPreference<*> -> { val values by item.preference.collectAsState() MultiSelectListPreferenceWidget( @@ -144,6 +204,7 @@ internal fun PreferenceItem( }, ) } + is Preference.PreferenceItem.TextPreference -> { TextPreferenceWidget( title = item.title, @@ -153,6 +214,7 @@ internal fun PreferenceItem( onPreferenceClick = item.onClick, ) } + is Preference.PreferenceItem.EditTextPreference -> { val values by item.preference.collectAsState() EditTextPreferenceWidget( @@ -180,6 +242,7 @@ internal fun PreferenceItem( is Preference.PreferenceItem.InfoPreference -> { InfoWidget(text = item.title) } + is Preference.PreferenceItem.CustomPreference -> { item.content() }