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