mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add list view for libraries (#1428)
## Description Adds an option to display libraries in a list layout The list can be regular showing 6-7 items or compact showing ~11 items. Can also toggle showing the details on the left side. Movies, tv shows, & albums have type-specific layouts and other types like videos, music videos, etc use a generic one similar to movies. Another change is that showing the backdrop is now a separate toggle from showing details for both grid & list layouts. Sorting, filtering, & jumping to a letter all work as expected! ### Related issues Closes #1101 ### Testing Emulator mostly ## Screenshots <details><summary>Click for screenshots</summary> <p> ### TV shows list <img width="1280" height="720" alt="series_list Large" src="https://github.com/user-attachments/assets/f8f9b801-e537-4cdf-b992-f2f1259457fa" /> ### TV shows compact list <img width="1280" height="720" alt="series_list_compact Large" src="https://github.com/user-attachments/assets/747e5929-90d7-4d59-a179-bc3210ac01b9" /> ### Movie list <img width="1280" height="720" alt="movie_list Large" src="https://github.com/user-attachments/assets/ee3e1c6c-fb25-438a-a11e-522d30fbaabf" /> ### Album list <img width="1280" height="720" alt="music_list Large" src="https://github.com/user-attachments/assets/9b1f59ec-c237-4ab2-9b51-6cb519f5327c" /> </p> </details> ## AI or LLM usage None
This commit is contained in:
parent
8177700e23
commit
982d9e6fe8
18 changed files with 1913 additions and 949 deletions
|
|
@ -27,6 +27,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -227,13 +228,7 @@ fun ItemCardImageOverlay(
|
|||
}
|
||||
}
|
||||
if (favorite) {
|
||||
Text(
|
||||
color = colorResource(android.R.color.holo_red_light),
|
||||
text = stringResource(R.string.fa_heart),
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontAwesome,
|
||||
modifier = Modifier,
|
||||
)
|
||||
FavoriteIndicator()
|
||||
}
|
||||
}
|
||||
Row(
|
||||
|
|
@ -281,3 +276,15 @@ fun ItemCardImageOverlay(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FavoriteIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
fontSize: TextUnit = 20.sp,
|
||||
) = Text(
|
||||
color = colorResource(android.R.color.holo_red_light),
|
||||
text = stringResource(R.string.fa_heart),
|
||||
fontSize = fontSize,
|
||||
fontFamily = FontAwesome,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Check
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
|
|
@ -19,7 +20,10 @@ import com.github.damontecres.wholphin.ui.theme.LocalTheme
|
|||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
|
||||
@Composable
|
||||
fun WatchedIcon(modifier: Modifier = Modifier) {
|
||||
fun WatchedIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
padding: Dp = 2.dp,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
|
|
@ -28,7 +32,7 @@ fun WatchedIcon(modifier: Modifier = Modifier) {
|
|||
modifier
|
||||
.background(WatchedIconBackground(), shape = CircleShape)
|
||||
.border(.5.dp, Color.Black, CircleShape)
|
||||
.padding(2.dp),
|
||||
.padding(padding),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,681 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.clip
|
||||
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.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ListItemDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.useExistingImageAsPlaceholder
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
||||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||
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.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
|
||||
import com.github.damontecres.wholphin.ui.cards.WatchedIcon
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
|
||||
import com.github.damontecres.wholphin.ui.enableMarquee
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.roundMinutes
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderList(
|
||||
preferences: UserPreferences,
|
||||
item: BaseItem?,
|
||||
title: String,
|
||||
loadingState: DataLoadingState<List<BaseItem?>>,
|
||||
sortAndDirection: SortAndDirection,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
onSortChange: (SortAndDirection) -> Unit,
|
||||
letterPosition: suspend (Char) -> Int,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
|
||||
viewOptions: ViewOptions,
|
||||
onClickPlayAll: (shuffle: Boolean) -> Unit,
|
||||
onClickPlay: (Int, BaseItem) -> Unit,
|
||||
onChangeBackdrop: (BaseItem) -> Unit,
|
||||
onClickShowViewOptions: () -> Unit,
|
||||
initialPosition: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
currentFilter: GetItemsFilter = GetItemsFilter(),
|
||||
filterOptions: List<ItemFilterBy<*>> = listOf(),
|
||||
onFilterChange: (GetItemsFilter) -> Unit = {},
|
||||
focusRequesterOnEmpty: FocusRequester? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pager = (loadingState as? DataLoadingState.Success)?.data
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
val headerRowFocusRequester = remember { FocusRequester() }
|
||||
val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME
|
||||
|
||||
val gridFocusRequester = remember { FocusRequester() }
|
||||
if (pager?.isNotEmpty() == true) {
|
||||
RequestOrRestoreFocus(gridFocusRequester)
|
||||
} else {
|
||||
LaunchedEffect(Unit) {
|
||||
(focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus()
|
||||
}
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
var contentHashFocus by remember { mutableStateOf(false) }
|
||||
var position by rememberInt(initialPosition)
|
||||
val positionFocusRequester = remember { FocusRequester() }
|
||||
val focusedItem = pager?.getOrNull(position)
|
||||
if (viewOptions.showBackdrop) {
|
||||
LaunchedEffect(focusedItem) {
|
||||
focusedItem?.let(onChangeBackdrop)
|
||||
}
|
||||
}
|
||||
|
||||
fun jumpTo(newPosition: Int) {
|
||||
scope.launch {
|
||||
position = newPosition
|
||||
listState.animateScrollToItem(newPosition)
|
||||
positionFocusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(contentHashFocus && position > 0) {
|
||||
jumpTo(0)
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
CollectionFolderHeader(
|
||||
showHeader = showHeader || loadingState !is DataLoadingState.Success,
|
||||
showTitle = showTitle,
|
||||
playEnabled = playEnabled && pager?.isNotEmpty() == true,
|
||||
title = title,
|
||||
sortAndDirection = sortAndDirection,
|
||||
onSortChange = onSortChange,
|
||||
sortOptions = sortOptions,
|
||||
getPossibleFilterValues = getPossibleFilterValues,
|
||||
onClickShowViewOptions = onClickShowViewOptions,
|
||||
onClickPlayAll = onClickPlayAll,
|
||||
currentFilter = currentFilter,
|
||||
filterOptions = filterOptions,
|
||||
onFilterChange = onFilterChange,
|
||||
modifier = Modifier.focusRequester(headerRowFocusRequester),
|
||||
)
|
||||
when (val state = loadingState) {
|
||||
DataLoadingState.Pending,
|
||||
DataLoadingState.Loading,
|
||||
-> {
|
||||
// This shouldn't happen, so just show placeholder
|
||||
Text(stringResource(R.string.loading))
|
||||
}
|
||||
|
||||
is DataLoadingState.Error -> {
|
||||
ErrorMessage(state.message, state.exception)
|
||||
}
|
||||
|
||||
is DataLoadingState.Success<List<BaseItem?>> -> {
|
||||
val pager = state.data
|
||||
val topPadding by animateDpAsState(
|
||||
targetValue =
|
||||
when {
|
||||
showHeader -> 8.dp
|
||||
|
||||
// showClock-> TODO()
|
||||
else -> 48.dp
|
||||
},
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = topPadding, bottom = 16.dp)
|
||||
.onFocusChanged {
|
||||
contentHashFocus = it.hasFocus
|
||||
},
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = viewOptions.showDetails,
|
||||
enter = fadeIn() + expandHorizontally(expandFrom = Alignment.Start),
|
||||
exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.Start),
|
||||
) {
|
||||
CollectionFolderListDetails(
|
||||
item = focusedItem,
|
||||
showLogo = true,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(.33f)
|
||||
.fillMaxHeight()
|
||||
.background(
|
||||
color = Color.Transparent, // MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
).padding(8.dp),
|
||||
)
|
||||
}
|
||||
CollectionFolderContent(
|
||||
items = state.data,
|
||||
viewOptions = viewOptions,
|
||||
collectionType = item?.data?.collectionType,
|
||||
listState = listState,
|
||||
position = position,
|
||||
positionFocusRequester = positionFocusRequester,
|
||||
onPositionChange = {
|
||||
showHeader = it < 1
|
||||
positionCallback?.invoke(1, it)
|
||||
position = it
|
||||
},
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = onLongClickItem,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.focusRequester(gridFocusRequester),
|
||||
)
|
||||
val letters = stringResource(R.string.jump_letters)
|
||||
// Letters
|
||||
val currentLetter =
|
||||
remember(focusedItem) {
|
||||
focusedItem
|
||||
?.sortName
|
||||
?.firstOrNull()
|
||||
?.uppercaseChar()
|
||||
?.let {
|
||||
when (it) {
|
||||
in '0'..'9' -> '#'
|
||||
in 'A'..'Z' -> it
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
?: letters[0]
|
||||
}
|
||||
if (showLetterButtons && pager.isNotEmpty()) {
|
||||
AlphabetButtons(
|
||||
letters = letters,
|
||||
currentLetter = currentLetter,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(start = 16.dp),
|
||||
letterClicked = {
|
||||
scope.launchIO {
|
||||
val position = letterPosition.invoke(it)
|
||||
jumpTo(position)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun CollectionFolderListDetails(
|
||||
item: BaseItem?,
|
||||
showLogo: Boolean,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val imageUrl =
|
||||
remember(item) {
|
||||
item?.imageUrlOverride ?: imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = true)
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier,
|
||||
) {
|
||||
SimpleTitleOrLogo(
|
||||
item,
|
||||
showLogo,
|
||||
Modifier
|
||||
.height(HeaderUtils.logoHeight)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalContext.current)
|
||||
.data(imageUrl)
|
||||
.crossfade(300)
|
||||
.useExistingImageAsPlaceholder(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(shape = RoundedCornerShape(8.dp))
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
item?.let {
|
||||
QuickDetails(item.ui.quickDetails, null)
|
||||
}
|
||||
|
||||
OverviewText(
|
||||
overview = item?.data?.overview ?: "",
|
||||
maxLines = 5,
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
modifier = Modifier,
|
||||
)
|
||||
|
||||
// index?
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderContent(
|
||||
items: List<BaseItem?>,
|
||||
viewOptions: ViewOptions,
|
||||
collectionType: CollectionType?,
|
||||
listState: LazyListState,
|
||||
position: Int,
|
||||
positionFocusRequester: FocusRequester,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
onPositionChange: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp),
|
||||
modifier = modifier.focusRestorer(positionFocusRequester),
|
||||
) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
val onClick: () -> Unit =
|
||||
remember(index, item) {
|
||||
{ item?.let { onClickItem.invoke(index, item) } }
|
||||
}
|
||||
val onLongClick: () -> Unit =
|
||||
remember(index, item) {
|
||||
{ item?.let { onLongClickItem.invoke(index, item) } }
|
||||
}
|
||||
CollectionFolderListItem(
|
||||
item = item,
|
||||
dense = viewOptions.type == ViewOptionsType.DENSE_LIST,
|
||||
collectionType = collectionType,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) onPositionChange.invoke(index)
|
||||
}.ifElse(index == position, Modifier.focusRequester(positionFocusRequester)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderListItem(
|
||||
item: BaseItem?,
|
||||
dense: Boolean,
|
||||
collectionType: CollectionType?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (collectionType) {
|
||||
CollectionType.MOVIES -> ListItemMovie(dense, item, onClick, onLongClick, modifier)
|
||||
CollectionType.TVSHOWS -> ListItemSeries(dense, item, onClick, onLongClick, modifier)
|
||||
CollectionType.MUSIC -> ListItemAlbum(dense, item, onClick, onLongClick, modifier)
|
||||
else -> ListItemGeneric(dense, item, onClick, onLongClick, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListItemGeneric(
|
||||
dense: Boolean,
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
ListItemWrapper(
|
||||
dense = dense,
|
||||
selected = false,
|
||||
enabled = true,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
leadingContent = {
|
||||
if (item?.favorite == true) {
|
||||
FavoriteIndicator()
|
||||
}
|
||||
},
|
||||
headlineContent = { TitleContent(item?.title, interactionSource) },
|
||||
supportingContent =
|
||||
item?.subtitle?.let { subtitle ->
|
||||
{
|
||||
Text(
|
||||
text = subtitle,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Text(
|
||||
text =
|
||||
item
|
||||
?.data
|
||||
?.runTimeTicks
|
||||
?.ticks
|
||||
?.roundMinutes
|
||||
?.toString() ?: "",
|
||||
)
|
||||
},
|
||||
scale = scale,
|
||||
colors = listItemColors(),
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListItemMovie(
|
||||
dense: Boolean,
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val title =
|
||||
remember(item, dense) {
|
||||
if (dense && item != null && item.title != null && item.data.productionYear != null) {
|
||||
"${item.title} (${ item.data.productionYear})"
|
||||
} else {
|
||||
item?.title ?: ""
|
||||
}
|
||||
}
|
||||
val supportingContent =
|
||||
remember(item, dense) {
|
||||
if (!dense) {
|
||||
@Composable {
|
||||
Text(item?.subtitle ?: "")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
ListItemWrapper(
|
||||
dense = dense,
|
||||
selected = false,
|
||||
enabled = true,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
leadingContent = {
|
||||
if (item?.favorite == true) {
|
||||
FavoriteIndicator()
|
||||
}
|
||||
},
|
||||
headlineContent = { TitleContent(title, interactionSource) },
|
||||
supportingContent = supportingContent,
|
||||
trailingContent = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
item
|
||||
?.data
|
||||
?.runTimeTicks
|
||||
?.ticks
|
||||
?.roundMinutes
|
||||
?.toString() ?: "",
|
||||
)
|
||||
if (item?.played == true) {
|
||||
WatchedIcon(padding = 0.dp)
|
||||
}
|
||||
}
|
||||
},
|
||||
scale = scale,
|
||||
colors = listItemColors(),
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListItemSeries(
|
||||
dense: Boolean,
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val title =
|
||||
remember(item, dense) {
|
||||
if (item != null) {
|
||||
if (dense) {
|
||||
"${item.title} (${item.subtitle})"
|
||||
} else {
|
||||
item.title ?: ""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
val supportingContent =
|
||||
remember(item, dense) {
|
||||
if (!dense) {
|
||||
@Composable {
|
||||
Text(item?.subtitle ?: "")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val trailingContent =
|
||||
remember(item) {
|
||||
val unplayedItemCount = item?.data?.userData?.unplayedItemCount
|
||||
if (unplayedItemCount != null && unplayedItemCount > 0) {
|
||||
@Composable { Text(unplayedItemCount.toString()) }
|
||||
} else if (item?.played == true) {
|
||||
@Composable { WatchedIcon(padding = 0.dp) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
ListItemWrapper(
|
||||
dense = dense,
|
||||
selected = false,
|
||||
enabled = true,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
leadingContent = {
|
||||
if (item?.favorite == true) {
|
||||
FavoriteIndicator()
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
TitleContent(title, interactionSource)
|
||||
},
|
||||
supportingContent = supportingContent,
|
||||
trailingContent = trailingContent,
|
||||
scale = scale,
|
||||
colors = listItemColors(),
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListItemAlbum(
|
||||
dense: Boolean,
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val title =
|
||||
remember(item, dense) {
|
||||
if (dense && item != null && item.title != null && item.data.productionYear != null) {
|
||||
"${item.title} (${item.data.productionYear})"
|
||||
} else {
|
||||
item?.title ?: ""
|
||||
}
|
||||
}
|
||||
val supportingContent =
|
||||
remember(item, dense) {
|
||||
if (!dense && item?.subtitle != null) {
|
||||
@Composable {
|
||||
Text(item.subtitle ?: "")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val overlineContent =
|
||||
remember(item, dense) {
|
||||
if (!dense && item?.data?.albumArtist != null) {
|
||||
@Composable {
|
||||
Text(item.data.albumArtist ?: "")
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val trailingContent =
|
||||
remember(item) {
|
||||
if (item?.type == BaseItemKind.MUSIC_ALBUM) {
|
||||
@Composable {
|
||||
Text(
|
||||
text =
|
||||
item
|
||||
.data
|
||||
.runTimeTicks
|
||||
?.ticks
|
||||
?.roundMinutes
|
||||
?.toString() ?: "",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
ListItemWrapper(
|
||||
dense = dense,
|
||||
selected = false,
|
||||
enabled = true,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
leadingContent = {
|
||||
if (item?.favorite == true) {
|
||||
FavoriteIndicator()
|
||||
}
|
||||
},
|
||||
headlineContent = { TitleContent(title, interactionSource) },
|
||||
supportingContent = supportingContent,
|
||||
trailingContent = trailingContent,
|
||||
overlineContent = overlineContent,
|
||||
scale = scale,
|
||||
colors = listItemColors(),
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleContent(
|
||||
title: String?,
|
||||
interactionSource: MutableInteractionSource,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||
|
||||
val hideOverlayDelay = 500L
|
||||
if (focused) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(hideOverlayDelay)
|
||||
if (focused) {
|
||||
focusedAfterDelay = true
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
Text(
|
||||
text = title ?: "",
|
||||
modifier = modifier.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun listItemColors() =
|
||||
ListItemDefaults.colors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme
|
||||
.surfaceColorAtElevation(1.dp)
|
||||
.copy(alpha = .5f),
|
||||
)
|
||||
|
||||
private val scale = ListItemDefaults.scale(focusedScale = 1f, pressedScale = .95f)
|
||||
|
|
@ -0,0 +1,899 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
||||
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
||||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.music.addToQueue
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class)
|
||||
class CollectionFolderViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
api: ApiClient,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
val serverRepository: ServerRepository,
|
||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
private val musicService: MusicService,
|
||||
val streamChoiceService: StreamChoiceService,
|
||||
val mediaReportService: MediaReportService,
|
||||
@Assisted itemId: String,
|
||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||
@Assisted("recursive") private val recursive: Boolean,
|
||||
@Assisted private val collectionFilter: CollectionFolderFilter,
|
||||
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
|
||||
@Assisted defaultViewOptions: ViewOptions,
|
||||
) : ItemViewModel(api) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
itemId: String,
|
||||
initialSortAndDirection: SortAndDirection?,
|
||||
@Assisted("recursive") recursive: Boolean,
|
||||
collectionFilter: CollectionFolderFilter,
|
||||
@Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
): CollectionFolderViewModel
|
||||
}
|
||||
|
||||
val loading = MutableLiveData<DataLoadingState<List<BaseItem?>>>(DataLoadingState.Loading)
|
||||
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
val sortAndDirection = MutableLiveData<SortAndDirection>()
|
||||
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
|
||||
val viewOptions = MutableStateFlow<ViewOptions>(defaultViewOptions)
|
||||
|
||||
var position: Int
|
||||
get() = savedStateHandle.get<Int>("position") ?: 0
|
||||
set(value) {
|
||||
savedStateHandle["position"] = value
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
super.itemId = itemId
|
||||
try {
|
||||
val item =
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
fetchItem(it)
|
||||
}
|
||||
|
||||
val libraryDisplayInfo =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
libraryDisplayInfoDao.getItem(user, itemId)
|
||||
}
|
||||
this@CollectionFolderViewModel.viewOptions.value =
|
||||
libraryDisplayInfo?.viewOptions ?: defaultViewOptions
|
||||
|
||||
val sortAndDirection =
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||
libraryDisplayInfo?.sortAndDirection
|
||||
} else {
|
||||
null
|
||||
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
|
||||
|
||||
val filterToUse =
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) {
|
||||
collectionFilter.filter.merge(libraryDisplayInfo.filter)
|
||||
} else {
|
||||
collectionFilter.filter
|
||||
}
|
||||
|
||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||
.join()
|
||||
// onResumePage()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during init")
|
||||
loading.setValueOnMain(DataLoadingState.Error(ex))
|
||||
}
|
||||
}
|
||||
mediaManagementService.deletedItemFlow
|
||||
.onEach { deletedItem ->
|
||||
refreshAfterDelete(position, deletedItem.item)
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error refreshing after deleted item")
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun refreshAfterDelete(
|
||||
position: Int,
|
||||
deletedItem: BaseItem,
|
||||
) {
|
||||
try {
|
||||
val pager =
|
||||
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
||||
position.let {
|
||||
Timber.v("Item deleted: position=%s, id=%s", it, itemId)
|
||||
val item = pager?.get(it)
|
||||
// Exact item deleted (eg a movie) or deleted item was within the series
|
||||
if (item?.id == deletedItem.id ||
|
||||
equalsNotNull(item?.data?.id, deletedItem.data.seriesId)
|
||||
) {
|
||||
pager?.refreshPagesAfter(position)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error refreshing after deleted item %s", itemId)
|
||||
showToast(context, "Error refreshing after item deleted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLibraryDisplayInfo(
|
||||
newFilter: GetItemsFilter = this.filter.value!!,
|
||||
newSort: SortAndDirection = this.sortAndDirection.value!!,
|
||||
viewOptions: ViewOptions? = this.viewOptions.value,
|
||||
) {
|
||||
if (collectionFilter.useSavedLibraryDisplayInfo) {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
viewModelScope.launchIO {
|
||||
val libraryDisplayInfo =
|
||||
LibraryDisplayInfo(
|
||||
userId = user.rowId,
|
||||
itemId = itemId,
|
||||
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)
|
||||
if (!viewOptions.showBackdrop) {
|
||||
backdropService.clearBackdrop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onFilterChange(
|
||||
newFilter: GetItemsFilter,
|
||||
recursive: Boolean,
|
||||
) {
|
||||
Timber.v("onFilterChange: filter=%s", newFilter)
|
||||
saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!)
|
||||
loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary)
|
||||
}
|
||||
|
||||
fun onSortChange(
|
||||
sortAndDirection: SortAndDirection,
|
||||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
) {
|
||||
Timber.v(
|
||||
"onSortChange: sort=%s, recursive=%s, filter=%s",
|
||||
sortAndDirection,
|
||||
recursive,
|
||||
filter,
|
||||
)
|
||||
saveLibraryDisplayInfo(filter, sortAndDirection)
|
||||
loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary)
|
||||
}
|
||||
|
||||
private fun loadResults(
|
||||
resetState: Boolean,
|
||||
sortAndDirection: SortAndDirection,
|
||||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
useSeriesForPrimary: Boolean,
|
||||
) = viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (resetState) {
|
||||
loading.value = DataLoadingState.Loading
|
||||
}
|
||||
backgroundLoading.value = LoadingState.Loading
|
||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||
this@CollectionFolderViewModel.filter.value = filter
|
||||
}
|
||||
try {
|
||||
val newPager =
|
||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Success(newPager)
|
||||
backgroundLoading.value = LoadingState.Success
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(
|
||||
ex,
|
||||
"Exception while loading data: sort=%s, filter=%s",
|
||||
sortAndDirection,
|
||||
filter,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Error(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPager(
|
||||
sortAndDirection: SortAndDirection,
|
||||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
useSeriesForPrimary: Boolean,
|
||||
): ApiRequestPager<out Any> =
|
||||
when (filter.override) {
|
||||
GetItemsFilterOverride.NONE -> {
|
||||
val request =
|
||||
createGetItemsRequest(
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = recursive,
|
||||
filter = filter,
|
||||
)
|
||||
val newPager =
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetItemsRequestHandler,
|
||||
viewModelScope,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
newPager
|
||||
}
|
||||
|
||||
GetItemsFilterOverride.PERSON -> {
|
||||
val request =
|
||||
filter.applyTo(
|
||||
GetPersonsRequest(
|
||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
||||
),
|
||||
)
|
||||
val newPager =
|
||||
ApiRequestPager(
|
||||
api,
|
||||
request,
|
||||
GetPersonsHandler,
|
||||
viewModelScope,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
newPager
|
||||
}
|
||||
}
|
||||
|
||||
private fun createGetItemsRequest(
|
||||
sortAndDirection: SortAndDirection,
|
||||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
): GetItemsRequest {
|
||||
val item = item.value
|
||||
val includeItemTypes =
|
||||
item
|
||||
?.data
|
||||
?.collectionType
|
||||
?.baseItemKinds
|
||||
.orEmpty()
|
||||
val request =
|
||||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
parentId = item?.id,
|
||||
enableImageTypes =
|
||||
listOf(
|
||||
ImageType.PRIMARY,
|
||||
ImageType.THUMB,
|
||||
ImageType.BACKDROP,
|
||||
ImageType.LOGO,
|
||||
),
|
||||
includeItemTypes = includeItemTypes,
|
||||
recursive = recursive,
|
||||
excludeItemIds = item?.let { listOf(item.id) },
|
||||
sortBy =
|
||||
buildList {
|
||||
if (sortAndDirection.sort != ItemSortBy.DEFAULT) {
|
||||
add(sortAndDirection.sort)
|
||||
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
|
||||
add(ItemSortBy.SORT_NAME)
|
||||
}
|
||||
if (item?.data?.collectionType == CollectionType.MOVIES) {
|
||||
add(ItemSortBy.PRODUCTION_YEAR)
|
||||
}
|
||||
}
|
||||
},
|
||||
sortOrder =
|
||||
buildList {
|
||||
if (sortAndDirection.sort != ItemSortBy.DEFAULT) {
|
||||
add(sortAndDirection.direction)
|
||||
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
|
||||
add(SortOrder.ASCENDING)
|
||||
}
|
||||
if (item?.data?.collectionType == CollectionType.MOVIES) {
|
||||
add(SortOrder.ASCENDING)
|
||||
}
|
||||
}
|
||||
},
|
||||
fields = SlimItemFields,
|
||||
),
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||
FilterUtils.getFilterOptionValues(
|
||||
api,
|
||||
serverRepository.currentUser.value?.id,
|
||||
itemUuid,
|
||||
filterOption,
|
||||
)
|
||||
|
||||
suspend fun positionOfLetter(letter: Char): Int? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val sort = sortAndDirection.value
|
||||
val filter = filter.value
|
||||
if (sort == null || filter == null) {
|
||||
return@withContext null
|
||||
}
|
||||
val request =
|
||||
createGetItemsRequest(
|
||||
sortAndDirection = sort,
|
||||
recursive = recursive,
|
||||
filter = filter,
|
||||
).copy(
|
||||
enableImageTypes = null,
|
||||
fields = null,
|
||||
nameLessThan = letter.toString(),
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
)
|
||||
val result by GetItemsRequestHandler.execute(api, request)
|
||||
result.totalRecordCount
|
||||
}
|
||||
|
||||
fun setWatched(
|
||||
position: Int,
|
||||
itemId: UUID,
|
||||
played: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setWatched(itemId, played)
|
||||
(loading.value as? DataLoadingState.Success)?.let {
|
||||
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||
}
|
||||
}
|
||||
|
||||
fun setFavorite(
|
||||
position: Int,
|
||||
itemId: UUID,
|
||||
favorite: Boolean,
|
||||
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
favoriteWatchManager.setFavorite(itemId, favorite)
|
||||
(loading.value as? DataLoadingState.Success)?.let {
|
||||
(it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBackdrop(item: BaseItem) {
|
||||
viewModelScope.launchIO {
|
||||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Destination) {
|
||||
release()
|
||||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
themeSongPlayer.stop()
|
||||
}
|
||||
|
||||
fun onResumePage() {
|
||||
viewModelScope.launchIO {
|
||||
item.value?.let {
|
||||
Timber.v("onResumePage: %s", loading.value!!::class)
|
||||
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
|
||||
themeSongPlayer.playThemeFor(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(
|
||||
index: Int,
|
||||
item: BaseItem,
|
||||
) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
viewModelScope.launchDefault {
|
||||
refreshAfterDelete(index, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
|
||||
fun addToQueue(
|
||||
item: BaseItem,
|
||||
index: Int,
|
||||
) = addToQueue(api, musicService, item, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a collection folder as a grid
|
||||
*
|
||||
* This is the "Library" tab for Movies or TV shows
|
||||
*/
|
||||
@Composable
|
||||
fun CollectionFolderView(
|
||||
preferences: UserPreferences,
|
||||
itemId: UUID,
|
||||
initialFilter: CollectionFolderFilter,
|
||||
recursive: Boolean,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModelKey: String? = itemId.toServerString(),
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||
focusRequesterOnEmpty: FocusRequester? = null,
|
||||
) = CollectionFolderView(
|
||||
preferences,
|
||||
itemId.toServerString(),
|
||||
initialFilter,
|
||||
recursive,
|
||||
GridClickActions(onClickItem = onClickItem),
|
||||
sortOptions,
|
||||
playEnabled,
|
||||
viewModelKey = viewModelKey,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
modifier = modifier,
|
||||
initialSortAndDirection = initialSortAndDirection,
|
||||
showTitle = showTitle,
|
||||
positionCallback = positionCallback,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
filterOptions = filterOptions,
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderView(
|
||||
preferences: UserPreferences,
|
||||
itemId: UUID,
|
||||
initialFilter: CollectionFolderFilter,
|
||||
recursive: Boolean,
|
||||
actions: GridClickActions,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModelKey: String? = itemId.toServerString(),
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||
focusRequesterOnEmpty: FocusRequester? = null,
|
||||
) = CollectionFolderView(
|
||||
preferences,
|
||||
itemId.toServerString(),
|
||||
initialFilter,
|
||||
recursive,
|
||||
actions,
|
||||
sortOptions,
|
||||
playEnabled,
|
||||
viewModelKey = viewModelKey,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
modifier = modifier,
|
||||
initialSortAndDirection = initialSortAndDirection,
|
||||
showTitle = showTitle,
|
||||
positionCallback = positionCallback,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
filterOptions = filterOptions,
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderView(
|
||||
preferences: UserPreferences,
|
||||
itemId: String,
|
||||
initialFilter: CollectionFolderFilter,
|
||||
recursive: Boolean,
|
||||
actions: GridClickActions,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModelKey: String? = itemId,
|
||||
initialSortAndDirection: SortAndDirection? = null,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||
focusRequesterOnEmpty: FocusRequester? = null,
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
viewModel: CollectionFolderViewModel =
|
||||
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
|
||||
key = viewModelKey,
|
||||
) {
|
||||
it.create(
|
||||
itemId = itemId,
|
||||
initialSortAndDirection = initialSortAndDirection,
|
||||
recursive = recursive,
|
||||
collectionFilter = initialFilter,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
)
|
||||
},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
|
||||
val filter by viewModel.filter.observeAsState(initialFilter.filter)
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
|
||||
val item by viewModel.item.observeAsState()
|
||||
val viewOptions by viewModel.viewOptions.collectAsState()
|
||||
|
||||
var showContextMenu by remember { mutableStateOf<ContextMenu?>(null) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var showViewOptions by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val contextActions =
|
||||
remember {
|
||||
ContextMenuActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(viewModel.position, itemId, watched)
|
||||
},
|
||||
onClickFavorite = { itemId, favorite ->
|
||||
viewModel.setFavorite(viewModel.position, itemId, favorite)
|
||||
},
|
||||
onClickAddPlaylist = { itemId ->
|
||||
playlistViewModel.loadPlaylists(MediaType.VIDEO)
|
||||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onDeleteItem = { viewModel.deleteItem(viewModel.position, it) },
|
||||
onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) },
|
||||
onChooseVersion = { _, _ ->
|
||||
// Not supported on this page
|
||||
},
|
||||
onChooseTracks = { result ->
|
||||
// Not supported on this page
|
||||
},
|
||||
onClearChosenStreams = {
|
||||
// Not supported on this page
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val gridActions =
|
||||
remember(actions) {
|
||||
GridClickActions(
|
||||
onClickItem = actions.onClickItem,
|
||||
onLongClickItem =
|
||||
actions.onLongClickItem ?: { position, item ->
|
||||
showContextMenu =
|
||||
ContextMenu.ForBaseItem(
|
||||
fromLongClick = true,
|
||||
item = item,
|
||||
chosenStreams = null,
|
||||
showGoTo = true,
|
||||
showStreamChoices = false,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
canRemoveContinueWatching = false,
|
||||
canRemoveNextUp = false,
|
||||
actions = contextActions,
|
||||
)
|
||||
},
|
||||
onClickPlayAll =
|
||||
actions.onClickPlayAll ?: { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
val destination =
|
||||
if (item?.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
Destination.Slideshow(
|
||||
parentId = it,
|
||||
index = 0,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
} else {
|
||||
Destination.PlaybackList(
|
||||
itemId = it,
|
||||
startIndex = 0,
|
||||
shuffle = shuffle,
|
||||
recursive = recursive,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
}
|
||||
Unit
|
||||
},
|
||||
onClickPlayRemoteButton =
|
||||
actions.onClickPlayRemoteButton ?: { index, item ->
|
||||
val destination =
|
||||
if (item.type == BaseItemKind.PHOTO_ALBUM) {
|
||||
Destination.Slideshow(
|
||||
parentId = item.id,
|
||||
index = index,
|
||||
filter = CollectionFolderFilter(filter = filter),
|
||||
sortAndDirection = sortAndDirection,
|
||||
recursive = true,
|
||||
startSlideshow = true,
|
||||
)
|
||||
} else {
|
||||
Destination.Playback(item)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
when (val state = loading) {
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> {
|
||||
LoadingPage(modifier)
|
||||
}
|
||||
|
||||
is DataLoadingState.Error,
|
||||
is DataLoadingState.Success<*>,
|
||||
-> {
|
||||
val title =
|
||||
initialFilter.nameOverride
|
||||
?: item?.name
|
||||
?: item?.data?.collectionType?.name
|
||||
?: stringResource(R.string.collection)
|
||||
Box(modifier = modifier) {
|
||||
LifecycleResumeEffect(itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
if (viewOptions.type == ViewOptionsType.GRID) {
|
||||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
initialPosition = viewModel.position,
|
||||
item = item,
|
||||
title = title,
|
||||
loadingState = state as DataLoadingState<List<BaseItem?>>,
|
||||
sortAndDirection = sortAndDirection!!,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
onClickItem = gridActions.onClickItem,
|
||||
onLongClickItem = gridActions.onLongClickItem!!,
|
||||
onSortChange = {
|
||||
viewModel.onSortChange(it, recursive, filter)
|
||||
},
|
||||
filterOptions = filterOptions,
|
||||
currentFilter = filter,
|
||||
onFilterChange = {
|
||||
viewModel.onFilterChange(it, recursive)
|
||||
},
|
||||
getPossibleFilterValues = {
|
||||
viewModel.getFilterOptionValues(it)
|
||||
},
|
||||
showTitle = showTitle,
|
||||
sortOptions = sortOptions,
|
||||
positionCallback = { columns, position ->
|
||||
viewModel.position = position
|
||||
positionCallback?.invoke(columns, position)
|
||||
},
|
||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = viewOptions,
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||
onClickPlayAll = gridActions.onClickPlayAll!!,
|
||||
onClickShowViewOptions = { showViewOptions = true },
|
||||
)
|
||||
} else {
|
||||
CollectionFolderList(
|
||||
preferences = preferences,
|
||||
initialPosition = viewModel.position,
|
||||
item = item,
|
||||
title = title,
|
||||
loadingState = state as DataLoadingState<List<BaseItem?>>,
|
||||
sortAndDirection = sortAndDirection!!,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
onClickItem = gridActions.onClickItem,
|
||||
onLongClickItem = gridActions.onLongClickItem!!,
|
||||
onSortChange = {
|
||||
viewModel.onSortChange(it, recursive, filter)
|
||||
},
|
||||
filterOptions = filterOptions,
|
||||
currentFilter = filter,
|
||||
onFilterChange = {
|
||||
viewModel.onFilterChange(it, recursive)
|
||||
},
|
||||
getPossibleFilterValues = {
|
||||
viewModel.getFilterOptionValues(it)
|
||||
},
|
||||
showTitle = showTitle,
|
||||
sortOptions = sortOptions,
|
||||
positionCallback = { columns, position ->
|
||||
viewModel.position = position
|
||||
positionCallback?.invoke(columns, position)
|
||||
},
|
||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = viewOptions,
|
||||
onChangeBackdrop = viewModel::updateBackdrop,
|
||||
playEnabled = playEnabled,
|
||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||
onClickPlayAll = gridActions.onClickPlayAll!!,
|
||||
onClickShowViewOptions = { showViewOptions = true },
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
backgroundLoading == LoadingState.Loading,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
CircularProgress(
|
||||
Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = .25f),
|
||||
shape = CircleShape,
|
||||
).size(64.dp)
|
||||
.padding(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
overviewDialog?.let { info ->
|
||||
ItemDetailsDialog(
|
||||
info = info,
|
||||
showFilePath =
|
||||
viewModel.serverRepository.currentUserDto.value
|
||||
?.policy
|
||||
?.isAdministrator == true,
|
||||
onDismissRequest = { overviewDialog = null },
|
||||
)
|
||||
}
|
||||
showContextMenu?.let { contextMenu ->
|
||||
ContextMenuDialog(
|
||||
onDismissRequest = { showContextMenu = null },
|
||||
getMediaSource = null,
|
||||
contextMenu = contextMenu,
|
||||
preferredSubtitleLanguage = null,
|
||||
)
|
||||
}
|
||||
showPlaylistDialog.compose { itemId ->
|
||||
PlaylistDialog(
|
||||
title = stringResource(R.string.add_to_playlist),
|
||||
state = playlistState,
|
||||
onDismissRequest = { showPlaylistDialog.makeAbsent() },
|
||||
onClick = {
|
||||
playlistViewModel.addToPlaylist(it.id, itemId)
|
||||
showPlaylistDialog.makeAbsent()
|
||||
},
|
||||
createEnabled = true,
|
||||
onCreatePlaylist = {
|
||||
playlistViewModel.createPlaylistAndAddItem(it, itemId)
|
||||
showPlaylistDialog.makeAbsent()
|
||||
},
|
||||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(showViewOptions) {
|
||||
ViewOptionsDialog(
|
||||
viewOptions = viewOptions,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
onDismissRequest = {
|
||||
showViewOptions = false
|
||||
viewModel.saveViewOptions(viewOptions)
|
||||
},
|
||||
onViewOptionsChange = viewModel::saveViewOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.tv.material3.DenseListItem
|
||||
import androidx.tv.material3.ListItem
|
||||
import androidx.tv.material3.ListItemBorder
|
||||
import androidx.tv.material3.ListItemColors
|
||||
import androidx.tv.material3.ListItemDefaults
|
||||
import androidx.tv.material3.ListItemGlow
|
||||
import androidx.tv.material3.ListItemScale
|
||||
import androidx.tv.material3.ListItemShape
|
||||
|
||||
/**
|
||||
* Displays either a [ListItem] or [DenseListItem] based on the dense parameter
|
||||
*/
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun ListItemWrapper(
|
||||
dense: Boolean,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
headlineContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
overlineContent: (@Composable () -> Unit)? = null,
|
||||
supportingContent: (@Composable () -> Unit)? = null,
|
||||
leadingContent: (@Composable BoxScope.() -> Unit)? = null,
|
||||
trailingContent: (@Composable () -> Unit)? = null,
|
||||
tonalElevation: Dp = ListItemDefaults.TonalElevation,
|
||||
shape: ListItemShape = ListItemDefaults.shape(),
|
||||
colors: ListItemColors = ListItemDefaults.colors(),
|
||||
scale: ListItemScale = ListItemDefaults.scale(),
|
||||
border: ListItemBorder = ListItemDefaults.border(),
|
||||
glow: ListItemGlow = ListItemDefaults.glow(),
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
) = if (dense) {
|
||||
DenseListItem(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
headlineContent = headlineContent,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onLongClick = onLongClick,
|
||||
overlineContent = overlineContent,
|
||||
supportingContent = supportingContent,
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent,
|
||||
tonalElevation = tonalElevation,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
scale = scale,
|
||||
border = border,
|
||||
glow = glow,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
} else {
|
||||
ListItem(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
headlineContent = headlineContent,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onLongClick = onLongClick,
|
||||
overlineContent = overlineContent,
|
||||
supportingContent = supportingContent,
|
||||
leadingContent = leadingContent,
|
||||
trailingContent = trailingContent,
|
||||
tonalElevation = tonalElevation,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
scale = scale,
|
||||
border = border,
|
||||
glow = glow,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
|
|
@ -9,6 +10,7 @@ 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.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
|
@ -93,3 +95,28 @@ fun rememberLogoUrl(item: BaseItem?): String? {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleTitleOrLogo(
|
||||
item: BaseItem?,
|
||||
showLogo: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val logoImageUrl = rememberLogoUrl(item)
|
||||
var imageError by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier,
|
||||
) {
|
||||
if (showLogo && logoImageUrl != null && !imageError) {
|
||||
AsyncImage(
|
||||
model = logoImageUrl,
|
||||
contentDescription = item?.title,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Title(item?.title, Modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import org.jellyfin.sdk.model.api.ImageType
|
|||
/**
|
||||
* A dialog that shows the controls for making changes to a [ViewOptions] object. The caller must manage the state of the [ViewOptions].
|
||||
*
|
||||
* It displays the [AppPreference] objects from [ViewOptions.OPTIONS]
|
||||
* It displays the [AppPreference] objects from [ViewOptions.GRID_OPTIONS]
|
||||
*/
|
||||
@Composable
|
||||
fun ViewOptionsDialog(
|
||||
|
|
@ -66,6 +66,14 @@ fun ViewOptionsDialog(
|
|||
window.setGravity(Gravity.END)
|
||||
window.setDimAmount(0f)
|
||||
}
|
||||
val options =
|
||||
remember(viewOptions.type) {
|
||||
when (viewOptions.type) {
|
||||
ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS
|
||||
ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS
|
||||
ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
state = columnState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
|
|
@ -86,7 +94,7 @@ fun ViewOptionsDialog(
|
|||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
items(ViewOptions.OPTIONS) { pref ->
|
||||
items(options, key = { it.title }) { pref ->
|
||||
pref as AppPreference<ViewOptions, Any>
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val value = pref.getter.invoke(viewOptions)
|
||||
|
|
@ -122,8 +130,10 @@ data class ViewOptions(
|
|||
val contentScale: PrefContentScale = PrefContentScale.FIT,
|
||||
val aspectRatio: AspectRatio = AspectRatio.TALL,
|
||||
val showDetails: Boolean = false,
|
||||
val showBackdrop: Boolean = false,
|
||||
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
|
||||
val showTitles: Boolean = true,
|
||||
val type: ViewOptionsType = ViewOptionsType.GRID,
|
||||
) {
|
||||
companion object {
|
||||
val ViewOptionsColumns =
|
||||
|
|
@ -176,6 +186,13 @@ data class ViewOptions(
|
|||
getter = { it.showDetails },
|
||||
setter = { vo, value -> vo.copy(showDetails = value) },
|
||||
)
|
||||
val ViewOptionsBackdrop =
|
||||
AppSwitchPreference<ViewOptions>(
|
||||
title = R.string.show_backdrop,
|
||||
defaultValue = false,
|
||||
getter = { it.showBackdrop },
|
||||
setter = { vo, value -> vo.copy(showBackdrop = value) },
|
||||
)
|
||||
val ViewOptionsShowTitles =
|
||||
AppSwitchPreference<ViewOptions>(
|
||||
title = R.string.show_titles,
|
||||
|
|
@ -202,22 +219,52 @@ data class ViewOptions(
|
|||
valueToIndex = { it.ordinal },
|
||||
)
|
||||
|
||||
val ViewOptionsTypePref =
|
||||
AppChoicePreference<ViewOptions, ViewOptionsType>(
|
||||
title = R.string.layout,
|
||||
defaultValue = ViewOptionsType.GRID,
|
||||
displayValues = R.array.view_options_types,
|
||||
getter = { it.type },
|
||||
setter = { viewOptions, value ->
|
||||
val spacing =
|
||||
when (value) {
|
||||
ViewOptionsType.GRID -> 16
|
||||
ViewOptionsType.LIST -> 4
|
||||
ViewOptionsType.DENSE_LIST -> 2
|
||||
}
|
||||
viewOptions.copy(type = value, spacing = spacing)
|
||||
},
|
||||
indexToValue = { ViewOptionsType.entries[it] },
|
||||
valueToIndex = { it.ordinal },
|
||||
)
|
||||
|
||||
val ViewOptionsReset =
|
||||
AppClickablePreference<ViewOptions>(
|
||||
title = R.string.reset,
|
||||
)
|
||||
|
||||
val OPTIONS =
|
||||
val GRID_OPTIONS =
|
||||
listOf(
|
||||
ViewOptionsTypePref,
|
||||
ViewOptionsImageType,
|
||||
ViewOptionsAspectRatio,
|
||||
ViewOptionsDetailHeader,
|
||||
ViewOptionsBackdrop,
|
||||
ViewOptionsShowTitles,
|
||||
ViewOptionsColumns,
|
||||
ViewOptionsSpacing,
|
||||
ViewOptionsContentScale,
|
||||
ViewOptionsReset,
|
||||
)
|
||||
|
||||
val LIST_OPTIONS =
|
||||
listOf(
|
||||
ViewOptionsTypePref,
|
||||
ViewOptionsDetailHeader,
|
||||
ViewOptionsBackdrop,
|
||||
ViewOptionsSpacing,
|
||||
ViewOptionsReset,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -249,3 +296,9 @@ enum class ViewOptionImageType(
|
|||
THUMB(ImageType.THUMB),
|
||||
// BANNER(ImageType.BANNER),
|
||||
}
|
||||
|
||||
enum class ViewOptionsType {
|
||||
GRID,
|
||||
LIST,
|
||||
DENSE_LIST,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
|
|||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
||||
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
|
||||
|
|
@ -41,7 +41,7 @@ fun CollectionFolderGeneric(
|
|||
ViewOptionsWide
|
||||
}
|
||||
}
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
onClickItem = { index, item ->
|
||||
preferencesViewModel.navigationManager.navigateTo(item.destination(index))
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
|
|
@ -165,7 +165,7 @@ fun CollectionFolderLiveTv(
|
|||
else -> {
|
||||
val folderIndex = selectedTabIndex - 2
|
||||
if (folderIndex in folders.indices) {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
onClickItem = onClickItem,
|
||||
itemId = folders[folderIndex].id,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
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.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
|
||||
import com.github.damontecres.wholphin.ui.components.RecommendedMovie
|
||||
|
|
@ -106,7 +106,7 @@ fun CollectionFolderMovie(
|
|||
|
||||
// Library
|
||||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item ->
|
||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
|
|
@ -138,7 +138,7 @@ fun CollectionFolderMovie(
|
|||
|
||||
// Collections
|
||||
2 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item ->
|
||||
preferencesViewModel.navigationManager.navigateTo(item.destination())
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
|||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.MusicService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
|
||||
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||
|
|
@ -204,7 +204,7 @@ fun CollectionFolderMusic(
|
|||
|
||||
// Albums
|
||||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions = actions,
|
||||
itemId = destination.itemId,
|
||||
|
|
@ -234,7 +234,7 @@ fun CollectionFolderMusic(
|
|||
|
||||
// Artists
|
||||
2 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions = actions,
|
||||
itemId = destination.itemId,
|
||||
|
|
@ -276,7 +276,7 @@ fun CollectionFolderMusic(
|
|||
|
||||
// Songs
|
||||
4 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions = actions,
|
||||
itemId = destination.itemId,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
|||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
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.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel
|
||||
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
|
||||
|
|
@ -50,7 +50,7 @@ fun CollectionFolderPhotoAlbum(
|
|||
},
|
||||
) {
|
||||
var showHeader by remember { mutableStateOf(true) }
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions =
|
||||
remember {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
|
||||
import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
|
|
@ -25,7 +25,7 @@ fun CollectionFolderPlaylist(
|
|||
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
||||
) {
|
||||
var showHeader by remember { mutableStateOf(true) }
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
|
||||
itemId = itemId,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
|
||||
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
|
|
@ -25,7 +25,7 @@ fun CollectionFolderRecordings(
|
|||
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
|
||||
) {
|
||||
var showHeader by remember { mutableStateOf(true) }
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
|
||||
itemId = itemId,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
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.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
|
||||
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
|
||||
|
|
@ -113,7 +113,7 @@ fun CollectionFolderTv(
|
|||
|
||||
// Library
|
||||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
itemId = destination.itemId,
|
||||
initialFilter =
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
|||
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.CollectionFolderView
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.wholphin.ui.components.GridClickActions
|
||||
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||
|
|
@ -121,7 +121,7 @@ fun FavoritesPage(
|
|||
when (selectedTabIndex) {
|
||||
// Movies
|
||||
0 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_movies",
|
||||
|
|
@ -153,7 +153,7 @@ fun FavoritesPage(
|
|||
|
||||
// TV
|
||||
1 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_series",
|
||||
|
|
@ -185,7 +185,7 @@ fun FavoritesPage(
|
|||
|
||||
// Episodes
|
||||
2 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_episodes",
|
||||
|
|
@ -218,7 +218,7 @@ fun FavoritesPage(
|
|||
|
||||
// Videos
|
||||
3 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_videos",
|
||||
|
|
@ -250,7 +250,7 @@ fun FavoritesPage(
|
|||
|
||||
// Playlists
|
||||
4 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_playlists",
|
||||
|
|
@ -282,7 +282,7 @@ fun FavoritesPage(
|
|||
|
||||
// People
|
||||
5 -> {
|
||||
CollectionFolderGrid(
|
||||
CollectionFolderView(
|
||||
preferences = preferences,
|
||||
actions = actions,
|
||||
itemId = "${NavDrawerItem.Favorites.id}_people",
|
||||
|
|
|
|||
|
|
@ -703,6 +703,14 @@
|
|||
<item>@string/image_type_thumb</item>
|
||||
<!-- <item>Banner</item>-->
|
||||
</string-array>
|
||||
<string name="view_options_type_grid">Grid</string>
|
||||
<string name="view_options_type_list">List</string>
|
||||
<string name="view_options_type_list_compact">Compact List</string>
|
||||
<string-array name="view_options_types">
|
||||
<item>@string/view_options_type_grid</item>
|
||||
<item>@string/view_options_type_list</item>
|
||||
<item>@string/view_options_type_list_compact</item>
|
||||
</string-array>
|
||||
|
||||
<string name="backdrop_style_dynamic">Image with dynamic color</string>
|
||||
<string name="backdrop_style_image">Image only</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue