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,149 +294,144 @@ 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
if (imageState.image.data.mediaType == MediaType.VIDEO) { LaunchedEffect(imageState) {
LaunchedEffect(imageState.id) { reset(true)
val mediaItem = }
MediaItem if (imageState.image.data.mediaType == MediaType.VIDEO) {
.Builder() LaunchedEffect(imageState.id) {
.setUri(imageState.url) val mediaItem =
.build() MediaItem
player.setMediaItem(mediaItem) .Builder()
player.repeatMode = .setUri(imageState.url)
if (slideshowState.enabled) { .build()
Player.REPEAT_MODE_OFF player.setMediaItem(mediaItem)
} else { player.repeatMode =
Player.REPEAT_MODE_ONE if (slideshowState.enabled) {
} Player.REPEAT_MODE_OFF
player.prepare() } else {
player.play() Player.REPEAT_MODE_ONE
viewModel.pulseSlideshow(Long.MAX_VALUE)
}
LifecycleStartEffect(Unit) {
onStopOrDispose {
player.stop()
} }
player.prepare()
player.play()
viewModel.pulseSlideshow(Long.MAX_VALUE)
}
LifecycleStartEffect(Unit) {
onStopOrDispose {
player.stop()
} }
val contentScale = ContentScale.Fit }
val scaledModifier = val contentScale = ContentScale.Fit
contentModifier.resizeWithContentScale( val scaledModifier =
contentScale, Modifier.resizeWithContentScale(
presentationState.videoSizeDp, 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),
) )
if (presentationState.coverSurface) { PlayerSurface(
Box( player = player,
Modifier surfaceType = SURFACE_TYPE_SURFACE_VIEW,
.matchParentSize() modifier =
.background(Color.Black), scaledModifier
) .fillMaxSize()
} .graphicsLayer {
} else { scaleX = zoomAnimation
val colorFilter = scaleY = zoomAnimation
remember(imageState.id, imageFilter) { translationX = panXAnimation
if (imageFilter.hasImageFilter()) { translationY = panYAnimation
ColorMatrixColorFilter(imageFilter.colorMatrix) }.rotate(rotateAnimation),
} else { )
null if (presentationState.coverSurface) {
} Box(
} Modifier
// If the image loading is large, show the thumbnail while waiting .matchParentSize()
// TODO .background(Color.Black),
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()
},
) )
} }
} 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 }, exit = slideOutVertically { it },
modifier = Modifier.align(Alignment.BottomStart), modifier = Modifier.align(Alignment.BottomStart),
) { ) {
imageState?.let { imageState -> when (val st = loadingState) {
ImageOverlay( ImageLoadingState.Error -> {}
modifier =
contentModifier ImageLoadingState.Loading -> {}
.fillMaxWidth()
.background(AppColors.TransparentBlack50), is ImageLoadingState.Success -> {
onDismiss = { showOverlay = false }, val imageState = st.image
player = player, ImageOverlay(
slideshowControls = slideshowControls, modifier =
slideshowEnabled = slideshowState.enabled, Modifier
image = imageState, .fillMaxWidth()
position = position, .background(AppColors.TransparentBlack50),
count = pager?.size ?: -1, onDismiss = { showOverlay = false },
onClickItem = {}, player = player,
onLongClickItem = {}, slideshowControls = slideshowControls,
onZoom = ::zoom, slideshowEnabled = slideshowState.enabled,
onRotate = { rotation += it }, image = imageState,
onReset = { reset(true) }, position = position,
onShowFilterDialogClick = { count = pager?.size ?: -1,
showFilterDialog = true onClickItem = {},
showOverlay = false onLongClickItem = {},
viewModel.pauseSlideshow() onZoom = ::zoom,
}, onRotate = { rotation += it },
) onReset = { reset(true) },
onShowFilterDialogClick = {
showFilterDialog = true
showOverlay = false
viewModel.pauseSlideshow()
},
)
}
} }
} }
AnimatedVisibility(showFilterDialog) { AnimatedVisibility(showFilterDialog) {

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)