Update playlist details to match library grid (#2113)

## Description
This PR updates the playlist details page to more closely match the look
of a library grid tab. This allows for a few things:
- Customize layout (grid, list, backdrop, etc)
- Show logos
- Random button
- The play all/filter/etc buttons no longer awkwardly show all the time

Basically, a playlist details will default to the list layout with "Show
details" enabled

The library list layout was actually based on the playlist details
layout originally, so this unifies the code. This means a bit less
maintenance and improvements to one page benefits the other.

### Dev note

This PR adds a `CollectionFolderViewScope` to pass down information
about the current collection and layout. This allows for easier
customizing of components. The PR also lifts the card & list item
composable to be parameters of `CollectionFolderViewContent` which,
again, allows for lots of customization.

### Related issues
Related to
https://github.com/damontecres/Wholphin/discussions/2078#discussioncomment-19046988

### Testing
Emulator

## Screenshots

### Before
<img width="1280" height="720" alt="playlist_ui_before Large"
src="https://github.com/user-attachments/assets/1cf8db82-4f53-4f20-a05e-ab937ffa3e3f"
/>

### After
<img width="1280" height="720" alt="playlist_ui_after Large"
src="https://github.com/user-attachments/assets/96ee6c1a-1a20-44dc-b979-0d314539a04a"
/>

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-10-25 20:56:18 -04:00 • committed by GitHub
parent 6cde8ce8a6
commit 52c26bc0fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 397 additions and 725 deletions

View file

@ -44,7 +44,6 @@ import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -53,19 +52,18 @@ import kotlin.time.Duration
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun CollectionFolderGrid( fun CollectionFolderViewScope.CollectionFolderGrid(
preferences: UserPreferences, preferences: UserPreferences,
collectionType: CollectionType?,
focusedItem: BaseItem?, focusedItem: BaseItem?,
items: List<BaseItem?>, items: List<BaseItem?>,
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
viewOptions: ViewOptions,
onClickPlay: (Int, BaseItem) -> Unit, onClickPlay: (Int, BaseItem) -> Unit,
initialPosition: Int, initialPosition: Int,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
cardContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) { ) {
@ -99,18 +97,8 @@ fun CollectionFolderGrid(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
initialPosition = initialPosition, initialPosition = initialPosition,
positionCallback = positionCallback, positionCallback = positionCallback,
cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> cardContent = { details ->
GridCard( cardContent.invoke(this@CollectionFolderGrid, details)
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = viewOptions.contentScale.scale,
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles,
fillWidth = widthPx,
modifier = mod,
)
}, },
columns = viewOptions.columns, columns = viewOptions.columns,
spacing = viewOptions.spacing.dp, spacing = viewOptions.spacing.dp,

View file

@ -28,6 +28,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.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator
import com.github.damontecres.wholphin.ui.cards.WatchedIcon import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
@ -75,7 +77,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@Composable @Composable
fun CollectionFolderList( fun CollectionFolderViewScope.CollectionFolderList(
preferences: UserPreferences, preferences: UserPreferences,
collectionType: CollectionType?, collectionType: CollectionType?,
focusedItem: BaseItem?, focusedItem: BaseItem?,
@ -84,10 +86,10 @@ fun CollectionFolderList(
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
viewOptions: ViewOptions,
onClickPlay: (Int, BaseItem) -> Unit, onClickPlay: (Int, BaseItem) -> Unit,
initialPosition: Int, initialPosition: Int,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) { ) {
@ -153,10 +155,8 @@ fun CollectionFolderList(
).padding(8.dp), ).padding(8.dp),
) )
} }
CollectionFolderContent( this@CollectionFolderList.CollectionFolderListContent(
items = items, items = items,
viewOptions = viewOptions,
collectionType = collectionType,
listState = listState, listState = listState,
position = position, position = position,
positionFocusRequester = positionFocusRequester, positionFocusRequester = positionFocusRequester,
@ -167,6 +167,7 @@ fun CollectionFolderList(
}, },
onClickItem = onClickItem, onClickItem = onClickItem,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
itemContent = itemContent,
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
@ -266,44 +267,43 @@ fun CollectionFolderListDetails(
} }
@Composable @Composable
fun CollectionFolderContent( fun CollectionFolderViewScope.CollectionFolderListContent(
items: List<BaseItem?>, items: List<BaseItem?>,
viewOptions: ViewOptions,
collectionType: CollectionType?,
listState: LazyListState, listState: LazyListState,
position: Int, position: Int,
positionFocusRequester: FocusRequester, positionFocusRequester: FocusRequester,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
onPositionChange: (Int) -> Unit, onPositionChange: (Int) -> Unit,
itemContent: @Composable CollectionFolderViewScope.(GridItemDetails<BaseItem>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val currentPosition by rememberUpdatedState(position)
LazyColumn( LazyColumn(
state = listState, state = listState,
verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp), verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp),
modifier = modifier.focusRestorer(positionFocusRequester), modifier = modifier.focusRestorer(positionFocusRequester),
) { ) {
itemsIndexed(items) { index, item -> itemsIndexed(items) { index, item ->
val onClick: () -> Unit = val details =
remember(index, item) { remember(item, index, onClickItem, onLongClickItem, onPositionChange) {
{ item?.let { onClickItem.invoke(index, item) } } GridItemDetails(
item = item,
index = index,
onClick = { item?.let { onClickItem.invoke(index, item) } },
onLongClick = { item?.let { onLongClickItem.invoke(index, item) } },
widthPx = -1,
mod =
Modifier
.onFocusChanged {
if (it.isFocused) onPositionChange.invoke(index)
}.ifElse(
index == currentPosition,
Modifier.focusRequester(positionFocusRequester),
),
)
} }
val onLongClick: () -> Unit = itemContent.invoke(this@CollectionFolderListContent, details)
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)),
)
} }
} }
} }

View file

@ -54,12 +54,15 @@ import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.GridItemDetails
import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.detail.music.addToQueue
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
@ -843,6 +846,33 @@ fun CollectionFolderViewContent(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null, focusRequesterOnEmpty: FocusRequester? = null,
gridCardContent: @Composable CollectionFolderViewScope.(
GridItemDetails<BaseItem>,
) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = viewOptions.contentScale.scale,
imageAspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType,
showTitle = viewOptions.showTitles,
fillWidth = widthPx,
modifier = mod,
)
},
listItemContent: @Composable CollectionFolderViewScope.(
GridItemDetails<BaseItem>,
) -> Unit = { (item, index, onClick, onLongClick, _, mod) ->
CollectionFolderListItem(
item = item,
dense = viewOptions.type == ViewOptionsType.DENSE_LIST,
collectionType = collectionType,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
)
},
) { ) {
var position by rememberInt(savedPosition) var position by rememberInt(savedPosition)
@ -1005,11 +1035,22 @@ fun CollectionFolderViewContent(
focusRequester?.tryRequestFocus() focusRequester?.tryRequestFocus()
clickedRandom = false clickedRandom = false
} }
val scope =
object : CollectionFolderViewScope {
override val viewOptions: ViewOptions
get() = state.viewOptions
override val collectionType: CollectionType?
get() = item?.data?.collectionType
override val sortAndDirection: SortAndDirection
get() = state.sortAndDirection
override val collectionSize: Int
get() = pager.data.size
}
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
if (state.viewOptions.type == ViewOptionsType.GRID) { if (state.viewOptions.type == ViewOptionsType.GRID) {
CollectionFolderGrid( scope.CollectionFolderGrid(
preferences = preferences, preferences = preferences,
collectionType = item?.data?.collectionType,
initialPosition = savedPosition, initialPosition = savedPosition,
items = pager.data, items = pager.data,
sortAndDirection = state.sortAndDirection, sortAndDirection = state.sortAndDirection,
@ -1023,12 +1064,12 @@ fun CollectionFolderViewContent(
positionCallback?.invoke(columns, pos) positionCallback?.invoke(columns, pos)
}, },
letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!, onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem, focusedItem = focusedItem,
cardContent = gridCardContent,
) )
} else { } else {
CollectionFolderList( scope.CollectionFolderList(
preferences = preferences, preferences = preferences,
collectionType = item?.data?.collectionType, collectionType = item?.data?.collectionType,
initialPosition = savedPosition, initialPosition = savedPosition,
@ -1044,9 +1085,9 @@ fun CollectionFolderViewContent(
positionCallback?.invoke(columns, pos) positionCallback?.invoke(columns, pos)
}, },
letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!, onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem, focusedItem = focusedItem,
itemContent = listItemContent,
) )
} }
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
@ -1085,3 +1126,13 @@ fun CollectionFolderViewContent(
) )
} }
} }
/**
* Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items
*/
interface CollectionFolderViewScope {
val viewOptions: ViewOptions
val collectionType: CollectionType?
val sortAndDirection: SortAndDirection
val collectionSize: Int
}