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

@ -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.
*

View file

@ -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<AppPreferences>,
) : 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()
}
}
}

View file

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

View file

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

View file

@ -186,7 +186,6 @@ fun PlaybackPageContent(
}
}
AmbientPlayerListener(player)
var contentScale by remember(playerBackend) {
mutableStateOf(
if (playerBackend == PlayerBackend.MPV) {

View file

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

View file

@ -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 <T> 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 <T> ComposablePreference(
list
}
MultiChoicePreference(
possibleValues = values as SortedSet<Any>,
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])
},
)
}

View file

@ -19,12 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
fun <T> MultiChoicePreference(
title: String,
summary: String?,
possibleValues: Set<T>,
possibleValues: List<T>,
selectedValues: Set<T>,
onValueChange: (List<T>) -> 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 <T> MultiChoicePreference(
items =
possibleValues.mapIndexed { index, item ->
DialogItem(
headlineContent = { valueDisplay.invoke(item) },
headlineContent = { valueDisplay.invoke(index, item) },
trailingContent = {
Switch(
checked = selectedValues.contains(item),

View file

@ -35,6 +35,7 @@ enum class PreferenceScreenOption {
ADVANCED,
EXO_PLAYER,
MPV,
SCREENSAVER,
;
companion object {

View file

@ -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,

View file

@ -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<AppPreferences>,
val navigationManager: NavigationManager,
val backdropService: BackdropService,
val screensaverService: ScreensaverService,
private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository,
private val seerrServerRepository: SeerrServerRepository,

View file

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

View file

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