From a7e86fbc15fd56170b97ec7b61d98faa088ef2d1 Mon Sep 17 00:00:00 2001
From: Ray <154766448+damontecres@users.noreply.github.com>
Date: Wed, 25 Feb 2026 14:11:00 -0500
Subject: [PATCH] Add in-app & Android TV screensaver (#946)
## Description
Adds both an optional in-app and Android TV OS (DreamService)
screensaver
Has settings for:
- Starting delay, options from 30s to 1 hour
- Duration to show each image, 30s to 2 minutes
- Show clock (separate from app-wide clock)
- Toggle animated zooming
- Set a max age rating to limit what content is shown
- Included media types: movies, tv shows, and/or photos
By default only Movies & TV Shows are included. But if you have like
family albums on your server, you could enable Photos too.
There's also an button to start the screensaver so you can see what it
looks like.
### Related issues
Closes #230
### Testing
Emulator, Onn 4k pro, nvidia shield using both in-app and OS
screensavers
## Screenshots

## AI or LLM usage
None
## Acknowledgements
Some of the skeleton code for setting up the `DreamService` with the
right lifecycle was copied and modified from the [official app's
implementation](https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamServiceCompat.kt).
---
app/src/main/AndroidManifest.xml | 11 +
.../damontecres/wholphin/MainActivity.kt | 32 ++-
.../wholphin/WholphinDreamService.kt | 98 ++++++++
.../wholphin/preferences/AppPreference.kt | 27 ++-
.../preferences/AppPreferencesSerializer.kt | 20 ++
.../preferences/ScreensaverPreference.kt | 183 ++++++++++++++
.../wholphin/services/AppUpgradeHandler.kt | 17 ++
.../wholphin/services/ImageUrlService.kt | 25 +-
.../wholphin/services/ScreensaverService.kt | 224 ++++++++++++++++++
.../services/UserPreferencesService.kt | 3 +
.../damontecres/wholphin/ui/Extensions.kt | 15 +-
.../wholphin/ui/components/AppScreensaver.kt | 214 +++++++++++++++++
.../wholphin/ui/nav/ApplicationContent.kt | 4 +-
.../ui/playback/AmbientPlayerListener.kt | 32 ---
.../wholphin/ui/playback/PlaybackPage.kt | 1 -
.../wholphin/ui/playback/PlaybackViewModel.kt | 11 +-
.../ui/preferences/ComposablePreference.kt | 10 +-
.../ui/preferences/MultiChoicePreference.kt | 6 +-
.../ui/preferences/PreferenceUtils.kt | 1 +
.../ui/preferences/PreferencesContent.kt | 46 +++-
.../ui/preferences/PreferencesViewModel.kt | 2 +
.../wholphin/ui/slideshow/SlideshowPage.kt | 9 -
.../ui/slideshow/SlideshowViewModel.kt | 5 +
app/src/main/proto/WholphinDataStore.proto | 11 +
app/src/main/res/values/strings.xml | 25 ++
25 files changed, 954 insertions(+), 78 deletions(-)
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt
delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 161352af..4479ae86 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -68,6 +68,17 @@
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
index b7f4b1d3..7b6f0c9b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt
@@ -3,10 +3,12 @@ package com.github.damontecres.wholphin
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
+import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -49,6 +51,7 @@ import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
+import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
@@ -59,8 +62,10 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
+import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
+import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
@@ -134,6 +139,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
+ @Inject
+ lateinit var screensaverService: ScreensaverService
+
private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class)
@@ -317,6 +325,15 @@ class MainActivity : AppCompatActivity() {
}
},
)
+ val screenSaverState by screensaverService.state.collectAsState()
+ if (screenSaverState.enabled || screenSaverState.enabledTemp) {
+ AnimatedVisibility(
+ screenSaverState.show,
+ Modifier.fillMaxSize(),
+ ) {
+ AppScreensaver(appPreferences, Modifier.fillMaxSize())
+ }
+ }
}
}
}
@@ -324,10 +341,22 @@ class MainActivity : AppCompatActivity() {
}
}
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (screensaverService.state.value.show) {
+ screensaverService.stop(false)
+ screensaverService.pulse()
+ return true
+ } else {
+ screensaverService.pulse()
+ return super.dispatchKeyEvent(event)
+ }
+ }
+
override fun onResume() {
super.onResume()
Timber.d("onResume")
- lifecycleScope.launchIO {
+ lifecycleScope.launchDefault {
+ screensaverService.pulse()
appUpgradeHandler.run()
}
}
@@ -341,6 +370,7 @@ class MainActivity : AppCompatActivity() {
override fun onStop() {
super.onStop()
Timber.d("onStop")
+ screensaverService.stop(true)
tvProviderSchedulerService.launchOneTimeRefresh()
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
new file mode 100644
index 00000000..1c0e4e0e
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt
@@ -0,0 +1,98 @@
+package com.github.damontecres.wholphin
+
+import android.service.dreams.DreamService
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.LaunchedEffect
+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.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 com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.ScreensaverService
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
+import com.github.damontecres.wholphin.ui.theme.WholphinTheme
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collectLatest
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.milliseconds
+
+@AndroidEntryPoint
+class WholphinDreamService :
+ DreamService(),
+ SavedStateRegistryOwner {
+ @Inject
+ lateinit var screensaverService: ScreensaverService
+
+ @Inject
+ lateinit var userPreferencesService: UserPreferencesService
+
+ 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 = screensaverService.createItemFlow(lifecycleScope)
+ setContentView(
+ ComposeView(this).apply {
+ setViewTreeLifecycleOwner(this@WholphinDreamService)
+ setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
+ setContent {
+ var prefs by remember { mutableStateOf(null) }
+ LaunchedEffect(Unit) {
+ userPreferencesService.flow.collectLatest { prefs = it }
+ }
+ prefs?.let { prefs ->
+ WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) {
+ val screensaverPrefs =
+ prefs.appPreferences.interfacePreferences.screensaverPreference
+ val currentItem by itemFlow.collectAsState(null)
+ AppScreensaverContent(
+ currentItem = currentItem,
+ showClock = screensaverPrefs.showClock,
+ duration = screensaverPrefs.duration.milliseconds,
+ animate = screensaverPrefs.animate,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ }
+ },
+ )
+ }
+
+ override fun onDreamingStarted() {
+ super.onDreamingStarted()
+ lifecycleRegistry.currentState = Lifecycle.State.STARTED
+ }
+
+ override fun onDreamingStopped() {
+ super.onDreamingStopped()
+ lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
index 709abd6c..379b782b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
@@ -997,6 +997,12 @@ sealed interface AppPreference {
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
+
+ val ScreensaverSettings =
+ AppDestinationPreference(
+ title = R.string.screensaver_settings,
+ destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER),
+ )
}
}
@@ -1011,6 +1017,7 @@ val basicPreferences =
AppPreference.RememberSelectedTab,
AppPreference.SubtitleStyle,
AppPreference.ThemeColors,
+ AppPreference.ScreensaverSettings,
),
),
PreferenceGroup(
@@ -1201,6 +1208,24 @@ val liveTvPreferences =
AppPreference.LiveTvColorCodePrograms,
)
+val screensaverPreferences =
+ listOf(
+ PreferenceGroup(
+ title = R.string.screensaver,
+ preferences =
+ listOf(
+ ScreensaverPreference.Enabled,
+ ScreensaverPreference.StartDelay,
+ ScreensaverPreference.Duration,
+ ScreensaverPreference.ShowClock,
+ ScreensaverPreference.Animate,
+ ScreensaverPreference.MaxAge,
+ ScreensaverPreference.ItemTypes,
+ ScreensaverPreference.Start,
+ ),
+ ),
+ )
+
data class AppSwitchPreference(
@get:StringRes override val title: Int,
override val defaultValue: Boolean,
@@ -1255,8 +1280,6 @@ data class AppMultiChoicePreference(
override val getter: (prefs: Pref) -> List,
override val setter: (prefs: Pref, value: List) -> Pref,
@param:StringRes val summary: Int? = null,
- val toSharedPrefs: (T) -> String,
- val fromSharedPrefs: (String) -> T?,
) : AppPreference>
data class AppClickablePreference(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
index 6adcdca4..bf8ab2a4 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt
@@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.google.protobuf.InvalidProtocolBufferException
+import org.jellyfin.sdk.model.api.BaseItemKind
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
@@ -120,6 +121,20 @@ class AppPreferencesSerializer
colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue
}.build()
+
+ screensaverPreference =
+ ScreensaverPreferences
+ .newBuilder()
+ .apply {
+ startDelay = ScreensaverPreference.DEFAULT_START_DELAY
+ duration = ScreensaverPreference.DEFAULT_DURATION
+ animate = ScreensaverPreference.Animate.defaultValue
+ maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
+ showClock = ScreensaverPreference.ShowClock.defaultValue
+ clearItemTypes()
+ addItemTypes(BaseItemKind.MOVIE.serialName)
+ addItemTypes(BaseItemKind.SERIES.serialName)
+ }.build()
}.build()
advancedPreferences =
@@ -200,6 +215,11 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder
photoPreferences = photoPreferences.toBuilder().apply(block).build()
}
+inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences =
+ updateInterfacePreferences {
+ screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
+ }
+
fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt
new file mode 100644
index 00000000..1e1ad91c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt
@@ -0,0 +1,183 @@
+package com.github.damontecres.wholphin.preferences
+
+import com.github.damontecres.wholphin.R
+import com.github.damontecres.wholphin.WholphinApplication
+import org.jellyfin.sdk.model.api.BaseItemKind
+import kotlin.time.Duration.Companion.milliseconds
+
+object ScreensaverPreference {
+ val Enabled =
+ AppSwitchPreference(
+ title = R.string.in_app_screensaver,
+ defaultValue = false,
+ getter = { it.interfacePreferences.screensaverPreference.enabled },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences { enabled = value }
+ },
+ summaryOn = R.string.yes,
+ summaryOff = R.string.no,
+ )
+
+ const val DEFAULT_START_DELAY = 15 * 60_000L
+ private val startDelayValues =
+ listOf(
+ 30_000L,
+ 60_000L,
+ 2 * 60_000L,
+ 5 * 60_000L,
+ 10 * 60_000L,
+ 15 * 60_000L,
+ 30 * 60_000L,
+ 60 * 60_000L,
+ )
+ val StartDelay =
+ AppSliderPreference(
+ title = R.string.start_after,
+ defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(),
+ min = 0,
+ max = startDelayValues.size - 1L,
+ interval = 1,
+ getter = {
+ startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong()
+ },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences {
+ startDelay = startDelayValues[value.toInt()]
+ }
+ },
+ summarizer = { value ->
+ if (value != null) {
+ val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY
+ v.milliseconds.toString()
+ } else {
+ null
+ }
+ },
+ )
+
+ const val DEFAULT_DURATION = 30_000L
+ private val durationValues =
+ listOf(
+ 15_000L,
+ 30_000L,
+ 60_000L,
+ 2 * 60_000L,
+ )
+ val Duration =
+ AppSliderPreference(
+ title = R.string.duration,
+ defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(),
+ min = 0,
+ max = durationValues.size - 1L,
+ interval = 1,
+ getter = {
+ durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong()
+ },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences {
+ duration = durationValues[value.toInt()]
+ }
+ },
+ summarizer = { value ->
+ if (value != null) {
+ val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION
+ v.milliseconds.toString()
+ } else {
+ null
+ }
+ },
+ )
+
+ val ShowClock =
+ AppSwitchPreference(
+ title = R.string.show_clock,
+ defaultValue = AppPreference.ShowClock.defaultValue,
+ getter = { it.interfacePreferences.screensaverPreference.showClock },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences { showClock = value }
+ },
+ summaryOn = R.string.yes,
+ summaryOff = R.string.no,
+ )
+
+ val Animate =
+ AppSwitchPreference(
+ title = R.string.animate,
+ defaultValue = true,
+ getter = { it.interfacePreferences.screensaverPreference.animate },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences { animate = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
+
+ const val DEFAULT_MAX_AGE = 16
+ private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1)
+ val MaxAge =
+ AppSliderPreference(
+ title = R.string.max_age_rating,
+ defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(),
+ min = 0,
+ max = maxAgeValues.size - 1L,
+ interval = 1,
+ getter = {
+ it.interfacePreferences.screensaverPreference.maxAgeFilter
+ .takeIf { it >= 0 }
+ ?.let { maxAgeValues.indexOf(it).toLong() }
+ ?: maxAgeValues.lastIndex.toLong()
+ },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences {
+ maxAgeFilter = maxAgeValues[value.toInt()]
+ }
+ },
+ summarizer = { value ->
+ when (value) {
+ null -> {
+ null
+ }
+
+ maxAgeValues.lastIndex.toLong() -> {
+ WholphinApplication.instance.getString(R.string.no_max)
+ }
+
+ 0L -> {
+ WholphinApplication.instance.getString(R.string.for_all_ages)
+ }
+
+ else -> {
+ WholphinApplication.instance.getString(
+ R.string.up_to_age,
+ maxAgeValues[value.toInt()].toString(),
+ )
+ }
+ }
+ },
+ )
+
+ val ItemTypes =
+ AppMultiChoicePreference(
+ title = R.string.include_types,
+ summary = R.string.include_types_summary,
+ defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
+ allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO),
+ displayValues = R.array.screensaver_item_types,
+ getter = {
+ it.interfacePreferences.screensaverPreference.itemTypesList.map { type ->
+ BaseItemKind.fromName(type)
+ }
+ },
+ setter = { prefs, value ->
+ prefs.updateScreensaverPreferences {
+ clearItemTypes()
+ addAllItemTypes(value.map { it.serialName })
+ }
+ },
+ )
+
+ val Start =
+ AppClickablePreference(
+ title = R.string.start_screensaver,
+ )
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
index 057ec550..23d6cef6 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt
@@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
@@ -16,11 +17,13 @@ import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
+import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
+import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@@ -246,4 +249,18 @@ suspend fun upgradeApp(
}
}
}
+
+ if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) {
+ appPreferences.updateData {
+ it.updateScreensaverPreferences {
+ startDelay = ScreensaverPreference.DEFAULT_START_DELAY
+ duration = ScreensaverPreference.DEFAULT_DURATION
+ animate = ScreensaverPreference.Animate.defaultValue
+ maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
+ clearItemTypes()
+ addItemTypes(BaseItemKind.MOVIE.serialName)
+ addItemTypes(BaseItemKind.SERIES.serialName)
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
index f0de9d24..5e6bfa8c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt
@@ -37,9 +37,7 @@ class ImageUrlService
fillHeight: Int? = null,
): String? =
when (imageType) {
- ImageType.BACKDROP,
- ImageType.LOGO,
- -> {
+ ImageType.LOGO -> {
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
getItemImageUrl(
itemId = seriesId,
@@ -57,6 +55,27 @@ class ImageUrlService
}
}
+ ImageType.BACKDROP,
+ -> {
+ if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
+ getItemImageUrl(
+ itemId = seriesId,
+ imageType = imageType,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ } else if (backdropTags.isNotEmpty()) {
+ getItemImageUrl(
+ itemId = itemId,
+ imageType = imageType,
+ fillWidth = fillWidth,
+ fillHeight = fillHeight,
+ )
+ } else {
+ null
+ }
+ }
+
ImageType.THUMB -> {
if (useSeriesForPrimary && parentThumbId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
new file mode 100644
index 00000000..ea806436
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
@@ -0,0 +1,224 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import android.view.WindowManager
+import coil3.imageLoader
+import coil3.request.ImageRequest
+import com.github.damontecres.wholphin.MainActivity
+import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
+import com.github.damontecres.wholphin.ui.components.CurrentItem
+import com.github.damontecres.wholphin.ui.formatDate
+import com.github.damontecres.wholphin.util.ApiRequestPager
+import com.github.damontecres.wholphin.util.ExceptionHandler
+import com.github.damontecres.wholphin.util.GetItemsRequestHandler
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.cancellable
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.libraryApi
+import org.jellyfin.sdk.model.api.BaseItemKind
+import org.jellyfin.sdk.model.api.ImageType
+import org.jellyfin.sdk.model.api.ItemSortBy
+import org.jellyfin.sdk.model.api.request.GetItemsRequest
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.milliseconds
+
+@Singleton
+class ScreensaverService
+ @Inject
+ constructor(
+ @param:ApplicationContext private val context: Context,
+ @param:DefaultCoroutineScope private val scope: CoroutineScope,
+ private val api: ApiClient,
+ private val userPreferencesService: UserPreferencesService,
+ private val imageUrlService: ImageUrlService,
+ ) {
+ private val _state = MutableStateFlow(ScreensaverState(false, false, false, false))
+ val state: StateFlow = _state
+
+ private var waitJob: Job? = null
+
+ init {
+ userPreferencesService.flow
+ .onEach { prefs ->
+ _state.update {
+ val enabled =
+ prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
+ keepScreenOnInternal(enabled)
+ ScreensaverState(enabled, false, false, false)
+ }
+ }.launchIn(scope)
+ }
+
+ fun pulse() {
+ waitJob?.cancel()
+ if (_state.value.enabled) {
+// Timber.v("pulse")
+ _state.update {
+ if (!it.active) {
+ it.copy(active = false)
+ } else {
+ it
+ }
+ }
+
+ if (!_state.value.paused) {
+ waitJob =
+ scope.launch(ExceptionHandler()) {
+ val startDelay =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
+ delay(startDelay)
+ _state.update {
+ it.copy(active = true)
+ }
+ }
+ }
+ }
+ }
+
+ fun start() {
+ _state.update {
+ it.copy(
+ enabledTemp = true,
+ active = true,
+ )
+ }
+ }
+
+ fun stop(cancelJob: Boolean) {
+ _state.update {
+ it.copy(
+ enabledTemp = false,
+ active = false,
+ )
+ }
+ if (cancelJob) waitJob?.cancel()
+ }
+
+ fun keepScreenOn(keep: Boolean) {
+ scope.launch {
+ val screensaverEnabled = _state.value.enabled
+ Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
+ if (screensaverEnabled) {
+ // Page is requesting to keep screen on, so we don't wait to show the screensaver
+ _state.update {
+ it.copy(active = false, paused = keep)
+ }
+ if (!keep) {
+ pulse()
+ }
+ } else {
+ keepScreenOnInternal(keep)
+ }
+ }
+ }
+
+ private suspend fun keepScreenOnInternal(keep: Boolean) =
+ withContext(Dispatchers.Main) {
+ val window = MainActivity.instance.window
+ if (keep) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ }
+
+ fun createItemFlow(scope: CoroutineScope): Flow =
+ flow {
+ val prefs =
+ userPreferencesService.flow
+ .first()
+ .appPreferences
+ .interfacePreferences.screensaverPreference
+ val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
+ val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
+ val request =
+ GetItemsRequest(
+ recursive = true,
+ includeItemTypes = itemTypes,
+ imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
+ sortBy = listOf(ItemSortBy.RANDOM),
+ maxOfficialRating = maxAge?.toString(),
+ hasParentalRating = maxAge?.let { true },
+ )
+ val pager =
+ ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
+ Timber.v("Got %s items", pager.size)
+ var index = 0
+ if (pager.isEmpty()) {
+ emit(null)
+ } else {
+ while (true) {
+ val item = pager.getBlocking(index)
+ Timber.v("Next index=%s, item=%s", index, item?.id)
+ if (item != null) {
+ val backdropUrl =
+ if (item.type == BaseItemKind.PHOTO) {
+ api.libraryApi.getDownloadUrl(item.id)
+ } else {
+ imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
+ }
+ val title =
+ if (item.type == BaseItemKind.PHOTO) {
+ item.data.premiereDate?.let {
+ formatDate(it.toLocalDate())
+ }
+ } else {
+ item.title
+ }
+ val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO)
+ if (backdropUrl != null) {
+ val result =
+ context.imageLoader
+ .enqueue(
+ ImageRequest
+ .Builder(context)
+ .data(backdropUrl)
+ .build(),
+ ).job
+ .await()
+ try {
+ emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
+ } catch (_: CancellationException) {
+ break
+ }
+ val duration =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences
+ .interfacePreferences.screensaverPreference.duration.milliseconds
+ delay(duration)
+ }
+ }
+ index++
+ }
+ }
+ }.cancellable()
+ }
+
+data class ScreensaverState(
+ val enabled: Boolean,
+ val enabledTemp: Boolean,
+ val active: Boolean,
+ val paused: Boolean,
+) {
+ val show get() = (enabled || enabledTemp) && active && !paused
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
index 90d68e52..67e940b5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt
@@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@@ -15,6 +16,8 @@ class UserPreferencesService
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore,
) {
+ val flow = preferencesDataStore.data.map { UserPreferences(it) }
+
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
index c997efd1..f6439bf1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt
@@ -5,7 +5,6 @@ import android.content.Context
import android.content.ContextWrapper
import android.media.AudioManager
import android.view.KeyEvent
-import android.view.WindowManager
import android.widget.Toast
import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.basicMarquee
@@ -296,7 +295,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) =
}
/**
- * Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn].
+ * Tries to find the [Activity] for the given [Context]
*/
fun Context.findActivity(): Activity? {
if (this is Activity) {
@@ -310,18 +309,6 @@ fun Context.findActivity(): Activity? {
return null
}
-/**
- * Keep the screen on for an [Activity]. Often used with [findActivity].
- */
-fun Activity.keepScreenOn(keep: Boolean) {
- Timber.v("Keep screen on: $keep")
- if (keep) {
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- } else {
- window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- }
-}
-
/**
* Selectively log errors from Coil image loading.
*
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt
new file mode 100644
index 00000000..e55a2aa6
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt
@@ -0,0 +1,214 @@
+package com.github.damontecres.wholphin.ui.components
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RadialGradientShader
+import androidx.compose.ui.graphics.Shader
+import androidx.compose.ui.graphics.ShaderBrush
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.datastore.core.DataStore
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil3.annotation.ExperimentalCoilApi
+import coil3.compose.AsyncImage
+import coil3.compose.useExistingImageAsPlaceholder
+import coil3.request.ImageRequest
+import coil3.request.transitionFactory
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.services.ScreensaverService
+import com.github.damontecres.wholphin.ui.AppColors
+import com.github.damontecres.wholphin.ui.CrossFadeFactory
+import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
+import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+
+@HiltViewModel
+class ScreensaverViewModel
+ @Inject
+ constructor(
+ private val screensaverService: ScreensaverService,
+ val preferencesDataStore: DataStore,
+ ) : ViewModel() {
+ val currentItem = screensaverService.createItemFlow(viewModelScope)
+ }
+
+data class CurrentItem(
+ val item: BaseItem,
+ val backdropUrl: String,
+ val logoUrl: String?,
+ val title: String,
+)
+
+@Composable
+fun AppScreensaver(
+ prefs: AppPreferences,
+ modifier: Modifier = Modifier,
+ viewModel: ScreensaverViewModel = hiltViewModel(),
+) {
+ val currentItem by viewModel.currentItem.collectAsState(null)
+ AppScreensaverContent(
+ currentItem = currentItem,
+ showClock = prefs.interfacePreferences.screensaverPreference.showClock,
+ duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds,
+ animate = prefs.interfacePreferences.screensaverPreference.animate,
+ modifier = modifier,
+ )
+}
+
+@OptIn(ExperimentalCoilApi::class)
+@Composable
+fun AppScreensaverContent(
+ currentItem: CurrentItem?,
+ showClock: Boolean,
+ duration: Duration,
+ animate: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier
+ .background(Color.Black),
+ ) {
+ val infiniteTransition = rememberInfiniteTransition()
+ val scale by infiniteTransition.animateFloat(
+ 1f,
+ if (animate) 1.1f else 1f,
+ infiniteRepeatable(
+ tween(
+ durationMillis = duration.inWholeMilliseconds.toInt(),
+ delayMillis = 500,
+ easing = LinearEasing,
+ ),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ )
+ AsyncImage(
+ model =
+ ImageRequest
+ .Builder(LocalContext.current)
+ .data(currentItem?.backdropUrl)
+ .transitionFactory(CrossFadeFactory(2000.milliseconds))
+ .useExistingImageAsPlaceholder(true)
+ .build(),
+ contentDescription = null,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ },
+ )
+
+ var logoError by remember(currentItem) { mutableStateOf(false) }
+ val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random()
+ if (!logoError) {
+ AsyncImage(
+ model =
+ ImageRequest
+ .Builder(LocalContext.current)
+ .data(currentItem?.logoUrl)
+ .transitionFactory(CrossFadeFactory(750.milliseconds))
+ .build(),
+ contentDescription = "Logo",
+ onError = {
+ logoError = true
+ },
+ modifier =
+ Modifier
+ .align(alignment)
+ .size(width = 240.dp, height = 120.dp)
+ .padding(16.dp),
+ )
+ } else {
+ Box(
+ modifier =
+ Modifier
+ .align(alignment)
+ .padding(16.dp)
+ .fillMaxWidth(.5f)
+ .fillMaxHeight(.3f),
+ ) {
+ Text(
+ text = currentItem?.title ?: "",
+ style = MaterialTheme.typography.displaySmall,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.align(alignment),
+ )
+ }
+ }
+
+ val largeRadialGradient =
+ remember {
+ object : ShaderBrush() {
+ override fun createShader(size: Size): Shader {
+ val biggerDimension = maxOf(size.height, size.width)
+ return RadialGradientShader(
+ colors = listOf(Color.Transparent, AppColors.TransparentBlack25),
+ center = size.center,
+ radius = biggerDimension / 1.5f,
+ colorStops = listOf(0f, 0.85f),
+ )
+ }
+ }
+ }
+ Canvas(Modifier.fillMaxSize()) {
+ drawRect(
+ brush = largeRadialGradient,
+ blendMode = BlendMode.Multiply,
+ )
+ if (showClock) {
+ // Add scrim to make clock more readable
+ drawRect(
+ brush =
+ Brush.verticalGradient(
+ colorStops =
+ arrayOf(
+ 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
+ TOP_SCRIM_END_FRACTION to Color.Transparent,
+ ),
+ ),
+ blendMode = BlendMode.Multiply,
+ )
+ }
+ }
+ if (showClock) {
+ TimeDisplay()
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
index 0e3d5a43..5f01f20d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt
@@ -55,8 +55,8 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
// Top scrim configuration for text readability (clock, season tabs)
-private const val TOP_SCRIM_ALPHA = 0.55f
-private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height
+const val TOP_SCRIM_ALPHA = 0.55f
+const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height
@HiltViewModel
class ApplicationContentViewModel
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt
deleted file mode 100644
index 7fc97366..00000000
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package com.github.damontecres.wholphin.ui.playback
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.ui.platform.LocalContext
-import androidx.media3.common.Player
-import androidx.media3.common.Player.Listener
-import com.github.damontecres.wholphin.ui.findActivity
-import com.github.damontecres.wholphin.ui.keepScreenOn
-
-/**
- * Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback
- *
- * This will clean up the listener when disposed
- */
-@Composable
-fun AmbientPlayerListener(player: Player) {
- val context = LocalContext.current
- DisposableEffect(player) {
- val listener =
- object : Listener {
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- context.findActivity()?.keepScreenOn(isPlaying)
- }
- }
- player.addListener(listener)
- onDispose {
- player.removeListener(listener)
- context.findActivity()?.keepScreenOn(false)
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
index 0313a8c5..1459736d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
@@ -186,7 +186,6 @@ fun PlaybackPageContent(
}
}
- AmbientPlayerListener(player)
var contentScale by remember(playerBackend) {
mutableStateOf(
if (playerBackend == PlayerBackend.MPV) {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
index 4d44ff80..0d23f4bf 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
@@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService
+import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@@ -140,6 +141,7 @@ class PlaybackViewModel
val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService,
private val imageUrlService: ImageUrlService,
+ private val screensaverService: ScreensaverService,
@Assisted private val destination: Destination,
) : ViewModel(),
Player.Listener,
@@ -189,7 +191,10 @@ class PlaybackViewModel
init {
viewModelScope.launchIO {
- addCloseable { disconnectPlayer() }
+ addCloseable {
+ screensaverService.keepScreenOn(false)
+ disconnectPlayer()
+ }
init()
}
}
@@ -1424,6 +1429,10 @@ class PlaybackViewModel
}
}
}
+
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ screensaverService.keepScreenOn(isPlaying)
+ }
}
data class PlayerState(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt
index 9b9bcf9c..1762f916 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt
@@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import java.io.File
-import java.util.SortedSet
@Suppress("UNCHECKED_CAST")
@Composable
@@ -226,7 +225,7 @@ fun ComposablePreference(
}
is AppMultiChoicePreference<*, *> -> {
- val values = stringArrayResource(preference.displayValues).toSortedSet()
+ val values = stringArrayResource(preference.displayValues)
val summary =
preference.summary?.let { stringResource(it) }
?: preference.summary(context, value)
@@ -237,12 +236,15 @@ fun ComposablePreference(
list
}
MultiChoicePreference(
- possibleValues = values as SortedSet,
+ possibleValues = preference.allValues,
selectedValues = selectedValues,
title = title,
summary = summary,
onValueChange = {
- onValueChange.invoke(selectedValues.toList() as T)
+ onValueChange.invoke(it.toList() as T)
+ },
+ valueDisplay = { index, _ ->
+ Text(values[index])
},
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt
index a4686b10..bb34207f 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt
@@ -19,12 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
fun MultiChoicePreference(
title: String,
summary: String?,
- possibleValues: Set,
+ possibleValues: List,
selectedValues: Set,
onValueChange: (List) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
- valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) },
+ valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) },
) {
// val values = stringArrayResource(preference.displayValues).toList()
// val summary =
@@ -59,7 +59,7 @@ fun MultiChoicePreference(
items =
possibleValues.mapIndexed { index, item ->
DialogItem(
- headlineContent = { valueDisplay.invoke(item) },
+ headlineContent = { valueDisplay.invoke(index, item) },
trailingContent = {
Switch(
checked = selectedValues.contains(item),
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
index c9f6f529..454a0266 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt
@@ -35,6 +35,7 @@ enum class PreferenceScreenOption {
ADVANCED,
EXO_PLAYER,
MPV,
+ SCREENSAVER,
;
companion object {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
index b04293f8..d9f1fb4d 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
@@ -50,8 +50,10 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend
+import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences
+import com.github.damontecres.wholphin.preferences.screensaverPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
import com.github.damontecres.wholphin.services.UpdateChecker
@@ -128,6 +130,7 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> advancedPreferences
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
PreferenceScreenOption.MPV -> MpvPreferences
+ PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
}
val screenTitle =
when (preferenceScreenOption) {
@@ -135,6 +138,7 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
PreferenceScreenOption.MPV -> R.string.mpv_options
+ PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
}
var visible by remember { mutableStateOf(false) }
@@ -283,7 +287,9 @@ fun PreferencesContent(
if (movementSounds) playOnClickSound(context)
if (release != null && updateAvailable) {
release?.let {
- viewModel.navigationManager.navigateTo(Destination.UpdateApp)
+ viewModel.navigationManager.navigateTo(
+ Destination.UpdateApp,
+ )
}
} else {
updateVM.init(preferences.updateUrl)
@@ -313,7 +319,8 @@ fun PreferencesContent(
val summary =
remember(cacheUsage) {
cacheUsage.let {
- val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT
+ val diskMB =
+ it.imageDiskUsed / AppPreference.MEGA_BIT
val memoryUsedMB =
it.imageMemoryUsed / AppPreference.MEGA_BIT
val memoryMaxMB =
@@ -392,7 +399,10 @@ fun PreferencesContent(
seerrDialogMode =
when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> {
- SeerrDialogMode.Error(conn.serverUrl, conn.ex)
+ SeerrDialogMode.Error(
+ conn.serverUrl,
+ conn.ex,
+ )
}
SeerrConnectionStatus.NotConfigured -> {
@@ -409,9 +419,19 @@ fun PreferencesContent(
modifier = Modifier,
summary =
when (seerrConnection) {
- is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server)
- SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server)
- is SeerrConnectionStatus.Success -> stringResource(R.string.enabled)
+ is SeerrConnectionStatus.Error -> {
+ stringResource(R.string.voice_error_server)
+ }
+
+ SeerrConnectionStatus.NotConfigured -> {
+ stringResource(
+ R.string.add_server,
+ )
+ }
+
+ is SeerrConnectionStatus.Success -> {
+ stringResource(R.string.enabled)
+ }
},
onLongClick = {},
interactionSource = interactionSource,
@@ -434,6 +454,19 @@ fun PreferencesContent(
)
}
+ ScreensaverPreference.Start -> {
+ ClickPreference(
+ title = stringResource(pref.title),
+ onClick = {
+ viewModel.screensaverService.start()
+ },
+ modifier = Modifier,
+ summary = pref.summary(context, null),
+ onLongClick = {},
+ interactionSource = interactionSource,
+ )
+ }
+
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
@@ -597,6 +630,7 @@ fun PreferencesPage(
PreferenceScreenOption.ADVANCED,
PreferenceScreenOption.EXO_PLAYER,
PreferenceScreenOption.MPV,
+ PreferenceScreenOption.SCREENSAVER,
-> {
PreferencesContent(
initialPreferences,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
index bec82e58..58f9a5e3 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
@@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
@@ -35,6 +36,7 @@ class PreferencesViewModel
val preferenceDataStore: DataStore,
val navigationManager: NavigationManager,
val backdropService: BackdropService,
+ val screensaverService: ScreensaverService,
private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository,
private val seerrServerRepository: SeerrServerRepository,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt
index 1c121ef6..f35bc102 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -63,8 +62,6 @@ import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
-import com.github.damontecres.wholphin.ui.findActivity
-import com.github.damontecres.wholphin.ui.keepScreenOn
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
import com.github.damontecres.wholphin.ui.playback.isDpad
@@ -208,12 +205,6 @@ fun SlideshowPage(
LaunchedEffect(slideshowActive) {
player.repeatMode =
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE
- context.findActivity()?.keepScreenOn(slideshowActive)
- }
- DisposableEffect(Unit) {
- onDispose {
- context.findActivity()?.keepScreenOn(false)
- }
}
var longPressing by remember { mutableStateOf(false) }
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt
index 4d2d0662..992659ca 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt
@@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.PlayerFactory
+import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.PhotoItemFields
import com.github.damontecres.wholphin.ui.launchIO
@@ -67,6 +68,7 @@ class SlideshowViewModel
private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService,
private val userPreferencesService: UserPreferencesService,
+ private val screensaverService: ScreensaverService,
@Assisted val slideshowSettings: Destination.Slideshow,
) : ViewModel(),
Player.Listener {
@@ -110,6 +112,7 @@ class SlideshowViewModel
init {
addCloseable {
+ screensaverService.keepScreenOn(false)
player.removeListener(this@SlideshowViewModel)
player.release()
}
@@ -282,6 +285,7 @@ class SlideshowViewModel
private var slideshowJob: Job? = null
fun startSlideshow() {
+ screensaverService.keepScreenOn(true)
_slideshow.update {
SlideshowState(enabled = true, paused = false)
}
@@ -295,6 +299,7 @@ class SlideshowViewModel
}
fun stopSlideshow() {
+ screensaverService.keepScreenOn(false)
slideshowJob?.cancel()
_slideshow.update {
SlideshowState(enabled = false, paused = false)
diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto
index 737dbb57..8d6034b1 100644
--- a/app/src/main/proto/WholphinDataStore.proto
+++ b/app/src/main/proto/WholphinDataStore.proto
@@ -144,6 +144,16 @@ enum BackdropStyle{
BACKDROP_NONE = 2;
}
+message ScreensaverPreferences{
+ int64 start_delay = 1;
+ int64 duration = 2;
+ bool animate = 3;
+ int32 max_age_filter = 4;
+ bool enabled = 5;
+ repeated string item_types = 6;
+ bool show_clock = 7;
+}
+
message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2;
@@ -155,6 +165,7 @@ message InterfacePreferences {
LiveTvPreferences live_tv_preferences = 8;
BackdropStyle backdrop_style = 9;
SubtitlePreferences hdr_subtitles_preferences = 10;
+ ScreensaverPreferences screensaver_preference = 11;
}
message AdvancedPreferences {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 93476b74..65afe072 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -276,6 +276,11 @@
- %d second
- %d seconds
+
+ - %s minutes
+ - %s minute
+ - %s minutes
+
- Movies
@@ -522,6 +527,19 @@
Display presets
Built-in presets to quickly style all rows
Choose rows and images on the home page
+ Start after
+ Animate
+ Max age rating
+ No max
+ For all ages
+ Up to age %s
+ Screensaver
+ Screensaver settings
+ Start screensaver
+ Duration
+ Photos
+ Include types
+ What types of items to show images for
Fit
Crop
Fill
@@ -673,10 +691,17 @@
No remote subtitles were found
Present
+ Use in-app screensaver
- @string/backdrop_style_dynamic
- @string/backdrop_style_image
- @string/none
+
+ - @string/movies
+ - @string/tv_shows
+ - @string/photos
+
+