From fdde98602921b8ecd7f52b3d18f32d3965ba8ab7 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 26 Aug 2026 21:50:13 -0400 Subject: [PATCH] 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 --- .../wholphin/data/model/HomeRowConfig.kt | 10 +- .../wholphin/services/HomeSettingsService.kt | 140 ++-- .../wholphin/services/NavDrawerService.kt | 2 + .../damontecres/wholphin/ui/Extensions.kt | 3 + .../ui/components/CollectionFolderGrid.kt | 24 +- .../ui/components/CollectionFolderView.kt | 126 ++- .../wholphin/ui/components/TabRow.kt | 76 ++ .../wholphin/ui/components/TabbedPage.kt | 102 ++- .../ui/components/ViewOptionsDialog.kt | 25 + .../wholphin/ui/detail/FavoritesPage.kt | 352 +++----- .../wholphin/ui/detail/FavoritesViewModel.kt | 774 ++++++++++++++++++ .../ui/main/settings/HomeRowPresets.kt | 44 + .../main/settings/HomeSettingsFavoriteList.kt | 20 +- .../ui/main/settings/HomeSettingsPage.kt | 4 + .../ui/main/settings/HomeSettingsViewModel.kt | 21 +- .../wholphin/ui/search/SearchViewModel.kt | 12 +- app/src/main/res/values/strings.xml | 1 + .../damontecres/wholphin/test/TestUtils.kt | 17 + .../ui/detail/TestFavoritesViewModel.kt | 427 ++++++++++ 19 files changed, 1783 insertions(+), 397 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 231cb670..4914248f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -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) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 934e440d..ff7d7411 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index a4716ac9..6b8701ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -245,6 +245,7 @@ class NavDrawerService it.copy( items = items, moreItems = moreItems, + allLibraries = allLibraries, ) } } @@ -255,6 +256,7 @@ data class NavDrawerItemState( val moreItems: List = emptyList(), val nowPlayingEnabled: Boolean = false, val nowPlayingTitle: String? = null, + val allLibraries: List = emptyList(), ) val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 2097dd9e..312bc6cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -427,6 +427,9 @@ fun Response.toBaseItems( useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } +fun Response.toBaseItems(useSeriesForPrimary: Boolean = false) = + this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) } + /** * Check if this, coalescing nulls to zero, is greater than that */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 024eeea4..1df5ee90 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -237,7 +237,29 @@ val CollectionType.baseItemKinds: List 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() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index f2b17325..c1566b61 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -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 = + override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = 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 + + 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, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + viewActions: CollectionFolderViewActions, + modifier: Modifier = Modifier, + provider: ContextMenuProvider, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + filterOptions: List> = 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, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index 293005dc..f2f60ad8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -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 KeyedTabRow( + selectedTabKey: T, + tabs: SortedMap, + 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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt index 28e163a0..0749c068 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabbedPage.kt @@ -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(UNSET) val state: StateFlow = _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, + 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 KeyedTabbedPage( + selectedTabKey: T, + tabs: SortedMap, + 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) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index c9c8f98b..a1237acd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -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 + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index b8e40895..0e46999f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -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) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt new file mode 100644 index 00000000..734ac3d0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt @@ -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 = _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>>>, + ) { + val completed = + jobs[firstTabKey]?.let { + when (val result = it.await()) { + DataLoadingState.Loading, + DataLoadingState.Pending, + -> false + + is DataLoadingState.Error, + -> true + + is DataLoadingState.Success> -> 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> && 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> && 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>> = + 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> { + 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 = + // 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 = emptyList(), + val favorites: SnapshotStateMap = SnapshotStateMap(), + val tabs: SortedMap = 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.() -> Unit) = + TreeMap(compareBy { favoriteOptions.indexOf(it) }) + .apply { + putAll(this@createMap.tabs) + block.invoke(this) + } + +/** + * Given the list of libraries, return the possible favorite types + */ +fun List.possibleFavoriteTypes(sort: Boolean) = + flatMap { it.collectionType.baseItemKinds } + .distinct() + .filter { it in favoriteOptions } + .let { list -> + if (sort) { + list.sortedWith(compareBy { favoriteOptions.indexOf(it) }) + } else { + list + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index 383ed075..922ae3cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt index 72208d0d..96c7583c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -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, 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() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index e3bea235..5a2d6c78 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -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) } }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index ff8099c7..6faf855b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -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) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt index d0c55c05..b9f8a9de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt @@ -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 -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfb3caac..549614c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -914,4 +914,5 @@ Dim percentage Dim screen Photo albums + No favorites found! diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index 21841c25..7f51e994 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -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? = 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( diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt new file mode 100644 index 00000000..6066cae4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/detail/TestFavoritesViewModel.kt @@ -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() + + private fun successJob(items: List) = CompletableDeferred(DataLoadingState.Success(items)) + + private fun errorJob() = CompletableDeferred(DataLoadingState.Error()) + + private suspend fun testInitialFetchAndWait( + firstTabKey: BaseItemKind, + jobs: Map>>>, + 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()) } 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()) } + } + + @Test + fun `Test fetchType with error`() = + runTest { + val ex = Exception() + coEvery { itemsApi.getItems(any()) } 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()) } + } + + @Test + fun `Test fetchType with empty result does not add tab`() = + runTest { + coEvery { itemsApi.getItems(any()) } 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()) } + } + + @Test + fun `Test fetchType for artists`() = + runTest { + val item = item(BaseItemKind.MUSIC_ARTIST) + coEvery { artistsApi.getArtists(any()) } 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()) } + coVerify(exactly = 0) { itemsApi.getItems(any()) } + coVerify(exactly = 0) { personsApi.getPersons(any()) } + } + + @Test + fun `Test fetchType for persons`() = + runTest { + val item = item(BaseItemKind.PERSON) + coEvery { personsApi.getPersons(any()) } 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()) } + coVerify(exactly = 0) { itemsApi.getItems(any()) } + coVerify(exactly = 0) { artistsApi.getArtists(any()) } + } +} + +private fun library(type: CollectionType) = + Library( + itemId = UUID.randomUUID(), + name = "name-${type.serialName}", + type = BaseItemKind.COLLECTION_FOLDER, + collectionType = type, + isRecordingFolder = false, + )