Update favorites page with more types (#1864)

## Description
This PR adds many more data types as tabs to the favorites page.
Additionally, only tabs for the types the user has favorites for will
appear. For example, if you have no favorited music videos, that tab
will not show.

Added types:
- Collections
- TV Channels
- Music albums
- Music artists
- Music videos
- Songs
- Home videos
- Photos
- Photo albums

Dev note: The tabs are implemented using keys instead of indexes which
could be useful for the series overview season tabs. Plus it maybe even
be adapted for the episode list to eliminate the terrible index search
logic. A major difference is that the possible favorites tabs are
known/static list where seasons/episodes are queried & paginated.

### Related issues
Closes #1041
Closes #1844

### Testing
Manually on emulator, plus unit tests

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-08-26 21:50:13 -04:00 • committed by GitHub
parent 786b95a393
commit fdde986029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1783 additions and 397 deletions

View file

@ -109,15 +109,7 @@ sealed interface HomeRowConfig {
@SerialName("Favorite")
data class Favorite(
val kind: BaseItemKind,
override val viewOptions: HomeRowViewOptions =
if (kind == BaseItemKind.EPISODE) {
HomeRowViewOptions(
heightDp = Cards.HEIGHT_EPISODE,
aspectRatio = AspectRatio.WIDE,
)
} else {
HomeRowViewOptions()
},
override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
) : HomeRowConfig {
override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions)
}

View file

@ -15,8 +15,8 @@ import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.HomeItemFields
import com.github.damontecres.wholphin.ui.ProgramItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions
import com.github.damontecres.wholphin.ui.playback.getTypeFor
import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString
@ -26,6 +26,7 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider
import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.ui.util.StringStringProvider
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler
@ -65,6 +66,7 @@ import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
@ -507,7 +509,7 @@ class HomeSettingsService
val name =
ResProviderStringProvider(
R.string.favorite_items_title,
ResStringProvider(favoriteOptions[config.kind]!!),
ResStringProvider(formatTypeName(config.kind)),
)
HomeRowConfigDisplay(id, name, config)
}
@ -959,68 +961,86 @@ class HomeSettingsService
val title =
ResProviderStringProvider(
R.string.favorite_items_title,
ResStringProvider(favoriteOptions[row.kind]!!),
ResStringProvider(formatTypeName(row.kind)),
)
if (row.kind == BaseItemKind.PERSON) {
val request =
GetPersonsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY),
)
GetPersonsHandler
.execute(api, request)
.content.items
.map { BaseItem(it, true) }
.let {
Success(
title,
it,
row.viewOptions,
showViewMore = it.size >= limit,
)
val resultList =
when (row.kind) {
BaseItemKind.PERSON -> {
val request =
GetPersonsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
enableImages = true,
enableImageTypes = listOf(ImageType.PRIMARY),
)
GetPersonsHandler.execute(api, request).toBaseItems()
}
} else {
val fields =
if (row.kind == BaseItemKind.BOX_SET) {
HomeItemFieldsBoxSets
} else {
HomeItemFields
BaseItemKind.MUSIC_ARTIST -> {
val request =
GetArtistsRequest(
userId = userDto.id,
limit = limit,
fields = HomeItemFields,
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetArtistsHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetArtistsHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
}
}
else -> {
val fields =
if (row.kind == BaseItemKind.BOX_SET) {
HomeItemFieldsBoxSets
} else {
HomeItemFields
}
val request =
GetItemsRequest(
userId = userDto.id,
recursive = true,
limit = limit,
fields = fields,
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.toBaseItems(row.viewOptions.useSeries)
}
}
val request =
GetItemsRequest(
userId = userDto.id,
recursive = true,
limit = limit,
fields = fields,
includeItemTypes = listOf(row.kind),
isFavorite = true,
)
if (usePaging) {
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
scope,
useSeriesForPrimary = row.viewOptions.useSeries,
).init()
} else {
GetItemsRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, row.viewOptions.useSeries) }
}.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
resultList.let {
Success(
title,
it,
row.viewOptions,
rowType = row,
showViewMore = it.size >= limit,
)
}
}

View file

@ -245,6 +245,7 @@ class NavDrawerService
it.copy(
items = items,
moreItems = moreItems,
allLibraries = allLibraries,
)
}
}
@ -255,6 +256,7 @@ data class NavDrawerItemState(
val moreItems: List<NavDrawerItem> = emptyList(),
val nowPlayingEnabled: Boolean = false,
val nowPlayingTitle: String? = null,
val allLibraries: List<Library> = emptyList(),
)
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true

View file

@ -427,6 +427,9 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
fun Response<BaseItemDtoQueryResult>.toBaseItems(useSeriesForPrimary: Boolean = false) =
this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) }
/**
* Check if this, coalescing nulls to zero, is greater than that
*/

View file

@ -237,7 +237,29 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
}
else -> {
CollectionType.LIVETV -> {
listOf(
BaseItemKind.TV_CHANNEL,
BaseItemKind.LIVE_TV_CHANNEL,
BaseItemKind.PROGRAM,
BaseItemKind.TV_PROGRAM,
BaseItemKind.LIVE_TV_PROGRAM,
)
}
CollectionType.TRAILERS -> {
listOf(BaseItemKind.TRAILER)
}
CollectionType.BOOKS -> {
listOf(BaseItemKind.BOOK)
}
CollectionType.FOLDERS -> {
listOf(BaseItemKind.FOLDER)
}
CollectionType.UNKNOWN -> {
listOf()
}
}

View file

@ -127,7 +127,8 @@ class CollectionFolderViewModel
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
@Assisted defaultViewOptions: ViewOptions,
) : ViewModel(),
ContextMenuProvider {
ContextMenuProvider,
CollectionFolderViewActions {
@AssistedFactory
interface Factory {
fun create(
@ -252,7 +253,7 @@ class CollectionFolderViewModel
}
}
fun saveViewOptions(viewOptions: ViewOptions) {
override fun saveViewOptions(viewOptions: ViewOptions) {
position = 0
_state.update { it.copy(viewOptions = viewOptions) }
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
@ -263,7 +264,7 @@ class CollectionFolderViewModel
}
}
fun onFilterChange(
override fun onFilterChange(
newFilter: GetItemsFilter,
recursive: Boolean,
) {
@ -278,7 +279,7 @@ class CollectionFolderViewModel
)
}
fun onSortChange(
override fun onSortChange(
sortAndDirection: SortAndDirection,
recursive: Boolean,
filter: GetItemsFilter,
@ -459,7 +460,7 @@ class CollectionFolderViewModel
return request
}
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
filterOptionCache.getFilterOptionValues(
itemId.toUUID(),
filterOption,
@ -469,7 +470,7 @@ class CollectionFolderViewModel
* The count must come from the same endpoint that [createPager] pages through, otherwise
* the index refers to a different result set and lands past the end of the list.
*/
suspend fun positionOfLetter(letter: Char): Int? =
override suspend fun positionOfLetter(letter: Char): Int? =
withContext(WholphinDispatchers.IO) {
val filter = state.value.filter
when (filter.override) {
@ -541,7 +542,7 @@ class CollectionFolderViewModel
}
}
fun updateBackdrop(item: BaseItem) {
override fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
@ -714,9 +715,83 @@ fun CollectionFolderView(
},
) {
val state by viewModel.state.collectAsState()
var position by rememberInt(viewModel.position)
LifecycleResumeEffect(itemId) {
viewModel.onResumePage()
val contextMenu = rememberContextMenu(preferences, viewModel)
onPauseOrDispose {
viewModel.release()
}
}
CollectionFolderViewContent(
preferences = preferences,
state = state,
savedPosition = viewModel.position,
itemId = itemId,
initialFilter = initialFilter,
recursive = recursive,
actions = actions,
sortOptions = sortOptions,
playEnabled = playEnabled,
defaultViewOptions = defaultViewOptions,
viewActions = viewModel,
modifier = modifier,
provider = viewModel,
showTitle = showTitle,
positionCallback = { columns, index ->
viewModel.position = index
positionCallback?.invoke(columns, index)
},
filterOptions = filterOptions,
focusRequesterOnEmpty = focusRequesterOnEmpty,
)
}
interface CollectionFolderViewActions {
fun navigateTo(destination: Destination)
fun updateBackdrop(item: BaseItem)
fun onSortChange(
sortAndDirection: SortAndDirection,
recursive: Boolean,
filter: GetItemsFilter,
)
fun onFilterChange(
newFilter: GetItemsFilter,
recursive: Boolean,
)
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption>
suspend fun positionOfLetter(letter: Char): Int?
fun saveViewOptions(viewOptions: ViewOptions)
}
@Composable
fun CollectionFolderViewContent(
preferences: UserPreferences,
state: CollectionFolderState,
savedPosition: Int,
itemId: String,
initialFilter: CollectionFolderFilter,
recursive: Boolean,
actions: GridClickActions,
sortOptions: List<ItemSortBy>,
playEnabled: Boolean,
defaultViewOptions: ViewOptions,
viewActions: CollectionFolderViewActions,
modifier: Modifier = Modifier,
provider: ContextMenuProvider,
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
focusRequesterOnEmpty: FocusRequester? = null,
) {
var position by rememberInt(savedPosition)
val contextMenu = rememberContextMenu(preferences, provider)
var showViewOptions by rememberSaveable { mutableStateOf(false) }
var filterDropdownShowing by remember { mutableStateOf(false) }
val headerRowFocusRequester = remember { FocusRequester() }
@ -760,7 +835,7 @@ fun CollectionFolderView(
filter = state.filter,
)
}
viewModel.navigateTo(destination)
viewActions.navigateTo(destination)
}
Unit
},
@ -779,7 +854,7 @@ fun CollectionFolderView(
} else {
Destination.Playback(item)
}
viewModel.navigateTo(destination)
viewActions.navigateTo(destination)
},
)
}
@ -803,13 +878,6 @@ fun CollectionFolderView(
?: item?.data?.collectionType?.name
?: stringResource(R.string.collection)
Column(modifier = Modifier.fillMaxSize()) {
LifecycleResumeEffect(itemId) {
viewModel.onResumePage()
onPauseOrDispose {
viewModel.release()
}
}
var showHeader by rememberSaveable { mutableStateOf(true) }
val gridFocusRequester = remember { FocusRequester() }
val pager = remember(state.items) { state.items.successValue }
@ -817,7 +885,7 @@ fun CollectionFolderView(
val focusedItem = pager?.getOrNull(position)
if (state.viewOptions.showBackdrop) {
LaunchedEffect(focusedItem) {
focusedItem?.let(viewModel::updateBackdrop)
focusedItem?.let(viewActions::updateBackdrop)
}
}
CollectionFolderHeader(
@ -827,15 +895,15 @@ fun CollectionFolderView(
title = title,
sortAndDirection = state.sortAndDirection,
onSortChange = {
viewModel.onSortChange(it, recursive, state.filter)
viewActions.onSortChange(it, recursive, state.filter)
},
sortOptions = sortOptions,
currentFilter = state.filter,
onFilterChange = {
viewModel.onFilterChange(it, recursive)
viewActions.onFilterChange(it, recursive)
},
getPossibleFilterValues = {
viewModel.getFilterOptionValues(it)
viewActions.getFilterOptionValues(it)
},
filterOptions = filterOptions,
onClickPlayAll = gridActions.onClickPlayAll!!,
@ -875,7 +943,7 @@ fun CollectionFolderView(
CollectionFolderGrid(
preferences = preferences,
collectionType = item?.data?.collectionType,
initialPosition = viewModel.position,
initialPosition = savedPosition,
items = pager.data,
sortAndDirection = state.sortAndDirection,
modifier = Modifier.fillMaxSize(),
@ -883,12 +951,11 @@ fun CollectionFolderView(
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
positionCallback = { columns, pos ->
viewModel.position = pos
showHeader = pos < columns
position = pos
positionCallback?.invoke(columns, pos)
},
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem,
@ -897,7 +964,7 @@ fun CollectionFolderView(
CollectionFolderList(
preferences = preferences,
collectionType = item?.data?.collectionType,
initialPosition = viewModel.position,
initialPosition = savedPosition,
items = pager.data,
sortAndDirection = state.sortAndDirection,
modifier = Modifier.fillMaxSize(),
@ -905,12 +972,11 @@ fun CollectionFolderView(
onClickItem = gridActions.onClickItem,
onLongClickItem = gridActions.onLongClickItem!!,
positionCallback = { columns, pos ->
viewModel.position = pos
showHeader = pos < columns
position = pos
positionCallback?.invoke(columns, pos)
},
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
viewOptions = state.viewOptions,
onClickPlay = gridActions.onClickPlayRemoteButton!!,
focusedItem = focusedItem,
@ -946,9 +1012,9 @@ fun CollectionFolderView(
defaultViewOptions = defaultViewOptions,
onDismissRequest = {
showViewOptions = false
viewModel.saveViewOptions(state.viewOptions)
viewActions.saveViewOptions(state.viewOptions)
},
onViewOptionsChange = viewModel::saveViewOptions,
onViewOptionsChange = viewActions::saveViewOptions,
)
}
}

View file

@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -43,6 +45,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.StringStringProvider
import timber.log.Timber
import java.util.SortedMap
@Composable
fun TabRow(
@ -109,6 +112,79 @@ fun TabRow(
}
}
@Composable
fun <T> KeyedTabRow(
selectedTabKey: T,
tabs: SortedMap<T, TabDetails>,
onClick: (T) -> Unit,
modifier: Modifier = Modifier,
) {
val state = rememberLazyListState()
LaunchedEffect(selectedTabKey) {
tabs[selectedTabKey]?.bringIntoViewRequester?.bringIntoView()
}
var rowHasFocus by remember { mutableStateOf(false) }
val currentSelectedTabKey by rememberUpdatedState(selectedTabKey)
val currentOnClick by rememberUpdatedState(onClick)
val tabKeyList = remember(tabs) { tabs.keys.filterNotNull().toList() }
LazyRow(
state = state,
modifier =
modifier
.onFocusChanged {
rowHasFocus = it.hasFocus
}.focusGroup()
.focusProperties {
onEnter = {
// If entering from left or right, use last or first tab
// Otherwise use the selected tab
val currentKey = currentSelectedTabKey
// val requesters = currentFocusRequesters
Timber.v(
"onEnter requestedFocusDirection=%s, currentSelectedTabKey=%s",
requestedFocusDirection,
currentSelectedTabKey,
)
val key =
if (requestedFocusDirection == FocusDirection.Left) {
tabs.lastKey()
} else if (requestedFocusDirection == FocusDirection.Right) {
tabs.firstKey()
} else {
currentKey
}
val tab = tabs[key]
(tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus()
}
},
) {
items(tabKeyList, key = { it }) { tabKey ->
tabs[tabKey]?.let { tab ->
val interactionSource = remember { MutableInteractionSource() }
val onTabClick =
remember(tabKey) {
{
currentOnClick(tabKey)
}
}
Tab(
title = tab.title.getString(),
selected = tabKey == selectedTabKey,
rowActive = rowHasFocus,
interactionSource = interactionSource,
onClick = onTabClick,
modifier =
Modifier
.focusRequester(tab.tabFocusRequester)
.bringIntoViewRequester(tab.bringIntoViewRequester),
)
}
}
}
}
@Composable
fun Tab(
title: String,

View file

@ -1,14 +1,21 @@
package com.github.damontecres.wholphin.ui.components
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.unit.dp
@ -27,13 +34,15 @@ import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import java.util.SortedMap
@HiltViewModel(assistedFactory = TabViewModel.Factory::class)
class TabViewModel
@AssistedInject
constructor(
private val userPreferencesService: UserPreferencesService,
val userPreferencesService: UserPreferencesService,
private val rememberedTabService: RememberedTabService,
private val backdropService: BackdropService,
@param:Assisted private val itemId: String,
@ -50,6 +59,9 @@ class TabViewModel
private val _state = MutableStateFlow<Int>(UNSET)
val state: StateFlow<Int> = _state
val isShowClock =
userPreferencesService.flow.map { it.appPreferences.interfacePreferences.showClock }
init {
viewModelScope.launchIO {
val startingTab =
@ -91,6 +103,7 @@ data class TabDetails(
val title: StringProvider,
val tabFocusRequester: FocusRequester = FocusRequester(),
val contentFocusRequester: FocusRequester = FocusRequester(),
val bringIntoViewRequester: BringIntoViewRequester = BringIntoViewRequester(),
) {
constructor(
@StringRes stringResId: Int,
@ -118,7 +131,36 @@ fun TabbedPage(
tabContent: @Composable (Int, TabDetails) -> Unit,
) {
val selectedTabIndex by viewModel.state.collectAsState()
val isShowClock by viewModel.isShowClock.collectAsState(true)
TabbedPage(
selectedTabIndex = selectedTabIndex,
tabs = tabs,
updateSelectedTabIndex = viewModel::updateSelectedTabIndex,
isShowClock = isShowClock,
modifier = modifier,
showTabs = showTabs,
tabContent = tabContent,
)
}
@Composable
fun TabbedPage(
selectedTabIndex: Int,
tabs: List<TabDetails>,
updateSelectedTabIndex: (Int) -> Unit,
isShowClock: Boolean,
modifier: Modifier = Modifier,
showTabs: Boolean = true,
tabContent: @Composable (Int, TabDetails) -> Unit,
) {
val endPadding =
remember(isShowClock) {
if (isShowClock) {
184.dp
} else {
0.dp
}
}
Column(
modifier = modifier,
) {
@ -131,13 +173,13 @@ fun TabbedPage(
selectedTabIndex = selectedTabIndex,
modifier =
Modifier
.padding(vertical = 16.dp),
.padding(top = 16.dp, bottom = 16.dp, end = endPadding),
tabs = tabs,
onClick = viewModel::updateSelectedTabIndex,
onClick = updateSelectedTabIndex,
)
}
selectedTabIndex.let { tabIndex ->
if (tabIndex >= 0) {
if (tabIndex in tabs.indices) {
tabContent.invoke(tabIndex, tabs[tabIndex])
} else {
DelayedLoadingPage(focusEnabled = false)
@ -145,3 +187,55 @@ fun TabbedPage(
}
}
}
@Composable
fun <T> KeyedTabbedPage(
selectedTabKey: T,
tabs: SortedMap<T, TabDetails>,
updateSelectedTabKey: (T) -> Unit,
isShowClock: Boolean,
modifier: Modifier = Modifier,
showTabs: Boolean = true,
tabContent: @Composable (T, TabDetails) -> Unit,
) {
val endPadding =
remember(isShowClock) {
if (isShowClock) {
184.dp
} else {
0.dp
}
}
Column(
modifier = modifier,
) {
AnimatedVisibility(
showTabs,
enter = expandVertically(),
exit = shrinkVertically(),
) {
KeyedTabRow(
selectedTabKey = selectedTabKey,
modifier =
Modifier
.padding(top = 16.dp, bottom = 16.dp, end = endPadding),
tabs = tabs,
onClick = updateSelectedTabKey,
)
}
AnimatedContent(
targetState = selectedTabKey,
transitionSpec = {
(fadeIn(animationSpec = tween(300, delayMillis = 100)))
.togetherWith(fadeOut(animationSpec = tween(200)))
},
) { tabKey ->
val tab = tabs[tabKey]
if (tab != null) {
tabContent.invoke(tabKey, tab)
} else {
DelayedLoadingPage(focusEnabled = false)
}
}
}
}

View file

@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.preferences.ComposablePreference
import com.github.damontecres.wholphin.ui.tryRequestFocus
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
/**
@ -313,3 +314,27 @@ enum class ViewOptionsType {
LIST,
DENSE_LIST,
}
val BaseItemKind.defaultViewOptions: ViewOptions
get() =
when (this) {
BaseItemKind.AUDIO,
BaseItemKind.MUSIC_ALBUM,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.PLAYLIST,
-> ViewOptionsSquare
BaseItemKind.EPISODE,
BaseItemKind.VIDEO,
BaseItemKind.MUSIC_VIDEO,
BaseItemKind.PHOTO,
BaseItemKind.PHOTO_ALBUM,
BaseItemKind.TV_CHANNEL,
BaseItemKind.LIVE_TV_CHANNEL,
BaseItemKind.PROGRAM,
BaseItemKind.TV_PROGRAM,
BaseItemKind.LIVE_TV_PROGRAM,
-> ViewOptionsWide
else -> ViewOptionsPoster
}

View file

@ -1,51 +1,37 @@
package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderView
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GridClickActions
import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.TabbedPage
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
import com.github.damontecres.wholphin.ui.components.ViewOptionsWide
import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import javax.inject.Inject
@HiltViewModel
class FavoritesViewModel
@Inject
constructor(
val navigationManager: NavigationManager,
) : ViewModel()
import com.github.damontecres.wholphin.ui.components.KeyedTabbedPage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.defaultViewOptions
import com.github.damontecres.wholphin.ui.components.rememberContextMenu
import com.github.damontecres.wholphin.ui.data.rememberSortOptions
import com.github.damontecres.wholphin.ui.tryRequestFocus
@Composable
fun FavoritesPage(
@ -53,235 +39,91 @@ fun FavoritesPage(
modifier: Modifier = Modifier,
viewModel: FavoritesViewModel = hiltViewModel(),
) {
val tabs =
remember {
listOf(
TabDetails(R.string.movies_title),
TabDetails(R.string.tv_shows_title),
TabDetails(R.string.episodes),
TabDetails(R.string.videos),
TabDetails(R.string.playlists),
TabDetails(R.string.people_title),
)
}
val actions =
remember {
GridClickActions(
onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) },
)
OneTimeLaunchedEffect { viewModel.init() }
val state by viewModel.state.collectAsState()
var showTabs by rememberSaveable { mutableStateOf(true) }
when (val s = state.loadingState) {
is FavoritesLoadingState.Error -> {
ErrorMessage(s.message, s.exception, modifier)
}
var showHeader by rememberSaveable { mutableStateOf(true) }
FavoritesLoadingState.Loading,
FavoritesLoadingState.Pending,
-> {
LoadingPage(modifier)
}
FavoritesLoadingState.NoFavorites -> {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.focusRequester(focusRequester)
.focusable(),
) {
Text(
text = stringResource(R.string.no_favorites_found),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier,
)
}
}
FavoritesLoadingState.Success -> {
// LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
TabbedPage(
itemId = NavDrawerItem.Favorites.id,
tabs = tabs,
showTabs = showHeader,
modifier = modifier,
) { tabIndex, tabDetails ->
// TODO playEnabled = true for movies & episodes
when (tabIndex) {
// Movies
0 -> {
CollectionFolderView(
preferences = preferences,
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_movies",
initialFilter =
CollectionFolderFilter(
filter =
GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.MOVIE),
),
),
showTitle = false,
recursive = true,
sortOptions = MovieSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
KeyedTabbedPage(
selectedTabKey = state.tabKey,
updateSelectedTabKey = viewModel::updateSelectedTabKey,
tabs = state.tabs,
showTabs = showTabs,
isShowClock = state.isShowClock,
modifier = modifier,
) { type, tabDetails ->
val collectionState = state.favorites[type]
if (collectionState != null) {
val provider = remember(type) { viewModel.createTypedProvider(type) }
val contextMenu = rememberContextMenu(preferences, provider)
val actions =
remember(type) {
GridClickActions(
onClickItem = { _, item ->
viewModel.navigationManager.navigateTo(
item.destination(),
)
},
onLongClickItem = contextMenu::showContextMenu,
)
}
CollectionFolderViewContent(
preferences = preferences,
state = collectionState,
savedPosition = 0,
itemId = remember { viewModel.libraryDisplayItemId(type) },
initialFilter = CollectionFolderFilter(),
recursive = true,
actions = actions,
sortOptions = rememberSortOptions(type),
// TODO playEnabled = true for movies & episodes
playEnabled = false,
defaultViewOptions = type.defaultViewOptions,
viewActions = provider,
provider = provider,
showTitle = false,
positionCallback = { columns, index ->
showTabs = index < columns
},
focusRequesterOnEmpty = null,
filterOptions = DefaultForFavoritesFilterOptions,
)
// TV
1 -> {
CollectionFolderView(
preferences = preferences,
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_series",
initialFilter =
CollectionFolderFilter(
filter =
GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.SERIES),
),
),
showTitle = false,
recursive = true,
sortOptions = SeriesSortOptions,
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
// Episodes
2 -> {
CollectionFolderView(
preferences = preferences,
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_episodes",
initialFilter =
CollectionFolderFilter(
filter =
GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.EPISODE),
),
),
showTitle = false,
recursive = true,
sortOptions = EpisodeSortOptions,
defaultViewOptions = ViewOptionsWide,
useSeriesForPrimary = false,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
// Videos
3 -> {
CollectionFolderView(
preferences = preferences,
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_videos",
initialFilter =
CollectionFolderFilter(
filter =
GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.VIDEO),
),
),
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
defaultViewOptions = ViewOptionsWide,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
// Playlists
4 -> {
CollectionFolderView(
preferences = preferences,
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_playlists",
initialFilter =
CollectionFolderFilter(
filter =
GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
),
),
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
defaultViewOptions = ViewOptionsSquare,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
filterOptions = DefaultForFavoritesFilterOptions,
focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
// People
5 -> {
CollectionFolderView(
preferences = preferences,
actions = actions,
itemId = "${NavDrawerItem.Favorites.id}_people",
initialFilter =
CollectionFolderFilter(
filter =
GetItemsFilter(
favorite = true,
override = GetItemsFilterOverride.PERSON,
),
),
initialSortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
showTitle = false,
recursive = true,
sortOptions = listOf(),
defaultViewOptions = ViewOptionsPoster,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(tabDetails.contentFocusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
playEnabled = false,
filterOptions = listOf(),
focusRequesterOnEmpty = tabDetails.tabFocusRequester,
)
}
else -> {
ErrorMessage("Invalid tab index $tabIndex", null)
contextMenu.Compose()
} else {
ErrorMessage("Invalid tab $type", null)
}
}
}
}

View file

@ -0,0 +1,774 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.lifecycle.ViewModel
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.FilterOptionCache
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.RememberedTabService
import com.github.damontecres.wholphin.services.StreamChoiceService
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.components.CollectionFolderState
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewActions
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
import com.github.damontecres.wholphin.ui.components.TabDetails
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.components.defaultViewOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.equalsNotNull
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.main.settings.Library
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
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.GetArtistsHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.WholphinDispatchers
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import timber.log.Timber
import java.util.SortedMap
import java.util.TreeMap
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class FavoritesViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
val navigationManager: NavigationManager,
private val serverRepository: ServerRepository,
private val navDrawerService: NavDrawerService,
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
val streamChoiceService: StreamChoiceService,
val mediaReportService: MediaReportService,
private val filterOptionCache: FilterOptionCache,
private val rememberedTabService: RememberedTabService,
) : ViewModel() {
private val _state = MutableStateFlow(FavoritesPageState())
val state: StateFlow<FavoritesPageState> = _state
fun init() {
viewModelScope.launchIO {
val rememberTabs =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.rememberSelectedTab
setupPossibleTypes()
val favoriteTypeOptions = state.value.favoriteTypeOptions
if (favoriteTypeOptions.isNotEmpty()) {
val tabKey =
if (rememberTabs) {
val rememberedTab =
rememberedTabService
.getRememberedTab(NavDrawerItem.Favorites.id)
?.let {
BaseItemKind.entries[it]
}
if (rememberedTab != null && rememberedTab in favoriteTypeOptions) {
rememberedTab
} else {
favoriteTypeOptions.first()
}
} else {
favoriteTypeOptions.first()
}
val jobs =
favoriteTypeOptions.associateWith { type -> initialFetchForType(type) }
initialFetchAndWait(tabKey, jobs)
} else {
Timber.w("No available types to check for favorites!")
_state.update {
it.copy(loadingState = FavoritesLoadingState.NoFavorites)
}
}
userPreferencesService.flow
.map { it.appPreferences.interfacePreferences.showClock }
.collectLatestIn(viewModelScope) { isShowClock ->
_state.update { it.copy(isShowClock = isShowClock) }
}
}
}
@VisibleForTesting
internal fun setupPossibleTypes() {
val availableTypes =
navDrawerService.state.value.allLibraries
.possibleFavoriteTypes(false)
.toSet()
val favoriteTypeOptions = favoriteOptions.filter { it in availableTypes }
_state.update {
it.copy(
favoriteTypeOptions = favoriteTypeOptions,
)
}
}
/**
* Starts queries for favorites, waiting for [firstTabKey] to complete
*
* Exposed for testing
*/
internal suspend fun initialFetchAndWait(
firstTabKey: BaseItemKind,
jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>,
) {
val completed =
jobs[firstTabKey]?.let {
when (val result = it.await()) {
DataLoadingState.Loading,
DataLoadingState.Pending,
-> false
is DataLoadingState.Error,
-> true
is DataLoadingState.Success<List<BaseItem?>> -> result.data.isNotEmpty()
}
} == true
if (completed) {
Timber.v("Got completed for %s", firstTabKey)
_state.update {
it.copy(
loadingState = FavoritesLoadingState.Success,
tabKey = firstTabKey,
)
}
} else {
// If the first key wasn't successful, wait in order until first successful & non-empty
val favoriteTypeOptions = state.value.favoriteTypeOptions
val firstCompleted =
favoriteTypeOptions.firstOrNull { type ->
val job = jobs[type] ?: return@firstOrNull false
val result = job.await()
result is DataLoadingState.Error ||
(result is DataLoadingState.Success<List<BaseItem?>> && result.data.isNotEmpty())
}
_state.update {
if (firstCompleted != null) {
it.copy(
loadingState = FavoritesLoadingState.Success,
tabKey = firstCompleted,
)
} else {
// None were error or successful & non-empty
// Check if they were all empty which means the user has no favorites at all
val allEmpty =
jobs.all { (_, job) ->
val result = job.await()
result is DataLoadingState.Success<List<BaseItem?>> && result.data.isEmpty()
}
if (allEmpty) {
it.copy(
loadingState = FavoritesLoadingState.NoFavorites,
)
} else {
// This means a least one failed
val firstError =
jobs.entries.firstOrNull { (_, job) ->
job.await() is DataLoadingState.Error
}
if (firstError != null) {
it.copy(
loadingState = FavoritesLoadingState.Success,
tabKey = firstError.key,
)
} else {
// This shouldn't happen
it.copy(
loadingState = FavoritesLoadingState.Error("An unknown error occurred"),
)
}
}
}
}
}
}
/**
* Do the initial fetch for a [BaseItemKind] which will look up any existing saved sort and filter
*/
private fun initialFetchForType(type: BaseItemKind): Deferred<DataLoadingState<List<BaseItem?>>> =
viewModelScope.async(WholphinDispatchers.IO) {
try {
val collectionState =
CollectionFolderState(
item = DataLoadingState.Loading,
items = DataLoadingState.Loading,
backgroundLoading = LoadingState.Loading,
viewOptions = ViewOptions(),
)
_state.value.favorites[type] = collectionState
val libraryDisplayInfo =
serverRepository.currentUser?.let { user ->
libraryDisplayInfoDao.getItem(user, libraryDisplayItemId(type))
}
val sortAndDirection =
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection.DEFAULT
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter(favorite = true)
val viewOptions = libraryDisplayInfo?.viewOptions ?: type.defaultViewOptions
fetchType(type, sortAndDirection, filter, viewOptions)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error fetching favorites for %s", type)
updateForException(type, ex)
DataLoadingState.Error(ex)
}
}
/**
* Query for favorites of the specified [BaseItemKind] using the specified sort and filter
*/
@VisibleForTesting
internal suspend fun fetchType(
type: BaseItemKind,
sortAndDirection: SortAndDirection,
filter: GetItemsFilter,
viewOptions: ViewOptions,
): DataLoadingState<List<BaseItem?>> {
val collectionState =
CollectionFolderState(
item = DataLoadingState.Loading,
items = DataLoadingState.Loading,
backgroundLoading = LoadingState.Loading,
viewOptions = ViewOptions(),
)
_state.value.favorites[type] = collectionState
try {
val pager = createPager(type, filter, sortAndDirection)
_state.value.favorites[type] =
collectionState.copy(
item = DataLoadingState.Success(null),
items = DataLoadingState.Success(pager),
backgroundLoading = LoadingState.Success,
sortAndDirection = sortAndDirection,
filter = filter,
viewOptions = viewOptions,
)
Timber.v("Got %s favorites for %s", pager.size, type)
_state.update {
val newTabs =
it.createMap {
if (pager.isNotEmpty()) {
put(type, TabDetails(formatTypeName(type)))
} else {
remove(type)
}
}
it.copy(
tabs = newTabs,
)
}
return DataLoadingState.Success(pager)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
updateForException(type, ex)
return DataLoadingState.Error(ex)
}
}
/**
* If fetching data for a type fails, this should be called to update the state with the error
*/
private fun updateForException(
type: BaseItemKind,
ex: Exception,
) {
_state.update {
// Want to store the error for the tab
_state.value.favorites[type] =
CollectionFolderState(
item = DataLoadingState.Success(null),
items = DataLoadingState.Error(ex),
backgroundLoading = LoadingState.Success,
viewOptions = type.defaultViewOptions,
)
// And make sure the tab is included
val newTabs =
it.createMap {
put(type, TabDetails(formatTypeName(type)))
}
it.copy(tabs = newTabs)
}
}
private fun createGetItemsRequest(
type: BaseItemKind,
filter: GetItemsFilter,
sortAndDirection: SortAndDirection,
): GetItemsRequest =
filter.applyTo(
GetItemsRequest(
isFavorite = true,
recursive = true,
includeItemTypes = listOf(type),
fields = SlimItemFields,
sortBy = listOf(sortAndDirection.sort),
sortOrder = listOf(sortAndDirection.direction),
),
overwriteIncludeTypes = false,
)
private fun createGetArtistsRequest(
filter: GetItemsFilter,
sortAndDirection: SortAndDirection,
): GetArtistsRequest =
filter.applyTo(
GetArtistsRequest(
isFavorite = true,
fields = SlimItemFields,
sortBy = listOf(sortAndDirection.sort),
sortOrder = listOf(sortAndDirection.direction),
),
)
private fun createGetPersonsRequest(filter: GetItemsFilter): GetPersonsRequest =
filter.applyTo(
GetPersonsRequest(
isFavorite = true,
fields = SlimItemFields,
),
)
private suspend fun createPager(
type: BaseItemKind,
filter: GetItemsFilter,
sortAndDirection: SortAndDirection,
): ApiRequestPager<*> =
when (type) {
BaseItemKind.MUSIC_ARTIST -> {
val request = createGetArtistsRequest(filter, sortAndDirection)
ApiRequestPager(api, request, GetArtistsHandler, viewModelScope, pageSize = 50)
}
BaseItemKind.PERSON -> {
val request = createGetPersonsRequest(filter)
ApiRequestPager(api, request, GetPersonsHandler, viewModelScope, pageSize = 50)
}
else -> {
val request = createGetItemsRequest(type, filter, sortAndDirection)
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope, pageSize = 50)
}
}.init()
fun libraryDisplayItemId(type: BaseItemKind): String {
// Previous version used hardcoded keys before more types were supported
// So override those type names so that user's settings are maintained
val typeKey =
when (type) {
BaseItemKind.MOVIE -> "movies"
BaseItemKind.SERIES -> "series"
BaseItemKind.EPISODE -> "episodes"
BaseItemKind.VIDEO -> "videos"
BaseItemKind.PLAYLIST -> "playlists"
BaseItemKind.PERSON -> "people"
else -> type.serialName
}
return "${NavDrawerItem.Favorites.id}_$typeKey"
}
private fun collectionStateFor(type: BaseItemKind): CollectionFolderState? = state.value.favorites[type]
fun updateSelectedTabKey(newKey: BaseItemKind) {
viewModelScope.launchDefault { backdropService.clearBackdrop() }
viewModelScope.launchIO {
val rememberTabs =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.rememberSelectedTab
if (rememberTabs) {
rememberedTabService.saveRememberedTab(
NavDrawerItem.Favorites.id,
newKey.ordinal,
)
}
}
_state.update { it.copy(tabKey = newKey) }
}
private suspend fun refreshAfterMutate(
type: BaseItemKind,
position: Int,
itemId: UUID,
) {
try {
val pager =
collectionStateFor(type)?.let {
((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
}
pager?.refreshItem(position, itemId)
} catch (ex: Exception) {
Timber.e(ex, "Error refreshing after mutate %s", itemId)
showToast(context, "Error refreshing after item mutate")
}
}
private suspend fun refreshAfterDelete(
type: BaseItemKind,
position: Int,
deletedItem: BaseItem,
) {
try {
val pager =
collectionStateFor(type)?.let {
((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
}
position.let {
Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.id)
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", deletedItem.id)
showToast(context, "Error refreshing after item deleted")
}
}
private fun saveLibraryDisplayInfo(
type: BaseItemKind,
newFilter: GetItemsFilter,
newSort: SortAndDirection,
viewOptions: ViewOptions?,
) {
serverRepository.currentUser?.let { user ->
viewModelScope.launchIO {
val libraryDisplayInfo =
LibraryDisplayInfo(
userId = user.rowId,
itemId = libraryDisplayItemId(type),
sort = newSort.sort,
direction = newSort.direction,
filter = newFilter,
viewOptions = viewOptions,
)
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
}
}
}
private fun saveViewOptions(
type: BaseItemKind,
viewOptions: ViewOptions,
) {
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
collectionStateFor(type)?.let { collectionState ->
saveLibraryDisplayInfo(
type = type,
newFilter = collectionState.filter,
newSort = collectionState.sortAndDirection,
viewOptions = viewOptions,
)
state.value.favorites[type] =
collectionState.copy(
viewOptions = viewOptions,
)
if (!viewOptions.showBackdrop) {
backdropService.clearBackdrop()
}
}
}
}
fun createTypedProvider(type: BaseItemKind) = TypedProvider(type)
/**
* This provides context menu actions and other actions on a per [BaseItemKind] basis
*/
@Immutable
inner class TypedProvider(
val type: BaseItemKind,
) : ContextMenuProvider,
CollectionFolderViewActions {
override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true
override fun navigateTo(destination: Destination) = navigationManager.navigateTo(destination)
override fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
override fun deleteItem(
index: Int,
item: BaseItem,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
refreshAfterDelete(type, index, item)
}
}
}
override fun setWatched(
position: Int,
itemId: UUID,
played: Boolean,
) {
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played)
refreshAfterMutate(type, position, itemId)
}
}
override fun setFavorite(
position: Int,
itemId: UUID,
favorite: Boolean,
) {
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
favoriteWatchManager.setWatched(itemId, favorite)
collectionStateFor(type)?.let { collectionState ->
// TODO use pager.refreshPagesAfter ??
fetchType(
type,
collectionState.sortAndDirection,
collectionState.filter,
collectionState.viewOptions,
)
}
}
}
override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId)
override fun updateBackdrop(item: BaseItem) {
viewModelScope.launchIO {
backdropService.submit(item)
}
}
override fun onSortChange(
sortAndDirection: SortAndDirection,
recursive: Boolean,
filter: GetItemsFilter,
) {
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
Timber.v("onSortChange: type=%s, sortAndDirection=%s", type, sortAndDirection)
collectionStateFor(type)?.let { collectionState ->
saveLibraryDisplayInfo(
type = type,
newFilter = filter,
newSort = sortAndDirection,
viewOptions = collectionState.viewOptions,
)
state.value.favorites[type] =
collectionState.copy(
filter = filter,
sortAndDirection = sortAndDirection,
)
fetchType(type, sortAndDirection, filter, collectionState.viewOptions)
}
}
}
override fun onFilterChange(
newFilter: GetItemsFilter,
recursive: Boolean,
) {
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
Timber.v("onFilterChange: type=%s, newFilter=%s", type, newFilter)
collectionStateFor(type)?.let { collectionState ->
saveLibraryDisplayInfo(
type = type,
newFilter = newFilter,
newSort = collectionState.sortAndDirection,
viewOptions = collectionState.viewOptions,
)
state.value.favorites[type] =
collectionState.copy(
filter = newFilter,
)
fetchType(
type,
collectionState.sortAndDirection,
newFilter,
collectionState.viewOptions,
)
}
}
}
override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
// TODO use includeItemTypes?
filterOptionCache.getFilterOptionValues(
null,
filterOption,
)
override suspend fun positionOfLetter(letter: Char): Int? =
collectionStateFor(type)?.let { collectionState ->
when (type) {
BaseItemKind.MUSIC_ARTIST -> {
val request =
createGetArtistsRequest(
collectionState.filter,
collectionState.sortAndDirection,
).copy(
enableUserData = false,
limit = 0,
enableTotalRecordCount = true,
)
GetArtistsHandler.execute(api, request).content.totalRecordCount
}
BaseItemKind.PERSON -> {
null
}
else -> {
val request =
createGetItemsRequest(
type,
collectionState.filter,
collectionState.sortAndDirection,
).copy(
enableUserData = false,
limit = 0,
enableTotalRecordCount = true,
)
GetItemsRequestHandler.execute(api, request).content.totalRecordCount
}
}
}
override fun saveViewOptions(viewOptions: ViewOptions) {
saveViewOptions(type, viewOptions)
}
}
}
data class FavoritesPageState(
val loadingState: FavoritesLoadingState = FavoritesLoadingState.Pending,
val favoriteTypeOptions: List<BaseItemKind> = emptyList(),
val favorites: SnapshotStateMap<BaseItemKind, CollectionFolderState> = SnapshotStateMap(),
val tabs: SortedMap<BaseItemKind, TabDetails> = TreeMap(compareBy { favoriteOptions.indexOf(it) }),
val tabKey: BaseItemKind = favoriteOptions.first(),
val isShowClock: Boolean = true,
)
sealed interface FavoritesLoadingState {
data object Pending : FavoritesLoadingState
data object Loading : FavoritesLoadingState
data object Success : FavoritesLoadingState
data object NoFavorites : FavoritesLoadingState
data class Error(
val message: String? = null,
val exception: Throwable? = null,
) : FavoritesLoadingState {
constructor(exception: Throwable) : this(null, exception)
val localizedMessage: String =
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
}
}
/**
* List of supported favorite types
*
* This is also the order they will appear in the UI
*/
val favoriteOptions =
listOf(
BaseItemKind.MOVIE,
BaseItemKind.SERIES,
BaseItemKind.EPISODE,
BaseItemKind.BOX_SET,
BaseItemKind.PERSON,
BaseItemKind.TV_CHANNEL,
BaseItemKind.MUSIC_ALBUM,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.AUDIO,
BaseItemKind.MUSIC_VIDEO,
BaseItemKind.PLAYLIST,
BaseItemKind.VIDEO,
BaseItemKind.PHOTO,
BaseItemKind.PHOTO_ALBUM,
)
/**
* Creates a new tab map from the current state applying the supplied block which can contain mutations
*/
private inline fun FavoritesPageState.createMap(block: TreeMap<BaseItemKind, TabDetails>.() -> Unit) =
TreeMap<BaseItemKind, TabDetails>(compareBy { favoriteOptions.indexOf(it) })
.apply {
putAll(this@createMap.tabs)
block.invoke(this)
}
/**
* Given the list of libraries, return the possible favorite types
*/
fun List<Library>.possibleFavoriteTypes(sort: Boolean) =
flatMap { it.collectionType.baseItemKinds }
.distinct()
.filter { it in favoriteOptions }
.let { list ->
if (sort) {
list.sortedWith(compareBy { favoriteOptions.indexOf(it) })
} else {
list
}
}

View file

@ -15,10 +15,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions.Companion.genreDefault
import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
data class HomeRowPresets(
@ -59,6 +61,48 @@ data class HomeRowPresets(
-> HomeRowViewOptions()
}
fun getByItemType(type: BaseItemKind): HomeRowViewOptions =
when (type) {
BaseItemKind.MOVIE,
BaseItemKind.BOX_SET,
-> movieLibrary
BaseItemKind.SERIES,
BaseItemKind.SEASON,
-> tvLibrary
BaseItemKind.EPISODE -> continueWatching
BaseItemKind.VIDEO,
BaseItemKind.MUSIC_VIDEO,
BaseItemKind.PHOTO,
BaseItemKind.PHOTO_ALBUM,
-> videoLibrary
BaseItemKind.PLAYLIST -> playlist
BaseItemKind.PERSON -> movieLibrary
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.MUSIC_ALBUM,
BaseItemKind.AUDIO,
-> musicLibrary
BaseItemKind.TV_CHANNEL,
BaseItemKind.LIVE_TV_CHANNEL,
BaseItemKind.PROGRAM,
BaseItemKind.TV_PROGRAM,
BaseItemKind.LIVE_TV_PROGRAM,
-> liveTv
BaseItemKind.GENRE,
BaseItemKind.MUSIC_GENRE,
BaseItemKind.STUDIO,
-> genreDefault.copy(heightDp = genreSize)
else -> movieLibrary
}
companion object {
val WholphinDefault by lazy {
HomeRowPresets(

View file

@ -12,12 +12,14 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.res.stringResource
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.formatTypeName
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind
@Composable
fun HomeSettingsFavoriteList(
favoriteTypeOptions: List<BaseItemKind>,
onClick: (BaseItemKind) -> Unit,
modifier: Modifier = Modifier,
firstFocus: FocusRequester = remember { FocusRequester() },
@ -33,10 +35,10 @@ fun HomeSettingsFavoriteList(
.fillMaxHeight()
.focusRestorer(firstFocus),
) {
itemsIndexed(favoriteOptionsList) { index, type ->
itemsIndexed(favoriteTypeOptions) { index, type ->
HomeSettingsListItem(
selected = false,
headlineText = stringResource(favoriteOptions[type]!!),
headlineText = stringResource(formatTypeName(type)),
onClick = { onClick.invoke(type) },
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
@ -44,17 +46,3 @@ fun HomeSettingsFavoriteList(
}
}
}
val favoriteOptions by lazy {
mapOf(
BaseItemKind.MOVIE to R.string.movies_title,
BaseItemKind.SERIES to R.string.tv_shows_title,
BaseItemKind.EPISODE to R.string.episodes,
BaseItemKind.VIDEO to R.string.videos,
BaseItemKind.PLAYLIST to R.string.playlists,
BaseItemKind.PERSON to R.string.people_title,
BaseItemKind.MUSIC_ARTIST to R.string.artists,
BaseItemKind.MUSIC_ALBUM to R.string.albums,
)
}
val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }

View file

@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.possibleFavoriteTypes
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType
@ -256,7 +257,10 @@ fun HomeSettingsPage(
}
HomeSettingsDestination.ChooseFavorite -> {
val favoriteTypeOptions =
remember { state.libraries.possibleFavoriteTypes(true) }
HomeSettingsFavoriteList(
favoriteTypeOptions = favoriteTypeOptions,
onClick = { type ->
addRow { viewModel.addFavoriteRow(type) }
},

View file

@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.getRecentlyAddedTitle
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.tvAccess
import com.github.damontecres.wholphin.ui.AspectRatio
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.showToast
@ -415,9 +416,13 @@ class HomeSettingsViewModel
title =
ResProviderStringProvider(
R.string.favorite_items_title,
ResStringProvider(favoriteOptions[type]!!),
ResStringProvider(formatTypeName(type)),
),
config =
HomeRowConfig.Favorite(
kind = type,
viewOptions = HomeRowPresets.WholphinDefault.getByItemType(type),
),
config = HomeRowConfig.Favorite(type),
)
updateState {
it.copy(
@ -709,17 +714,7 @@ class HomeSettingsViewModel
}
is HomeRowConfig.Favorite -> {
val viewOptions =
when (it.config.kind) {
BaseItemKind.MOVIE -> preset.movieLibrary
BaseItemKind.SERIES -> preset.tvLibrary
BaseItemKind.EPISODE -> preset.continueWatching
BaseItemKind.VIDEO -> preset.videoLibrary
BaseItemKind.PLAYLIST -> preset.playlist
BaseItemKind.PERSON -> preset.movieLibrary
BaseItemKind.MUSIC_ARTIST, BaseItemKind.MUSIC_ALBUM -> preset.musicLibrary
else -> preset.movieLibrary
}
val viewOptions = preset.getByItemType(it.config.kind)
it.config.updateViewOptions(viewOptions)
}

View file

@ -99,16 +99,10 @@ class SearchViewModel
private fun init() {
viewModelScope.launchDefault {
val tvAccess = serverRepository.currentUserDto?.tvAccess == true
userLibraryTypes =
serverRepository.currentUser
?.id
?.let { userId ->
navDrawerService
.getAllUserLibraries(userId, tvAccess)
.flatMap { it.collectionType.baseItemKinds }
.toSet()
}.orEmpty()
navDrawerService.state.value.allLibraries
.flatMap { it.collectionType.baseItemKinds }
.toSet()
val excludedSearchableTypes =
serverRepository.currentUser?.id?.let { userId ->

View file

@ -914,4 +914,5 @@
<string name="dim_percentage">Dim percentage</string>
<string name="dim_screen">Dim screen</string>
<string name="photo_albums">Photo albums</string>
<string name="no_favorites_found">No favorites found!</string>
</resources>

View file

@ -80,6 +80,23 @@ fun playlist(
genreItems = genres,
)
/**
* Create a simple [BaseItemDto] of the specified type
*/
fun item(
type: BaseItemKind,
id: UUID = UUID.randomUUID(),
name: String = "Test $type",
genres: List<NameGuidPair>? = null,
): BaseItemDto =
BaseItemDto(
id = id,
type = type,
name = name,
seriesId = null,
genreItems = genres,
)
fun server(serverId: UUID = UUID.randomUUID()) = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11")
fun user(

View file

@ -0,0 +1,427 @@
package com.github.damontecres.wholphin.ui.detail
import android.content.Context
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.FilterOptionCache
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerItemState
import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.RememberedTabService
import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.test.item
import com.github.damontecres.wholphin.test.movie
import com.github.damontecres.wholphin.ui.components.ViewOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.util.DataLoadingState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.artistsApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.personsApi
import org.jellyfin.sdk.api.operations.ArtistsApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.PersonsApi
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class TestFavoritesViewModel {
private val context: Context = mockk()
private val api: ApiClient = mockk()
private val navigationManager: NavigationManager = mockk()
private val serverRepository: ServerRepository = mockk()
private val libraryDisplayInfoDao: LibraryDisplayInfoDao = mockk()
private val favoriteWatchManager: FavoriteWatchManager = mockk()
private val backdropService: BackdropService = mockk()
private val userPreferencesService: UserPreferencesService = mockk()
private val mediaManagementService: MediaManagementService = mockk()
private val streamChoiceService: StreamChoiceService = mockk()
private val mediaReportService: MediaReportService = mockk()
private val filterOptionCache: FilterOptionCache = mockk()
private val rememberedTabService: RememberedTabService = mockk()
private val navDrawerService: NavDrawerService = mockk()
private val itemsApi: ItemsApi = mockk()
private val artistsApi: ArtistsApi = mockk()
private val personsApi: PersonsApi = mockk()
@Before
fun setup() {
every { api.itemsApi } returns itemsApi
every { api.artistsApi } returns artistsApi
every { api.personsApi } returns personsApi
every { navDrawerService.state } returns
MutableStateFlow(
NavDrawerItemState(
allLibraries =
listOf(
library(CollectionType.MOVIES),
library(CollectionType.TVSHOWS),
library(CollectionType.BOXSETS),
),
),
)
}
private fun createViewModel(): FavoritesViewModel =
FavoritesViewModel(
context = context,
api = api,
navigationManager = navigationManager,
serverRepository = serverRepository,
libraryDisplayInfoDao = libraryDisplayInfoDao,
favoriteWatchManager = favoriteWatchManager,
backdropService = backdropService,
userPreferencesService = userPreferencesService,
mediaManagementService = mediaManagementService,
streamChoiceService = streamChoiceService,
mediaReportService = mediaReportService,
filterOptionCache = filterOptionCache,
rememberedTabService = rememberedTabService,
navDrawerService = navDrawerService,
).apply {
setupPossibleTypes()
}
private val items = listOf(BaseItem(movie()))
private val empty = emptyList<BaseItem>()
private fun successJob(items: List<BaseItem>) = CompletableDeferred(DataLoadingState.Success(items))
private fun errorJob() = CompletableDeferred(DataLoadingState.Error())
private suspend fun testInitialFetchAndWait(
firstTabKey: BaseItemKind,
jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>,
asserts: (FavoritesPageState) -> Unit,
) {
createViewModel().let { viewModel ->
viewModel.initialFetchAndWait(
firstTabKey = firstTabKey,
jobs = jobs,
)
viewModel.state.value.let { state ->
asserts.invoke(state)
}
}
}
@Test
fun `Test tabKey for first success`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to successJob(items),
BaseItemKind.SERIES to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.MOVIE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.MOVIE, state.tabKey)
}
}
@Test
fun `Test tabKey for second success`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to successJob(empty),
BaseItemKind.SERIES to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.MOVIE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.SERIES,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
}
@Test
fun `Test tabKey for first failure`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to errorJob(),
BaseItemKind.SERIES to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.MOVIE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.MOVIE, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.SERIES,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
}
@Test
fun `Test tabKey for multiple empty`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to successJob(empty),
BaseItemKind.SERIES to successJob(items),
BaseItemKind.EPISODE to successJob(empty),
BaseItemKind.BOX_SET to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.MOVIE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.SERIES,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.EPISODE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.SERIES, state.tabKey)
}
}
@Test
fun `Test tabKey for multiple empty with first error`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to errorJob(),
BaseItemKind.SERIES to successJob(empty),
BaseItemKind.EPISODE to successJob(items),
BaseItemKind.BOX_SET to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.SERIES,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.MOVIE, state.tabKey)
}
}
@Test
fun `Test tabKey for multiple empty with first empty`() =
runTest {
val jobs =
mapOf(
BaseItemKind.MOVIE to successJob(empty),
BaseItemKind.SERIES to successJob(empty),
BaseItemKind.EPISODE to successJob(empty),
BaseItemKind.BOX_SET to successJob(items),
)
testInitialFetchAndWait(
firstTabKey = BaseItemKind.EPISODE,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.BOX_SET, state.tabKey)
}
testInitialFetchAndWait(
firstTabKey = BaseItemKind.BOX_SET,
jobs = jobs,
) { state ->
assertEquals(BaseItemKind.BOX_SET, state.tabKey)
}
}
@Test
fun `Test fetchType basic`() =
runTest {
val movie = movie()
coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(movie))
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.MOVIE,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Success)
assertEquals(BaseItem(movie), (data as DataLoadingState.Success).data.first())
viewModel.state.value.let { state ->
assertTrue(BaseItemKind.MOVIE in state.tabs)
assertTrue(BaseItemKind.MOVIE in state.favorites)
state.favorites[BaseItemKind.MOVIE]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Success)
}
}
coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) }
}
@Test
fun `Test fetchType with error`() =
runTest {
val ex = Exception()
coEvery { itemsApi.getItems(any<GetItemsRequest>()) } throws ex
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.MOVIE,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Error)
assertEquals(ex, (data as DataLoadingState.Error).exception)
viewModel.state.value.let { state ->
assertTrue(BaseItemKind.MOVIE in state.tabs)
assertTrue(BaseItemKind.MOVIE in state.favorites)
state.favorites[BaseItemKind.MOVIE]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Error)
assertEquals(ex, (st.items as DataLoadingState.Error).exception)
}
}
coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) }
}
@Test
fun `Test fetchType with empty result does not add tab`() =
runTest {
coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult()
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.MOVIE,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Success)
assertEquals(0, (data as DataLoadingState.Success).data.size)
viewModel.state.value.let { state ->
assertFalse(BaseItemKind.MOVIE in state.tabs)
assertTrue(BaseItemKind.MOVIE in state.favorites)
state.favorites[BaseItemKind.MOVIE]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Success)
assertEquals(0, (st.items as DataLoadingState.Success).data.size)
}
}
coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) }
}
@Test
fun `Test fetchType for artists`() =
runTest {
val item = item(BaseItemKind.MUSIC_ARTIST)
coEvery { artistsApi.getArtists(any<GetArtistsRequest>()) } returns successQueryResult(listOf(item))
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.MUSIC_ARTIST,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Success)
assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first())
viewModel.state.value.let { state ->
assertTrue(BaseItemKind.MUSIC_ARTIST in state.tabs)
assertTrue(BaseItemKind.MUSIC_ARTIST in state.favorites)
state.favorites[BaseItemKind.MUSIC_ARTIST]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Success)
}
}
coVerify(exactly = 1) { artistsApi.getArtists(any<GetArtistsRequest>()) }
coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) }
coVerify(exactly = 0) { personsApi.getPersons(any<GetPersonsRequest>()) }
}
@Test
fun `Test fetchType for persons`() =
runTest {
val item = item(BaseItemKind.PERSON)
coEvery { personsApi.getPersons(any<GetPersonsRequest>()) } returns successQueryResult(listOf(item))
val viewModel = createViewModel()
val data =
viewModel.fetchType(
type = BaseItemKind.PERSON,
sortAndDirection = SortAndDirection.DEFAULT,
filter = GetItemsFilter(),
viewOptions = ViewOptions(),
)
assertTrue(data is DataLoadingState.Success)
assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first())
viewModel.state.value.let { state ->
assertTrue(BaseItemKind.PERSON in state.tabs)
assertTrue(BaseItemKind.PERSON in state.favorites)
state.favorites[BaseItemKind.PERSON]!!.let { st ->
assertTrue(st.item is DataLoadingState.Success)
assertTrue(st.items is DataLoadingState.Success)
}
}
coVerify(exactly = 1) { personsApi.getPersons(any<GetPersonsRequest>()) }
coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) }
coVerify(exactly = 0) { artistsApi.getArtists(any<GetArtistsRequest>()) }
}
}
private fun library(type: CollectionType) =
Library(
itemId = UUID.randomUUID(),
name = "name-${type.serialName}",
type = BaseItemKind.COLLECTION_FOLDER,
collectionType = type,
isRecordingFolder = false,
)