Better collection pages including more view options (#1137)

## Description
This is a large update to the collection page!

- Show the collection's details including overview & backdrop
- Separate collection by type (Movies, TV, etc) into rows
- View options are now shared across all collections
- Filter & sort are still saved per collection

By default, the collection will be separated by type, but there is a
view option to restore the combined grid.

### Related issues
Closes #527 
Closes #1088
Fixes
https://github.com/damontecres/Wholphin/issues/877#issuecomment-3889013124
Related to #737

### Testing
Emulator mostly

## Screenshots
![collection_page
Large](https://github.com/user-attachments/assets/2b9a8740-5610-491a-b139-018dabd5f2f1)

### Separated types into rows
![collection_rows
Large](https://github.com/user-attachments/assets/34556f64-6089-4d65-b243-6df01bae5423)

### Combined type into grid
![collection_grid
Large](https://github.com/user-attachments/assets/feea14ef-2b7a-4c09-bba7-d146ec92eb83)

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-28 10:31:03 -04:00 committed by GitHub
parent b8a9cb5027
commit c35b5cdd0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1853 additions and 59 deletions

View file

@ -219,6 +219,7 @@ dependencies {
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.protobuf.kotlin.lite)
implementation(libs.androidx.tvprovider)
implementation(libs.androidx.work.runtime.ktx)

View file

@ -7,6 +7,7 @@ import androidx.room.Query
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.coroutines.flow.Flow
import java.util.UUID
@Dao
@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao {
itemId: String,
): LibraryDisplayInfo?
@Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId")
fun getItemAsFlow(
userId: Int,
itemId: String,
): Flow<LibraryDisplayInfo?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun saveItem(item: LibraryDisplayInfo): Long

View file

@ -127,6 +127,9 @@ data class BaseItem(
} else if (data.premiereDate != null) {
add(data.premiereDate!!.toLocalDate().toString())
}
} else if (type == BaseItemKind.BOX_SET) {
data.productionYear?.let { add(it.toString()) }
data.childCount?.let { add("$it items") }
} else {
data.productionYear?.let { add(it.toString()) }
}

View file

@ -9,12 +9,14 @@ import androidx.room.Ignore
import androidx.room.Index
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.toServerString
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@Entity(
foreignKeys = [
@ -41,4 +43,13 @@ data class LibraryDisplayInfo(
) {
@Ignore @Transient
val sortAndDirection = SortAndDirection(sort, direction)
constructor(
user: JellyfinUser,
itemId: UUID,
sort: ItemSortBy,
direction: SortOrder,
filter: GetItemsFilter,
viewOptions: ViewOptions?,
) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions)
}

View file

@ -0,0 +1,82 @@
package com.github.damontecres.wholphin.services
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Gets/saves a serializable object by name
*/
@Singleton
class KeyValueService
@Inject
constructor(
val dataStore: DataStore<Preferences>,
) {
val json =
Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
}
inline fun <reified T> get(key: String): Flow<T?> =
dataStore.data.map { preferences ->
preferences[stringPreferencesKey(key)]?.let {
json.decodeFromString<T>(serializer<T>(), it)
}
}
inline fun <reified T> get(
key: String,
defaultValue: T,
): Flow<T> =
dataStore.data.map { preferences ->
preferences[stringPreferencesKey(key)]?.let {
json.decodeFromString<T>(serializer<T>(), it)
} ?: defaultValue
}
inline fun <reified T> get(
userId: UUID,
key: String,
defaultValue: T,
): Flow<T> =
dataStore.data.map { preferences ->
preferences[stringPreferencesKey("${userId}_$key")]?.let {
json.decodeFromString<T>(serializer<T>(), it)
} ?: defaultValue
}
suspend inline fun <reified T> save(
key: String,
value: T,
) {
dataStore.updateData { preferences ->
val valueStr = json.encodeToString(value)
preferences.toMutablePreferences().apply {
set(stringPreferencesKey(key), valueStr)
}
}
}
suspend inline fun <reified T> save(
userId: UUID,
key: String,
value: T,
) {
dataStore.updateData { preferences ->
val valueStr = json.encodeToString(value)
preferences.toMutablePreferences().apply {
set(stringPreferencesKey("${userId}_$key"), valueStr)
}
}
}
}

View file

@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room
import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.ItemPlaybackDao
@ -85,4 +88,13 @@ object DatabaseModule {
produceNewData = { AppPreferences.getDefaultInstance() },
),
)
@Provides
@Singleton
fun keyValueDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("key_value") },
)
}

View file

@ -39,6 +39,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -437,6 +438,16 @@ fun Int?.gt(that: Int) = (this ?: 0) > that
fun Int?.lt(that: Int) = (this ?: 0) < that
/**
* Simplifies endlessly collecting a flow
*/
fun <T> Flow<T>.collectLatestIn(
scope: CoroutineScope,
action: suspend (value: T) -> Unit,
) {
scope.launchDefault { this@collectLatestIn.collectLatest(action) }
}
/**
* Easy way to combine two flows into a [Pair]
*/

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber
import java.time.LocalDate
@ -196,3 +197,45 @@ fun listToDotString(
}
}
}
@StringRes
fun formatTypeName(type: BaseItemKind): Int =
when (type) {
BaseItemKind.MOVIE -> R.string.movies
BaseItemKind.SERIES -> R.string.tv_shows
BaseItemKind.EPISODE -> R.string.episodes
BaseItemKind.VIDEO -> R.string.videos
BaseItemKind.PLAYLIST -> R.string.playlists
BaseItemKind.PERSON -> R.string.people
BaseItemKind.BOX_SET -> R.string.collections
BaseItemKind.AUDIO -> TODO()
BaseItemKind.CHANNEL -> R.string.channels
BaseItemKind.GENRE -> R.string.genres
BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels
BaseItemKind.MUSIC_ALBUM -> TODO()
BaseItemKind.MUSIC_ARTIST -> TODO()
BaseItemKind.MUSIC_GENRE -> TODO()
BaseItemKind.MUSIC_VIDEO -> TODO()
BaseItemKind.PHOTO -> R.string.photos
BaseItemKind.PHOTO_ALBUM -> TODO()
BaseItemKind.PROGRAM -> TODO()
BaseItemKind.RECORDING -> TODO()
BaseItemKind.SEASON -> R.string.tv_seasons
BaseItemKind.STUDIO -> R.string.studios
BaseItemKind.TRAILER -> R.string.trailers
BaseItemKind.TV_CHANNEL -> R.string.channels
BaseItemKind.TV_PROGRAM -> TODO()
BaseItemKind.USER_ROOT_FOLDER -> TODO()
BaseItemKind.USER_VIEW -> TODO()
BaseItemKind.YEAR -> TODO()
BaseItemKind.AGGREGATE_FOLDER -> TODO()
BaseItemKind.AUDIO_BOOK -> TODO()
BaseItemKind.BASE_PLUGIN_FOLDER -> TODO()
BaseItemKind.BOOK -> TODO()
BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO()
BaseItemKind.COLLECTION_FOLDER -> TODO()
BaseItemKind.FOLDER -> TODO()
BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO()
BaseItemKind.LIVE_TV_PROGRAM -> TODO()
BaseItemKind.PLAYLISTS_FOLDER -> TODO()
}

View file

@ -1,48 +0,0 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import java.util.UUID
@Composable
fun CollectionFolderBoxSet(
preferences: UserPreferences,
itemId: UUID,
recursive: Boolean,
modifier: Modifier = Modifier,
filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
playEnabled: Boolean = false,
) {
var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
itemId = itemId,
initialFilter = filter,
showTitle = showHeader,
recursive = recursive,
sortOptions = BoxSetSortOptions,
initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
modifier = modifier,
positionCallback = { columns, position ->
showHeader = position < columns
},
defaultViewOptions = ViewOptionsPoster,
playEnabled = playEnabled,
)
}

View file

@ -0,0 +1,148 @@
package com.github.damontecres.wholphin.ui.detail.collection
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
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.GetItemsFilter
import com.github.damontecres.wholphin.ui.components.DeleteButton
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
import com.github.damontecres.wholphin.ui.components.FilterByButton
import com.github.damontecres.wholphin.ui.components.SortByButton
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import kotlin.time.Duration
@Composable
fun CollectionButtons(
state: CollectionState,
onSortChange: (SortAndDirection) -> Unit,
onClickPlayAll: (Boolean) -> Unit,
onFilterChange: (GetItemsFilter) -> Unit,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
onClickViewOptions: () -> Unit,
favoriteOnClick: () -> Unit,
deleteOnClick: () -> Unit,
canDelete: Boolean,
moreOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val sortOptions = MovieSortOptions
val filterOptions = DefaultFilterOptions
val firstFocus = remember { FocusRequester() }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier,
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.focusGroup()
.focusRestorer(firstFocus),
) {
item {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { onClickPlayAll.invoke(false) },
modifier = Modifier.focusRequester(firstFocus),
)
}
item {
ExpandableFaButton(
title = R.string.shuffle,
iconStringRes = R.string.fa_shuffle,
onClick = { onClickPlayAll.invoke(true) },
)
}
item("favorite") {
val favorite = remember(state.collection) { state.collection?.favorite == true }
ExpandableFaButton(
title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
onClick = favoriteOnClick,
iconColor = if (favorite) Color.Red else Color.Unspecified,
modifier = Modifier,
)
}
if (canDelete) {
item("delete") {
DeleteButton(
onClick = deleteOnClick,
modifier =
Modifier,
)
}
}
item {
ExpandableFaButton(
title = R.string.view_options,
iconStringRes = R.string.fa_sliders,
onClick = onClickViewOptions,
modifier = Modifier,
)
}
// More button
item("more") {
ExpandablePlayButton(
title = R.string.more,
resume = Duration.ZERO,
icon = Icons.Default.MoreVert,
onClick = { moreOnClick.invoke() },
modifier = Modifier,
)
}
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(8.dp),
modifier =
Modifier
.focusGroup(),
) {
item {
SortByButton(
sortOptions = sortOptions,
current = state.sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
)
}
item {
FilterByButton(
filterOptions = filterOptions,
current = state.itemFilter,
onFilterChange = onFilterChange,
getPossibleValues = getPossibleFilterValues,
modifier = Modifier,
)
}
}
}
}

View file

@ -0,0 +1,551 @@
package com.github.damontecres.wholphin.ui.detail.collection
import android.content.Context
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
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.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
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.RowColumn
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import java.util.UUID
@Composable
fun CollectionDetails(
preferences: UserPreferences,
itemId: UUID,
modifier: Modifier = Modifier,
viewModel: CollectionViewModel =
hiltViewModel<CollectionViewModel, CollectionViewModel.Factory>(
creationCallback = { it.create(itemId) },
),
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val state by viewModel.state.collectAsState()
// Dialogs
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<Pair<RowColumn?, BaseItem>?>(null) }
var showViewOptionsDialog by remember { mutableStateOf(false) }
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
// Actions
val onClickItem =
remember {
{ _: RowColumn, item: BaseItem -> viewModel.navigate(item.destination()) }
}
val onLongClickItem =
remember {
{ position: RowColumn, item: BaseItem ->
val dialogItems =
buildMoreDialogItemsForHome(
context = context,
item = item,
seriesId = item.data.seriesId,
playbackPosition = item.playbackPosition,
watched = item.played,
favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions =
MoreDialogActions(
navigateTo = viewModel::navigate,
onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched, position)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite, position)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { item -> showDeleteDialog = Pair(position, item) },
),
)
moreDialog =
DialogParams(
fromLongClick = true,
title = item.title ?: "",
items = dialogItems,
)
}
}
val onSortChange =
remember {
{ sort: SortAndDirection -> viewModel.changeSort(sort) }
}
val onFilterChange =
remember {
{ filter: GetItemsFilter -> viewModel.changeFilter(filter) }
}
val onClickPlay = { _: RowColumn, item: BaseItem ->
viewModel.navigate(Destination.Playback(item = item))
}
val onClickPlayAll =
remember {
{ shuffle: Boolean ->
val dest =
Destination.PlaybackList(
itemId = itemId,
startIndex = 0,
shuffle = shuffle,
recursive = true,
sortAndDirection = state.sortAndDirection,
filter = state.itemFilter,
)
viewModel.navigate(dest)
}
}
val onClickViewOptions = remember { { showViewOptionsDialog = true } }
when (val s = state.loadingState) {
is LoadingState.Error -> {
ErrorMessage(s, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
CollectionDetailsContent(
preferences = preferences,
state = state,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
onSortChange = onSortChange,
onClickPlay = onClickPlay,
onClickPlayAll = onClickPlayAll,
onChangeBackdrop = viewModel::updateBackdrop,
onFilterChange = onFilterChange,
getPossibleFilterValues = viewModel::getPossibleFilterValues,
letterPosition = viewModel::letterPosition,
onClickViewOptions = onClickViewOptions,
modifier = modifier,
overviewOnClick = {
val collection = state.collection!!
overviewDialog =
ItemDetailsDialogInfo(
title = collection.title ?: "",
overview = collection.data.overview,
genres = collection.data.genres.orEmpty(),
files = emptyList(),
)
},
favoriteOnClick =
remember {
{
state.collection?.let {
viewModel.setFavorite(it.id, !it.favorite, null)
}
}
},
deleteOnClick =
remember {
{
state.collection?.let {
viewModel.deleteItem(it, null)
}
}
},
canDelete =
remember(state.collection) {
state.collection?.let {
viewModel.canDelete(it, preferences.appPreferences)
} ?: false
},
moreOnClick = {
val collection = state.collection!!
val items =
buildMoreDialogItemsForCollection(
context = context,
item = collection,
favorite = collection.favorite,
canDelete = viewModel.canDelete(collection, preferences.appPreferences),
onClickPlayAll = onClickPlayAll,
actions =
MoreDialogActions(
navigateTo = viewModel::navigate,
onClickWatch = { itemId, watched ->
viewModel.setWatched(itemId, watched, null)
},
onClickFavorite = { itemId, favorite ->
viewModel.setFavorite(itemId, favorite, null)
},
onClickAddPlaylist = { itemId ->
playlistViewModel.loadPlaylists(MediaType.VIDEO)
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { item -> showDeleteDialog = Pair(null, item) },
),
)
moreDialog =
DialogParams(
fromLongClick = false,
title = collection.title ?: "",
items = items,
)
},
)
}
}
if (showViewOptionsDialog) {
CollectionViewOptionsDialog(
viewOptions = state.viewOptions,
onDismissRequest = { showViewOptionsDialog = false },
onViewOptionsChange = viewModel::changeViewOptions,
)
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
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,
)
}
showDeleteDialog?.let { (position, item) ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item, position)
showDeleteDialog = null
},
)
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
showFilePath =
viewModel.serverRepository.currentUserDto.value
?.policy
?.isAdministrator == true,
onDismissRequest = { overviewDialog = null },
)
}
}
@Composable
fun CollectionDetailsContent(
preferences: UserPreferences,
state: CollectionState,
onClickItem: (RowColumn, BaseItem) -> Unit,
onLongClickItem: (RowColumn, BaseItem) -> Unit,
onSortChange: (SortAndDirection) -> Unit,
onClickPlay: (RowColumn, BaseItem) -> Unit,
onClickPlayAll: (Boolean) -> Unit,
onChangeBackdrop: (BaseItem) -> Unit,
onFilterChange: (GetItemsFilter) -> Unit,
getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List<FilterValueOption>,
letterPosition: suspend (Char) -> Int,
onClickViewOptions: () -> Unit,
overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
deleteOnClick: () -> Unit,
canDelete: Boolean,
moreOnClick: () -> Unit,
modifier: Modifier,
) {
var itemsContentHasFocus by rememberSaveable { mutableStateOf(false) }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val focusRequester = remember { FocusRequester() }
val contentFocusRequester = remember { FocusRequester() }
var focusedItem by remember { mutableStateOf<BaseItem?>(state.collection) }
LaunchedEffect(focusedItem) {
focusedItem?.let { onChangeBackdrop.invoke(it) }
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
SharedTransitionLayout {
AnimatedContent(
targetState = itemsContentHasFocus,
label = "header_transition",
) { targetState ->
if (targetState) {
// Show item header
LaunchedEffect(Unit) {
contentFocusRequester.tryRequestFocus()
}
Column(
Modifier.sharedBounds(
rememberSharedContentState(key = "header"),
animatedVisibilityScope = this@AnimatedContent,
enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(),
),
) {
// This box exists so that there is something focusable above the item content
// allowing focus to move up to restore the collection's header
Box(
modifier =
Modifier
.fillMaxWidth()
.height(0.dp)
.onFocusChanged {
if (it.isFocused) itemsContentHasFocus = false
}.focusable(),
)
if (state.viewOptions.cardViewOptions.showDetails) {
HomePageHeader(
item = focusedItem,
modifier =
Modifier
.padding(top = 48.dp, start = 8.dp)
.fillMaxHeight(.33f)
.fillMaxWidth(),
)
}
}
} else {
// Show collection header
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
focusedItem = state.collection
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.sharedBounds(
rememberSharedContentState(key = "header"),
animatedVisibilityScope = this@AnimatedContent,
enter = slideInVertically { -it / 2 } + fadeIn(),
exit = slideOutVertically { -it / 2 } + fadeOut(),
).padding(bottom = 16.dp)
.fillMaxWidth()
.onFocusChanged {
if (it.hasFocus) {
onChangeBackdrop.invoke(state.collection!!)
}
},
) {
CollectionDetailsHeader(
collection = state.collection!!,
logoImageUrl = state.logoImageUrl,
overviewOnClick = overviewOnClick,
bringIntoViewRequester = bringIntoViewRequester,
modifier =
Modifier
.padding(top = 48.dp, start = 8.dp)
// TODO
.fillMaxHeight(.36f)
.fillMaxWidth(),
)
CollectionButtons(
state = state,
onSortChange = onSortChange,
onClickPlayAll = onClickPlayAll,
onFilterChange = onFilterChange,
getPossibleFilterValues = getPossibleFilterValues,
onClickViewOptions = onClickViewOptions,
favoriteOnClick = favoriteOnClick,
deleteOnClick = deleteOnClick,
canDelete = canDelete,
moreOnClick = moreOnClick,
modifier =
Modifier
.focusRequester(focusRequester)
.fillMaxWidth(),
)
}
}
}
}
Box(
modifier =
Modifier
.fillMaxSize()
.onFocusChanged {
if (it.hasFocus) itemsContentHasFocus = true
}.focusProperties {
up = focusRequester
}.focusRequester(contentFocusRequester),
) {
if (state.viewOptions.separateTypes) {
CollectionRows(
preferences = preferences,
state = state,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
onClickPlay = onClickPlay,
modifier = Modifier.fillMaxSize(),
onFocusPosition = { position ->
Timber.v("onFocusPosition=%s", position)
focusedItem =
position.let {
val key =
state.separateItems.keys
.toList()
.getOrNull(it.row)
(state.separateItems[key] as? HomeRowLoadingState.Success)?.items?.getOrNull(
it.column,
)
}
},
)
} else {
CollectionMixedGrid(
preferences = preferences,
state = state,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
onClickPlay = onClickPlay,
letterPosition = letterPosition,
modifier = Modifier.fillMaxSize(),
onFocusPosition = {
Timber.v("onFocusPosition=%s", it)
focusedItem = state.items.getOrNull(it.column)
},
)
}
}
}
}
fun buildMoreDialogItemsForCollection(
context: Context,
item: BaseItem,
favorite: Boolean,
canDelete: Boolean,
onClickPlayAll: (shuffle: Boolean) -> Unit,
actions: MoreDialogActions,
): List<DialogItem> =
buildList {
add(
DialogItem(
context.getString(R.string.play),
Icons.Default.PlayArrow,
iconColor = Color.Green.copy(alpha = .8f),
) {
onClickPlayAll.invoke(false)
},
)
add(
DialogItem(
context.getString(R.string.shuffle),
R.string.fa_shuffle,
) {
onClickPlayAll.invoke(true)
},
)
add(
DialogItem(
text = R.string.add_to_playlist,
iconStringRes = R.string.fa_list_ul,
) {
actions.onClickAddPlaylist.invoke(item.id)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
add(
DialogItem(
text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (favorite) Color.Red else Color.Unspecified,
) {
actions.onClickFavorite.invoke(item.id, !favorite)
},
)
}

View file

@ -0,0 +1,111 @@
package com.github.damontecres.wholphin.ui.detail.collection
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
@Composable
fun CollectionDetailsHeader(
collection: BaseItem,
logoImageUrl: String?,
overviewOnClick: () -> Unit,
bringIntoViewRequester: BringIntoViewRequester,
modifier: Modifier = Modifier,
) {
val dto = collection.data
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
if (logoImageUrl != null) {
AsyncImage(
model = logoImageUrl,
contentDescription = collection.name,
modifier = Modifier.height(80.dp),
alignment = Alignment.TopStart,
contentScale = ContentScale.Fit,
)
} else {
// Title
Text(
text = collection.name ?: "",
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth(.75f)
.padding(start = 8.dp),
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
QuickDetails(
collection.ui.quickDetails,
collection.timeRemainingOrRuntime,
Modifier.padding(start = 8.dp),
)
dto.genres?.letNotEmpty {
GenreText(it, Modifier.padding(start = 8.dp))
}
dto.taglines?.firstOrNull()?.let { tagline ->
Text(
text = tagline,
style = MaterialTheme.typography.bodyLarge,
fontStyle = FontStyle.Italic,
modifier = Modifier.padding(start = 8.dp),
)
}
// Description
dto.overview?.let { overview ->
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}
}
}
}

View file

@ -0,0 +1,89 @@
package com.github.damontecres.wholphin.ui.detail.collection
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import org.jellyfin.sdk.model.api.ItemSortBy
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CollectionMixedGrid(
preferences: UserPreferences,
state: CollectionState,
onClickItem: (RowColumn, BaseItem) -> Unit,
onLongClickItem: (RowColumn, BaseItem) -> Unit,
onClickPlay: (RowColumn, BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int,
modifier: Modifier = Modifier,
onFocusPosition: (RowColumn) -> Unit = {},
) {
val gridFocusRequester = remember { FocusRequester() }
Box(modifier = modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.fillMaxSize(),
) {
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
val density = LocalDensity.current
val cardViewOptions = state.viewOptions.cardViewOptions
CardGrid(
pager = state.items,
onClickItem = { index: Int, item: BaseItem -> onClickItem.invoke(RowColumn(0, index), item) },
onLongClickItem = { index: Int, item: BaseItem -> onLongClickItem.invoke(RowColumn(0, index), item) },
onClickPlay = { index: Int, item: BaseItem -> onClickPlay.invoke(RowColumn(0, index), item) },
letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference
showLetterButtons = state.sortAndDirection.sort == ItemSortBy.SORT_NAME,
modifier =
Modifier
.fillMaxSize(),
initialPosition = 0,
positionCallback = { _, newPosition ->
onFocusPosition.invoke(RowColumn(0, newPosition))
},
cardContent = { item, onClick, onLongClick, mod ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = cardViewOptions.contentScale.scale,
imageAspectRatio = cardViewOptions.aspectRatio.ratio,
imageType = cardViewOptions.imageType,
showTitle = cardViewOptions.showTitles,
modifier = mod,
)
},
columns = cardViewOptions.columns,
spacing = cardViewOptions.spacing.dp,
bringIntoViewSpec =
remember(cardViewOptions) {
val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() }
if (cardViewOptions.showDetails) {
ScrollToTopBringIntoViewSpec(spacingPx)
} else {
defaultBringIntoViewSpec
}
},
)
}
}
}

View file

@ -0,0 +1,90 @@
package com.github.damontecres.wholphin.ui.detail.collection
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import org.jellyfin.sdk.model.api.BaseItemKind
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CollectionRows(
preferences: UserPreferences,
state: CollectionState,
onClickItem: (RowColumn, BaseItem) -> Unit,
onLongClickItem: (RowColumn, BaseItem) -> Unit,
onClickPlay: (RowColumn, BaseItem) -> Unit,
modifier: Modifier = Modifier,
onFocusPosition: (RowColumn) -> Unit = {},
) {
var position by rememberPosition(0, 0)
Box(modifier = modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.fillMaxSize()
.padding(top = 8.dp),
) {
val cardViewOptions = state.viewOptions.cardViewOptions
val homeRows =
remember(state.separateItems, cardViewOptions) {
state.separateItems.map { (type, row) ->
if (row is HomeRowLoadingState.Success) {
// TODO not great to do this in the UI
val viewOptions =
if (type == BaseItemKind.EPISODE) {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
episodeAspectRatio = AspectRatio.WIDE,
showTitles = cardViewOptions.showTitles,
useSeries = false,
)
} else {
HomeRowViewOptions(
showTitles = cardViewOptions.showTitles,
)
}
row.copy(viewOptions = viewOptions)
} else {
row
}
}
}
HomePageContent(
homeRows = homeRows,
position = position,
onFocusPosition = { newPosition ->
position = newPosition
onFocusPosition.invoke(newPosition)
},
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
onClickPlay = onClickPlay,
showClock = false,
onUpdateBackdrop = {},
headerComposable = {},
takeFocus = false,
modifier = Modifier,
)
}
}
}

View file

@ -0,0 +1,493 @@
package com.github.damontecres.wholphin.ui.detail.collection
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
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.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.KeyValueService
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
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.collectLatestIn
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.formatTypeName
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.toServerString
import com.github.damontecres.wholphin.ui.util.FilterUtils
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel(assistedFactory = CollectionViewModel.Factory::class)
class CollectionViewModel
@AssistedInject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
val serverRepository: ServerRepository,
private val navigationManager: NavigationManager,
private val preferencesService: UserPreferencesService,
private val themeSongPlayer: ThemeSongPlayer,
private val mediaManagementService: MediaManagementService,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
private val keyValueService: KeyValueService,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val imageUrlService: ImageUrlService,
val mediaReportService: MediaReportService,
@Assisted private val itemId: UUID,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId: UUID): CollectionViewModel
}
private val viewOptionsFlow =
serverRepository.currentUser
.asFlow()
.filterNotNull()
.flatMapLatest {
keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions())
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions())
private val libraryDisplayInfoFlow =
serverRepository.currentUser
.asFlow()
.filterNotNull()
.flatMapLatest {
libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString())
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
private val _state = MutableStateFlow(CollectionState())
val state: StateFlow<CollectionState> = _state
init {
addCloseable { release() }
// Get global per-user view options for collections
viewOptionsFlow.collectLatestIn(viewModelScope) { viewOptions ->
Timber.v("Updated viewOptions")
_state.update {
it.copy(viewOptions = viewOptions)
}
}
libraryDisplayInfoFlow
.filterNotNull()
.collectLatestIn(viewModelScope) { libraryDisplayInfo ->
Timber.v("Updated libraryDisplayInfo")
_state.update {
it.copy(
itemFilter = libraryDisplayInfo.filter,
sortAndDirection = libraryDisplayInfo.sortAndDirection,
)
}
}
viewModelScope.launchDefault {
val collection =
api.userLibraryApi
.getItem(itemId)
.content
.let { BaseItem(it, false) }
backdropService.submit(collection)
val logoImageUrl = null
// TODO add logo back
// if (ImageType.LOGO in collection.data.imageTags.orEmpty()) {
// imageUrlService.getItemImageUrl(collection, ImageType.LOGO)
// } else {
// null
// }
_state.update {
it.copy(
collection = collection,
logoImageUrl = logoImageUrl,
)
}
listenForStateUpdates()
themeSongPlayer.playThemeFor(
itemId,
preferencesService
.getCurrent()
.appPreferences.interfacePreferences.playThemeSongs,
)
}
}
fun release() {
themeSongPlayer.stop()
}
/**
* Collects on [state] and fetches data when needed
*/
private fun listenForStateUpdates() =
viewModelScope.launchDefault {
state
.map {
Triple(
it.sortAndDirection,
it.itemFilter,
it.viewOptions.separateTypes,
)
}.distinctUntilChanged()
.collectLatest { (sort, filter, separateTypes) ->
try {
updateData(sort, filter, separateTypes)
} catch (ex: Exception) {
Timber.e(
ex,
"Error fetching data for collection %s",
itemId,
)
_state.update { it.copy(loadingState = LoadingState.Error(ex)) }
}
}
}
private suspend fun updateData(
sort: SortAndDirection,
filter: GetItemsFilter,
separateTypes: Boolean,
) {
Timber.d("Begin updateData for %s", itemId)
_state.update {
it.copy(
loadingState = LoadingState.Loading,
items = emptyList(),
separateItems = emptyMap(),
)
}
if (!separateTypes) {
val result = fetchItems(sort, filter, typesInCollection)
_state.update { it.copy(items = result) }
} else {
supervisorScope {
val jobs =
typesInCollection.map { type ->
async(Dispatchers.IO) {
val title = context.getString(formatTypeName(type))
val result =
try {
val pager = fetchItems(sort, filter, listOf(type))
HomeRowLoadingState.Success(title, pager)
} catch (ex: Exception) {
Timber.e(
ex,
"Error fetching %s for collection %s",
type,
itemId,
)
HomeRowLoadingState.Error(title, exception = ex)
}
type to result
}
}
jobs.forEach { job ->
val (type, row) = job.await()
_state.update {
val separateItems =
it.separateItems.toMutableMap().apply {
put(type, row)
}
it.copy(separateItems = separateItems)
}
}
}
}
_state.update { it.copy(loadingState = LoadingState.Success) }
Timber.d("End updateData for %s", itemId)
}
private suspend fun fetchItems(
sort: SortAndDirection?,
filter: GetItemsFilter?,
types: List<BaseItemKind>,
): ApiRequestPager<GetItemsRequest> {
val request = createGetItemsRequest(sort, filter, types)
val useSeriesForPrimary = !state.value.viewOptions.separateTypes
return ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = useSeriesForPrimary,
).init()
}
private fun createGetItemsRequest(
sort: SortAndDirection?,
filter: GetItemsFilter?,
types: List<BaseItemKind>,
): GetItemsRequest {
val includeItemTypes: List<BaseItemKind>?
val excludeItemTypes: List<BaseItemKind>?
// Workaround for https://github.com/jellyfin/jellyfin/issues/16454
if (types.size == 1 && types.first() == BaseItemKind.BOX_SET) {
includeItemTypes = null
excludeItemTypes =
BaseItemKind.entries
.toMutableList()
.apply { remove(BaseItemKind.BOX_SET) }
} else {
includeItemTypes = types
excludeItemTypes = null
}
val request =
GetItemsRequest(
userId = serverRepository.currentUser.value?.id,
parentId = itemId,
includeItemTypes = includeItemTypes,
excludeItemTypes = excludeItemTypes,
recursive = false,
sortBy = sort?.let { listOf(sort.sort) },
sortOrder = sort?.let { listOf(sort.direction) },
fields = SlimItemFields,
).let {
filter?.applyTo(it, false) ?: it
}
return request
}
fun changeSort(sortAndDirection: SortAndDirection) {
viewModelScope.launchIO {
val user = serverRepository.currentUser.value
val state = _state.value
if (user != null) {
libraryDisplayInfoDao.saveItem(
LibraryDisplayInfo(
user = user,
itemId = itemId,
sort = sortAndDirection.sort,
direction = sortAndDirection.direction,
filter = state.itemFilter,
viewOptions = null,
),
)
}
}
}
fun changeFilter(filter: GetItemsFilter) {
viewModelScope.launchIO {
val user = serverRepository.currentUser.value
val state = _state.value
if (user != null) {
libraryDisplayInfoDao.saveItem(
LibraryDisplayInfo(
user = user,
itemId = itemId,
sort = state.sortAndDirection.sort,
direction = state.sortAndDirection.direction,
filter = filter,
viewOptions = null,
),
)
}
}
}
fun changeViewOptions(viewOptions: CollectionViewOptions) {
viewModelScope.launchIO {
if (!viewOptions.cardViewOptions.showDetails) {
val collection = state.value.collection
if (collection != null) {
backdropService.submit(collection)
} else {
backdropService.clearBackdrop()
}
}
serverRepository.currentUser.value?.id?.let { userId ->
keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions)
}
}
}
suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
FilterUtils.getFilterOptionValues(
api,
serverRepository.currentUser.value?.id,
itemId,
filterOption,
)
suspend fun letterPosition(letter: Char): Int =
withContext(Dispatchers.IO) {
val sort = state.value.sortAndDirection
val filter = state.value.itemFilter
val request =
createGetItemsRequest(
sort = sort,
filter = filter,
types = typesInCollection,
).copy(
enableImageTypes = null,
fields = null,
nameLessThan = letter.toString(),
limit = 0,
enableTotalRecordCount = true,
)
val result by GetItemsRequestHandler.execute(api, request)
result.totalRecordCount
}
fun navigate(destination: Destination) {
release()
navigationManager.navigateTo(destination)
}
fun setWatched(
itemId: UUID,
played: Boolean,
position: RowColumn?,
) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
favoriteWatchManager.setWatched(itemId, played)
if (position != null) {
refreshItem(itemId, position, false)
}
}
fun setFavorite(
itemId: UUID,
favorite: Boolean,
position: RowColumn?,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
if (position != null) {
refreshItem(itemId, position, false)
}
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
fun deleteItem(
item: BaseItem,
position: RowColumn?,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchIO {
if (position != null) {
refreshItem(itemId, position, true)
}
}
}
}
private suspend fun refreshItem(
itemId: UUID,
position: RowColumn,
isDelete: Boolean,
) {
state.value.let { state ->
val items =
if (state.viewOptions.separateTypes) {
val key =
state.separateItems.keys
.toList()
.getOrNull(position.row)
(state.separateItems[key] as? HomeRowLoadingState.Success)?.items as? ApiRequestPager<*>
} else {
state.items as? ApiRequestPager<*>
}
if (isDelete) {
items?.refreshPagesAfter(position.column)
} else {
items?.refreshItem(position.column, itemId)
}
}
}
fun updateBackdrop(item: BaseItem) {
viewModelScope.launchDefault {
val collection = state.value.collection
if (item.id == collection?.id) {
// Always show the collection's backdrop if requested
backdropService.submit(collection)
} else if (state.value.viewOptions.cardViewOptions.showDetails) {
backdropService.submit(item)
} else {
backdropService.clearBackdrop()
}
}
}
companion object {
val typesInCollection =
listOf(
BaseItemKind.MOVIE,
BaseItemKind.SERIES,
BaseItemKind.EPISODE,
BaseItemKind.VIDEO,
BaseItemKind.BOX_SET,
)
const val VIEW_OPTIONS_KEY = "CollectionViewOptions"
}
}
@Stable
data class CollectionState(
val loadingState: LoadingState = LoadingState.Pending,
val collection: BaseItem? = null,
val sortAndDirection: SortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
val itemFilter: GetItemsFilter = GetItemsFilter(),
val viewOptions: CollectionViewOptions = CollectionViewOptions(),
val items: List<BaseItem?> = emptyList(),
val separateItems: Map<BaseItemKind, HomeRowLoadingState> = emptyMap(),
val logoImageUrl: String? = null,
)

View file

@ -0,0 +1,174 @@
package com.github.damontecres.wholphin.ui.detail.collection
import android.view.Gravity
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsAspectRatio
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsColumns
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsContentScale
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsDetailHeader
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsImageType
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsReset
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsShowTitles
import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsSpacing
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.serialization.Serializable
@Composable
fun CollectionViewOptionsDialog(
viewOptions: CollectionViewOptions,
onDismissRequest: () -> Unit,
onViewOptionsChange: (CollectionViewOptions) -> Unit,
defaultViewOptions: CollectionViewOptions = CollectionViewOptions(),
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val columnState = rememberLazyListState()
val options =
if (viewOptions.separateTypes) CollectionViewOptions.SeparateOptions else CollectionViewOptions.MixedOptions
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
),
) {
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
dialogWindowProvider?.window?.let { window ->
window.setGravity(Gravity.END)
window.setDimAmount(0f)
}
Column(
modifier =
Modifier
.width(256.dp)
.heightIn(max = 380.dp)
.focusRequester(focusRequester)
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
shape = RoundedCornerShape(8.dp),
).padding(16.dp),
) {
Text(
text = stringResource(R.string.view_options),
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn(
state = columnState,
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
item {
val pref = CollectionViewOptions.SeparateTypes
val interactionSource = remember { MutableInteractionSource() }
ComposablePreference(
preference = pref,
value = viewOptions.separateTypes,
onNavigate = {},
onValueChange = { newValue ->
onViewOptionsChange.invoke(pref.setter(viewOptions, newValue))
},
interactionSource = interactionSource,
modifier = Modifier,
onClickPreference = {},
)
}
items(options, key = { it.title }) { pref ->
pref as AppPreference<ViewOptions, Any>
val interactionSource = remember { MutableInteractionSource() }
val value = pref.getter.invoke(viewOptions.cardViewOptions)
ComposablePreference(
preference = pref,
value = value,
onNavigate = {},
onValueChange = { newValue ->
val newCardViewOptions =
pref.setter(viewOptions.cardViewOptions, newValue)
onViewOptionsChange.invoke(viewOptions.copy(cardViewOptions = newCardViewOptions))
},
interactionSource = interactionSource,
modifier = Modifier.animateItem(),
onClickPreference = { pref ->
if (pref == ViewOptionsReset) {
onViewOptionsChange.invoke(defaultViewOptions)
}
},
)
}
}
}
}
}
@Serializable
data class CollectionViewOptions(
val separateTypes: Boolean = true,
val cardViewOptions: ViewOptions =
ViewOptions(
showDetails = true,
showTitles = true,
),
) {
companion object {
val SeparateTypes =
AppSwitchPreference<CollectionViewOptions>(
title = R.string.separate_types,
defaultValue = false,
getter = { it.separateTypes },
setter = { vo, value -> vo.copy(separateTypes = value) },
)
val MixedOptions =
listOf(
ViewOptionsImageType,
ViewOptionsAspectRatio,
ViewOptionsDetailHeader,
ViewOptionsShowTitles,
ViewOptionsColumns,
ViewOptionsSpacing,
ViewOptionsContentScale,
ViewOptionsReset,
)
val SeparateOptions =
listOf(
ViewOptionsDetailHeader,
ViewOptionsShowTitles,
ViewOptionsReset,
)
}
}

View file

@ -227,6 +227,15 @@ fun HomePageContent(
listState: LazyListState = rememberLazyListState(),
takeFocus: Boolean = true,
showEmptyRows: Boolean = false,
headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem ->
HomePageHeader(
item = focusedItem,
modifier =
Modifier
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f),
)
},
) {
val focusedItem =
position.let {
@ -266,13 +275,8 @@ fun HomePageContent(
}
Box(modifier = modifier) {
Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader(
item = focusedItem,
modifier =
Modifier
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f),
)
headerComposable.invoke(focusedItem)
val density = LocalDensity.current
val spaceAbovePx =
with(density) {

View file

@ -11,7 +11,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ItemGrid
import com.github.damontecres.wholphin.ui.components.LicenseInfo
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
@ -24,6 +23,7 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage
import com.github.damontecres.wholphin.ui.detail.FavoritesPage
import com.github.damontecres.wholphin.ui.detail.PersonPage
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
@ -146,11 +146,9 @@ fun DestinationContent(
BaseItemKind.BOX_SET -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
CollectionFolderBoxSet(
CollectionDetails(
preferences = preferences,
itemId = destination.itemId,
recursive = false,
playEnabled = true,
modifier = modifier,
)
}

View file

@ -746,5 +746,6 @@
<string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string>
<string name="continue_string">Continue</string>
<string name="select_all">Select all</string>
<string name="separate_types">Separate types</string>
</resources>

View file

@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig
@ -278,5 +281,14 @@ object TestDatabaseModule {
produceNewData = { AppPreferences.getDefaultInstance() },
),
)
@Provides
@Singleton
fun keyValueDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("key_value") },
)
}
}

View file

@ -72,6 +72,7 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }