Photo/slideshow fixes & Seerr server removal fix (#1076)

## Description
A few fixes

- Better crossfade for photo slideshow
- Fix "Go to" in context menu for photos
- Fix certain home video/photo libraries not working for photos
- Fix removing seerr server in some cases

### Related issues
Closes #835
Fixes #1069
Addresses
https://github.com/damontecres/Wholphin/issues/634#issuecomment-4018580518

### Testing
Emulator mostly

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-10 14:16:19 -04:00 committed by GitHub
parent afc47f254b
commit 7bb47bf329
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 198 additions and 184 deletions

View file

@ -25,7 +25,7 @@ import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
@ -71,10 +71,11 @@ class SeerrServerRepository
}
fun error(
serverUrl: String,
server: SeerrServer,
user: SeerrUser,
exception: Exception,
) {
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
_connection.update { SeerrConnectionStatus.Error(server, user, exception) }
seerrApi.update("", null)
}
@ -175,8 +176,13 @@ class SeerrServerRepository
}
suspend fun removeServerForCurrentUser(): Boolean {
val current = current.firstOrNull() ?: return false
val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
val user =
when (val conn = connection.first()) {
SeerrConnectionStatus.NotConfigured -> return false
is SeerrConnectionStatus.Error -> conn.user
is SeerrConnectionStatus.Success -> conn.current.user
}
val rows = seerrServerDao.deleteUser(user)
clear()
return rows > 0
}
@ -191,7 +197,8 @@ sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus
data class Error(
val serverUrl: String,
val server: SeerrServer,
val user: SeerrUser,
val ex: Exception,
) : SeerrConnectionStatus
@ -317,7 +324,7 @@ class UserSwitchListener
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server.url, ex)
seerrServerRepository.error(server, seerrUser, ex)
}
}
}

View file

@ -771,6 +771,9 @@ fun CollectionFolderGrid(
onClickDelete = {
showDeleteDialog = PositionItem(position, item)
},
onClickGoTo = {
onClickItem.invoke(position, it)
},
),
),
onDismissRequest = { moreDialog.makeAbsent() },

View file

@ -59,7 +59,7 @@ fun CollectionFolderPhotoAlbum(
index = index,
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
recursive = true,
recursive = recursive,
startSlideshow = false,
)
} else {

View file

@ -30,6 +30,7 @@ data class MoreDialogActions(
val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
)
enum class ClearChosenStreams {
@ -246,7 +247,7 @@ fun buildMoreDialogItemsForHome(
context.getString(R.string.go_to),
Icons.Default.ArrowForward,
) {
actions.navigateTo(item.destination())
actions.onClickGoTo(item)
},
)
if (item.type in supportedPlayableTypes) {

View file

@ -209,7 +209,7 @@ fun DestinationContent(
CollectionFolderPhotoAlbum(
preferences = preferences,
itemId = destination.itemId,
recursive = true,
recursive = false,
modifier = modifier,
)
}
@ -387,10 +387,19 @@ fun CollectionFolder(
}
CollectionType.HOMEVIDEOS,
CollectionType.PHOTOS,
-> {
CollectionFolderPhotoAlbum(
preferences = preferences,
itemId = destination.itemId,
recursive = recursiveOverride ?: false,
modifier = modifier,
)
}
CollectionType.MUSICVIDEOS,
CollectionType.MUSIC,
CollectionType.BOOKS,
CollectionType.PHOTOS,
-> {
CollectionFolderGeneric(
preferences,

View file

@ -400,7 +400,7 @@ fun PreferencesContent(
when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(
conn.serverUrl,
conn.server.url,
conn.ex,
)
}

View file

@ -8,7 +8,6 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -53,13 +52,16 @@ import androidx.media3.ui.compose.modifiers.resizeWithContentScale
import androidx.media3.ui.compose.state.rememberPresentationState
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.request.transitionFactory
import coil3.size.Size
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination
@ -70,12 +72,14 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import kotlin.math.abs
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "ImagePage"
private const val DEBUG = false
@SuppressLint("ConfigurationScreenWidthHeight")
@OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalCoilApi::class)
@Composable
fun SlideshowPage(
slideshow: Destination.Slideshow,
@ -93,7 +97,7 @@ fun SlideshowPage(
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
val position by viewModel.position.observeAsState(0)
val pager by viewModel.pager.observeAsState()
val imageState by viewModel.image.observeAsState()
// val imageState by viewModel.image.observeAsState()
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
val isZoomed = zoomFactor * 100 > 102
@ -197,9 +201,6 @@ fun SlideshowPage(
}
}
LaunchedEffect(imageState) {
reset(true)
}
val player = viewModel.player
val presentationState = rememberPresentationState(player)
LaunchedEffect(slideshowActive) {
@ -209,16 +210,6 @@ fun SlideshowPage(
var longPressing by remember { mutableStateOf(false) }
val contentModifier =
Modifier
.clickable(
interactionSource = null,
indication = null,
onClick = {
showOverlay = !showOverlay
},
)
Box(
modifier =
modifier
@ -303,149 +294,144 @@ fun SlideshowPage(
result
},
) {
when (loadingState) {
when (val st = loadingState) {
ImageLoadingState.Error -> {
ErrorMessage("Error loading image", null, modifier)
}
ImageLoadingState.Loading -> {
LoadingPage(modifier)
LoadingPage(modifier, false)
}
is ImageLoadingState.Success -> {
imageState?.let { imageState ->
if (imageState.image.data.mediaType == MediaType.VIDEO) {
LaunchedEffect(imageState.id) {
val mediaItem =
MediaItem
.Builder()
.setUri(imageState.url)
.build()
player.setMediaItem(mediaItem)
player.repeatMode =
if (slideshowState.enabled) {
Player.REPEAT_MODE_OFF
} else {
Player.REPEAT_MODE_ONE
}
player.prepare()
player.play()
viewModel.pulseSlideshow(Long.MAX_VALUE)
}
LifecycleStartEffect(Unit) {
onStopOrDispose {
player.stop()
val imageState = st.image
LaunchedEffect(imageState) {
reset(true)
}
if (imageState.image.data.mediaType == MediaType.VIDEO) {
LaunchedEffect(imageState.id) {
val mediaItem =
MediaItem
.Builder()
.setUri(imageState.url)
.build()
player.setMediaItem(mediaItem)
player.repeatMode =
if (slideshowState.enabled) {
Player.REPEAT_MODE_OFF
} else {
Player.REPEAT_MODE_ONE
}
player.prepare()
player.play()
viewModel.pulseSlideshow(Long.MAX_VALUE)
}
LifecycleStartEffect(Unit) {
onStopOrDispose {
player.stop()
}
val contentScale = ContentScale.Fit
val scaledModifier =
contentModifier.resizeWithContentScale(
contentScale,
presentationState.videoSizeDp,
)
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier =
scaledModifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
}.rotate(rotateAnimation),
}
val contentScale = ContentScale.Fit
val scaledModifier =
Modifier.resizeWithContentScale(
contentScale,
presentationState.videoSizeDp,
)
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
)
}
} else {
val colorFilter =
remember(imageState.id, imageFilter) {
if (imageFilter.hasImageFilter()) {
ColorMatrixColorFilter(imageFilter.colorMatrix)
} else {
null
}
}
// If the image loading is large, show the thumbnail while waiting
// TODO
val showLoadingThumbnail = true
SubcomposeAsyncImage(
modifier =
contentModifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
val xTransform =
(screenWidth - panXAnimation) / (screenWidth * 2)
val yTransform =
(screenHeight - panYAnimation) / (screenHeight * 2)
if (DEBUG) {
Timber.d(
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
)
}
transformOrigin = TransformOrigin(xTransform, yTransform)
}.rotate(rotateAnimation),
model =
ImageRequest
.Builder(LocalContext.current)
.data(imageState.url)
.size(Size.ORIGINAL)
.crossfade(!showLoadingThumbnail)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter = colorFilter,
error = {
Text(
modifier =
Modifier
.align(Alignment.Center),
text = "Error loading image",
color = MaterialTheme.colorScheme.onBackground,
)
},
loading = {
ImageLoadingPlaceholder(
thumbnailUrl = imageState.thumbnailUrl,
showThumbnail = showLoadingThumbnail,
colorFilter = colorFilter,
modifier = Modifier.fillMaxSize(),
)
},
// Ensure that if an image takes a long time to load, it won't be skipped
onLoading = {
viewModel.pulseSlideshow(Long.MAX_VALUE)
},
onSuccess = {
viewModel.pulseSlideshow()
},
onError = {
Timber.e(
it.result.throwable,
"Error loading image ${imageState.id}",
)
Toast
.makeText(
context,
"Error loading image: ${it.result.throwable.localizedMessage}",
Toast.LENGTH_LONG,
).show()
viewModel.pulseSlideshow()
},
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier =
scaledModifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
}.rotate(rotateAnimation),
)
if (presentationState.coverSurface) {
Box(
Modifier
.matchParentSize()
.background(Color.Black),
)
}
} else {
val colorFilter =
remember(imageState.id, imageFilter) {
if (imageFilter.hasImageFilter()) {
ColorMatrixColorFilter(imageFilter.colorMatrix)
} else {
null
}
}
// If the image loading is large, show the thumbnail while waiting
// TODO
val showLoadingThumbnail = true
SubcomposeAsyncImage(
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = zoomAnimation
scaleY = zoomAnimation
translationX = panXAnimation
translationY = panYAnimation
val xTransform =
(screenWidth - panXAnimation) / (screenWidth * 2)
val yTransform =
(screenHeight - panYAnimation) / (screenHeight * 2)
if (DEBUG) {
Timber.d(
"graphicsLayer: xTransform=$xTransform, yTransform=$yTransform",
)
}
transformOrigin = TransformOrigin(xTransform, yTransform)
}.rotate(rotateAnimation),
model =
ImageRequest
.Builder(LocalContext.current)
.data(imageState.url)
.size(Size.ORIGINAL)
.transitionFactory(CrossFadeFactory(750.milliseconds))
.useExistingImageAsPlaceholder(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter = colorFilter,
error = {
Text(
modifier =
Modifier
.align(Alignment.Center),
text = "Error loading image",
color = MaterialTheme.colorScheme.onBackground,
)
},
// Ensure that if an image takes a long time to load, it won't be skipped
onLoading = {
viewModel.pulseSlideshow(Long.MAX_VALUE)
},
onSuccess = {
viewModel.pulseSlideshow()
},
onError = {
Timber.e(
it.result.throwable,
"Error loading image ${imageState.id}",
)
Toast
.makeText(
context,
"Error loading image: ${it.result.throwable.localizedMessage}",
Toast.LENGTH_LONG,
).show()
viewModel.pulseSlideshow()
},
)
}
}
}
@ -455,30 +441,37 @@ fun SlideshowPage(
exit = slideOutVertically { it },
modifier = Modifier.align(Alignment.BottomStart),
) {
imageState?.let { imageState ->
ImageOverlay(
modifier =
contentModifier
.fillMaxWidth()
.background(AppColors.TransparentBlack50),
onDismiss = { showOverlay = false },
player = player,
slideshowControls = slideshowControls,
slideshowEnabled = slideshowState.enabled,
image = imageState,
position = position,
count = pager?.size ?: -1,
onClickItem = {},
onLongClickItem = {},
onZoom = ::zoom,
onRotate = { rotation += it },
onReset = { reset(true) },
onShowFilterDialogClick = {
showFilterDialog = true
showOverlay = false
viewModel.pauseSlideshow()
},
)
when (val st = loadingState) {
ImageLoadingState.Error -> {}
ImageLoadingState.Loading -> {}
is ImageLoadingState.Success -> {
val imageState = st.image
ImageOverlay(
modifier =
Modifier
.fillMaxWidth()
.background(AppColors.TransparentBlack50),
onDismiss = { showOverlay = false },
player = player,
slideshowControls = slideshowControls,
slideshowEnabled = slideshowState.enabled,
image = imageState,
position = position,
count = pager?.size ?: -1,
onClickItem = {},
onLongClickItem = {},
onZoom = ::zoom,
onRotate = { rotation += it },
onReset = { reset(true) },
onShowFilterDialogClick = {
showFilterDialog = true
showOverlay = false
viewModel.pauseSlideshow()
},
)
}
}
}
AnimatedVisibility(showFilterDialog) {

View file

@ -141,7 +141,7 @@ class SlideshowViewModel
parentId = slideshowSettings.parentId,
includeItemTypes = includeItemTypes,
fields = PhotoItemFields,
recursive = true,
recursive = slideshowSettings.recursive,
sortBy = listOf(slideshowSettings.sortAndDirection.sort),
sortOrder = listOf(slideshowSettings.sortAndDirection.direction),
),
@ -193,7 +193,8 @@ class SlideshowViewModel
_pager.value?.let { pager ->
viewModelScope.launchIO {
try {
val image = pager.getBlocking(position)
val image =
if (position in pager.indices) pager.getBlocking(position) else null
Timber.v("Got image for $position: ${image != null}")
if (image != null) {
this@SlideshowViewModel.position.setValueOnMain(position)