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"