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 index 6252a2a7..6069c00a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -4,7 +4,7 @@ import android.content.Context import coil3.imageLoader import coil3.request.ImageRequest import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope -import com.github.damontecres.wholphin.ui.components.CurrentItem +import com.github.damontecres.wholphin.ui.components.ScreensaverItem import com.github.damontecres.wholphin.ui.formatDate import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.util.ApiRequestPager @@ -154,30 +154,20 @@ class ScreensaverService /** * Create a flow of items to show on the screensaver */ - fun createItemFlow(scope: CoroutineScope): Flow = + 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() + try { + createPager() + } catch (ex: Exception) { + Timber.e(ex, "Error creating pager for screensaver") + emit(ScreensaverItem.Error(ex)) + return@flow + } Timber.v("Got %s items", pager.size) var index = 0 if (pager.isEmpty()) { - emit(null) + emit(ScreensaverItem.Empty) } else { val duration = userPreferencesService @@ -213,7 +203,14 @@ class ScreensaverService .build(), ).job .await() - emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) + emit( + ScreensaverItem.CurrentItem( + item, + backdropUrl, + logoUrl, + title ?: "", + ), + ) delay(duration) } } @@ -228,6 +225,26 @@ class ScreensaverService } } }.flowOn(Dispatchers.Default).cancellable() + + private suspend fun createPager(): ApiRequestPager { + 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 }, + ) + return ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() + } } data class ScreensaverState( 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 index fe4ae115..8aa43946 100644 --- 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 @@ -1,11 +1,15 @@ package com.github.damontecres.wholphin.ui.components +import androidx.compose.animation.AnimatedContent 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.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -15,6 +19,7 @@ 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.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,6 +37,7 @@ 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.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore @@ -45,6 +51,7 @@ import coil3.compose.AsyncImage import coil3.compose.useExistingImageAsPlaceholder import coil3.request.ImageRequest import coil3.request.transitionFactory +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ScreensaverService @@ -53,6 +60,7 @@ 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 kotlinx.coroutines.delay import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -67,12 +75,20 @@ class ScreensaverViewModel val currentItem = screensaverService.createItemFlow(viewModelScope) } -data class CurrentItem( - val item: BaseItem, - val backdropUrl: String, - val logoUrl: String?, - val title: String, -) +sealed interface ScreensaverItem { + data class Error( + val exception: Exception, + ) : ScreensaverItem + + data object Empty : ScreensaverItem + + data class CurrentItem( + val item: BaseItem, + val backdropUrl: String, + val logoUrl: String?, + val title: String, + ) : ScreensaverItem +} @Composable fun AppScreensaver( @@ -93,7 +109,7 @@ fun AppScreensaver( @OptIn(ExperimentalCoilApi::class) @Composable fun AppScreensaverContent( - currentItem: CurrentItem?, + currentItem: ScreensaverItem?, showClock: Boolean, duration: Duration, animate: Boolean, @@ -116,61 +132,83 @@ fun AppScreensaverContent( 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) } - 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.BottomStart) - .size(width = 240.dp, height = 120.dp) - .padding(16.dp), - ) - } else { - Box( - modifier = - Modifier - .align(Alignment.BottomStart) - .padding(16.dp) - .fillMaxWidth(.5f) - .fillMaxHeight(.3f), - ) { - Text( - text = currentItem?.title ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displaySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(Alignment.BottomStart), + when (currentItem) { + ScreensaverItem.Empty -> { + ScreensaverPlaceholder( + text = stringResource(R.string.no_results), + duration = duration, + modifier = Modifier.fillMaxSize(), ) } + + is ScreensaverItem.Error -> { + ScreensaverPlaceholder( + text = "Error connecting to Jellyfin server", + duration = duration, + modifier = Modifier.fillMaxSize(), + ) + } + + null, + is ScreensaverItem.CurrentItem, + -> { + 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) } + 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.BottomStart) + .size(width = 240.dp, height = 120.dp) + .padding(16.dp), + ) + } else { + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(16.dp) + .fillMaxWidth(.5f) + .fillMaxHeight(.3f), + ) { + Text( + text = currentItem?.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.BottomStart), + ) + } + } + } } val largeRadialGradient = @@ -212,3 +250,53 @@ fun AppScreensaverContent( } } } + +@Composable +fun ScreensaverPlaceholder( + text: String, + duration: Duration, + modifier: Modifier = Modifier, +) { + var alignment by remember { mutableStateOf(Alignment.BottomStart) } + val alignments = + remember(alignment) { + mutableListOf( + Alignment.TopStart, + Alignment.TopCenter, + Alignment.TopEnd, + Alignment.CenterStart, + Alignment.Center, + Alignment.CenterEnd, + Alignment.BottomStart, + Alignment.BottomCenter, + Alignment.BottomEnd, + ).apply { remove(alignment) } + } + LaunchedEffect(Unit) { + while (true) { + delay(duration) + alignment = alignments.random() + } + } + AnimatedContent( + targetState = alignment, + label = "alignment animation", + transitionSpec = { + fadeIn(animationSpec = tween(500, delayMillis = 100)) + .togetherWith(fadeOut(animationSpec = tween(500, 100))) + }, + modifier = modifier, + ) { align -> + Box(Modifier.fillMaxSize()) { + Text( + text = text, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.error, + modifier = + Modifier + .align(align) + .padding(40.dp), + ) + } + } +}