From 7bb47bf329f58c91072184f762a1d6b8da059e5e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:16:19 -0400 Subject: [PATCH] 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 --- .../services/SeerrServerRepository.kt | 21 +- .../ui/components/CollectionFolderGrid.kt | 3 + .../ui/detail/CollectionFolderPhotoAlbum.kt | 2 +- .../wholphin/ui/detail/DetailUtils.kt | 3 +- .../wholphin/ui/nav/DestinationContent.kt | 13 +- .../ui/preferences/PreferencesContent.kt | 2 +- .../wholphin/ui/slideshow/SlideshowPage.kt | 333 +++++++++--------- .../ui/slideshow/SlideshowViewModel.kt | 5 +- 8 files changed, 198 insertions(+), 184 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 6db60574..80eafb7a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -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) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 8415c7a6..a4e4ef22 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -771,6 +771,9 @@ fun CollectionFolderGrid( onClickDelete = { showDeleteDialog = PositionItem(position, item) }, + onClickGoTo = { + onClickItem.invoke(position, it) + }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index 4ddc41c1..562af214 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -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 { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index f34c0228..5fe13871 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -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) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 0888e721..c0e4ce26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -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, 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 2dc4f3cf..c214d3cb 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 @@ -400,7 +400,7 @@ fun PreferencesContent( when (val conn = seerrConnection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( - conn.serverUrl, + conn.server.url, conn.ex, ) } 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 f35bc102..edb1e859 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 @@ -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) { 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 992659ca..3a4f3bce 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 @@ -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)