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

@ -44,6 +44,7 @@ import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import java.util.UUID
@ -308,7 +309,7 @@ fun logCoilError(
url: String?,
errorResult: ErrorResult,
) {
if (errorResult.throwable is coil3.network.HttpException) {
if (errorResult.throwable is coil3.network.HttpException || errorResult.throwable is coil3.request.NullRequestDataException) {
Timber.w("Error loading image: %s for %s", errorResult.throwable.localizedMessage, url)
} else {
Timber.e(errorResult.throwable, "Error loading image: %s", url)
@ -401,3 +402,9 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
api: ApiClient,
useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
@Composable
fun rememberBackDropImage(item: BaseItem) {
val imageUrlService = LocalImageUrlService.current
return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) }
}

View file

@ -2,18 +2,23 @@ package com.github.damontecres.wholphin.ui
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.ImageUrlService
import org.jellyfin.sdk.model.api.ItemFields
// This file is for constants used for the UI
val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900))
val LocalImageUrlService =
staticCompositionLocalOf<ImageUrlService> { throw IllegalStateException("LocalImageUrlService not set") }
/**
* Colors not associated with the theme
*/
@ -81,6 +86,15 @@ object AspectRatios {
const val SQUARE = 1f
}
enum class AspectRatio(
val ratio: Float,
) {
TALL(AspectRatios.TALL),
WIDE(AspectRatios.WIDE),
FOUR_THREE(AspectRatios.FOUR_THREE),
SQUARE(AspectRatios.SQUARE),
}
@Preview(
device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535,

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -33,11 +34,14 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.ImageType
/**
* Displays an image as a card. If no image is available, the name will be shown instead
@ -45,7 +49,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@Composable
fun BannerCard(
name: String?,
imageUrl: String?,
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
@ -57,6 +61,29 @@ fun BannerCard(
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null,
) {
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
val imageUrl =
remember(item, cardHeight) {
if (item != null) {
val fillHeight =
if (cardHeight != Dp.Unspecified) {
with(density) {
cardHeight.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = null,
fillHeight = fillHeight,
)
} else {
null
}
}
var imageError by remember { mutableStateOf(false) }
Card(
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),

View file

@ -90,7 +90,7 @@ fun EpisodeCard(
.fillMaxSize(),
) {
ItemCardImage(
imageUrl = item?.imageUrl,
item = item,
name = item?.name,
showOverlay = false,
favorite = dto?.userData?.isFavorite ?: false,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
@ -11,6 +12,8 @@ import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.pluralRes
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun ExtrasRow(
@ -27,6 +30,17 @@ fun ExtrasRow(
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
cardContent = { index, item, mod, onClick, onLongClick ->
val imageUrlService = LocalImageUrlService.current
val imageUrl =
remember {
val item =
when (item) {
is ExtrasItem.Group -> item.items.random()
is ExtrasItem.Single -> item.item
null -> null
}
imageUrlService.getItemImageUrl(item, ImageType.PRIMARY)
}
SeasonCard(
title =
when (item) {
@ -49,7 +63,7 @@ fun ExtrasRow(
showImageOverlay = true,
imageHeight = Cards.height2x3 * .75f,
imageWidth = Dp.Unspecified,
imageUrl = item?.imageUrl,
imageUrl = imageUrl,
isFavorite = false,
isPlayed = false,
unplayedItemCount = -1,

View file

@ -27,6 +27,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay
@ -42,6 +43,7 @@ fun GridCard(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageAspectRatio: Float = AspectRatios.TALL,
imageContentScale: ContentScale = ContentScale.Fit,
imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
@ -78,7 +80,8 @@ fun GridCard(
),
) {
ItemCardImage(
imageUrl = item?.imageUrl,
item = item,
imageType = imageType.imageType,
name = item?.name,
// showOverlay = !focusedAfterDelay,
showOverlay = true,

View file

@ -1,12 +1,10 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -26,20 +24,79 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onLayoutRectChanged
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.logCoilError
import org.jellyfin.sdk.model.api.ImageType
/**
* Display an image for an item with optional overlay data
*
* This will fetch the image using fillWidth/fillHeight based on the layout size
*/
@Composable
fun ItemCardImage(
item: BaseItem?,
name: String?,
showOverlay: Boolean,
favorite: Boolean,
watched: Boolean,
unwatchedCount: Int,
watchedPercent: Double?,
modifier: Modifier = Modifier,
imageType: ImageType = ImageType.PRIMARY,
useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit,
) {
val imageUrlService = LocalImageUrlService.current
var size by remember { mutableStateOf(IntSize.Zero) }
val imageUrl =
remember(size, item) {
if (size != IntSize.Zero && item != null) {
imageUrlService.getItemImageUrl(
item,
imageType,
fillWidth = size.width,
fillHeight = size.height,
)
} else {
null
}
}
ItemCardImage(
imageUrl = imageUrl,
name = name,
showOverlay = showOverlay,
favorite = favorite,
watched = watched,
unwatchedCount = unwatchedCount,
watchedPercent = watchedPercent,
modifier =
modifier.onLayoutRectChanged(
throttleMillis = 100,
debounceMillis = 25,
) {
size = IntSize(width = it.width, height = it.height)
},
useFallbackText = useFallbackText,
contentScale = contentScale,
)
}
@Composable
fun ItemCardImage(
@ -54,8 +111,10 @@ fun ItemCardImage(
useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit,
) {
var imageError by remember { mutableStateOf(false) }
Box(modifier = modifier) {
var imageError by remember(imageUrl) { mutableStateOf(false) }
Box(
modifier = modifier,
) {
if (!imageError && imageUrl.isNotNullOrBlank()) {
AsyncImage(
model = imageUrl,
@ -72,104 +131,130 @@ fun ItemCardImage(
.align(Alignment.TopCenter),
)
} else {
// TODO options for overriding fallback
Box(
modifier =
Modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.fillMaxSize()
.align(Alignment.TopCenter),
) {
if (useFallbackText && name.isNotNullOrBlank()) {
Text(
text = name,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier =
Modifier
.padding(8.dp)
.align(Alignment.Center),
)
} else {
Image(
painter = painterResource(id = R.drawable.video_solid),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter =
ColorFilter.tint(
MaterialTheme.colorScheme.onSurfaceVariant,
BlendMode.SrcIn,
),
modifier =
Modifier
.fillMaxSize(.4f)
.align(Alignment.Center),
)
}
}
ItemCardImageFallback(
name = name,
useFallbackText = useFallbackText,
modifier = Modifier,
)
}
AnimatedVisibility(
visible = showOverlay,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(modifier = Modifier.fillMaxSize()) {
if (favorite) {
Text(
modifier =
Modifier
.align(Alignment.TopStart)
.padding(8.dp),
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 20.sp,
fontFamily = FontAwesome,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(4.dp)
.align(Alignment.TopEnd),
) {
if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) {
WatchedIcon(Modifier.size(24.dp))
}
if (unwatchedCount > 0) {
Box(
modifier =
Modifier
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(25),
),
) {
Text(
text = unwatchedCount.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
// fontSize = 16.sp,
modifier = Modifier.padding(4.dp),
)
}
}
}
watchedPercent?.let { percent ->
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(Cards.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()),
)
}
}
if (showOverlay) {
ItemCardImageOverlay(
favorite = favorite,
watched = watched,
unwatchedCount = unwatchedCount,
watchedPercent = watchedPercent,
modifier = Modifier,
)
}
}
}
@Composable
fun BoxScope.ItemCardImageFallback(
name: String?,
useFallbackText: Boolean,
modifier: Modifier = Modifier,
) {
// TODO options for overriding fallback
Box(
modifier =
modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.fillMaxSize()
.align(Alignment.TopCenter),
) {
if (useFallbackText && name.isNotNullOrBlank()) {
Text(
text = name,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 14.sp,
modifier =
Modifier
.padding(8.dp)
.align(Alignment.Center),
)
} else {
Image(
painter = painterResource(id = R.drawable.video_solid),
contentDescription = null,
contentScale = ContentScale.Fit,
colorFilter =
ColorFilter.tint(
MaterialTheme.colorScheme.onSurfaceVariant,
BlendMode.SrcIn,
),
modifier =
Modifier
.fillMaxSize(.4f)
.align(Alignment.Center),
)
}
}
}
@Composable
fun ItemCardImageOverlay(
favorite: Boolean,
watched: Boolean,
unwatchedCount: Int,
watchedPercent: Double?,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
if (favorite) {
Text(
modifier =
Modifier
.align(Alignment.TopStart)
.padding(8.dp),
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 20.sp,
fontFamily = FontAwesome,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(4.dp)
.align(Alignment.TopEnd),
) {
if (watched && (watchedPercent == null || watchedPercent <= 0.0 || watchedPercent >= 100.0)) {
WatchedIcon(Modifier.size(24.dp))
}
if (unwatchedCount > 0) {
Box(
modifier =
Modifier
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(25),
),
) {
Text(
text = unwatchedCount.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
// fontSize = 16.sp,
modifier = Modifier.padding(4.dp),
)
}
}
}
watchedPercent?.let { percent ->
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.background(
MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape)
.height(Cards.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()),
)
}
}
}

View file

@ -111,7 +111,7 @@ fun BannerItemRow(
cardContent = { index, item, modifier, onClick, onLongClick ->
BannerCard(
name = title,
imageUrl = item?.imageUrl,
item = item,
aspectRatio =
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE,

View file

@ -19,6 +19,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -27,8 +28,10 @@ import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.ImageType
/**
* A Card for a TV Show Season, but can generically show most items
@ -44,24 +47,57 @@ fun SeasonCard(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL,
) = SeasonCard(
title = item?.title,
subtitle = item?.subtitle,
name = item?.name,
imageUrl = item?.imageUrl,
isFavorite = item?.data?.userData?.isFavorite ?: false,
isPlayed = item?.data?.userData?.played ?: false,
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0,
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
imageHeight = imageHeight,
imageWidth = imageWidth,
interactionSource = interactionSource,
showImageOverlay = showImageOverlay,
aspectRatio = aspectRatio,
)
) {
val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current
val imageUrl =
remember(item, imageHeight, imageWidth) {
if (item != null) {
val fillHeight =
if (imageHeight != Dp.Unspecified) {
with(density) {
imageHeight.roundToPx()
}
} else {
null
}
val fillWidth =
if (imageWidth != Dp.Unspecified) {
with(density) {
imageWidth.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl(
item,
ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
)
} else {
null
}
}
SeasonCard(
title = item?.title,
subtitle = item?.subtitle,
name = item?.name,
imageUrl = imageUrl,
isFavorite = item?.data?.userData?.isFavorite ?: false,
isPlayed = item?.data?.userData?.played ?: false,
unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0,
playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
imageHeight = imageHeight,
imageWidth = imageWidth,
interactionSource = interactionSource,
showImageOverlay = showImageOverlay,
aspectRatio = aspectRatio,
)
}
/**
* A Card for a TV Show Season, but can generically show most items

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
@ -75,7 +76,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
@ -125,6 +130,7 @@ class CollectionFolderViewModel
val pager = MutableLiveData<List<BaseItem?>>(listOf())
val sortAndDirection = MutableLiveData<SortAndDirection>()
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
val viewOptions = MutableLiveData<ViewOptions>()
private var useSeriesForPrimary: Boolean = true
@ -134,6 +140,7 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
useSeriesForPrimary: Boolean,
defaultViewOptions: ViewOptions,
): Job =
viewModelScope.launch(
LoadingExceptionHandler(
@ -151,6 +158,9 @@ class CollectionFolderViewModel
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)
}
this@CollectionFolderViewModel.viewOptions.setValueOnMain(
libraryDisplayInfo?.viewOptions ?: defaultViewOptions,
)
val sortAndDirection =
libraryDisplayInfo?.sortAndDirection
@ -167,24 +177,40 @@ class CollectionFolderViewModel
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
}
fun onFilterChange(
newFilter: GetItemsFilter,
recursive: Boolean,
private fun saveLibraryDisplayInfo(
newFilter: GetItemsFilter = this.filter.value!!,
newSort: SortAndDirection = this.sortAndDirection.value!!,
viewOptions: ViewOptions? = this.viewOptions.value,
) {
Timber.v("onFilterChange: filter=%s", newFilter)
serverRepository.currentUser.value?.let { user ->
viewModelScope.launch(Dispatchers.IO) {
val libraryDisplayInfo =
LibraryDisplayInfo(
userId = user.rowId,
itemId = itemId,
sort = sortAndDirection.value!!.sort,
direction = sortAndDirection.value!!.direction,
sort = newSort.sort,
direction = newSort.direction,
filter = newFilter,
viewOptions = viewOptions,
)
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
}
}
}
fun saveViewOptions(viewOptions: ViewOptions) {
this.viewOptions.value = viewOptions
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
saveLibraryDisplayInfo(viewOptions = viewOptions)
}
}
fun onFilterChange(
newFilter: GetItemsFilter,
recursive: Boolean,
) {
Timber.v("onFilterChange: filter=%s", newFilter)
saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!)
loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary)
}
@ -199,19 +225,7 @@ class CollectionFolderViewModel
recursive,
filter,
)
serverRepository.currentUser.value?.let { user ->
viewModelScope.launch(Dispatchers.IO) {
val libraryDisplayInfo =
LibraryDisplayInfo(
userId = user.rowId,
itemId = itemId,
sort = sortAndDirection.sort,
direction = sortAndDirection.direction,
filter = filter,
)
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
}
}
saveLibraryDisplayInfo(filter, sortAndDirection)
loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary)
}
@ -454,11 +468,11 @@ fun CollectionFolderGrid(
onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier,
initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
params: CollectionFolderGridParameters = CollectionFolderGridParameters(),
useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
) = CollectionFolderGrid(
@ -469,11 +483,11 @@ fun CollectionFolderGrid(
onClickItem,
sortOptions,
playEnabled,
modifier,
defaultViewOptions = defaultViewOptions,
modifier = modifier,
initialSortAndDirection = initialSortAndDirection,
showTitle = showTitle,
positionCallback = positionCallback,
params = params,
useSeriesForPrimary = useSeriesForPrimary,
filterOptions = filterOptions,
)
@ -487,13 +501,13 @@ fun CollectionFolderGrid(
onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
defaultViewOptions: ViewOptions,
modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
params: CollectionFolderGridParameters = CollectionFolderGridParameters(),
useSeriesForPrimary: Boolean = true,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
) {
@ -505,6 +519,7 @@ fun CollectionFolderGrid(
recursive,
initialFilter,
useSeriesForPrimary,
defaultViewOptions,
)
}
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
@ -513,6 +528,7 @@ fun CollectionFolderGrid(
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val pager by viewModel.pager.observeAsState()
val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions)
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
@ -551,7 +567,9 @@ fun CollectionFolderGrid(
sortOptions = sortOptions,
positionCallback = positionCallback,
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
params = params,
viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
onSaveViewOptions = { viewModel.saveViewOptions(it) },
playEnabled = playEnabled,
onClickPlay = { shuffle ->
itemId.toUUIDOrNull()?.let {
@ -656,117 +674,174 @@ fun CollectionFolderGridContent(
modifier: Modifier = Modifier,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
params: CollectionFolderGridParameters = CollectionFolderGridParameters(),
currentFilter: GetItemsFilter = GetItemsFilter(),
filterOptions: List<ItemFilterBy<*>> = listOf(),
onFilterChange: (GetItemsFilter) -> Unit = {},
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
defaultViewOptions: ViewOptions,
viewOptions: ViewOptions,
onSaveViewOptions: (ViewOptions) -> Unit,
) {
val context = LocalContext.current
val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection)
var showHeader by rememberSaveable { mutableStateOf(true) }
var showViewOptions by rememberSaveable { mutableStateOf(false) }
var viewOptions by remember { mutableStateOf(viewOptions) }
val gridFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier,
) {
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
var position by rememberInt(0)
val focusedItem = pager.getOrNull(position)
Box(modifier = modifier) {
if (viewOptions.showDetails) {
DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize(),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
AnimatedVisibility(
showHeader,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
) {
if (showTitle) {
Text(
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(start = 16.dp, end = endPadding)
.fillMaxWidth(),
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
if (sortOptions.isNotEmpty()) {
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
)
}
if (filterOptions.isNotEmpty()) {
FilterByButton(
filterOptions = filterOptions,
current = currentFilter,
onFilterChange = onFilterChange,
getPossibleValues = getPossibleFilterValues,
if (showTitle) {
Text(
text = title,
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
val endPadding =
16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(start = 16.dp, end = endPadding)
.fillMaxWidth(),
) {
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
if (sortOptions.isNotEmpty()) {
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
)
}
if (filterOptions.isNotEmpty()) {
FilterByButton(
filterOptions = filterOptions,
current = currentFilter,
onFilterChange = onFilterChange,
getPossibleValues = getPossibleFilterValues,
modifier = Modifier,
)
}
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,
onClick = { showViewOptions = true },
modifier = Modifier,
)
}
}
}
if (playEnabled) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlay.invoke(false) },
)
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlay.invoke(true) },
)
if (playEnabled) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier,
) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlay.invoke(false) },
)
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlay.invoke(true) },
)
}
}
}
}
}
AnimatedVisibility(viewOptions.showDetails) {
HomePageHeader(
item = focusedItem,
modifier =
Modifier
.fillMaxWidth(.6f)
// .fillMaxHeight(.25f)
.height(140.dp)
.padding(16.dp),
)
}
CardGrid(
pager = pager,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
modifier = Modifier.fillMaxSize(),
initialPosition = 0,
positionCallback = { columns, newPosition ->
showHeader = newPosition < columns
position = newPosition
positionCallback?.invoke(columns, newPosition)
},
cardContent = { item, onClick, onLongClick, mod ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = viewOptions.contentScale.scale,
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
modifier = mod,
)
},
columns = viewOptions.columns,
spacing = viewOptions.spacing.dp,
)
AnimatedVisibility(showViewOptions) {
ViewOptionsDialog(
viewOptions = viewOptions,
defaultViewOptions = defaultViewOptions,
onDismissRequest = {
showViewOptions = false
onSaveViewOptions.invoke(viewOptions)
},
onViewOptionsChange = { viewOptions = it },
)
}
}
CardGrid(
pager = pager,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference
showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME,
modifier = Modifier.fillMaxSize(),
initialPosition = 0,
positionCallback = { columns, position ->
showHeader = position < columns
positionCallback?.invoke(columns, position)
},
cardContent = params.cardContent,
columns = params.columns,
spacing = params.spacing,
)
}
}

View file

@ -2,7 +2,13 @@ package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -10,9 +16,35 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.ImageType
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun BoxScope.DetailsBackdropImage(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
val imageUrlService = LocalImageUrlService.current
val backdropImageUrl =
remember(item) {
if (item != null) {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} else {
null
}
}
DetailsBackdropImage(backdropImageUrl, modifier)
}
@Composable
fun BoxScope.DetailsBackdropImage(
@ -50,3 +82,73 @@ fun BoxScope.DetailsBackdropImage(
)
}
}
@Composable
fun BoxScope.DelayedDetailsBackdropImage(
item: BaseItem?,
modifier: Modifier = Modifier,
) {
val imageUrlService = LocalImageUrlService.current
val backdropImageUrl =
remember(item) {
if (item != null) {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} else {
null
}
}
DelayedDetailsBackdropImage(backdropImageUrl, modifier)
}
/**
* Shows a backdrop image, but with a crossfade & delay
*
* Used for change backdrops when change items frequently
*/
@Composable
fun BoxScope.DelayedDetailsBackdropImage(
focusedBackdropImageUrl: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(focusedBackdropImageUrl) {
backdropImageUrl = null
delay(150)
backdropImageUrl = focusedBackdropImageUrl
}
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model =
ImageRequest
.Builder(context)
.data(backdropImageUrl)
.transitionFactory(CrossFadeFactory(250.milliseconds))
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
modifier
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.alpha(.75f)
.align(Alignment.TopEnd)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .33f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
startX = 0f,
endX = size.width * .5f,
),
)
},
)
}

View file

@ -76,3 +76,44 @@ fun EpisodeQuickDetails(
modifier = modifier,
)
}
@Composable
fun SeriesQuickDetails(
dto: BaseItemDto?,
modifier: Modifier = Modifier,
) {
val details =
remember(dto) {
buildList {
if (dto?.productionYear != null) {
val date =
buildString {
append(dto.productionYear.toString())
if (dto.status == "Continuing") {
append(" - ")
append("Present")
} else if (dto.status == "Ended") {
dto.endDate?.let {
append(" - ")
append(it.year)
}
}
}
add(date)
}
val duration = dto?.runTimeTicks?.ticks
duration
?.roundMinutes
?.toString()
?.let(::add)
dto?.officialRating?.let(::add)
}
}
DotSeparatedRow(
texts = details,
communityRating = dto?.communityRating,
criticRating = dto?.criticRating,
textStyle = MaterialTheme.typography.titleSmall,
modifier = modifier,
)
}

View file

@ -0,0 +1,232 @@
package com.github.damontecres.wholphin.ui.components
import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppChoicePreference
import com.github.damontecres.wholphin.preferences.AppClickablePreference
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppSliderPreference
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.ImageType
@Composable
fun ViewOptionsDialog(
viewOptions: ViewOptions,
onDismissRequest: () -> Unit,
onViewOptionsChange: (ViewOptions) -> Unit,
defaultViewOptions: ViewOptions = ViewOptions(),
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val columnState = rememberLazyListState()
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.END)
window.setDimAmount(0f)
}
LazyColumn(
state = columnState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.width(256.dp)
.heightIn(max = 380.dp)
.focusRequester(focusRequester)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
shape = RoundedCornerShape(8.dp),
),
) {
stickyHeader {
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.titleMedium,
)
}
items(ViewOptions.OPTIONS) { pref ->
pref as AppPreference<ViewOptions, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(viewOptions)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
onClickPreference = { pref ->
if (pref == ViewOptions.ViewOptionsReset) {
onViewOptionsChange.invoke(defaultViewOptions)
}
},
)
}
}
}
}
@Serializable
data class ViewOptions(
val columns: Int = 6,
val spacing: Int = 16,
val contentScale: PrefContentScale = PrefContentScale.FIT,
val aspectRatio: AspectRatio = AspectRatio.TALL,
val showDetails: Boolean = false,
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
) {
companion object {
val ViewOptionsColumns =
AppSliderPreference<ViewOptions>(
title = R.string.columns,
defaultValue = 6,
min = 1,
max = 12,
interval = 1,
getter = { it.columns.toLong() },
setter = { prefs, value -> prefs.copy(columns = value.toInt()) },
)
val ViewOptionsSpacing =
AppSliderPreference<ViewOptions>(
title = R.string.spacing,
defaultValue = 16,
min = 0,
max = 32,
interval = 2,
getter = { it.spacing.toLong() },
setter = { prefs, value -> prefs.copy(spacing = value.toInt()) },
)
val ViewOptionsContentScale =
AppChoicePreference<ViewOptions, PrefContentScale>(
title = R.string.global_content_scale,
defaultValue = PrefContentScale.FIT,
displayValues = R.array.content_scale,
getter = { it.contentScale },
setter = { viewOptions, value -> viewOptions.copy(contentScale = value) },
indexToValue = { PrefContentScale.forNumber(it) },
valueToIndex = { it.number },
)
val ViewOptionsAspectRatio =
AppChoicePreference<ViewOptions, AspectRatio>(
title = R.string.aspect_ratio,
defaultValue = AspectRatio.TALL,
displayValues = R.array.aspect_ratios,
getter = { it.aspectRatio },
setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) },
indexToValue = { AspectRatio.entries[it] },
valueToIndex = { it.ordinal },
)
val ViewOptionsDetailHeader =
AppSwitchPreference<ViewOptions>(
title = R.string.show_details,
defaultValue = false,
getter = { it.showDetails },
setter = { vo, value -> vo.copy(showDetails = value) },
)
val ViewOptionsImageType =
AppChoicePreference<ViewOptions, ViewOptionImageType>(
title = R.string.image_type,
defaultValue = ViewOptionImageType.PRIMARY,
displayValues = R.array.image_types,
getter = { it.imageType },
setter = { viewOptions, value ->
val aspectRatio =
when (value) {
ViewOptionImageType.PRIMARY -> AspectRatio.TALL
ViewOptionImageType.THUMB -> AspectRatio.WIDE
}
viewOptions.copy(imageType = value, aspectRatio = aspectRatio)
},
indexToValue = { ViewOptionImageType.entries[it] },
valueToIndex = { it.ordinal },
)
val ViewOptionsReset =
AppClickablePreference<ViewOptions>(
title = R.string.reset,
)
val OPTIONS =
listOf(
ViewOptionsImageType,
ViewOptionsAspectRatio,
ViewOptionsDetailHeader,
ViewOptionsColumns,
ViewOptionsSpacing,
ViewOptionsContentScale,
ViewOptionsReset,
)
}
}
val ViewOptionsPoster =
ViewOptions(
columns = 6,
spacing = 16,
contentScale = PrefContentScale.FILL,
)
val ViewOptionsWide =
ViewOptions(
columns = 4,
spacing = 24,
contentScale = PrefContentScale.CROP,
aspectRatio = AspectRatio.WIDE,
)
val ViewOptionsSquare =
ViewOptions(
columns = 6,
spacing = 16,
contentScale = PrefContentScale.FILL,
aspectRatio = AspectRatio.SQUARE,
)
enum class ViewOptionImageType(
val imageType: ImageType,
) {
PRIMARY(ImageType.PRIMARY),
THUMB(ImageType.THUMB),
// BANNER(ImageType.BANNER),
}

View file

@ -13,7 +13,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
@ -48,7 +48,7 @@ fun CollectionFolderBoxSet(
positionCallback = { columns, position ->
showHeader = position < columns
},
params = CollectionFolderGridParameters.POSTER,
defaultViewOptions = ViewOptionsPoster,
playEnabled = playEnabled,
)
}

View file

@ -14,7 +14,8 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import org.jellyfin.sdk.model.api.ItemSortBy
@ -34,12 +35,12 @@ fun CollectionFolderGeneric(
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
var showHeader by remember { mutableStateOf(true) }
val params =
val viewOptions =
remember(usePosters) {
if (usePosters) {
CollectionFolderGridParameters.POSTER
ViewOptionsPoster
} else {
CollectionFolderGridParameters.WIDE
ViewOptionsWide
}
}
CollectionFolderGrid(
@ -56,7 +57,7 @@ fun CollectionFolderGeneric(
positionCallback = { columns, position ->
showHeader = position < columns
},
params = params,
defaultViewOptions = viewOptions,
playEnabled = playEnabled,
filterOptions = filterOptions,
)

View file

@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule
import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
@ -170,6 +171,7 @@ fun CollectionFolderLiveTv(
showHeader = position < columns
},
playEnabled = false,
defaultViewOptions = ViewOptions(),
)
} else {
ErrorMessage("Invalid tab index $selectedTabIndex", null)

View file

@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedMovie
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.logTab
@ -118,6 +119,7 @@ fun CollectionFolderMovie(
showTitle = false,
recursive = true,
sortOptions = MovieSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)
@ -144,6 +146,7 @@ fun CollectionFolderMovie(
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)

View file

@ -13,7 +13,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import java.util.UUID
@ -43,7 +43,7 @@ fun CollectionFolderPlaylist(
positionCallback = { columns, position ->
showHeader = position < columns
},
params = CollectionFolderGridParameters.SQUARE,
defaultViewOptions = ViewOptionsSquare,
playEnabled = false,
)
}

View file

@ -12,7 +12,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import java.util.UUID
@ -41,7 +41,7 @@ fun CollectionFolderRecordings(
positionCallback = { columns, position ->
showHeader = position < columns
},
params = CollectionFolderGridParameters.POSTER,
defaultViewOptions = ViewOptionsPoster,
playEnabled = false,
)
}

View file

@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
@ -118,6 +119,7 @@ fun CollectionFolderTv(
showTitle = false,
recursive = true,
sortOptions = SeriesSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)

View file

@ -29,9 +29,11 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
@ -121,6 +123,7 @@ fun FavoritesPage(
showTitle = false,
recursive = true,
sortOptions = MovieSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)
@ -147,6 +150,7 @@ fun FavoritesPage(
showTitle = false,
recursive = true,
sortOptions = SeriesSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)
@ -173,7 +177,7 @@ fun FavoritesPage(
showTitle = false,
recursive = true,
sortOptions = EpisodeSortOptions,
params = CollectionFolderGridParameters.WIDE,
defaultViewOptions = ViewOptionsWide,
useSeriesForPrimary = false,
modifier =
Modifier
@ -201,7 +205,7 @@ fun FavoritesPage(
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
params = CollectionFolderGridParameters.WIDE,
defaultViewOptions = ViewOptionsWide,
modifier =
Modifier
.padding(start = 16.dp)
@ -228,7 +232,7 @@ fun FavoritesPage(
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
params = CollectionFolderGridParameters.SQUARE,
defaultViewOptions = ViewOptionsSquare,
modifier =
Modifier
.padding(start = 16.dp)
@ -260,6 +264,7 @@ fun FavoritesPage(
showTitle = false,
recursive = true,
sortOptions = listOf(),
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)

View file

@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.SlimItemFields
@ -69,6 +70,7 @@ import com.github.damontecres.wholphin.util.RowLoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
@ -185,11 +187,13 @@ fun PersonPage(
person?.let { person ->
var showOverviewDialog by remember { mutableStateOf(false) }
val name = person.name ?: person.id.toString()
val imageUrlService = LocalImageUrlService.current
val imageUrl = remember { imageUrlService.getItemImageUrl(itemId = person.id, imageType = ImageType.PRIMARY) }
PersonPageContent(
preferences = preferences,
name = name,
overview = person.data.overview,
imageUrl = person.imageUrl,
imageUrl = imageUrl,
birthdate = person.data.premiereDate?.toLocalDate(),
deathdate = person.data.endDate?.toLocalDate(),
birthPlace = person.data.productionLocations?.firstOrNull(),

View file

@ -32,16 +32,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -53,12 +48,12 @@ import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -69,7 +64,6 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -224,35 +218,7 @@ fun PlaylistDetailsContent(
Box(
modifier = modifier,
) {
if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = focusedItem.backdropImageUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.85f)
.alpha(.4f)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = 500f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
endX = 400f,
startX = 100f,
),
)
},
)
}
DelayedDetailsBackdropImage(focusedItem)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier =
@ -438,7 +404,7 @@ fun PlaylistItem(
style = MaterialTheme.typography.labelLarge,
)
ItemCardImage(
imageUrl = item?.imageUrl,
item = item,
name = item?.name,
showOverlay = true,
favorite = item?.data?.userData?.isFavorite ?: false,

View file

@ -271,7 +271,6 @@ fun EpisodeDetailsContent(
var position by rememberInt(0)
val focusRequesters = remember { List(1) { FocusRequester() } }
val dto = ep.data
val backdropImageUrl = ep.backdropImageUrl
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -279,7 +278,7 @@ fun EpisodeDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
DetailsBackdropImage(backdropImageUrl)
DetailsBackdropImage(ep)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

@ -371,7 +371,6 @@ fun MovieDetailsContent(
var position by rememberInt(0)
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val dto = movie.data
val backdropImageUrl = movie.backdropImageUrl
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -379,7 +378,7 @@ fun MovieDetailsContent(
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
DetailsBackdropImage(backdropImageUrl)
DetailsBackdropImage(movie)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),

View file

@ -316,7 +316,7 @@ fun SeriesDetailsContent(
Box(
modifier = modifier,
) {
DetailsBackdropImage(series.backdropImageUrl)
DetailsBackdropImage(series)
Column(
modifier =

View file

@ -183,7 +183,7 @@ fun SeriesOverviewContent(
?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard(
name = episode?.name,
imageUrl = episode?.imageUrl,
item = episode,
aspectRatio =
episode
?.data

View file

@ -28,15 +28,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -45,24 +41,22 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DelayedDetailsBackdropImage
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.MovieQuickDetails
import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.RowColumnSaver
@ -79,7 +73,6 @@ import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun HomePage(
@ -242,46 +235,10 @@ fun HomePageContent(
LaunchedEffect(position) {
listState.animateScrollToItem(position.row)
}
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(focusedItem) {
backdropImageUrl = null
delay(150)
backdropImageUrl = focusedItem?.backdropImageUrl
}
Box(modifier = modifier) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model =
ImageRequest
.Builder(context)
.data(backdropImageUrl)
.transitionFactory(CrossFadeFactory(250.milliseconds))
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
modifier =
Modifier
.fillMaxHeight(.7f)
.fillMaxWidth(.7f)
.alpha(.75f)
.align(Alignment.TopEnd)
.drawWithContent {
drawContent()
drawRect(
Brush.verticalGradient(
colors = listOf(Color.Transparent, gradientColor),
startY = size.height * .33f,
),
)
drawRect(
Brush.horizontalGradient(
colors = listOf(gradientColor, Color.Transparent),
startX = 0f,
endX = size.width * .5f,
),
)
},
DelayedDetailsBackdropImage(
item = focusedItem,
modifier = Modifier,
)
Column(modifier = Modifier.fillMaxSize()) {
@ -382,7 +339,7 @@ fun HomePageContent(
cardContent = { index, item, cardModifier, onClick, onLongClick ->
BannerCard(
name = item?.data?.seriesName ?: item?.name,
imageUrl = item?.imageUrl,
item = item,
aspectRatio = AspectRatios.TALL,
cornerText =
item?.data?.indexNumber?.let { "E$it" }
@ -486,10 +443,10 @@ fun HomePageHeader(
overflow = TextOverflow.Ellipsis,
)
}
if (isEpisode) {
EpisodeQuickDetails(dto, Modifier)
} else {
MovieQuickDetails(dto, Modifier)
when (item.type) {
BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier)
BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier)
else -> MovieQuickDetails(dto, Modifier)
}
val overviewModifier =
Modifier

View file

@ -63,7 +63,7 @@ import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.NavigationDrawer
import androidx.tv.material3.ModalNavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope
@ -295,7 +295,8 @@ fun NavDrawer(
}
}
val drawerWidth by animateDpAsState(if (drawerState.isOpen) 260.dp else 40.dp)
val closedDrawerWidth = 40.dp
val drawerWidth by animateDpAsState(if (drawerState.isOpen) 260.dp else closedDrawerWidth)
val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
val drawerBackground by animateColorAsState(
if (drawerState.isOpen) {
@ -329,7 +330,7 @@ fun NavDrawer(
scrollToSelected()
}
NavigationDrawer(
ModalNavigationDrawer(
modifier = modifier,
drawerState = drawerState,
drawerContent = {
@ -518,7 +519,10 @@ fun NavDrawer(
},
) {
Box(
modifier = Modifier.fillMaxSize(),
modifier =
Modifier
.padding(start = closedDrawerWidth)
.fillMaxSize(),
) {
// Drawer content
DestinationContent(

View file

@ -53,6 +53,7 @@ import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard
@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.MediaSegmentDto
import org.jellyfin.sdk.model.api.TrickplayInfo
import java.time.LocalTime
@ -381,14 +383,15 @@ fun PlaybackOverlay(
}
}
}
val logoImageUrl = LocalImageUrlService.current.rememberImageUrl(item, ImageType.LOGO)
AnimatedVisibility(
!showDebugInfo && item?.logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
!showDebugInfo && logoImageUrl.isNotNullOrBlank() && controllerViewState.controlsVisible,
modifier =
Modifier
.align(Alignment.TopStart),
) {
AsyncImage(
model = item?.logoImageUrl,
model = logoImageUrl,
contentDescription = "Logo",
modifier =
Modifier

View file

@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
@ -459,7 +460,7 @@ fun PlaybackPage(
it.name,
).joinToString(" - "),
description = it.data.overview,
imageUrl = it.imageUrl,
imageUrl = LocalImageUrlService.current.rememberImageUrl(it),
aspectRatio =
it.data.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE,

View file

@ -57,12 +57,13 @@ import java.util.SortedSet
@Suppress("UNCHECKED_CAST")
@Composable
fun <T> ComposablePreference(
preference: AppPreference<T>,
preference: AppPreference<*, T>,
value: T?,
onValueChange: (T) -> Unit,
onNavigate: (Destination) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onClickPreference: (AppClickablePreference<*>) -> Unit = {},
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@ -72,13 +73,6 @@ fun <T> ComposablePreference(
val title = stringResource(preference.title)
val onClick: () -> Unit = {
scope.launch(ExceptionHandler()) {
when (preference) {
else -> {}
}
}
}
val onLongClick: () -> Unit = {
scope.launch(ExceptionHandler()) {
when (preference) {
@ -102,7 +96,7 @@ fun <T> ComposablePreference(
is AppClickablePreference ->
ClickPreference(
title = title,
onClick = onClick,
onClick = { onClickPreference.invoke(preference) },
onLongClick = onLongClick,
summary = preference.summary(context, value),
interactionSource = interactionSource,
@ -196,7 +190,7 @@ fun <T> ComposablePreference(
)
}
is AppMultiChoicePreference<*> -> {
is AppMultiChoicePreference<*, *> -> {
val values = stringArrayResource(preference.displayValues).toSortedSet()
val summary =
preference.summary?.let { stringResource(it) }

View file

@ -10,13 +10,13 @@ import kotlinx.serialization.Serializable
*/
data class PreferenceGroup(
@param:StringRes val title: Int,
val preferences: List<AppPreference<out Any?>>,
val preferences: List<AppPreference<AppPreferences, out Any?>>,
val conditionalPreferences: List<ConditionalPreferences> = listOf(),
)
data class ConditionalPreferences(
val condition: (AppPreferences) -> Boolean,
val preferences: List<AppPreference<out Any?>>,
val preferences: List<AppPreference<AppPreferences, out Any?>>,
)
/**

View file

@ -219,7 +219,7 @@ fun PreferencesContent(
.map { it.preferences }
.flatten()
groupPreferences.forEachIndexed { prefIndex, pref ->
pref as AppPreference<Any>
pref as AppPreference<AppPreferences, Any>
item {
val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value

View file

@ -28,7 +28,7 @@ import com.github.damontecres.wholphin.ui.components.SliderBar
@Composable
fun SliderPreference(
preference: AppSliderPreference,
preference: AppSliderPreference<*>,
title: String,
summary: String?,
value: Long,

View file

@ -13,6 +13,7 @@ import androidx.media3.ui.CaptionStyleCompat
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppChoicePreference
import com.github.damontecres.wholphin.preferences.AppClickablePreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppSliderPreference
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.preferences.BackgroundStyle
@ -27,7 +28,7 @@ import timber.log.Timber
object SubtitleSettings {
val FontSize =
AppSliderPreference(
AppSliderPreference<AppPreferences>(
title = R.string.font_size,
defaultValue = 24,
min = 8,
@ -58,7 +59,7 @@ object SubtitleSettings {
)
val FontColor =
AppChoicePreference<Color>(
AppChoicePreference<AppPreferences, Color>(
title = R.string.font_color,
defaultValue = Color.White,
getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) },
@ -74,7 +75,7 @@ object SubtitleSettings {
)
val FontBold =
AppSwitchPreference(
AppSwitchPreference<AppPreferences>(
title = R.string.bold_font,
defaultValue = false,
getter = { it.interfacePreferences.subtitlesPreferences.fontBold },
@ -83,7 +84,7 @@ object SubtitleSettings {
},
)
val FontItalic =
AppSwitchPreference(
AppSwitchPreference<AppPreferences>(
title = R.string.italic_font,
defaultValue = false,
getter = { it.interfacePreferences.subtitlesPreferences.fontItalic },
@ -93,7 +94,7 @@ object SubtitleSettings {
)
val FontOpacity =
AppSliderPreference(
AppSliderPreference<AppPreferences>(
title = R.string.font_opacity,
defaultValue = 100,
min = 10,
@ -110,7 +111,7 @@ object SubtitleSettings {
)
val EdgeStylePref =
AppChoicePreference<EdgeStyle>(
AppChoicePreference<AppPreferences, EdgeStyle>(
title =
R.string.edge_style,
defaultValue = EdgeStyle.EDGE_SOLID,
@ -124,7 +125,7 @@ object SubtitleSettings {
)
val EdgeColor =
AppChoicePreference<Color>(
AppChoicePreference<AppPreferences, Color>(
title = R.string.edge_color,
defaultValue = Color.Black,
getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) },
@ -140,7 +141,7 @@ object SubtitleSettings {
)
val BackgroundColor =
AppChoicePreference<Color>(
AppChoicePreference<AppPreferences, Color>(
title = R.string.background_color,
defaultValue = Color.Transparent,
getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) },
@ -156,7 +157,7 @@ object SubtitleSettings {
)
val BackgroundOpacity =
AppSliderPreference(
AppSliderPreference<AppPreferences>(
title = R.string.background_opacity,
defaultValue = 50,
min = 10,
@ -173,7 +174,7 @@ object SubtitleSettings {
)
val BackgroundStylePref =
AppChoicePreference<BackgroundStyle>(
AppChoicePreference<AppPreferences, BackgroundStyle>(
title =
R.string.background_style,
defaultValue = BackgroundStyle.BG_NONE,
@ -187,7 +188,7 @@ object SubtitleSettings {
)
val Margin =
AppSliderPreference(
AppSliderPreference<AppPreferences>(
title = R.string.subtitle_margin,
defaultValue = 8,
min = 0,
@ -204,7 +205,7 @@ object SubtitleSettings {
)
val Reset =
AppClickablePreference(
AppClickablePreference<AppPreferences>(
title = R.string.reset,
getter = { },
setter = { prefs, _ -> prefs },

View file

@ -82,23 +82,24 @@ private fun ThemeExample(theme: AppThemeColors) {
modifier = Modifier.fillMaxWidth(),
) {
WatchedIcon()
BannerCard(
name = "Card",
imageUrl = null,
onClick = { },
onLongClick = {},
playPercent = .5,
cardHeight = 64.dp,
)
BannerCard(
name = "Card",
imageUrl = null,
onClick = { },
onLongClick = {},
playPercent = .5,
cardHeight = 64.dp,
interactionSource = source,
)
// TODO
// BannerCard(
// name = "Card",
// imageUrl = null,
// onClick = { },
// onLongClick = {},
// playPercent = .5,
// cardHeight = 64.dp,
// )
// BannerCard(
// name = "Card",
// imageUrl = null,
// onClick = { },
// onLongClick = {},
// playPercent = .5,
// cardHeight = 64.dp,
// interactionSource = source,
// )
SeasonCard(
title = "Card",
subtitle = "2025",