diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6b52cd820..1d046db96 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -66,6 +66,16 @@ + + + + + + diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterViewModel.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterViewModel.kt index 680512eef..c0d135793 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterViewModel.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterViewModel.kt @@ -65,7 +65,7 @@ class CreateFilterViewModel : ViewModel() { this.filterName.value = initialFilter?.name // Fetch all of the labels for any existing IDs in the initial object filter - viewModelScope.launch(StashCoroutineExceptionHandler()) { + viewModelScope.launch(StashCoroutineExceptionHandler(autoToast = true)) { getIdsByDataType(dataType, objectFilter.value!!).entries.forEach { val dt = it.key val ids = it.value diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/filter/BasicFilterSettings.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/filter/BasicFilterSettings.kt index 3715cfa2a..15efb26eb 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/filter/BasicFilterSettings.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/filter/BasicFilterSettings.kt @@ -34,6 +34,7 @@ fun BasicFilterSettings( findFilterInteractionSource: MutableInteractionSource, objectFilterInteractionSource: MutableInteractionSource, onSubmit: (Boolean) -> Unit, + saveEnabled: Boolean, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -45,14 +46,16 @@ fun BasicFilterSettings( .focusRestorer(focusRequester), verticalArrangement = Arrangement.spacedBy(0.dp), ) { - item { - SimpleListItem( - title = stringResource(R.string.stashapp_filter_name), - subtitle = name, - showArrow = false, - onClick = onFilterNameClick, - modifier = Modifier.focusRequester(focusRequester), - ) + if (saveEnabled) { + item { + SimpleListItem( + title = stringResource(R.string.stashapp_filter_name), + subtitle = name, + showArrow = false, + onClick = onFilterNameClick, + modifier = Modifier.focusRequester(focusRequester), + ) + } } item { SimpleListItem( @@ -82,7 +85,14 @@ fun BasicFilterSettings( } item { SimpleListItem( - title = stringResource(R.string.submit_without_saving), + title = + if (saveEnabled) { + stringResource(R.string.submit_without_saving) + } else { + stringResource( + R.string.stashapp_actions_submit, + ) + }, subtitle = if (resultCount >= 0) { resultCount.toString() + " " + stringResource(R.string.results) @@ -93,19 +103,21 @@ fun BasicFilterSettings( onClick = { onSubmit.invoke(false) }, ) } - item { - SimpleListItem( - enabled = name.isNotNullOrBlank(), - title = stringResource(R.string.save_and_submit), - subtitle = - if (name.isNotNullOrBlank()) { - null - } else { - stringResource(R.string.save_and_submit_no_name_desc) - }, - showArrow = false, - onClick = { onSubmit.invoke(true) }, - ) + if (saveEnabled) { + item { + SimpleListItem( + enabled = name.isNotNullOrBlank(), + title = stringResource(R.string.save_and_submit), + subtitle = + if (name.isNotNullOrBlank()) { + null + } else { + stringResource(R.string.save_and_submit_no_name_desc) + }, + showArrow = false, + onClick = { onSubmit.invoke(true) }, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/filter/CreateFilter.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/filter/CreateFilter.kt index 7d811f8c9..021cd11c1 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/filter/CreateFilter.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/filter/CreateFilter.kt @@ -81,9 +81,49 @@ fun CreateFilterScreen( onUpdateTitle: ((AnnotatedString) -> Unit)? = null, viewModel: CreateFilterViewModel = viewModel(), ) { - val context = LocalContext.current val server = LocalGlobalContext.current.server val scope = rememberCoroutineScope() + CreateFilterContent( + uiConfig = uiConfig, + dataType = dataType, + initialFilter = initialFilter, + saveEnabled = true, + onSubmit = { save, filterArgs -> + if (save) { + scope.launch( + LoggingCoroutineExceptionHandler( + server, + scope, + toastMessage = "Error saving filter", + ), + ) { + viewModel.saveFilter() + navigationManager.goBack() + navigationManager.navigate(Destination.Filter(filterArgs)) + } + } else { + navigationManager.goBack() + navigationManager.navigate(Destination.Filter(filterArgs)) + } + }, + modifier = modifier, + onUpdateTitle = onUpdateTitle, + viewModel = viewModel, + ) +} + +@Composable +fun CreateFilterContent( + uiConfig: ComposeUiConfig, + dataType: DataType, + initialFilter: FilterArgs?, + saveEnabled: Boolean, + onSubmit: (save: Boolean, filter: FilterArgs) -> Unit, + modifier: Modifier = Modifier, + onUpdateTitle: ((AnnotatedString) -> Unit)? = null, + viewModel: CreateFilterViewModel = viewModel(), +) { + val context = LocalContext.current val ready by viewModel.ready.observeAsState(false) val name by viewModel.filterName.observeAsState() @@ -133,23 +173,9 @@ fun CreateFilterScreen( }, idLookup = viewModel::lookupIds, idStore = viewModel::store, + saveEnabled = saveEnabled, onSubmit = { save -> - if (save) { - scope.launch( - LoggingCoroutineExceptionHandler( - server, - scope, - toastMessage = "Error saving filter", - ), - ) { - viewModel.saveFilter() - navigationManager.goBack() - navigationManager.navigate(Destination.Filter(viewModel.createFilterArgs())) - } - } else { - navigationManager.goBack() - navigationManager.navigate(Destination.Filter(viewModel.createFilterArgs())) - } + onSubmit.invoke(save, viewModel.createFilterArgs()) }, modifier = Modifier @@ -175,6 +201,7 @@ fun CreateFilterColumns( idLookup: (DataType, List) -> Map, idStore: (DataType, StashData) -> Unit, onSubmit: (Boolean) -> Unit, + saveEnabled: Boolean, modifier: Modifier = Modifier, ) { val findFilterInteractionSource = remember { MutableInteractionSource() } @@ -318,6 +345,7 @@ fun CreateFilterColumns( findFilterInteractionSource = findFilterInteractionSource, objectFilterInteractionSource = objectFilterInteractionSource, onSubmit = onSubmit, + saveEnabled = saveEnabled, modifier = Modifier .width(listWidth) diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/PreferencesContent.kt index d0b218ec0..a5add7d7b 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/PreferencesContent.kt @@ -47,7 +47,8 @@ import com.github.damontecres.stashapp.R import com.github.damontecres.stashapp.RootActivity import com.github.damontecres.stashapp.navigation.Destination import com.github.damontecres.stashapp.navigation.NavigationManager -import com.github.damontecres.stashapp.proto.StashPreferences +import com.github.damontecres.stashapp.ui.ComposeUiConfig +import com.github.damontecres.stashapp.ui.components.screensaver.ChooseScreensaverFilterDialog import com.github.damontecres.stashapp.ui.tryRequestFocus import com.github.damontecres.stashapp.ui.util.ifElse import com.github.damontecres.stashapp.ui.util.playOnClickSound @@ -166,6 +167,13 @@ val advancedPreferences = StashPreference.DirectPlayFormat, ), ), + PreferenceGroup( + R.string.screensaver, + listOf( + StashPreference.ScreensaverEnable, + StashPreference.ScreensaverFilter, + ), + ), PreferenceGroup( R.string.effects_filters, listOf( @@ -223,7 +231,7 @@ val advancedPreferences = fun PreferencesContent( server: StashServer, navigationManager: NavigationManager, - initialPreferences: StashPreferences, + uiConfig: ComposeUiConfig, preferenceScreenOption: PreferenceScreenOption, modifier: Modifier = Modifier, onUpdateTitle: ((AnnotatedString) -> Unit)? = null, @@ -234,7 +242,7 @@ fun PreferencesContent( val focusRequester = remember { FocusRequester() } var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() - var preferences by remember { mutableStateOf(initialPreferences) } + var preferences by remember { mutableStateOf(uiConfig.preferences) } val sharedPrefs = remember { PreferenceManager.getDefaultSharedPreferences(context) } LaunchedEffect(Unit) { context.preferences.data.collect { @@ -282,6 +290,8 @@ fun PreferencesContent( visible = true } + var showScreensaverFilterDialog by remember { mutableStateOf(false) } + AnimatedVisibility( visible = visible, enter = fadeIn() + slideInHorizontally { it / 2 }, @@ -439,6 +449,22 @@ fun PreferencesContent( ) } + StashPreference.ScreensaverFilter -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + showScreensaverFilterDialog = true + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -527,6 +553,12 @@ fun PreferencesContent( } } } + if (showScreensaverFilterDialog) { + ChooseScreensaverFilterDialog( + uiConfig = uiConfig, + onDismissRequest = { showScreensaverFilterDialog = false }, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/StashPreference.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/StashPreference.kt index 04c9b94f6..428c27385 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/StashPreference.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/prefs/StashPreference.kt @@ -1,7 +1,9 @@ package com.github.damontecres.stashapp.ui.components.prefs +import android.content.ComponentName import android.content.Context import android.content.SharedPreferences +import android.content.pm.PackageManager import android.util.Log import androidx.annotation.ArrayRes import androidx.annotation.StringRes @@ -19,6 +21,7 @@ import com.github.damontecres.stashapp.proto.StashPreferences import com.github.damontecres.stashapp.proto.StreamChoice import com.github.damontecres.stashapp.proto.TabType import com.github.damontecres.stashapp.proto.ThemeStyle +import com.github.damontecres.stashapp.util.StashDreamService import com.github.damontecres.stashapp.util.cacheDurationPrefToDuration import com.github.damontecres.stashapp.util.isNotNullOrBlank import com.github.damontecres.stashapp.util.updateAdvancedPreferences @@ -26,6 +29,7 @@ import com.github.damontecres.stashapp.util.updateCachePreferences import com.github.damontecres.stashapp.util.updateInterfacePreferences import com.github.damontecres.stashapp.util.updatePinPreferences import com.github.damontecres.stashapp.util.updatePlaybackPreferences +import com.github.damontecres.stashapp.util.updateScreensaverPreferences import com.github.damontecres.stashapp.util.updateSearchPreferences import com.github.damontecres.stashapp.util.updateTabPreferences import com.github.damontecres.stashapp.util.updateUpdatePreferences @@ -1424,6 +1428,37 @@ sealed interface StashPreference { title = R.string.migrate_preferences, summary = R.string.migrate_preferences_summary, ) + + // Screensaver + val ScreensaverEnable = + StashSwitchPreference( + title = R.string.enable_screensaver, + prefKey = R.string.pref_key_screensaver_enabled, + defaultValue = false, + getter = { it.screensaverPreferences.enabled }, + setter = { prefs, value -> + val pm: PackageManager = StashApplication.getApplication().packageManager + val componentName = + ComponentName( + StashApplication.getApplication(), + StashDreamService::class.java, + ) + val newState = + if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + pm.setComponentEnabledSetting( + componentName, + newState, + PackageManager.DONT_KILL_APP, + ) + prefs.updateScreensaverPreferences { enabled = value } + }, + summaryOn = R.string.stashapp_actions_enable, + summaryOff = R.string.transcode_options_disabled, + ) + val ScreensaverFilter = + StashClickablePreference( + title = R.string.screensaver_filter, + ) } } diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/screensaver/ChooseScreensaverFilter.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/screensaver/ChooseScreensaverFilter.kt new file mode 100644 index 000000000..efc4b8aa8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/screensaver/ChooseScreensaverFilter.kt @@ -0,0 +1,357 @@ +package com.github.damontecres.stashapp.ui.components.screensaver + +import android.content.Context +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.draw.drawBehind +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.stashapp.StashApplication +import com.github.damontecres.stashapp.api.fragment.SavedFilter +import com.github.damontecres.stashapp.api.type.SortDirectionEnum +import com.github.damontecres.stashapp.data.DataType +import com.github.damontecres.stashapp.data.SortAndDirection +import com.github.damontecres.stashapp.data.SortOption +import com.github.damontecres.stashapp.suppliers.FilterArgs +import com.github.damontecres.stashapp.suppliers.toFilterArgs +import com.github.damontecres.stashapp.ui.ComposeUiConfig +import com.github.damontecres.stashapp.ui.compat.ListItem +import com.github.damontecres.stashapp.ui.components.DialogItem +import com.github.damontecres.stashapp.ui.components.DialogPopup +import com.github.damontecres.stashapp.ui.components.filter.CreateFilterContent +import com.github.damontecres.stashapp.util.FilterParser +import com.github.damontecres.stashapp.util.QueryEngine +import com.github.damontecres.stashapp.util.StashParcelable +import com.github.damontecres.stashapp.util.StashServer +import com.github.damontecres.stashapp.util.launchDefault +import com.github.damontecres.stashapp.util.launchIO +import com.github.damontecres.stashapp.util.showToastOnMain +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToByteArray +import java.io.File + +private const val TAG = "ChooseScreensaver" + +@OptIn(ExperimentalSerializationApi::class) +class ChooseScreensaverFilterViewModel : ViewModel() { + private val context = StashApplication.getApplication() + private val server = StashServer.requireCurrentServer() + + val state = MutableStateFlow(ChooseScreensaverFilterState()) + + init { + viewModelScope.launchIO { + val file = getScreensaverFile(context, server) + if (file.exists()) { + try { + val filter = ScreensaverFilter.read(file) + if (filter.savedFilterId == null) { + state.update { + it.copy( + filter = filter.filter, + savedFilterId = filter.savedFilterId, + ) + } + } + state.update { + it.copy(filterConfigured = true) + } + } catch (ex: Exception) { + Log.e(TAG, "Error reading filter", ex) + showToastOnMain(context, "Error reading filter: ${ex.localizedMessage}", Toast.LENGTH_LONG) + } + } + val savedFilters = QueryEngine(server).getSavedFilters(DataType.IMAGE) + state.update { + val savedFilter = it.savedFilterId?.let { id -> savedFilters.firstOrNull { it.id == id } } + it.copy( + savedFilters = savedFilters, + savedFilter = savedFilter, + ) + } + } + } + + @OptIn(ExperimentalSerializationApi::class) + fun saveFilter( + filter: FilterArgs, + filterId: String? = null, + ) { + viewModelScope.launchIO { + try { + val toSave = ScreensaverFilter(filterId, filter) + val file = getScreensaverFile(context, server) + file.parentFile!!.mkdirs() + toSave.write(file) + val savedFilter = filterId?.let { id -> state.value.savedFilters.firstOrNull { it.id == id } } + state.update { + it.copy( + filter = filter, + filterConfigured = true, + savedFilterId = filterId, + savedFilter = savedFilter, + ) + } + } catch (ex: Exception) { + Log.e(TAG, "Error saving filter", ex) + showToastOnMain(context, "Error saving filter: ${ex.localizedMessage}", Toast.LENGTH_LONG) + } + } + } + + fun saveFilter(savedFilter: SavedFilter) { + viewModelScope.launchDefault { + val filterArgs = savedFilter.toFilterArgs(FilterParser(server.version)) + saveFilter(filterArgs, savedFilter.id) + } + } + + fun deleteFilter() { + viewModelScope.launchIO { + val file = getScreensaverFile(context, server) + file.delete() + state.update { + it.copy( + filter = ScreensaverFilter.makeDefault().filter, + filterConfigured = false, + ) + } + } + } +} + +fun getScreensaverFile( + context: Context, + server: StashServer, +): File { + val filename = server.serverPreferences.serverKey + val parentDir = File(context.filesDir, "screensaver") + return File(parentDir, filename) +} + +data class ChooseScreensaverFilterState( + val savedFilters: List = emptyList(), + val filter: FilterArgs = ScreensaverFilter.makeDefault().filter, + val filterConfigured: Boolean = false, + val savedFilterId: String? = null, + val savedFilter: SavedFilter? = null, +) + +@Serializable +data class ScreensaverFilter( + val savedFilterId: String?, + val filter: FilterArgs, +) { + companion object { + fun makeDefault() = + ScreensaverFilter( + null, + FilterArgs(DataType.IMAGE).with( + SortAndDirection( + SortOption.Random, + SortDirectionEnum.ASC, + ), + ), + ) + + @OptIn(ExperimentalSerializationApi::class) + suspend fun read(file: File): ScreensaverFilter { + val bytes = + file.inputStream().use { + it.readBytes() + } + return StashParcelable.decodeFromByteArray(serializer(), bytes) + } + } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun write(file: File) { + val bytes = StashParcelable.encodeToByteArray(this) + file.outputStream().use { it.write(bytes) } + } +} + +@Composable +fun ChooseScreensaverFilterDialog( + uiConfig: ComposeUiConfig, + onDismissRequest: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) { + val elevatedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + Box( + modifier = + Modifier + .graphicsLayer { + this.clip = true + this.shape = RoundedCornerShape(28.0.dp) + }.drawBehind { drawRect(color = elevatedContainerColor) } + .padding(PaddingValues(24.dp)), + ) { + ChooseScreensaverFilter( + uiConfig = uiConfig, + modifier = Modifier, + ) + } + } +} + +@Composable +fun ChooseScreensaverFilter( + uiConfig: ComposeUiConfig, + modifier: Modifier = Modifier, + viewModel: ChooseScreensaverFilterViewModel = viewModel(), +) { + val state by viewModel.state.collectAsState() + + var showSavedFilterDialog by remember { mutableStateOf(false) } + var showCreateFilterDialog by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + Text( + text = "Choose filter", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge, + ) + LazyColumn { + item { + ListItem( + selected = false, + enabled = state.savedFilters.isNotEmpty(), + headlineContent = { + Text("Use saved filter") + }, + supportingContent = + if (state.savedFilters.isEmpty()) { + { + Text("No saved filters") + } + } else { + null + }, + onClick = { + showSavedFilterDialog = true + }, + ) + } + item { + ListItem( + selected = false, + headlineContent = { + Text("Create filter") + }, + onClick = { + showCreateFilterDialog = true + }, + ) + } + item { + ListItem( + selected = false, + enabled = state.filterConfigured, + headlineContent = { + Text("Clear filter") + }, + supportingContent = + if (state.filterConfigured) { + { + val name = + if (state.savedFilter != null) { + state.savedFilter?.name ?: "" + } else { + "Custom filter" + } + Text(name) + } + } else { + null + }, + onClick = { + viewModel.deleteFilter() + }, + ) + } + } + } + DialogPopup( + showDialog = showSavedFilterDialog, + title = "Choose saved filter", + dialogItems = + remember(state) { + state.savedFilters.map { + DialogItem(it.name) { viewModel.saveFilter(it) } + } + }, + onDismissRequest = { showSavedFilterDialog = false }, + waitToLoad = false, + ) + if (showCreateFilterDialog) { + Dialog( + onDismissRequest = { showCreateFilterDialog = false }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val elevatedContainerColor = MaterialTheme.colorScheme.background + Box( + modifier = + Modifier + .fillMaxSize() + .drawBehind { drawRect(color = elevatedContainerColor) } + .padding(PaddingValues(24.dp)), + ) { + CreateScreensaverFilter( + uiConfig = uiConfig, + onSubmit = { + showCreateFilterDialog = false + viewModel.saveFilter(it) + }, + initialFilter = state.filter, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@Composable +fun CreateScreensaverFilter( + uiConfig: ComposeUiConfig, + onSubmit: (FilterArgs) -> Unit, + initialFilter: FilterArgs, + modifier: Modifier = Modifier, +) { + CreateFilterContent( + uiConfig = uiConfig, + dataType = DataType.IMAGE, + initialFilter = initialFilter, + saveEnabled = false, + onSubmit = { _, filter -> onSubmit.invoke(filter) }, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/components/screensaver/ScreensaverContent.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/components/screensaver/ScreensaverContent.kt new file mode 100644 index 000000000..62b699175 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/components/screensaver/ScreensaverContent.kt @@ -0,0 +1,41 @@ +package com.github.damontecres.stashapp.ui.components.screensaver + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.useExistingImageAsPlaceholder +import coil3.request.ImageRequest +import coil3.request.transitionFactory +import com.github.damontecres.stashapp.api.fragment.ImageData +import com.github.damontecres.stashapp.ui.util.CrossFadeFactory +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoilApi::class) +@Composable +fun ScreensaverContent( + imageData: ImageData?, + duration: Duration, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Box(modifier = modifier) { + AsyncImage( + contentDescription = null, + model = + ImageRequest + .Builder(context) + .data(imageData?.paths?.image) + .transitionFactory(CrossFadeFactory(2000.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + modifier = + Modifier + .fillMaxSize(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/stashapp/ui/pages/SettingsPage.kt b/app/src/main/java/com/github/damontecres/stashapp/ui/pages/SettingsPage.kt index f269c9e5e..9cf85f24f 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/ui/pages/SettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/ui/pages/SettingsPage.kt @@ -40,7 +40,7 @@ fun SettingsPage( PreferencesContent( server, navigationManager, - uiConfig.preferences, + uiConfig, preferenceScreenOption, newModifier, onUpdateTitle, diff --git a/app/src/main/java/com/github/damontecres/stashapp/util/OptionalSerializer.kt b/app/src/main/java/com/github/damontecres/stashapp/util/OptionalSerializer.kt index 579c9bda1..6980d36cb 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/util/OptionalSerializer.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/util/OptionalSerializer.kt @@ -12,6 +12,7 @@ import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.descriptors.element import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule val OptionalSerializersModule = @@ -29,6 +30,11 @@ val StashParcelable = serializersModule = OptionalSerializersModule } +val StashJson = + Json { + serializersModule = OptionalSerializersModule + } + /** * Serializes [Optional]s * diff --git a/app/src/main/java/com/github/damontecres/stashapp/util/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/stashapp/util/ServerPreferences.kt index c470abef9..22d78d019 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/util/ServerPreferences.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/util/ServerPreferences.kt @@ -25,7 +25,7 @@ import org.json.JSONObject class ServerPreferences( val server: StashServer, ) { - private val serverKey = server.url.replace(Regex("[^\\w.]"), "_") + val serverKey get() = server.url.replace(Regex("[^\\w.]"), "_") val preferences: SharedPreferences by lazy { StashApplication.getApplication().getSharedPreferences(serverKey, Context.MODE_PRIVATE) diff --git a/app/src/main/java/com/github/damontecres/stashapp/util/StashDreamService.kt b/app/src/main/java/com/github/damontecres/stashapp/util/StashDreamService.kt new file mode 100644 index 000000000..4a4dfe65d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/stashapp/util/StashDreamService.kt @@ -0,0 +1,173 @@ +package com.github.damontecres.stashapp.util + +import android.service.dreams.DreamService +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import coil3.imageLoader +import coil3.request.ImageRequest +import com.apollographql.apollo.api.Query +import com.github.damontecres.stashapp.api.fragment.ImageData +import com.github.damontecres.stashapp.api.fragment.StashData +import com.github.damontecres.stashapp.suppliers.DataSupplierFactory +import com.github.damontecres.stashapp.suppliers.StashPagingSource +import com.github.damontecres.stashapp.suppliers.toFilterArgs +import com.github.damontecres.stashapp.ui.AppTheme +import com.github.damontecres.stashapp.ui.components.screensaver.ScreensaverContent +import com.github.damontecres.stashapp.ui.components.screensaver.ScreensaverFilter +import com.github.damontecres.stashapp.ui.components.screensaver.getScreensaverFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.serialization.ExperimentalSerializationApi +import kotlin.time.Duration.Companion.seconds + +class StashDreamService : + DreamService(), + SavedStateRegistryOwner { + private val lifecycleRegistry = LifecycleRegistry(this) + + private val savedStateRegistryController = + SavedStateRegistryController.create(this).apply { + performAttach() + } + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + + override fun onCreate() { + super.onCreate() + + savedStateRegistryController.performRestore(null) + lifecycleRegistry.currentState = Lifecycle.State.CREATED + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val itemFlow = createFlow() + setContentView( + ComposeView(this).apply { + setViewTreeLifecycleOwner(this@StashDreamService) + setViewTreeSavedStateRegistryOwner(this@StashDreamService) + setContent { + AppTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + val currentItem by itemFlow.collectAsState(null) + ScreensaverContent( + imageData = currentItem, + duration = 60.seconds, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + }, + ) + } + + override fun onDreamingStarted() { + super.onDreamingStarted() + lifecycleRegistry.currentState = Lifecycle.State.STARTED + } + + override fun onDreamingStopped() { + super.onDreamingStopped() + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + } + + @OptIn(ExperimentalSerializationApi::class) + private fun createFlow(): Flow = + flow { + val server = StashServer.requireCurrentServer() + val queryEngine = QueryEngine(server) + val file = getScreensaverFile(this@StashDreamService, server) + val screensaverFilter = + try { + if (file.exists()) { + Log.d(TAG, "Reading ScreensaverFilter") + ScreensaverFilter.read(file) + } else { + ScreensaverFilter.makeDefault() + } + } catch (ex: Exception) { + Log.e(TAG, "Error loading file", ex) + ScreensaverFilter.makeDefault() + } + val filter = + if (screensaverFilter.savedFilterId.isNotNullOrBlank()) { + Log.v(TAG, "Fetching saved filter ${screensaverFilter.savedFilterId}") + queryEngine + .getSavedFilter(screensaverFilter.savedFilterId) + ?.toFilterArgs(FilterParser(server.version)) + } else { + screensaverFilter.filter + } ?: screensaverFilter.filter + val dataSupplier = + DataSupplierFactory(server.version).create(filter) + val pagingSource = + StashPagingSource( + QueryEngine(server), + dataSupplier = dataSupplier, + ) + val pager = + ComposePager( + filter = filter, + source = pagingSource, + scope = lifecycleScope, + pageSize = 25, + cacheSize = 2, + ) + pager.init() + Log.v(TAG, "Got ${pager.size} images") + var index = 0 + while (true) { + val imageData = + try { + pager.getBlocking(index) as ImageData? + } catch (ex: Exception) { + Log.w(TAG, "Error fetching image", ex) + null + } + if (imageData != null) { + this@StashDreamService + .imageLoader + .enqueue( + ImageRequest + .Builder(this@StashDreamService) + .data(imageData.paths.image) + .build(), + ).job + .await() + emit(imageData) + delay(60.seconds) + index++ + if (index >= pager.size) index = 0 + } + } + }.flowOn(Dispatchers.IO) + + companion object { + private const val TAG = "StashDreamService" + } +} diff --git a/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt index 4f1c6fa0a..fc0429481 100644 --- a/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/stashapp/util/StashPreferencesSerializer.kt @@ -10,6 +10,7 @@ import com.github.damontecres.stashapp.proto.CachePreferences import com.github.damontecres.stashapp.proto.InterfacePreferences import com.github.damontecres.stashapp.proto.PinPreferences import com.github.damontecres.stashapp.proto.PlaybackPreferences +import com.github.damontecres.stashapp.proto.ScreensaverPreferences import com.github.damontecres.stashapp.proto.SearchPreferences import com.github.damontecres.stashapp.proto.StashPreferences import com.github.damontecres.stashapp.proto.StreamChoice @@ -188,3 +189,8 @@ inline fun StashPreferences.updatePinPreferences(block: PinPreferences.Builder.( update { pinPreferences = pinPreferences.toBuilder().apply(block).build() } + +inline fun StashPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): StashPreferences = + update { + screensaverPreferences = screensaverPreferences.toBuilder().apply(block).build() + } diff --git a/app/src/main/proto/preferences.proto b/app/src/main/proto/preferences.proto index 1d1f12d94..d22f6319b 100644 --- a/app/src/main/proto/preferences.proto +++ b/app/src/main/proto/preferences.proto @@ -153,6 +153,10 @@ message SearchPreferences { int64 search_delay_ms = 2; } +message ScreensaverPreferences { + bool enabled = 1; +} + message StashPreferences { PinPreferences pin_preferences = 1; InterfacePreferences interface_preferences = 2; @@ -161,6 +165,7 @@ message StashPreferences { UpdatePreferences update_preferences = 5; AdvancedPreferences advanced_preferences = 6; SearchPreferences search_preferences = 7; + ScreensaverPreferences screensaver_preferences = 9; bool preferences_migrated_v1 = 8; } diff --git a/app/src/main/res/values/preferences.xml b/app/src/main/res/values/preferences.xml index eaeef148b..b5490db45 100644 --- a/app/src/main/res/values/preferences.xml +++ b/app/src/main/res/values/preferences.xml @@ -102,5 +102,6 @@ ui.movementSounds meta.preference.migration + screensaver.enabled diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8e6f1bea4..5a852fdf6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -238,5 +238,8 @@ Overwrite settings by migrating from legacy settings Advanced Playback Playback backend + Enable screensaver + Set filter for screensaver + Screensaver diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f31c5faff..87510bae1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ androidx-test-runner = "1.7.0" androidx-test-ext-junit = "1.3.0" androidx-test-ext-truth = "1.7.0" auto-service = "1.1.1" -coil-compose = "3.3.0" +coil-compose = "3.4.0" compose-bom = "2026.01.00" compose-wheel-picker = "1.0.0-rc02" constraintlayout = "2.2.1"