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

@ -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<UserPreferences?>(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
}
}