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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,6 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.media3.ui.compose.state.rememberPresentationState
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImage
import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.crossfade import coil3.request.transitionFactory
import coil3.size.Size import coil3.size.Size
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.ui.AppColors 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination 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 org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber import timber.log.Timber
import kotlin.math.abs import kotlin.math.abs
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "ImagePage" private const val TAG = "ImagePage"
private const val DEBUG = false private const val DEBUG = false
@SuppressLint("ConfigurationScreenWidthHeight") @SuppressLint("ConfigurationScreenWidthHeight")
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalCoilApi::class)
@Composable @Composable
fun SlideshowPage( fun SlideshowPage(
slideshow: Destination.Slideshow, slideshow: Destination.Slideshow,
@ -93,7 +97,7 @@ fun SlideshowPage(
val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter())
val position by viewModel.position.observeAsState(0) val position by viewModel.position.observeAsState(0)
val pager by viewModel.pager.observeAsState() val pager by viewModel.pager.observeAsState()
val imageState by viewModel.image.observeAsState() // val imageState by viewModel.image.observeAsState()
var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) }
val isZoomed = zoomFactor * 100 > 102 val isZoomed = zoomFactor * 100 > 102
@ -197,9 +201,6 @@ fun SlideshowPage(
} }
} }
LaunchedEffect(imageState) {
reset(true)
}
val player = viewModel.player val player = viewModel.player
val presentationState = rememberPresentationState(player) val presentationState = rememberPresentationState(player)
LaunchedEffect(slideshowActive) { LaunchedEffect(slideshowActive) {
@ -209,16 +210,6 @@ fun SlideshowPage(
var longPressing by remember { mutableStateOf(false) } var longPressing by remember { mutableStateOf(false) }
val contentModifier =
Modifier
.clickable(
interactionSource = null,
indication = null,
onClick = {
showOverlay = !showOverlay
},
)
Box( Box(
modifier = modifier =
modifier modifier
@ -303,17 +294,20 @@ fun SlideshowPage(
result result
}, },
) { ) {
when (loadingState) { when (val st = loadingState) {
ImageLoadingState.Error -> { ImageLoadingState.Error -> {
ErrorMessage("Error loading image", null, modifier) ErrorMessage("Error loading image", null, modifier)
} }
ImageLoadingState.Loading -> { ImageLoadingState.Loading -> {
LoadingPage(modifier) LoadingPage(modifier, false)
} }
is ImageLoadingState.Success -> { is ImageLoadingState.Success -> {
imageState?.let { imageState -> val imageState = st.image
LaunchedEffect(imageState) {
reset(true)
}
if (imageState.image.data.mediaType == MediaType.VIDEO) { if (imageState.image.data.mediaType == MediaType.VIDEO) {
LaunchedEffect(imageState.id) { LaunchedEffect(imageState.id) {
val mediaItem = val mediaItem =
@ -339,7 +333,7 @@ fun SlideshowPage(
} }
val contentScale = ContentScale.Fit val contentScale = ContentScale.Fit
val scaledModifier = val scaledModifier =
contentModifier.resizeWithContentScale( Modifier.resizeWithContentScale(
contentScale, contentScale,
presentationState.videoSizeDp, presentationState.videoSizeDp,
) )
@ -377,7 +371,7 @@ fun SlideshowPage(
val showLoadingThumbnail = true val showLoadingThumbnail = true
SubcomposeAsyncImage( SubcomposeAsyncImage(
modifier = modifier =
contentModifier Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { .graphicsLayer {
scaleX = zoomAnimation scaleX = zoomAnimation
@ -402,7 +396,8 @@ fun SlideshowPage(
.Builder(LocalContext.current) .Builder(LocalContext.current)
.data(imageState.url) .data(imageState.url)
.size(Size.ORIGINAL) .size(Size.ORIGINAL)
.crossfade(!showLoadingThumbnail) .transitionFactory(CrossFadeFactory(750.milliseconds))
.useExistingImageAsPlaceholder(true)
.build(), .build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
@ -416,14 +411,6 @@ fun SlideshowPage(
color = MaterialTheme.colorScheme.onBackground, 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 // Ensure that if an image takes a long time to load, it won't be skipped
onLoading = { onLoading = {
viewModel.pulseSlideshow(Long.MAX_VALUE) viewModel.pulseSlideshow(Long.MAX_VALUE)
@ -448,17 +435,22 @@ fun SlideshowPage(
} }
} }
} }
}
AnimatedVisibility( AnimatedVisibility(
showOverlay, showOverlay,
enter = slideInVertically { it }, enter = slideInVertically { it },
exit = slideOutVertically { it }, exit = slideOutVertically { it },
modifier = Modifier.align(Alignment.BottomStart), modifier = Modifier.align(Alignment.BottomStart),
) { ) {
imageState?.let { imageState -> when (val st = loadingState) {
ImageLoadingState.Error -> {}
ImageLoadingState.Loading -> {}
is ImageLoadingState.Success -> {
val imageState = st.image
ImageOverlay( ImageOverlay(
modifier = modifier =
contentModifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(AppColors.TransparentBlack50), .background(AppColors.TransparentBlack50),
onDismiss = { showOverlay = false }, onDismiss = { showOverlay = false },
@ -481,6 +473,7 @@ fun SlideshowPage(
) )
} }
} }
}
AnimatedVisibility(showFilterDialog) { AnimatedVisibility(showFilterDialog) {
ImageFilterDialog( ImageFilterDialog(
filter = imageFilter, filter = imageFilter,

View file

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