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
![screensaver_settings
Large](https://github.com/user-attachments/assets/9b3240dd-81cb-4c20-bc4e-b2bf38698b2f)

## 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).
This commit is contained in:
Ray 2026-02-25 14:11:00 -05:00 • committed by GitHub
parent 536e8d366c
commit a7e86fbc15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 954 additions and 78 deletions

View file

@ -997,6 +997,12 @@ sealed interface AppPreference<Pref, T> {
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val ScreensaverSettings =
AppDestinationPreference<AppPreferences>(
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<Pref>(
@get:StringRes override val title: Int,
override val defaultValue: Boolean,
@ -1255,8 +1280,6 @@ data class AppMultiChoicePreference<Pref, T>(
override val getter: (prefs: Pref) -> List<T>,
override val setter: (prefs: Pref, value: List<T>) -> Pref,
@param:StringRes val summary: Int? = null,
val toSharedPrefs: (T) -> String,
val fromSharedPrefs: (String) -> T?,
) : AppPreference<Pref, List<T>>
data class AppClickablePreference<Pref>(

View file

@ -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()

View file

@ -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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences, BaseItemKind>(
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<AppPreferences>(
title = R.string.start_screensaver,
)
}