Fix crash when screensaver networks errors out (#1279)

## Description
Fixes a crash if there is a network error when starting the screensaver.

Instead, a black screen with an error message shows. The error message
fades in/out to different parts of the screen. This applies to both
in-app and OS screensavers.

### Related issues
Fixes #1247

### Testing
Emulator w/ simulated errors

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-04-20 17:30:56 -04:00 committed by GitHub
parent 61afe6ba86
commit 36b7390c4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 186 additions and 81 deletions

View file

@ -4,7 +4,7 @@ import android.content.Context
import coil3.imageLoader import coil3.imageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope 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.formatDate
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
@ -154,30 +154,20 @@ class ScreensaverService
/** /**
* Create a flow of items to show on the screensaver * Create a flow of items to show on the screensaver
*/ */
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> = fun createItemFlow(scope: CoroutineScope): Flow<ScreensaverItem?> =
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 = 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) Timber.v("Got %s items", pager.size)
var index = 0 var index = 0
if (pager.isEmpty()) { if (pager.isEmpty()) {
emit(null) emit(ScreensaverItem.Empty)
} else { } else {
val duration = val duration =
userPreferencesService userPreferencesService
@ -213,7 +203,14 @@ class ScreensaverService
.build(), .build(),
).job ).job
.await() .await()
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) emit(
ScreensaverItem.CurrentItem(
item,
backdropUrl,
logoUrl,
title ?: "",
),
)
delay(duration) delay(duration)
} }
} }
@ -228,6 +225,26 @@ class ScreensaverService
} }
} }
}.flowOn(Dispatchers.Default).cancellable() }.flowOn(Dispatchers.Default).cancellable()
private suspend fun createPager(): ApiRequestPager<GetItemsRequest> {
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( data class ScreensaverState(

View file

@ -1,11 +1,15 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween 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.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.ShaderBrush
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
@ -45,6 +51,7 @@ import coil3.compose.AsyncImage
import coil3.compose.useExistingImageAsPlaceholder import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.transitionFactory import coil3.request.transitionFactory
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ScreensaverService 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_ALPHA
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -67,12 +75,20 @@ class ScreensaverViewModel
val currentItem = screensaverService.createItemFlow(viewModelScope) val currentItem = screensaverService.createItemFlow(viewModelScope)
} }
data class CurrentItem( sealed interface ScreensaverItem {
val item: BaseItem, data class Error(
val backdropUrl: String, val exception: Exception,
val logoUrl: String?, ) : ScreensaverItem
val title: String,
) data object Empty : ScreensaverItem
data class CurrentItem(
val item: BaseItem,
val backdropUrl: String,
val logoUrl: String?,
val title: String,
) : ScreensaverItem
}
@Composable @Composable
fun AppScreensaver( fun AppScreensaver(
@ -93,7 +109,7 @@ fun AppScreensaver(
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@Composable @Composable
fun AppScreensaverContent( fun AppScreensaverContent(
currentItem: CurrentItem?, currentItem: ScreensaverItem?,
showClock: Boolean, showClock: Boolean,
duration: Duration, duration: Duration,
animate: Boolean, animate: Boolean,
@ -116,61 +132,83 @@ fun AppScreensaverContent(
repeatMode = RepeatMode.Reverse, repeatMode = RepeatMode.Reverse,
), ),
) )
AsyncImage( when (currentItem) {
model = ScreensaverItem.Empty -> {
ImageRequest ScreensaverPlaceholder(
.Builder(LocalContext.current) text = stringResource(R.string.no_results),
.data(currentItem?.backdropUrl) duration = duration,
.transitionFactory(CrossFadeFactory(2000.milliseconds)) modifier = Modifier.fillMaxSize(),
.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),
) )
} }
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 = 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),
)
}
}
}