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, + )