Customize display of library grid pages (#368)

## Details

Adds options to customize how grid pages are displayed. Every grid page
can be individually customized.

The options are saved per user on the device and restored when
navigating back to the page. The pages that support it have a new button
next to the sort/filter buttons and changes are updated live.

The options are:
* Primary or Thumb image
* Aspect ratio
* Show details header w/ backdrop (similar to the one on the home page)
* Number of columns
* Spacing between cards
* Card image content scale (eg Fit, Fill, Crop, etc)

Note: the Genre tabs' grids cannot be customized yet

### Other changes
- Nav drawer now opens over top of content instead of pushing it
- Images for most cards are fetched with actual fill size

### Possible future work
* Customize Genre grids
* Add more image types, banners?
* Add similar customization options to pages with rows, eg Home page and
Recommended tabs

## Example

For example, this is a movie library using thumb images with 8 columns
and reduced spacing. It also shows the details header

## Issues
Closes #186
Related to #72
This commit is contained in:
damontecres 2025-12-04 12:20:50 -05:00 committed by GitHub
parent b98fb4a1d8
commit 384401a72c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1658 additions and 628 deletions

View file

@ -0,0 +1,165 @@
package com.github.damontecres.wholphin.services
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.github.damontecres.wholphin.data.model.BaseItem
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageFormat
import org.jellyfin.sdk.model.api.ImageType
import javax.inject.Inject
import javax.inject.Singleton
/**
* Provides image URLs for items with several convenience methods
*
* This is available in compose UI code via [com.github.damontecres.wholphin.ui.LocalImageUrlService]
*/
@Singleton
class ImageUrlService
@Inject
constructor(
private val api: ApiClient,
) {
fun getItemImageUrl(
itemId: UUID,
itemType: BaseItemKind,
seriesId: UUID?,
useSeriesForPrimary: Boolean,
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
): String? =
when (imageType) {
ImageType.BACKDROP,
ImageType.LOGO,
-> {
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
getItemImageUrl(
itemId = seriesId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
getItemImageUrl(
itemId = itemId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
}
}
ImageType.PRIMARY,
ImageType.THUMB,
ImageType.BANNER,
-> {
if (useSeriesForPrimary && seriesId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
) {
getItemImageUrl(
itemId = seriesId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
getItemImageUrl(
itemId = itemId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
}
}
else ->
getItemImageUrl(
itemId = itemId,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
}
fun getItemImageUrl(
item: BaseItem?,
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
): String? =
if (item != null) {
getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = item.data.seriesId,
useSeriesForPrimary = item.useSeriesForPrimary,
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
fun getItemImageUrl(
itemId: UUID,
imageType: ImageType,
maxWidth: Int? = null,
maxHeight: Int? = null,
width: Int? = null,
height: Int? = null,
quality: Int? = QUALITY,
fillWidth: Int? = null,
fillHeight: Int? = null,
tag: String? = null,
format: ImageFormat? = null,
percentPlayed: Double? = null,
unplayedCount: Int? = null,
blur: Int? = null,
backgroundColor: String? = null,
foregroundLayer: String? = null,
imageIndex: Int? = null,
): String =
api.imageApi.getItemImageUrl(
itemId = itemId,
imageType = imageType,
maxWidth = maxWidth,
maxHeight = maxHeight,
width = width,
height = height,
quality = quality,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = tag,
format = format,
percentPlayed = percentPlayed,
unplayedCount = unplayedCount,
blur = blur,
backgroundColor = backgroundColor,
foregroundLayer = foregroundLayer,
imageIndex = imageIndex,
)
/**
* Just a convenient way to get the image URL and remember it
*/
@Composable
fun rememberImageUrl(
item: BaseItem?,
imageType: ImageType = ImageType.PRIMARY,
) = remember(item, imageType) {
if (item != null) {
getItemImageUrl(item, imageType)
} else {
null
}
}
companion object {
private const val QUALITY = 96
}
}