diff --git a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt index 1d3f1764..be9ceedd 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt @@ -24,6 +24,8 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.dolphin.data.ServerRepository +import com.github.damontecres.dolphin.preferences.AppPreferences +import com.github.damontecres.dolphin.preferences.DefaultUserConfiguration import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.CoilConfig import com.github.damontecres.dolphin.ui.nav.ApplicationContent @@ -43,7 +45,7 @@ class MainActivity : AppCompatActivity() { lateinit var serverRepository: ServerRepository @Inject - lateinit var userPreferencesDataStore: DataStore + lateinit var userPreferencesDataStore: DataStore @Inject lateinit var okHttpClient: OkHttpClient @@ -61,23 +63,31 @@ class MainActivity : AppCompatActivity() { CoilConfig(serverRepository, okHttpClient, false) var isRestoringSession by remember { mutableStateOf(true) } - val preferences by userPreferencesDataStore.data.collectAsState(null) - preferences?.let { preferences -> + val appPreferences by userPreferencesDataStore.data.collectAsState(null) + appPreferences?.let { appPreferences -> LaunchedEffect(Unit) { - if (preferences.currentServerId.isNotBlank() && preferences.currentUserId.isNotBlank()) { + if (appPreferences.currentServerId.isNotBlank() && appPreferences.currentUserId.isNotBlank()) { serverRepository.restoreSession( - preferences.currentServerId, - preferences.currentUserId, + appPreferences.currentServerId, + appPreferences.currentUserId, ) } isRestoringSession = false } - val deviceProfile = - remember(preferences) { - createDeviceProfile(this@MainActivity, preferences, false) - } val server = serverRepository.currentServer val user = serverRepository.currentUser + val userDto = serverRepository.currentUserDto + + val preferences = + UserPreferences( + appPreferences, + userDto?.configuration ?: DefaultUserConfiguration, + ) + + val deviceProfile = + remember(appPreferences) { + createDeviceProfile(this@MainActivity, preferences, false) + } val backStack = rememberNavBackStack(Destination.Main) val navigationManager = NavigationManager(backStack) diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt index 1640569f..2d40bdde 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt @@ -4,13 +4,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.datastore.core.DataStore -import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.preferences.AppPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.model.api.AuthenticationResult +import org.jellyfin.sdk.model.api.UserDto import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -21,12 +22,14 @@ class ServerRepository constructor( val serverDao: JellyfinServerDao, val apiClient: ApiClient, - val userPreferencesDataStore: DataStore, + val userPreferencesDataStore: DataStore, ) { private var _currentServer by mutableStateOf(null) val currentServer get() = _currentServer private var _currentUser by mutableStateOf(null) val currentUser get() = _currentUser + private var _currentUserDto by mutableStateOf(null) + val currentUserDto get() = _currentUserDto suspend fun addAndChangeServer(server: JellyfinServer) { withContext(Dispatchers.IO) { @@ -76,6 +79,7 @@ class ServerRepository serverDao.addServer(updatedServer) serverDao.addUser(updatedUser) } + _currentUserDto = userDto _currentServer = updatedServer _currentUser = updatedUser } catch (e: InvalidStatusException) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/dolphin/hilt/DatabaseModule.kt index 9130a63f..538f2482 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/hilt/DatabaseModule.kt @@ -8,8 +8,8 @@ import androidx.datastore.dataStoreFile import androidx.room.Room import com.github.damontecres.dolphin.data.AppDatabase import com.github.damontecres.dolphin.data.JellyfinServerDao -import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.preferences.UserPreferencesSerializer +import com.github.damontecres.dolphin.preferences.AppPreferences +import com.github.damontecres.dolphin.preferences.AppPreferencesSerializer import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -41,14 +41,14 @@ object DatabaseModule { @Singleton fun userPreferencesDataStore( @ApplicationContext context: Context, - userPreferencesSerializer: UserPreferencesSerializer, - ): DataStore = + userPreferencesSerializer: AppPreferencesSerializer, + ): DataStore = DataStoreFactory.create( serializer = userPreferencesSerializer, - produceFile = { context.dataStoreFile("user_preferences.pb") }, + produceFile = { context.dataStoreFile("app_preferences.pb") }, corruptionHandler = ReplaceFileCorruptionHandler( - produceNewData = { UserPreferences.getDefaultInstance() }, + produceNewData = { AppPreferences.getDefaultInstance() }, ), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt new file mode 100644 index 00000000..7a0b7ba6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt @@ -0,0 +1,51 @@ +package com.github.damontecres.dolphin.preferences + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.github.damontecres.dolphin.ui.preferences.AppPreference +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +class AppPreferencesSerializer + @Inject + constructor() : Serializer { + override val defaultValue: AppPreferences = + AppPreferences + .newBuilder() + .apply { + playbackPreferences = + PlaybackPreferences + .newBuilder() + .apply { + skipForwardMs = + AppPreference.SkipForward.defaultValue.seconds.inWholeMilliseconds + skipBackMs = + AppPreference.SkipBack.defaultValue.seconds.inWholeMilliseconds + controllerTimeoutMs = AppPreference.ControllerTimeout.defaultValue + seekBarSteps = AppPreference.SeekBarSteps.defaultValue.toInt() + }.build() + }.build() + + override suspend fun readFrom(input: InputStream): AppPreferences { + try { + return AppPreferences.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo( + t: AppPreferences, + output: OutputStream, + ) = t.writeTo(output) + } + +inline fun AppPreferences.update(block: AppPreferences.Builder.() -> Unit): AppPreferences = toBuilder().apply(block).build() + +inline fun AppPreferences.updatePlaybackPreferences(block: PlaybackPreferences.Builder.() -> Unit): AppPreferences = + update { + playbackPreferences = playbackPreferences.toBuilder().apply(block).build() + } diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferences.kt new file mode 100644 index 00000000..3a7e0499 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferences.kt @@ -0,0 +1,26 @@ +package com.github.damontecres.dolphin.preferences + +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import org.jellyfin.sdk.model.api.UserConfiguration + +data class UserPreferences( + val appPreferences: AppPreferences, + val userConfig: UserConfiguration, +) + +val DefaultUserConfiguration = + UserConfiguration( + playDefaultAudioTrack = true, + displayMissingEpisodes = false, + groupedFolders = listOf(), + subtitleMode = SubtitlePlaybackMode.DEFAULT, + displayCollectionsView = false, + enableLocalPassword = false, + orderedViews = listOf(), + latestItemsExcludes = listOf(), + myMediaExcludes = listOf(), + hidePlayedInLatest = true, + rememberAudioSelections = true, + rememberSubtitleSelections = true, + enableNextEpisodeAutoPlay = true, + ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferencesSerializer.kt deleted file mode 100644 index cc15197c..00000000 --- a/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferencesSerializer.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.damontecres.dolphin.preferences - -import androidx.datastore.core.CorruptionException -import androidx.datastore.core.Serializer -import com.google.protobuf.InvalidProtocolBufferException -import java.io.InputStream -import java.io.OutputStream -import javax.inject.Inject - -class UserPreferencesSerializer - @Inject - constructor() : Serializer { - override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance() - - override suspend fun readFrom(input: InputStream): UserPreferences { - try { - return UserPreferences.parseFrom(input) - } catch (exception: InvalidProtocolBufferException) { - throw CorruptionException("Cannot read proto.", exception) - } - } - - override suspend fun writeTo( - t: UserPreferences, - output: OutputStream, - ) = t.writeTo(output) - } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt index 8892a58b..d728b46c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt @@ -49,6 +49,12 @@ import com.github.damontecres.dolphin.ui.FontAwesome import kotlinx.coroutines.delay import kotlinx.coroutines.launch +data class DialogParams( + val fromLongClick: Boolean, + val title: String, + val items: List, +) + sealed interface DialogItemEntry data object DialogItemDivider : DialogItemEntry diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SliderBar.kt new file mode 100644 index 00000000..b14a3ccb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SliderBar.kt @@ -0,0 +1,110 @@ +package com.github.damontecres.dolphin.ui.components + +import android.view.KeyEvent +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import com.github.damontecres.dolphin.ui.handleDPadKeyEvents + +@Composable +fun SliderBar( + value: Long, + min: Long, + max: Long, + onChange: (Long) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + interval: Int = 1, + color: Color = MaterialTheme.colorScheme.border, +) { + val isFocused by interactionSource.collectIsFocusedAsState() + val animatedIndicatorHeight by animateDpAsState( + targetValue = 6.dp.times((if (isFocused) 2f else 1f)), + ) + var currentValue by remember(value) { mutableLongStateOf(value) } + val percent = currentValue.toFloat() / (max - min) + + val handleSeekEventModifier = + Modifier.handleDPadKeyEvents( + triggerOnAction = KeyEvent.ACTION_DOWN, + onCenter = { + onChange(currentValue) + }, + onLeft = { + if (currentValue <= min) { + currentValue = max + } else { + currentValue = (currentValue - interval).coerceAtLeast(min) + } + onChange(currentValue) + }, + onRight = { + if (currentValue >= max) { + currentValue = min + } else { + currentValue = (currentValue + interval).coerceAtMost(max) + } + onChange(currentValue) + }, + ) + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Canvas( + modifier = + Modifier + .fillMaxWidth() + .height(animatedIndicatorHeight) + .padding(horizontal = 4.dp) + .then(handleSeekEventModifier) + .focusable(interactionSource = interactionSource), + onDraw = { + val yOffset = size.height.div(2) + drawLine( + color = color.copy(alpha = 0.15f), + start = Offset(x = 0f, y = yOffset), + end = Offset(x = size.width, y = yOffset), + strokeWidth = size.height, + cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(x = 0f, y = yOffset), + end = + Offset( +// x = size.width.times(if (isSelected) seekProgress else progress), + x = size.width.times(percent), + y = yOffset, + ), + strokeWidth = size.height, + cap = StrokeCap.Round, + ) + drawCircle( + color = Color.White, + radius = size.height + 2, + center = Offset(x = size.width.times(percent), y = yOffset), + ) + }, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt index 701f253e..c211d32d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt @@ -89,7 +89,7 @@ fun CardGrid( if (DEBUG) { Timber.d( - "StashGrid: hasRun=$hasRequestFocusRun, requestFocus=$requestFocus, initialPosition=$initialPosition, focusedIndex=$focusedIndex", + "Grid: hasRun=$hasRequestFocusRun, requestFocus=$requestFocus, initialPosition=$initialPosition, focusedIndex=$focusedIndex", ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index 8a75236b..3251a7db 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -13,6 +13,8 @@ import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview import com.github.damontecres.dolphin.ui.main.MainPage import com.github.damontecres.dolphin.ui.playback.PlaybackContent +import com.github.damontecres.dolphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.dolphin.ui.preferences.PreferencesPage import com.github.damontecres.dolphin.ui.setup.SwitchServerContent import com.github.damontecres.dolphin.ui.setup.SwitchUserContent import org.jellyfin.sdk.model.api.BaseItemKind @@ -46,6 +48,14 @@ fun DestinationContent( Destination.ServerList -> SwitchServerContent(navigationManager, modifier) Destination.UserList -> SwitchUserContent(navigationManager, modifier) + Destination.Settings -> + PreferencesPage( + navigationManager, + preferences.appPreferences, + PreferenceScreenOption.BASIC, + modifier, + ) + is Destination.MediaItem -> when (destination.type) { BaseItemKind.SERIES -> @@ -104,7 +114,6 @@ fun DestinationContent( } Destination.Search -> TODO() - Destination.Settings -> TODO() Destination.Setup -> TODO() } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt index 2f9c08d6..b87904b2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt @@ -48,8 +48,6 @@ import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.tryRequestFocus -import com.github.damontecres.stashapp.ui.components.playback.SkipIndicator -import com.github.damontecres.stashapp.ui.components.playback.rememberSeekBarState import org.jellyfin.sdk.model.api.DeviceProfile import kotlin.time.Duration.Companion.milliseconds diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt index 1154a61c..267667fd 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt @@ -59,7 +59,6 @@ import androidx.tv.material3.Text import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.ui.AppColors import com.github.damontecres.dolphin.ui.tryRequestFocus -import com.github.damontecres.stashapp.ui.components.playback.SeekBarImpl import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt index 2fcee96f..ad711667 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.stashapp.ui.components.playback +package com.github.damontecres.dolphin.ui.playback /* * Modified from https://github.com/android/tv-samples @@ -42,7 +42,6 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.dolphin.ui.handleDPadKeyEvents -import com.github.damontecres.dolphin.ui.playback.ControllerViewState @Composable fun SeekBarImpl( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt index cf84a01c..5f3e44ed 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBarState.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.stashapp.ui.components.playback +package com.github.damontecres.dolphin.ui.playback import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SkipIndicator.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SkipIndicator.kt index 24bdd0f8..90bd3ed3 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SkipIndicator.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SkipIndicator.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.stashapp.ui.components.playback +package com.github.damontecres.dolphin.ui.playback import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/AppPreference.kt new file mode 100644 index 00000000..f4430707 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/AppPreference.kt @@ -0,0 +1,305 @@ +package com.github.damontecres.dolphin.ui.preferences + +import android.content.Context +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes +import com.github.damontecres.dolphin.R +import com.github.damontecres.dolphin.preferences.AppPreferences +import com.github.damontecres.dolphin.preferences.updatePlaybackPreferences +import com.github.damontecres.dolphin.ui.nav.Destination +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * A preference that can be stored in the shared preferences. + * + * @param T The type of the preference value. + */ +sealed interface AppPreference { + /** + * String resource ID for the title of the preference + */ + @get:StringRes + val title: Int + + /** + * Default value for the preference for UI purposes + */ + val defaultValue: T + + /** + * A function that gets the value from the [AppPreferences] object for UI purposes. This means + * that it should return the value that is displayed in the UI, which isn't necessarily the raw value + */ + val getter: (prefs: AppPreferences) -> T + + /** + * A function that sets the value in the [AppPreferences] object from the UI. It should convert the value if needed + */ + val setter: (prefs: AppPreferences, value: T) -> AppPreferences + + fun summary( + context: Context, + value: T?, + ): String? = null + + fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid + + companion object { + val SkipForward = + AppSliderPreference( + title = R.string.skip_forward_preference, + defaultValue = 30, + min = 10, + max = 5.minutes.inWholeSeconds, + interval = 5, + getter = { + it.playbackPreferences.skipForwardMs + .milliseconds.inWholeSeconds + }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { + skipForwardMs = value.seconds.inWholeMilliseconds + } + }, + summarizer = { value -> + if (value != null) { + "$value seconds" + } else { + null + } + }, + ) + + val SkipBack = + AppSliderPreference( + title = R.string.skip_back_preference, + defaultValue = 10, + min = 5, + max = 5.minutes.inWholeSeconds, + interval = 5, + getter = { + it.playbackPreferences.skipBackMs + .milliseconds.inWholeSeconds + }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { + skipBackMs = value.seconds.inWholeMilliseconds + } + }, + summarizer = { value -> + if (value != null) { + "$value seconds" + } else { + null + } + }, + ) + +// val GridJumpButtons = +// AppSwitchPreference( +// title = R.string.show_grid_jump_buttons, +// defaultValue = true, +// getter = { it.interfacePreferences.showGridJumpButtons }, +// setter = { prefs, value -> +// prefs.updateInterfacePreferences { showGridJumpButtons = value } +// }, +// summaryOn = R.string.enabled, +// summaryOff = R.string.disabled, +// ) + +// val ShowGridFooter = +// AppSwitchPreference( +// title = R.string.grid_position_footer, +// defaultValue = true, +// getter = { it.interfacePreferences.showPositionFooter }, +// setter = { prefs, value -> +// prefs.updateInterfacePreferences { showPositionFooter = value } +// }, +// summaryOn = R.string.show, +// summaryOff = R.string.hide, +// ) + + val ControllerTimeout = + AppSliderPreference( + title = R.string.hide_controller_timeout, + defaultValue = 5000, + min = 500, + max = 15.seconds.inWholeMilliseconds, + interval = 100, + getter = { it.playbackPreferences.controllerTimeoutMs }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { controllerTimeoutMs = value } + }, + summarizer = { value -> value?.let { "${value / 1000.0} seconds" } }, + ) + + val SeekBarSteps = + AppSliderPreference( + title = R.string.seek_bar_steps, + defaultValue = 16, + min = 4, + max = 64, + interval = 1, + getter = { it.playbackPreferences.seekBarSteps.toLong() }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { seekBarSteps = value.toInt() } + }, + summarizer = { value -> value?.toString() }, + ) + +// val PlaybackDebugInfo = +// AppSwitchPreference( +// title = R.string.playback_debug_info, +// prefKey = R.string.pref_key_show_playback_debug_info, +// defaultValue = false, +// getter = { it.playbackPreferences.showDebugInfo }, +// setter = { prefs, value -> +// prefs.updatePlaybackPreferences { showDebugInfo = value } +// }, +// summaryOn = R.string.show, +// summaryOff = R.string.hide, +// ) + +// val AutoCheckForUpdates = +// AppSwitchPreference( +// title = R.string.check_for_updates, +// prefKey = R.string.pref_key_auto_check_updates, +// defaultValue = true, +// getter = { it.updatePreferences.checkForUpdates }, +// setter = { prefs, value -> +// prefs.updateUpdatePreferences { checkForUpdates = value } +// }, +// summaryOn = R.string.enabled, +// summaryOff = R.string.disabled, +// ) + +// val UpdateUrl = +// AppStringPreference( +// title = R.string.update_url, +// defaultValue = "", +// getter = { it.updatePreferences.updateUrl }, +// setter = { prefs, value -> +// prefs.updateUpdatePreferences { updateUrl = value } +// }, +// summary = R.string.update_url_summary, +// ) + +// val OssLicenseInfo = +// AppDestinationPreference( +// title = R.string.oss_license_info, +// destination = Destination.LicenseInfo, +// ) + } +} + +data class AppSwitchPreference( + @get:StringRes override val title: Int, + override val defaultValue: Boolean, + override val getter: (prefs: AppPreferences) -> Boolean, + override val setter: (prefs: AppPreferences, value: Boolean) -> AppPreferences, + val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid }, + @param:StringRes val summary: Int? = null, + @param:StringRes val summaryOn: Int? = null, + @param:StringRes val summaryOff: Int? = null, +) : AppPreference { + override fun summary( + context: Context, + value: Boolean?, + ): String? = + when { + summaryOn != null && value == true -> context.getString(summaryOn) + summaryOff != null && value == false -> context.getString(summaryOff) + else -> summary?.let { context.getString(summary) } + } +} + +open class AppStringPreference( + @param:StringRes override val title: Int, + override val defaultValue: String, + override val getter: (AppPreferences) -> String, + override val setter: (AppPreferences, String) -> AppPreferences, + @param:StringRes val summary: Int?, +) : AppPreference { + override fun summary( + context: Context, + value: String?, + ): String? = summary?.let { context.getString(it) } ?: value +} + +data class AppChoicePreference( + @param:StringRes override val title: Int, + override val defaultValue: T, + @param:ArrayRes val displayValues: Int, + val indexToValue: (index: Int) -> T, + val valueToIndex: (T) -> Int, + override val getter: (prefs: AppPreferences) -> T, + override val setter: (prefs: AppPreferences, value: T) -> AppPreferences, + @param:StringRes val summary: Int? = null, +) : AppPreference + +data class AppMultiChoicePreference( + @param:StringRes override val title: Int, + override val defaultValue: List, + val allValues: List, + @param:ArrayRes val displayValues: Int, + override val getter: (prefs: AppPreferences) -> List, + override val setter: (prefs: AppPreferences, value: List) -> AppPreferences, + @param:StringRes val summary: Int? = null, + val toSharedPrefs: (T) -> String, + val fromSharedPrefs: (String) -> T?, +) : AppPreference> + +data class AppClickablePreference( + @param:StringRes override val title: Int, + override val defaultValue: Unit = Unit, + override val getter: (prefs: AppPreferences) -> Unit = { }, + override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs }, + @param:StringRes val summary: Int? = null, +) : AppPreference { + override fun summary( + context: Context, + value: Unit?, + ): String? = summary?.let { context.getString(it) } +} + +data class AppDestinationPreference( + @param:StringRes override val title: Int, + override val defaultValue: Unit = Unit, + override val getter: (prefs: AppPreferences) -> Unit = { }, + override val setter: (prefs: AppPreferences, value: Unit) -> AppPreferences = { prefs, _ -> prefs }, + @param:StringRes val summary: Int? = null, + val destination: Destination, +) : AppPreference { + override fun summary( + context: Context, + value: Unit?, + ): String? = summary?.let { context.getString(it) } +} + +class AppSliderPreference( + @param:StringRes override val title: Int, + override val defaultValue: Long, + /** + * Minimum value for the slider. Similar to [defaultValue], this is for UI purposes only + */ + val min: Long = 0, + /** + * Max value for the slider. Similar to [defaultValue], this is for UI purposes only + */ + val max: Long = 100, + val interval: Int = 1, + override val getter: (prefs: AppPreferences) -> Long, + override val setter: (prefs: AppPreferences, value: Long) -> AppPreferences, + @param:StringRes val summary: Int? = null, + val summarizer: ((Long?) -> String?)? = null, +) : AppPreference { + override fun summary( + context: Context, + value: Long?, + ): String? = + summarizer?.invoke(value) + ?: summary?.let { context.getString(it) } + ?: value?.toString() +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/ClickPreference.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/ClickPreference.kt new file mode 100644 index 00000000..21118f38 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/ClickPreference.kt @@ -0,0 +1,31 @@ +package com.github.damontecres.dolphin.ui.preferences + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.tv.material3.ListItem + +@Composable +fun ClickPreference( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, + onLongClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + ListItem( + selected = false, + onClick = onClick, + onLongClick = onLongClick, + headlineContent = { + PreferenceTitle(title) + }, + supportingContent = { + PreferenceSummary(summary) + }, + interactionSource = interactionSource, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/ComposablePreference.kt new file mode 100644 index 00000000..4a4a81a5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/ComposablePreference.kt @@ -0,0 +1,390 @@ +package com.github.damontecres.dolphin.ui.preferences + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.dolphin.R +import com.github.damontecres.dolphin.ui.components.DialogItem +import com.github.damontecres.dolphin.ui.components.DialogParams +import com.github.damontecres.dolphin.ui.components.DialogPopup +import com.github.damontecres.dolphin.ui.components.EditTextBox +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import kotlinx.coroutines.launch + +@Suppress("UNCHECKED_CAST") +@Composable +fun ComposablePreference( + navigationManager: NavigationManager, + preference: AppPreference, + value: T?, + onValueChange: (T) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var dialogParams by remember { mutableStateOf(null) } + var showStringDialog by remember { mutableStateOf(null) } + + val title = stringResource(preference.title) + + val onClick: () -> Unit = { + scope.launch { + when (preference) { + else -> {} + } + } + } + val onLongClick: () -> Unit = { + scope.launch { + when (preference) { + else -> null + } + } + } + + when (preference) { + is AppDestinationPreference -> + ClickPreference( + title = title, + onClick = { + navigationManager.navigateTo(preference.destination) + }, + summary = preference.summary(context, value), + interactionSource = interactionSource, + modifier = modifier, + ) + + is AppClickablePreference -> + ClickPreference( + title = title, + onClick = onClick, + onLongClick = onLongClick, + summary = preference.summary(context, value), + interactionSource = interactionSource, + modifier = modifier, + ) + + is AppSwitchPreference -> + SwitchPreference( + title = title, + value = value as Boolean, + onClick = { onValueChange.invoke(!value as T) }, + summary = preference.summary(context, value), + interactionSource = interactionSource, + modifier = modifier, + ) + + is AppStringPreference -> + ClickPreference( + title = title, + onClick = { + showStringDialog = + StringInput( + title = title, + value = value as String?, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, +// keyboardType = +// if (preference == AppPreference.UpdateUrl) { +// KeyboardType.Uri +// } else { +// KeyboardType.Unspecified +// }, + imeAction = ImeAction.Done, + ), + onSubmit = { input -> + onValueChange.invoke(input as T) + showStringDialog = null + }, + ) + }, + summary = + preference.summary(context, value) + ?: preference.summary?.let { stringResource(it) }, + interactionSource = interactionSource, + modifier = modifier, + ) + + is AppChoicePreference -> { + val values = stringArrayResource(preference.displayValues).toList() + val summary = + preference.summary?.let { stringResource(it) } + ?: preference.summary(context, value) + ?: preference + .valueToIndex(value as T) + .let { values[it] } + val selectedIndex = remember(value) { preference.valueToIndex.invoke(value as T) } + ClickPreference( + title = title, + summary = summary, + onClick = { + dialogParams = + DialogParams( + title = title, + fromLongClick = false, + items = + values.mapIndexed { index, it -> + if (index == selectedIndex) { + DialogItem( + text = it, + icon = Icons.Default.Done, + onClick = { + onValueChange(preference.indexToValue(index)) + dialogParams = null + }, + ) + } else { + DialogItem( + text = it, + onClick = { + onValueChange(preference.indexToValue(index)) + dialogParams = null + }, + ) + } + }, + ) + }, + interactionSource = interactionSource, + modifier = modifier, + ) + } + + is AppMultiChoicePreference<*> -> { + val values = stringArrayResource(preference.displayValues).toList() + val summary = + preference.summary?.let { stringResource(it) } + ?: preference.summary(context, value) + val selectedValues = + remember { + val list = mutableStateSetOf() + list.addAll(value as List) + list + } + + val onClick = { item: Any -> + if (selectedValues.contains(item)) { + selectedValues.remove(item) + } else { + selectedValues.add(item) + } + onValueChange.invoke(selectedValues.toList() as T) + } + + ClickPreference( + title = title, + summary = summary, + onClick = { + dialogParams = + DialogParams( + title = title, + fromLongClick = false, + items = + values.mapIndexed { index, it -> + val item = preference.allValues[index]!! + DialogItem( + headlineContent = { Text(it) }, + trailingContent = { + Switch( + checked = selectedValues.contains(item), + onCheckedChange = { + onClick.invoke(item) + }, + ) + }, + onClick = { + onClick.invoke(item) + }, + ) + }, + ) + }, + interactionSource = interactionSource, + modifier = modifier, + ) + } + + is AppSliderPreference -> { + val summary = + preference.summary(context, value) + ?: preference.summary?.let { stringResource(it) } + SliderPreference( + preference = preference, + title = title, + summary = summary, + value = value as Long, + onChange = { onValueChange(it as T) }, + summaryBelow = false, + interactionSource = interactionSource, + modifier = modifier, + ) + } + } + + val dialogBackgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + + AnimatedVisibility(dialogParams != null) { + dialogParams?.let { + DialogPopup( + showDialog = true, + title = it.title, + dialogItems = it.items, + onDismissRequest = { dialogParams = null }, + waitToLoad = false, + dismissOnClick = false, + ) + } + } + AnimatedVisibility(showStringDialog != null) { + showStringDialog?.let { + var mutableValue by remember { mutableStateOf(it.value ?: "") } + val onDone = { + it.onSubmit.invoke(mutableValue) + } + Dialog( + onDismissRequest = { showStringDialog = null }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .padding(16.dp) + .background( + color = dialogBackgroundColor, + shape = RoundedCornerShape(8.dp), + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(16.dp), + ) { + Text( + text = it.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier, + ) + EditTextBox( + value = mutableValue, + onValueChange = { mutableValue = it }, + keyboardOptions = it.keyboardOptions.copy(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { onDone.invoke() }, + ), + leadingIcon = null, + isInputValid = { true }, + modifier = Modifier.fillMaxWidth(), + ) + + Row( + horizontalArrangement = Arrangement.SpaceAround, + modifier = Modifier.fillMaxWidth(), + ) { + Button( + onClick = { showStringDialog = null }, + modifier = Modifier, + ) { + Text( + text = stringResource(R.string.cancel), + ) + } + Button( + onClick = onDone, + modifier = Modifier, + ) { + Text( + text = stringResource(R.string.save), + ) + } + } + } + } + } + } + } +} + +val PreferenceTitleStyle: TextStyle + @Composable @ReadOnlyComposable + get() = MaterialTheme.typography.titleSmall + +val PreferenceSummaryStyle: TextStyle + @Composable @ReadOnlyComposable + get() = MaterialTheme.typography.bodySmall + +@Composable +fun PreferenceTitle( + title: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, +) { + Text( + text = title, + style = PreferenceTitleStyle, + color = color, + modifier = modifier, + ) +} + +@Composable +fun PreferenceSummary( + summary: String?, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, +) { + summary?.let { + Text( + text = it, + style = PreferenceSummaryStyle, + color = color, + modifier = modifier, + ) + } +} + +private data class StringInput( + val title: String, + val value: String?, + val keyboardOptions: KeyboardOptions, + val onSubmit: (String) -> Unit, +) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferenceUtils.kt new file mode 100644 index 00000000..b4f33516 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferenceUtils.kt @@ -0,0 +1,35 @@ +package com.github.damontecres.dolphin.ui.preferences + +import androidx.annotation.StringRes +import kotlinx.serialization.Serializable + +/** + * A group of preferences + */ +data class PreferenceGroup( + @param:StringRes val title: Int, + val preferences: List>, +) + +/** + * Results when validating a preference value. + */ +sealed interface PreferenceValidation { + data object Valid : PreferenceValidation + + data class Invalid( + val message: String, + ) : PreferenceValidation +} + +@Serializable +enum class PreferenceScreenOption { + BASIC, + ADVANCED, + USER_INTERFACE, + ; + + companion object { + fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt new file mode 100644 index 00000000..4c7a604f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt @@ -0,0 +1,343 @@ +package com.github.damontecres.dolphin.ui.preferences + +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.R +import com.github.damontecres.dolphin.preferences.AppPreferences +import com.github.damontecres.dolphin.ui.ifElse +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.ui.playOnClickSound +import com.github.damontecres.dolphin.ui.playSoundOnFocus +import com.github.damontecres.dolphin.ui.tryRequestFocus +import kotlinx.coroutines.launch + +val basicPreferences = + listOf( + PreferenceGroup( + title = R.string.basic_interface, + preferences = + listOf( + AppPreference.SkipForward, + AppPreference.SkipBack, + AppPreference.ControllerTimeout, + AppPreference.SeekBarSteps, + ), + ), + ) + +val uiPreferences = listOf() + +val advancedPreferences = listOf() + +data class Release( + val version: String, +) + +@Composable +fun PreferencesContent( + navigationManager: NavigationManager, + initialPreferences: AppPreferences, + preferenceScreenOption: PreferenceScreenOption, + modifier: Modifier = Modifier, + viewModel: PreferencesViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } + val state = rememberLazyListState() + var preferences by remember { mutableStateOf(initialPreferences) } + LaunchedEffect(Unit) { + viewModel.preferenceDataStore.data.collect { + preferences = it + } + } + + val movementSounds = true + val installedVersion = remember { "v1.0.0" } + var updateVersion by remember { mutableStateOf(null) } + val updateAvailable = false +// remember(updateVersion) { updateVersion?.version?.isGreaterThan(installedVersion) == true } + +// if (preferences.updatePreferences.checkForUpdates) { +// LaunchedEffect(Unit) { +// updateVersion = +// UpdateChecker.getLatestRelease(context, preferences.updatePreferences.updateUrl) +// } +// } + + val prefList = + when (preferenceScreenOption) { + PreferenceScreenOption.BASIC -> basicPreferences + PreferenceScreenOption.ADVANCED -> advancedPreferences + PreferenceScreenOption.USER_INTERFACE -> uiPreferences + } + val screenTitle = + when (preferenceScreenOption) { + PreferenceScreenOption.BASIC -> "Preferences" + PreferenceScreenOption.ADVANCED -> "Advanced Preferences" + PreferenceScreenOption.USER_INTERFACE -> "User Interface Preferences" + } + + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + // Forces the animated to trigger + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInHorizontally { it / 2 }, + exit = fadeOut() + slideOutHorizontally { it / 2 }, + modifier = modifier, + ) { + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.background(MaterialTheme.colorScheme.secondaryContainer), + ) { + stickyHeader { + Text( + text = screenTitle, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + prefList.forEachIndexed { groupIndex, group -> + item { + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.border, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) + } + if (updateAvailable && + groupIndex == 0 && + preferenceScreenOption == PreferenceScreenOption.BASIC + ) { + item { + val updateFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (focusedIndex.first == 0 && focusedIndex.second == 0) { + // Only re-focus if the user hasn't moved + updateFocusRequester.tryRequestFocus() + } + } + ClickPreference( + title = stringResource(R.string.install_update), + onClick = { + if (movementSounds) playOnClickSound(context) + updateVersion?.let { +// navigationManager.navigateTo(Destination.UpdateApp(it)) + } + }, + summary = updateVersion?.version?.toString(), + modifier = + Modifier + .focusRequester(updateFocusRequester) + .playSoundOnFocus(movementSounds), + ) + } + } + group.preferences.forEachIndexed { prefIndex, pref -> + pref as AppPreference + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + if (movementSounds) playOnClickSound(context) + } + } + when (pref) { +// AppPreference.InstalledVersion -> { +// var clickCount by remember { mutableIntStateOf(0) } +// ClickPreference( +// title = stringResource(R.string.installed_version), +// onClick = { +// if (movementSounds) playOnClickSound(context) +// if (clickCount++ >= 2) { +// clickCount = 0 +// // navigationManager.navigateTo(Destination.Debug) +// } +// }, +// summary = installedVersion.toString(), +// interactionSource = interactionSource, +// modifier = +// Modifier +// .ifElse( +// groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, +// Modifier.focusRequester(focusRequester), +// ), +// ) +// } + +// AppPreference.Update -> { +// ClickPreference( +// title = +// if (updateVersion != null && updateAvailable) { +// stringResource(R.string.install_update) +// } else if (!preferences.updatePreferences.checkForUpdates && updateVersion == null) { +// stringResource(R.string.check_for_updates) +// } else { +// stringResource(R.string.no_update_available) +// }, +// onClick = { +// if (movementSounds) playOnClickSound(context) +// if (updateVersion != null && updateAvailable) { +// updateVersion?.let { +// navigationManager.navigate( +// Destination.UpdateApp(it), +// ) +// } +// } else { +// scope.launch { +// updateVersion = +// UpdateChecker.getLatestRelease( +// context, +// preferences.updatePreferences.updateUrl, +// ) +// } +// } +// }, +// onLongClick = { +// if (movementSounds) playOnClickSound(context) +// updateVersion?.let { +// navigationManager.navigate( +// Destination.UpdateApp(it), +// ) +// } +// }, +// summary = +// if (updateAvailable) { +// updateVersion?.version?.toString() +// } else { +// null +// }, +// interactionSource = interactionSource, +// modifier = +// Modifier +// .ifElse( +// groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, +// Modifier.focusRequester(focusRequester), +// ), +// ) +// } + + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + navigationManager = navigationManager, + preference = pref, + value = value, + onValueChange = { newValue -> + val validation = pref.validate(newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + scope.launch { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter(prefs, newValue) + } + } + } + } + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + } + } + } + } + } +} + +@Composable +fun PreferencesPage( + navigationManager: NavigationManager, + initialPreferences: AppPreferences, + preferenceScreenOption: PreferenceScreenOption, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.background), + ) { + PreferencesContent( + navigationManager, + initialPreferences, + preferenceScreenOption, + Modifier + .fillMaxWidth(.4f) + .fillMaxHeight() + .align(Alignment.TopEnd), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesViewModel.kt new file mode 100644 index 00000000..cc688eff --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesViewModel.kt @@ -0,0 +1,14 @@ +package com.github.damontecres.dolphin.ui.preferences + +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import com.github.damontecres.dolphin.preferences.AppPreferences +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class PreferencesViewModel + @Inject + constructor( + val preferenceDataStore: DataStore, + ) : ViewModel() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/SliderPreference.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/SliderPreference.kt new file mode 100644 index 00000000..a936169e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/SliderPreference.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.dolphin.ui.preferences + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.ProvideTextStyle +import com.github.damontecres.dolphin.ui.components.SliderBar + +@Composable +fun SliderPreference( + preference: AppSliderPreference, + title: String, + summary: String?, + value: Long, + onChange: (Long) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + summaryBelow: Boolean = false, + heightAdjustment: Dp = 0.dp, + additionalSummary: @Composable (ColumnScope.() -> Unit)? = null, +) { + val focused = interactionSource.collectIsFocusedAsState().value + val background = + if (focused) { + MaterialTheme.colorScheme.onBackground + } else { + Color.Unspecified + } + val contentColor = + if (focused) { + Color.Unspecified + } else { + MaterialTheme.colorScheme.onSurface + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier +// .height(80.dp) // not dense + .height(72.dp + heightAdjustment) // dense + .fillMaxWidth() + .background(background, shape = RoundedCornerShape(8.dp)) + .padding(PaddingValues(horizontal = 12.dp, vertical = 10.dp)), // dense, + ) { + PreferenceTitle(title, color = contentColor) + + ProvideTextStyle(PreferenceSummaryStyle.copy(color = contentColor)) { + additionalSummary?.invoke(this) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxSize(), + ) { + SliderBar( + value = value, + min = preference.min, + max = preference.max, + interval = preference.interval, + onChange = onChange, + color = MaterialTheme.colorScheme.border, + interactionSource = interactionSource, + modifier = Modifier.weight(1f), + ) + + if (!summaryBelow) { + PreferenceSummary(summary, color = contentColor) + } + } + if (summaryBelow) { + PreferenceSummary(summary, color = contentColor) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/SwitchPreference.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/SwitchPreference.kt new file mode 100644 index 00000000..009b55da --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/SwitchPreference.kt @@ -0,0 +1,63 @@ +package com.github.damontecres.dolphin.ui.preferences + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.tv.material3.ListItem +import androidx.tv.material3.Switch +import androidx.tv.material3.SwitchDefaults + +@Composable +fun SwitchPreference( + title: String, + value: Boolean, + onClick: () -> Unit, + summaryOn: String?, + summaryOff: String?, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = SwitchPreference( + title = title, + value = value, + onClick = onClick, + modifier = modifier, + summary = if (value) summaryOn else summaryOff, + onLongClick = onLongClick, + interactionSource = interactionSource, +) + +@Composable +fun SwitchPreference( + title: String, + value: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, + onLongClick: (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + ListItem( + selected = false, + onClick = onClick, + onLongClick = onLongClick, + headlineContent = { + PreferenceTitle(title) + }, + supportingContent = { + PreferenceSummary(summary) + }, + trailingContent = { + Switch( + checked = value, + onCheckedChange = { onClick.invoke() }, + colors = + SwitchDefaults + .colors(), + ) + }, + interactionSource = interactionSource, + modifier = modifier, + ) +} diff --git a/app/src/main/proto/DolphinDataStore.proto b/app/src/main/proto/DolphinDataStore.proto index cb6762e2..f4d1a2da 100644 --- a/app/src/main/proto/DolphinDataStore.proto +++ b/app/src/main/proto/DolphinDataStore.proto @@ -3,8 +3,17 @@ syntax = "proto3"; option java_package = "com.github.damontecres.dolphin.preferences"; option java_multiple_files = true; -message UserPreferences { +message PlaybackPreferences { + int64 skip_forward_ms = 1; + int64 skip_back_ms = 2; + int64 controller_timeout_ms = 3; + int32 seek_bar_steps = 4; +} + +message AppPreferences { // The currently signed in server and user IDs, mostly for restoring a session string current_server_id = 1; string current_user_id = 2; -} \ No newline at end of file + + PlaybackPreferences playback_preferences = 3; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e018d071..0f5a6698 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,4 +18,22 @@ More Mark as unwatched Mark as watched + Cancel + Save + Dolphin + Basic Interface + Playback + About + Advanced Settings + Advanced UI + Install update + Installed version + Show + Hide + Enabled + Disabled + Skip forward + Skip back + Hide playback controls + Seek bar steps