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

@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem
import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
@ -22,7 +23,7 @@ import java.util.UUID
@Database(
entities = [JellyfinServer::class, JellyfinUser::class, ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class],
version = 8,
version = 9,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@ -30,6 +31,7 @@ import java.util.UUID
AutoMigration(5, 6),
AutoMigration(6, 7),
AutoMigration(7, 8),
AutoMigration(8, 9),
],
)
@TypeConverters(Converters::class)
@ -73,6 +75,18 @@ class Converters {
Timber.e(ex, "Error parsing filter")
GetItemsFilter()
}
@TypeConverter
fun convertViewOptions(viewOptions: ViewOptions?): String? = viewOptions?.let { Json.encodeToString(viewOptions) }
@TypeConverter
fun convertViewOptions(viewOptions: String?): ViewOptions? =
try {
viewOptions?.let { Json.decodeFromString(viewOptions) }
} catch (ex: Exception) {
Timber.e(ex, "Error parsing view options")
null
}
}
object Migrations {

View file

@ -13,7 +13,6 @@ sealed interface ExtrasItem {
val type: ExtraType
val destination: Destination
val title: String?
val imageUrl: String?
data class Group(
override val parentId: UUID,
@ -24,7 +23,6 @@ sealed interface ExtrasItem {
Destination.ItemGrid(null, type.stringRes, items.map { it.id })
override val title: String? = null
override val imageUrl: String? = items.random().imageUrl
}
data class Single(
@ -37,7 +35,6 @@ sealed interface ExtrasItem {
item = item,
)
override val title: String? get() = item.title
override val imageUrl: String? get() = item.imageUrl
}
}

View file

@ -8,20 +8,15 @@ import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import kotlin.time.Duration
@Serializable
data class BaseItem(
val data: BaseItemDto,
val imageUrl: String?,
val backdropImageUrl: String? = null,
val logoImageUrl: String? = null,
val useSeriesForPrimary: Boolean,
) {
val id get() = data.id
@ -95,72 +90,15 @@ data class BaseItem(
}
companion object {
var primaryMaxWidth: Int? = null
set(value) {
Timber.v("primaryMaxWidth=$value")
field = value
}
var primaryMaxHeight: Int? = null
set(value) {
Timber.v("primaryMaxHeight=$value")
field = value
}
fun from(
dto: BaseItemDto,
api: ApiClient,
useSeriesForPrimary: Boolean = false,
): BaseItem {
val backdropImageUrl =
if (dto.type == BaseItemKind.EPISODE) {
val seriesId = dto.seriesId
if (seriesId != null) {
api.imageApi.getItemImageUrl(seriesId, ImageType.BACKDROP)
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP)
}
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP)
}
val primaryImageUrl =
if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE && dto.seriesId != null) {
api.imageApi.getItemImageUrl(
dto.seriesId!!,
ImageType.PRIMARY,
maxHeight = primaryMaxHeight,
maxWidth = primaryMaxWidth,
quality = 96,
)
} else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) {
// TODO is this a bad assumption?
null
} else {
api.imageApi.getItemImageUrl(
dto.id,
ImageType.PRIMARY,
maxHeight = primaryMaxHeight,
maxWidth = primaryMaxWidth,
quality = 96,
)
}
val logoImageUrl =
if (dto.type == BaseItemKind.EPISODE || dto.type == BaseItemKind.SEASON) {
val seriesId = dto.seriesId
if (seriesId != null) {
api.imageApi.getItemImageUrl(seriesId, ImageType.LOGO)
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.LOGO)
}
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.LOGO)
}
return BaseItem(
): BaseItem =
BaseItem(
dto,
primaryImageUrl,
backdropImageUrl,
logoImageUrl,
useSeriesForPrimary,
)
}
}
}

View file

@ -7,6 +7,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.Index
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@ -36,6 +37,7 @@ data class LibraryDisplayInfo(
val direction: SortOrder,
@ColumnInfo(defaultValue = "{}")
val filter: GetItemsFilter,
val viewOptions: ViewOptions?,
) {
@Ignore @Transient
val sortAndDirection = SortAndDirection(sort, direction)