mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
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:
parent
b98fb4a1d8
commit
384401a72c
47 changed files with 1658 additions and 628 deletions
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ fun EpisodeCard(
|
|||
.fillMaxSize(),
|
||||
) {
|
||||
ItemCardImage(
|
||||
imageUrl = item?.imageUrl,
|
||||
item = item,
|
||||
name = item?.name,
|
||||
showOverlay = false,
|
||||
favorite = dto?.userData?.isFavorite ?: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -316,7 +316,7 @@ fun SeriesDetailsContent(
|
|||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
DetailsBackdropImage(series.backdropImageUrl)
|
||||
DetailsBackdropImage(series)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ fun SeriesOverviewContent(
|
|||
?: episode?.data?.premiereDate?.let(::formatDateTime)
|
||||
BannerCard(
|
||||
name = episode?.name,
|
||||
imageUrl = episode?.imageUrl,
|
||||
item = episode,
|
||||
aspectRatio =
|
||||
episode
|
||||
?.data
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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?>>,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue