diff --git a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/settings/TvSettingsScreen.kt b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/settings/TvSettingsScreen.kt index f34f07dc..2dca14b2 100644 --- a/app-tv/src/main/java/hu/bbara/purefin/ui/screen/settings/TvSettingsScreen.kt +++ b/app-tv/src/main/java/hu/bbara/purefin/ui/screen/settings/TvSettingsScreen.kt @@ -1,5 +1,6 @@ package hu.bbara.purefin.ui.screen.settings +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,6 +25,7 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,6 +33,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -39,7 +42,6 @@ import hu.bbara.purefin.core.settings.BooleanSetting import hu.bbara.purefin.core.settings.DropdownSetting import hu.bbara.purefin.core.settings.RangeSetting import hu.bbara.purefin.core.settings.SettingOption -import hu.bbara.purefin.core.settings.SettingsOptions import hu.bbara.purefin.core.settings.StringSetting import hu.bbara.purefin.core.settings.VoidSetting import java.util.Locale @@ -50,6 +52,15 @@ fun TvSettingsScreen( onBack: () -> Unit = viewModel::onBack, modifier: Modifier = Modifier ) { + val settingGroups by viewModel.settingGroups.collectAsState() + val context = LocalContext.current + + LaunchedEffect(viewModel, context) { + viewModel.snackbarMessages.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } + Scaffold( modifier = modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.background, @@ -72,7 +83,7 @@ fun TvSettingsScreen( ) } - SettingsOptions.groups.forEach { group -> + settingGroups.forEach { group -> group.title?.let { title -> item { Text( @@ -135,7 +146,7 @@ private fun TvSettingOptionItem( is VoidSetting -> { TvVoidSettingItem( title = option.title, - onClick = {} + onClick = { viewModel.onClick(option) } ) } diff --git a/app/src/main/java/hu/bbara/purefin/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/hu/bbara/purefin/ui/screen/settings/SettingsScreen.kt index 8a16586c..76108d3c 100644 --- a/app/src/main/java/hu/bbara/purefin/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/ui/screen/settings/SettingsScreen.kt @@ -9,10 +9,14 @@ import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -21,7 +25,6 @@ import hu.bbara.purefin.core.settings.BooleanSetting import hu.bbara.purefin.core.settings.DropdownSetting import hu.bbara.purefin.core.settings.RangeSetting import hu.bbara.purefin.core.settings.SettingOption -import hu.bbara.purefin.core.settings.SettingsOptions import hu.bbara.purefin.core.settings.StringSetting import hu.bbara.purefin.core.settings.VoidSetting import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar @@ -37,10 +40,20 @@ fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), modifier: Modifier = Modifier ) { + val settingGroups by viewModel.settingGroups.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(viewModel) { + viewModel.snackbarMessages.collect { message -> + snackbarHostState.showSnackbar(message) + } + } + Scaffold( modifier = modifier.fillMaxSize(), containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { SettingsTopBar(onBack = viewModel::onBack) } @@ -59,7 +72,7 @@ fun SettingsScreen( ) } - SettingsOptions.groups.forEach { group -> + settingGroups.forEach { group -> group.title?.let { title -> item { Text( @@ -122,7 +135,7 @@ private fun SettingOptionItem( is VoidSetting -> { VoidSettingItem( title = option.title, - onClick = {} + onClick = { viewModel.onClick(option) } ) } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt index 8d6cb394..c8312f31 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt @@ -7,6 +7,7 @@ import hu.bbara.purefin.core.data.HomeRepository import hu.bbara.purefin.core.data.LocalMediaRepository import hu.bbara.purefin.core.data.UserSessionRepository import hu.bbara.purefin.core.download.MediaDownloadController +import hu.bbara.purefin.core.feature.update.AppUpdateController import hu.bbara.purefin.core.jellyfin.JellyfinMediaMetadataUpdater import hu.bbara.purefin.core.model.EpisodeUiModel import hu.bbara.purefin.core.model.LibraryUiModel @@ -40,6 +41,7 @@ class AppViewModel @Inject constructor( private val jellyfinMediaMetadataUpdater: JellyfinMediaMetadataUpdater, private val navigationManager: NavigationManager, private val mediaDownloadManager: MediaDownloadController, + private val appUpdateController: AppUpdateController, ) : ViewModel() { private val _isRefreshing = MutableStateFlow(false) @@ -239,6 +241,10 @@ class AppViewModel @Inject constructor( viewModelScope.launch { try { homeRepository.refreshHomeData() + appUpdateController.checkForUpdates( + showUpToDateMessage = false, + showFailureMessage = false + ) } catch (e: Exception) { // Refresh is best-effort; don't crash on failure } @@ -255,6 +261,10 @@ class AppViewModel @Inject constructor( _isRefreshing.value = true try { homeRepository.refreshHomeData() + appUpdateController.checkForUpdates( + showUpToDateMessage = false, + showFailureMessage = false + ) } catch (e: Exception) { // Refresh is best-effort; don't crash on failure } finally { diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/settings/SettingsViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/settings/SettingsViewModel.kt index 7bb9403e..d9ff7703 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/settings/SettingsViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/settings/SettingsViewModel.kt @@ -7,8 +7,20 @@ import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.settings.BooleanSetting import hu.bbara.purefin.core.settings.DropdownSetting import hu.bbara.purefin.core.settings.RangeSetting +import hu.bbara.purefin.core.settings.SettingGroup +import hu.bbara.purefin.core.settings.SettingsGroupProvider import hu.bbara.purefin.core.settings.SettingsRepository +import hu.bbara.purefin.core.settings.SettingsOptions import hu.bbara.purefin.core.settings.StringSetting +import hu.bbara.purefin.core.settings.VoidSetting +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -16,8 +28,30 @@ import javax.inject.Inject class SettingsViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val navigationManager: NavigationManager, + settingsGroupProviders: Set<@JvmSuppressWildcards SettingsGroupProvider>, ) : ViewModel() { + private val _snackbarMessages = MutableSharedFlow() + val snackbarMessages: SharedFlow = _snackbarMessages.asSharedFlow() + + private val dynamicSettingGroups = settingsGroupProviders + .map { it.settingGroups } + .takeIf { it.isNotEmpty() } + ?.let { groupFlows -> + combine(groupFlows) { groups -> groups.toList().flatten() } + } + ?: flowOf(emptyList()) + + val settingGroups: StateFlow> = dynamicSettingGroups + .combine(flowOf(SettingsOptions.groups)) { dynamicGroups, staticGroups -> + staticGroups + dynamicGroups + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SettingsOptions.groups + ) + fun value(option: RangeSetting) = settingsRepository.value(option) fun value(option: BooleanSetting) = settingsRepository.value(option) @@ -50,6 +84,16 @@ class SettingsViewModel @Inject constructor( } } + fun onClick(option: VoidSetting) { + viewModelScope.launch { + try { + option.onClick() + } catch (e: Exception) { + _snackbarMessages.emit(e.message ?: "Setting action failed") + } + } + } + fun onBack() { navigationManager.pop() } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/update/AppUpdateController.kt b/core/src/main/java/hu/bbara/purefin/core/feature/update/AppUpdateController.kt new file mode 100644 index 00000000..c4a5d47b --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/core/feature/update/AppUpdateController.kt @@ -0,0 +1,106 @@ +package hu.bbara.purefin.core.feature.update + +import hu.bbara.purefin.core.settings.SettingGroup +import hu.bbara.purefin.core.settings.SettingsGroupProvider +import hu.bbara.purefin.core.settings.VoidSetting +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppUpdateController @Inject constructor( + private val appUpdateRepository: AppUpdateRepository, + private val appUpdateInstaller: AppUpdateInstaller +) : SettingsGroupProvider { + private val _isCheckingForUpdates = MutableStateFlow(false) + val isCheckingForUpdates: StateFlow = _isCheckingForUpdates.asStateFlow() + + private val _availableUpdate = MutableStateFlow(null) + val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + + private val _snackbarMessages = MutableSharedFlow() + val snackbarMessages: SharedFlow = _snackbarMessages.asSharedFlow() + + private val checkMutex = Mutex() + private val installMutex = Mutex() + + override val settingGroups: Flow> = availableUpdate + .map { update -> + if (update == null) { + emptyList() + } else { + listOf( + SettingGroup( + title = "App", + options = listOf(installUpdateSetting(update)) + ) + ) + } + } + + suspend fun checkForUpdates( + showUpToDateMessage: Boolean = true, + showFailureMessage: Boolean = true + ) { + if (!checkMutex.tryLock()) { + return + } + + _isCheckingForUpdates.value = true + try { + val update = appUpdateRepository.checkForUpdate() + _availableUpdate.value = update + if (update == null && showUpToDateMessage) { + _snackbarMessages.emit("Purefin is up to date") + } + } catch (e: Exception) { + if (showFailureMessage) { + _snackbarMessages.emit(e.message ?: "Update check failed") + } + } finally { + _isCheckingForUpdates.value = false + checkMutex.unlock() + } + } + + suspend fun installAvailableUpdate() { + val update = _availableUpdate.value ?: return + if (!installMutex.tryLock()) { + return + } + + _availableUpdate.value = null + try { + _snackbarMessages.emit(appUpdateInstaller.installUpdate(update)) + } catch (e: Exception) { + _snackbarMessages.emit(e.message ?: "Update install failed") + } finally { + installMutex.unlock() + } + } + + fun declineUpdate() { + _availableUpdate.value = null + } + + private fun installUpdateSetting(update: AppUpdateInfo): VoidSetting { + val versionLabel = update.versionName?.takeIf { it.isNotBlank() } ?: update.versionCode.toString() + return VoidSetting( + key = INSTALL_APP_UPDATE_KEY, + title = "Install Purefin $versionLabel", + onClick = { installAvailableUpdate() } + ) + } + + private companion object { + const val INSTALL_APP_UPDATE_KEY = "install_app_update" + } +} diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/update/AppUpdateViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/update/AppUpdateViewModel.kt index d2f666cb..0823209d 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/update/AppUpdateViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/update/AppUpdateViewModel.kt @@ -3,76 +3,35 @@ package hu.bbara.purefin.core.feature.update import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class AppUpdateViewModel @Inject constructor( - private val appUpdateRepository: AppUpdateRepository, - private val appUpdateInstaller: AppUpdateInstaller + private val appUpdateController: AppUpdateController ) : ViewModel() { - private val _isCheckingForUpdates = MutableStateFlow(false) - val isCheckingForUpdates: StateFlow = _isCheckingForUpdates.asStateFlow() + val isCheckingForUpdates: StateFlow = appUpdateController.isCheckingForUpdates - private val _availableUpdate = MutableStateFlow(null) - val availableUpdate: StateFlow = _availableUpdate.asStateFlow() + val availableUpdate: StateFlow = appUpdateController.availableUpdate - private val _snackbarMessages = MutableSharedFlow() - val snackbarMessages: SharedFlow = _snackbarMessages.asSharedFlow() - - private var isInstallingUpdate = false + val snackbarMessages: SharedFlow = appUpdateController.snackbarMessages fun checkForUpdates(showUpToDateMessage: Boolean = true) { - if (_isCheckingForUpdates.value) { - return - } - viewModelScope.launch { - _isCheckingForUpdates.value = true - try { - val update = appUpdateRepository.checkForUpdate() - if (update == null) { - if (showUpToDateMessage) { - _snackbarMessages.emit("Purefin is up to date") - } - } else { - _availableUpdate.value = update - } - } catch (e: Exception) { - _snackbarMessages.emit(e.message ?: "Update check failed") - } finally { - _isCheckingForUpdates.value = false - } + appUpdateController.checkForUpdates(showUpToDateMessage = showUpToDateMessage) } } fun acceptUpdate() { - val update = _availableUpdate.value ?: return - if (isInstallingUpdate) { - return - } - viewModelScope.launch { - isInstallingUpdate = true - _availableUpdate.value = null - try { - _snackbarMessages.emit(appUpdateInstaller.installUpdate(update)) - } catch (e: Exception) { - _snackbarMessages.emit(e.message ?: "Update install failed") - } finally { - isInstallingUpdate = false - } + appUpdateController.installAvailableUpdate() } } fun declineUpdate() { - _availableUpdate.value = null + appUpdateController.declineUpdate() } } diff --git a/core/src/main/java/hu/bbara/purefin/core/settings/SettingOption.kt b/core/src/main/java/hu/bbara/purefin/core/settings/SettingOption.kt index 9ee201c0..01d91d2c 100644 --- a/core/src/main/java/hu/bbara/purefin/core/settings/SettingOption.kt +++ b/core/src/main/java/hu/bbara/purefin/core/settings/SettingOption.kt @@ -28,7 +28,8 @@ data class StringSetting( data class VoidSetting( override val key: String, override val title: String, - override val defaultValue: Unit = Unit + override val defaultValue: Unit = Unit, + val onClick: suspend () -> Unit = {} ) : SettingOption data class DropdownSetting( @@ -44,18 +45,6 @@ data class SettingGroup( ) object SettingsOptions { - val defaultPlaybackSpeed = RangeSetting( - key = "default_playback_speed", - title = "Default playback speed", - defaultValue = 1.0, - valueRange = 0.5..2.0 - ) - - val confirmMobileDataPlayback = BooleanSetting( - key = "confirm_mobile_data_playback", - title = "Confirm mobile data playback", - defaultValue = true - ) val autoPlayNextMedia = BooleanSetting( key = "auto_play_next_media", @@ -63,34 +52,11 @@ object SettingsOptions { defaultValue = true ) - val preferredAudioLanguage = StringSetting( - key = "preferred_audio_language", - title = "Preferred audio language", - defaultValue = "English" - ) - - val resetPlaybackSettings = VoidSetting( - key = "reset_playback_settings", - title = "Reset playback settings" - ) - - val streamingQuality = DropdownSetting( - key = "streaming_quality", - title = "Streaming quality", - defaultValue = "Auto", - options = listOf("Auto", "Low", "Medium", "High") - ) - val groups = listOf( SettingGroup( title = "Playback", options = listOf( - defaultPlaybackSpeed, - confirmMobileDataPlayback, autoPlayNextMedia, - preferredAudioLanguage, - resetPlaybackSettings, - streamingQuality ) ) ) diff --git a/core/src/main/java/hu/bbara/purefin/core/settings/SettingsGroupProvider.kt b/core/src/main/java/hu/bbara/purefin/core/settings/SettingsGroupProvider.kt new file mode 100644 index 00000000..cd53365f --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/core/settings/SettingsGroupProvider.kt @@ -0,0 +1,7 @@ +package hu.bbara.purefin.core.settings + +import kotlinx.coroutines.flow.Flow + +interface SettingsGroupProvider { + val settingGroups: Flow> +} \ No newline at end of file diff --git a/core/src/main/java/hu/bbara/purefin/core/update/AppUpdateModule.kt b/core/src/main/java/hu/bbara/purefin/core/update/AppUpdateModule.kt index a5a20c62..480892dc 100644 --- a/core/src/main/java/hu/bbara/purefin/core/update/AppUpdateModule.kt +++ b/core/src/main/java/hu/bbara/purefin/core/update/AppUpdateModule.kt @@ -4,8 +4,11 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import hu.bbara.purefin.core.feature.update.AppUpdateController import hu.bbara.purefin.core.feature.update.AppUpdateInstaller import hu.bbara.purefin.core.feature.update.AppVersionProvider +import hu.bbara.purefin.core.settings.SettingsGroupProvider @Module @InstallIn(SingletonComponent::class) @@ -16,4 +19,8 @@ abstract class AppUpdateModule { @Binds abstract fun bindAppVersionProvider(impl: AndroidAppVersionProvider): AppVersionProvider + + @Binds + @IntoSet + abstract fun bindAppUpdateSettingsProvider(impl: AppUpdateController): SettingsGroupProvider }