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:
Damontecres 2026-05-24 18:07:56 -04:00 committed by GitHub
parent 8177700e23
commit 982d9e6fe8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1913 additions and 949 deletions

View file

@ -27,6 +27,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
@ -227,13 +228,7 @@ fun ItemCardImageOverlay(
} }
} }
if (favorite) { if (favorite) {
Text( FavoriteIndicator()
color = colorResource(android.R.color.holo_red_light),
text = stringResource(R.string.fa_heart),
fontSize = 20.sp,
fontFamily = FontAwesome,
modifier = Modifier,
)
} }
} }
Row( 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,
)

View file

@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme 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 import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@Composable @Composable
fun WatchedIcon(modifier: Modifier = Modifier) { fun WatchedIcon(
modifier: Modifier = Modifier,
padding: Dp = 2.dp,
) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = null, contentDescription = null,
@ -28,7 +32,7 @@ fun WatchedIcon(modifier: Modifier = Modifier) {
modifier modifier
.background(WatchedIconBackground(), shape = CircleShape) .background(WatchedIconBackground(), shape = CircleShape)
.border(.5.dp, Color.Black, CircleShape) .border(.5.dp, Color.Black, CircleShape)
.padding(2.dp), .padding(padding),
) )
} }

View file

@ -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)

View file

@ -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,
)
}
}

View file

@ -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,
)
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.components package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@ -9,6 +10,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight 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)
}
}
}

View file

@ -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]. * 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 @Composable
fun ViewOptionsDialog( fun ViewOptionsDialog(
@ -66,6 +66,14 @@ fun ViewOptionsDialog(
window.setGravity(Gravity.END) window.setGravity(Gravity.END)
window.setDimAmount(0f) 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( LazyColumn(
state = columnState, state = columnState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
@ -86,7 +94,7 @@ fun ViewOptionsDialog(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
} }
items(ViewOptions.OPTIONS) { pref -> items(options, key = { it.title }) { pref ->
pref as AppPreference<ViewOptions, Any> pref as AppPreference<ViewOptions, Any>
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(viewOptions) val value = pref.getter.invoke(viewOptions)
@ -122,8 +130,10 @@ data class ViewOptions(
val contentScale: PrefContentScale = PrefContentScale.FIT, val contentScale: PrefContentScale = PrefContentScale.FIT,
val aspectRatio: AspectRatio = AspectRatio.TALL, val aspectRatio: AspectRatio = AspectRatio.TALL,
val showDetails: Boolean = false, val showDetails: Boolean = false,
val showBackdrop: Boolean = false,
val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY,
val showTitles: Boolean = true, val showTitles: Boolean = true,
val type: ViewOptionsType = ViewOptionsType.GRID,
) { ) {
companion object { companion object {
val ViewOptionsColumns = val ViewOptionsColumns =
@ -176,6 +186,13 @@ data class ViewOptions(
getter = { it.showDetails }, getter = { it.showDetails },
setter = { vo, value -> vo.copy(showDetails = value) }, 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 = val ViewOptionsShowTitles =
AppSwitchPreference<ViewOptions>( AppSwitchPreference<ViewOptions>(
title = R.string.show_titles, title = R.string.show_titles,
@ -202,22 +219,52 @@ data class ViewOptions(
valueToIndex = { it.ordinal }, 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 = val ViewOptionsReset =
AppClickablePreference<ViewOptions>( AppClickablePreference<ViewOptions>(
title = R.string.reset, title = R.string.reset,
) )
val OPTIONS = val GRID_OPTIONS =
listOf( listOf(
ViewOptionsTypePref,
ViewOptionsImageType, ViewOptionsImageType,
ViewOptionsAspectRatio, ViewOptionsAspectRatio,
ViewOptionsDetailHeader, ViewOptionsDetailHeader,
ViewOptionsBackdrop,
ViewOptionsShowTitles, ViewOptionsShowTitles,
ViewOptionsColumns, ViewOptionsColumns,
ViewOptionsSpacing, ViewOptionsSpacing,
ViewOptionsContentScale, ViewOptionsContentScale,
ViewOptionsReset, ViewOptionsReset,
) )
val LIST_OPTIONS =
listOf(
ViewOptionsTypePref,
ViewOptionsDetailHeader,
ViewOptionsBackdrop,
ViewOptionsSpacing,
ViewOptionsReset,
)
} }
} }
@ -249,3 +296,9 @@ enum class ViewOptionImageType(
THUMB(ImageType.THUMB), THUMB(ImageType.THUMB),
// BANNER(ImageType.BANNER), // BANNER(ImageType.BANNER),
} }
enum class ViewOptionsType {
GRID,
LIST,
DENSE_LIST,
}

View file

@ -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.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences 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.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions
@ -41,7 +41,7 @@ fun CollectionFolderGeneric(
ViewOptionsWide ViewOptionsWide
} }
} }
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
onClickItem = { index, item -> onClickItem = { index, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination(index)) preferencesViewModel.navigationManager.navigateTo(item.destination(index))

View file

@ -31,7 +31,7 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.components.ViewOptions
@ -165,7 +165,7 @@ fun CollectionFolderLiveTv(
else -> { else -> {
val folderIndex = selectedTabIndex - 2 val folderIndex = selectedTabIndex - 2
if (folderIndex in folders.indices) { if (folderIndex in folders.indices) {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
onClickItem = onClickItem, onClickItem = onClickItem,
itemId = folders[folderIndex].id, itemId = folders[folderIndex].id,

View file

@ -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.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedMovie import com.github.damontecres.wholphin.ui.components.RecommendedMovie
@ -106,7 +106,7 @@ fun CollectionFolderMovie(
// Library // Library
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination()) preferencesViewModel.navigationManager.navigateTo(item.destination())
@ -138,7 +138,7 @@ fun CollectionFolderMovie(
// Collections // Collections
2 -> { 2 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> onClickItem = { _, item ->
preferencesViewModel.navigationManager.navigateTo(item.destination()) preferencesViewModel.navigationManager.navigateTo(item.destination())

View file

@ -31,7 +31,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.NavigationManager 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.GridClickActions
@ -204,7 +204,7 @@ fun CollectionFolderMusic(
// Albums // Albums
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions, actions = actions,
itemId = destination.itemId, itemId = destination.itemId,
@ -234,7 +234,7 @@ fun CollectionFolderMusic(
// Artists // Artists
2 -> { 2 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions, actions = actions,
itemId = destination.itemId, itemId = destination.itemId,
@ -276,7 +276,7 @@ fun CollectionFolderMusic(
// Songs // Songs
4 -> { 4 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions, actions = actions,
itemId = destination.itemId, itemId = destination.itemId,

View file

@ -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.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences 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.CollectionFolderViewModel
import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
@ -50,7 +50,7 @@ fun CollectionFolderPhotoAlbum(
}, },
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions =
remember { remember {

View file

@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences 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.components.ViewOptionsSquare
import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
@ -25,7 +25,7 @@ fun CollectionFolderPlaylist(
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
itemId = itemId, itemId = itemId,

View file

@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences 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.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
@ -25,7 +25,7 @@ fun CollectionFolderRecordings(
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
itemId = itemId, itemId = itemId,

View file

@ -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.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
@ -113,7 +113,7 @@ fun CollectionFolderTv(
// Library // Library
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
itemId = destination.itemId, itemId = destination.itemId,
initialFilter = initialFilter =

View file

@ -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.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
@ -121,7 +121,7 @@ fun FavoritesPage(
when (selectedTabIndex) { when (selectedTabIndex) {
// Movies // Movies
0 -> { 0 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_movies", itemId = "${NavDrawerItem.Favorites.id}_movies",
@ -153,7 +153,7 @@ fun FavoritesPage(
// TV // TV
1 -> { 1 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_series", itemId = "${NavDrawerItem.Favorites.id}_series",
@ -185,7 +185,7 @@ fun FavoritesPage(
// Episodes // Episodes
2 -> { 2 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_episodes", itemId = "${NavDrawerItem.Favorites.id}_episodes",
@ -218,7 +218,7 @@ fun FavoritesPage(
// Videos // Videos
3 -> { 3 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_videos", itemId = "${NavDrawerItem.Favorites.id}_videos",
@ -250,7 +250,7 @@ fun FavoritesPage(
// Playlists // Playlists
4 -> { 4 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_playlists", itemId = "${NavDrawerItem.Favorites.id}_playlists",
@ -282,7 +282,7 @@ fun FavoritesPage(
// People // People
5 -> { 5 -> {
CollectionFolderGrid( CollectionFolderView(
preferences = preferences, preferences = preferences,
actions = actions, actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_people", itemId = "${NavDrawerItem.Favorites.id}_people",

View file

@ -703,6 +703,14 @@
<item>@string/image_type_thumb</item> <item>@string/image_type_thumb</item>
<!-- <item>Banner</item>--> <!-- <item>Banner</item>-->
</string-array> </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_dynamic">Image with dynamic color</string>
<string name="backdrop_style_image">Image only</string> <string name="backdrop_style_image">Image only</string>