mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-05 18:30:59 +01:00
Update favorites page with more types (#1864)
## Description This PR adds many more data types as tabs to the favorites page. Additionally, only tabs for the types the user has favorites for will appear. For example, if you have no favorited music videos, that tab will not show. Added types: - Collections - TV Channels - Music albums - Music artists - Music videos - Songs - Home videos - Photos - Photo albums Dev note: The tabs are implemented using keys instead of indexes which could be useful for the series overview season tabs. Plus it maybe even be adapted for the episode list to eliminate the terrible index search logic. A major difference is that the possible favorites tabs are known/static list where seasons/episodes are queried & paginated. ### Related issues Closes #1041 Closes #1844 ### Testing Manually on emulator, plus unit tests ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
786b95a393
commit
fdde986029
19 changed files with 1783 additions and 397 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ class NavDrawerService
|
|||
it.copy(
|
||||
items = items,
|
||||
moreItems = moreItems,
|
||||
allLibraries = allLibraries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -255,6 +256,7 @@ data class NavDrawerItemState(
|
|||
val moreItems: List<NavDrawerItem> = emptyList(),
|
||||
val nowPlayingEnabled: Boolean = false,
|
||||
val nowPlayingTitle: String? = null,
|
||||
val allLibraries: List<Library> = emptyList(),
|
||||
)
|
||||
|
||||
val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true
|
||||
|
|
|
|||
|
|
@ -427,6 +427,9 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
|||
useSeriesForPrimary: Boolean,
|
||||
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||
|
||||
fun Response<BaseItemDtoQueryResult>.toBaseItems(useSeriesForPrimary: Boolean = false) =
|
||||
this.content.items.map { BaseItem(it, useSeriesForPrimary = useSeriesForPrimary) }
|
||||
|
||||
/**
|
||||
* Check if this, coalescing nulls to zero, is greater than that
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -237,7 +237,29 @@ val CollectionType.baseItemKinds: List<BaseItemKind>
|
|||
listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CollectionType.LIVETV -> {
|
||||
listOf(
|
||||
BaseItemKind.TV_CHANNEL,
|
||||
BaseItemKind.LIVE_TV_CHANNEL,
|
||||
BaseItemKind.PROGRAM,
|
||||
BaseItemKind.TV_PROGRAM,
|
||||
BaseItemKind.LIVE_TV_PROGRAM,
|
||||
)
|
||||
}
|
||||
|
||||
CollectionType.TRAILERS -> {
|
||||
listOf(BaseItemKind.TRAILER)
|
||||
}
|
||||
|
||||
CollectionType.BOOKS -> {
|
||||
listOf(BaseItemKind.BOOK)
|
||||
}
|
||||
|
||||
CollectionType.FOLDERS -> {
|
||||
listOf(BaseItemKind.FOLDER)
|
||||
}
|
||||
|
||||
CollectionType.UNKNOWN -> {
|
||||
listOf()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,8 @@ class CollectionFolderViewModel
|
|||
@Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean,
|
||||
@Assisted defaultViewOptions: ViewOptions,
|
||||
) : ViewModel(),
|
||||
ContextMenuProvider {
|
||||
ContextMenuProvider,
|
||||
CollectionFolderViewActions {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
|
|
@ -252,7 +253,7 @@ class CollectionFolderViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun saveViewOptions(viewOptions: ViewOptions) {
|
||||
override fun saveViewOptions(viewOptions: ViewOptions) {
|
||||
position = 0
|
||||
_state.update { it.copy(viewOptions = viewOptions) }
|
||||
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
|
||||
|
|
@ -263,7 +264,7 @@ class CollectionFolderViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun onFilterChange(
|
||||
override fun onFilterChange(
|
||||
newFilter: GetItemsFilter,
|
||||
recursive: Boolean,
|
||||
) {
|
||||
|
|
@ -278,7 +279,7 @@ class CollectionFolderViewModel
|
|||
)
|
||||
}
|
||||
|
||||
fun onSortChange(
|
||||
override fun onSortChange(
|
||||
sortAndDirection: SortAndDirection,
|
||||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
|
|
@ -459,7 +460,7 @@ class CollectionFolderViewModel
|
|||
return request
|
||||
}
|
||||
|
||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||
override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||
filterOptionCache.getFilterOptionValues(
|
||||
itemId.toUUID(),
|
||||
filterOption,
|
||||
|
|
@ -469,7 +470,7 @@ class CollectionFolderViewModel
|
|||
* The count must come from the same endpoint that [createPager] pages through, otherwise
|
||||
* the index refers to a different result set and lands past the end of the list.
|
||||
*/
|
||||
suspend fun positionOfLetter(letter: Char): Int? =
|
||||
override suspend fun positionOfLetter(letter: Char): Int? =
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
val filter = state.value.filter
|
||||
when (filter.override) {
|
||||
|
|
@ -541,7 +542,7 @@ class CollectionFolderViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun updateBackdrop(item: BaseItem) {
|
||||
override fun updateBackdrop(item: BaseItem) {
|
||||
viewModelScope.launchIO {
|
||||
backdropService.submit(item)
|
||||
}
|
||||
|
|
@ -714,9 +715,83 @@ fun CollectionFolderView(
|
|||
},
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var position by rememberInt(viewModel.position)
|
||||
LifecycleResumeEffect(itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
val contextMenu = rememberContextMenu(preferences, viewModel)
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
CollectionFolderViewContent(
|
||||
preferences = preferences,
|
||||
state = state,
|
||||
savedPosition = viewModel.position,
|
||||
itemId = itemId,
|
||||
initialFilter = initialFilter,
|
||||
recursive = recursive,
|
||||
actions = actions,
|
||||
sortOptions = sortOptions,
|
||||
playEnabled = playEnabled,
|
||||
defaultViewOptions = defaultViewOptions,
|
||||
viewActions = viewModel,
|
||||
modifier = modifier,
|
||||
provider = viewModel,
|
||||
showTitle = showTitle,
|
||||
positionCallback = { columns, index ->
|
||||
viewModel.position = index
|
||||
positionCallback?.invoke(columns, index)
|
||||
},
|
||||
filterOptions = filterOptions,
|
||||
focusRequesterOnEmpty = focusRequesterOnEmpty,
|
||||
)
|
||||
}
|
||||
|
||||
interface CollectionFolderViewActions {
|
||||
fun navigateTo(destination: Destination)
|
||||
|
||||
fun updateBackdrop(item: BaseItem)
|
||||
|
||||
fun onSortChange(
|
||||
sortAndDirection: SortAndDirection,
|
||||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
)
|
||||
|
||||
fun onFilterChange(
|
||||
newFilter: GetItemsFilter,
|
||||
recursive: Boolean,
|
||||
)
|
||||
|
||||
suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption>
|
||||
|
||||
suspend fun positionOfLetter(letter: Char): Int?
|
||||
|
||||
fun saveViewOptions(viewOptions: ViewOptions)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CollectionFolderViewContent(
|
||||
preferences: UserPreferences,
|
||||
state: CollectionFolderState,
|
||||
savedPosition: Int,
|
||||
itemId: String,
|
||||
initialFilter: CollectionFolderFilter,
|
||||
recursive: Boolean,
|
||||
actions: GridClickActions,
|
||||
sortOptions: List<ItemSortBy>,
|
||||
playEnabled: Boolean,
|
||||
defaultViewOptions: ViewOptions,
|
||||
viewActions: CollectionFolderViewActions,
|
||||
modifier: Modifier = Modifier,
|
||||
provider: ContextMenuProvider,
|
||||
showTitle: Boolean = true,
|
||||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
|
||||
focusRequesterOnEmpty: FocusRequester? = null,
|
||||
) {
|
||||
var position by rememberInt(savedPosition)
|
||||
|
||||
val contextMenu = rememberContextMenu(preferences, provider)
|
||||
var showViewOptions by rememberSaveable { mutableStateOf(false) }
|
||||
var filterDropdownShowing by remember { mutableStateOf(false) }
|
||||
val headerRowFocusRequester = remember { FocusRequester() }
|
||||
|
|
@ -760,7 +835,7 @@ fun CollectionFolderView(
|
|||
filter = state.filter,
|
||||
)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
viewActions.navigateTo(destination)
|
||||
}
|
||||
Unit
|
||||
},
|
||||
|
|
@ -779,7 +854,7 @@ fun CollectionFolderView(
|
|||
} else {
|
||||
Destination.Playback(item)
|
||||
}
|
||||
viewModel.navigateTo(destination)
|
||||
viewActions.navigateTo(destination)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -803,13 +878,6 @@ fun CollectionFolderView(
|
|||
?: item?.data?.collectionType?.name
|
||||
?: stringResource(R.string.collection)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LifecycleResumeEffect(itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
var showHeader by rememberSaveable { mutableStateOf(true) }
|
||||
val gridFocusRequester = remember { FocusRequester() }
|
||||
val pager = remember(state.items) { state.items.successValue }
|
||||
|
|
@ -817,7 +885,7 @@ fun CollectionFolderView(
|
|||
val focusedItem = pager?.getOrNull(position)
|
||||
if (state.viewOptions.showBackdrop) {
|
||||
LaunchedEffect(focusedItem) {
|
||||
focusedItem?.let(viewModel::updateBackdrop)
|
||||
focusedItem?.let(viewActions::updateBackdrop)
|
||||
}
|
||||
}
|
||||
CollectionFolderHeader(
|
||||
|
|
@ -827,15 +895,15 @@ fun CollectionFolderView(
|
|||
title = title,
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
onSortChange = {
|
||||
viewModel.onSortChange(it, recursive, state.filter)
|
||||
viewActions.onSortChange(it, recursive, state.filter)
|
||||
},
|
||||
sortOptions = sortOptions,
|
||||
currentFilter = state.filter,
|
||||
onFilterChange = {
|
||||
viewModel.onFilterChange(it, recursive)
|
||||
viewActions.onFilterChange(it, recursive)
|
||||
},
|
||||
getPossibleFilterValues = {
|
||||
viewModel.getFilterOptionValues(it)
|
||||
viewActions.getFilterOptionValues(it)
|
||||
},
|
||||
filterOptions = filterOptions,
|
||||
onClickPlayAll = gridActions.onClickPlayAll!!,
|
||||
|
|
@ -875,7 +943,7 @@ fun CollectionFolderView(
|
|||
CollectionFolderGrid(
|
||||
preferences = preferences,
|
||||
collectionType = item?.data?.collectionType,
|
||||
initialPosition = viewModel.position,
|
||||
initialPosition = savedPosition,
|
||||
items = pager.data,
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -883,12 +951,11 @@ fun CollectionFolderView(
|
|||
onClickItem = gridActions.onClickItem,
|
||||
onLongClickItem = gridActions.onLongClickItem!!,
|
||||
positionCallback = { columns, pos ->
|
||||
viewModel.position = pos
|
||||
showHeader = pos < columns
|
||||
position = pos
|
||||
positionCallback?.invoke(columns, pos)
|
||||
},
|
||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = state.viewOptions,
|
||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||
focusedItem = focusedItem,
|
||||
|
|
@ -897,7 +964,7 @@ fun CollectionFolderView(
|
|||
CollectionFolderList(
|
||||
preferences = preferences,
|
||||
collectionType = item?.data?.collectionType,
|
||||
initialPosition = viewModel.position,
|
||||
initialPosition = savedPosition,
|
||||
items = pager.data,
|
||||
sortAndDirection = state.sortAndDirection,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -905,12 +972,11 @@ fun CollectionFolderView(
|
|||
onClickItem = gridActions.onClickItem,
|
||||
onLongClickItem = gridActions.onLongClickItem!!,
|
||||
positionCallback = { columns, pos ->
|
||||
viewModel.position = pos
|
||||
showHeader = pos < columns
|
||||
position = pos
|
||||
positionCallback?.invoke(columns, pos)
|
||||
},
|
||||
letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
|
||||
letterPosition = { viewActions.positionOfLetter(it) ?: -1 },
|
||||
viewOptions = state.viewOptions,
|
||||
onClickPlay = gridActions.onClickPlayRemoteButton!!,
|
||||
focusedItem = focusedItem,
|
||||
|
|
@ -946,9 +1012,9 @@ fun CollectionFolderView(
|
|||
defaultViewOptions = defaultViewOptions,
|
||||
onDismissRequest = {
|
||||
showViewOptions = false
|
||||
viewModel.saveViewOptions(state.viewOptions)
|
||||
viewActions.saveViewOptions(state.viewOptions)
|
||||
},
|
||||
onViewOptionsChange = viewModel::saveViewOptions,
|
||||
onViewOptionsChange = viewActions::saveViewOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -43,6 +45,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.StringStringProvider
|
||||
import timber.log.Timber
|
||||
import java.util.SortedMap
|
||||
|
||||
@Composable
|
||||
fun TabRow(
|
||||
|
|
@ -109,6 +112,79 @@ fun TabRow(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> KeyedTabRow(
|
||||
selectedTabKey: T,
|
||||
tabs: SortedMap<T, TabDetails>,
|
||||
onClick: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state = rememberLazyListState()
|
||||
LaunchedEffect(selectedTabKey) {
|
||||
tabs[selectedTabKey]?.bringIntoViewRequester?.bringIntoView()
|
||||
}
|
||||
var rowHasFocus by remember { mutableStateOf(false) }
|
||||
val currentSelectedTabKey by rememberUpdatedState(selectedTabKey)
|
||||
val currentOnClick by rememberUpdatedState(onClick)
|
||||
|
||||
val tabKeyList = remember(tabs) { tabs.keys.filterNotNull().toList() }
|
||||
|
||||
LazyRow(
|
||||
state = state,
|
||||
modifier =
|
||||
modifier
|
||||
.onFocusChanged {
|
||||
rowHasFocus = it.hasFocus
|
||||
}.focusGroup()
|
||||
.focusProperties {
|
||||
onEnter = {
|
||||
// If entering from left or right, use last or first tab
|
||||
// Otherwise use the selected tab
|
||||
val currentKey = currentSelectedTabKey
|
||||
// val requesters = currentFocusRequesters
|
||||
Timber.v(
|
||||
"onEnter requestedFocusDirection=%s, currentSelectedTabKey=%s",
|
||||
requestedFocusDirection,
|
||||
currentSelectedTabKey,
|
||||
)
|
||||
val key =
|
||||
if (requestedFocusDirection == FocusDirection.Left) {
|
||||
tabs.lastKey()
|
||||
} else if (requestedFocusDirection == FocusDirection.Right) {
|
||||
tabs.firstKey()
|
||||
} else {
|
||||
currentKey
|
||||
}
|
||||
val tab = tabs[key]
|
||||
(tab?.tabFocusRequester ?: FocusRequester.Default).tryRequestFocus()
|
||||
}
|
||||
},
|
||||
) {
|
||||
items(tabKeyList, key = { it }) { tabKey ->
|
||||
tabs[tabKey]?.let { tab ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val onTabClick =
|
||||
remember(tabKey) {
|
||||
{
|
||||
currentOnClick(tabKey)
|
||||
}
|
||||
}
|
||||
Tab(
|
||||
title = tab.title.getString(),
|
||||
selected = tabKey == selectedTabKey,
|
||||
rowActive = rowHasFocus,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onTabClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(tab.tabFocusRequester)
|
||||
.bringIntoViewRequester(tab.bringIntoViewRequester),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Tab(
|
||||
title: String,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -27,13 +34,15 @@ import dagger.assisted.AssistedInject
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import timber.log.Timber
|
||||
import java.util.SortedMap
|
||||
|
||||
@HiltViewModel(assistedFactory = TabViewModel.Factory::class)
|
||||
class TabViewModel
|
||||
@AssistedInject
|
||||
constructor(
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
val userPreferencesService: UserPreferencesService,
|
||||
private val rememberedTabService: RememberedTabService,
|
||||
private val backdropService: BackdropService,
|
||||
@param:Assisted private val itemId: String,
|
||||
|
|
@ -50,6 +59,9 @@ class TabViewModel
|
|||
private val _state = MutableStateFlow<Int>(UNSET)
|
||||
val state: StateFlow<Int> = _state
|
||||
|
||||
val isShowClock =
|
||||
userPreferencesService.flow.map { it.appPreferences.interfacePreferences.showClock }
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
val startingTab =
|
||||
|
|
@ -91,6 +103,7 @@ data class TabDetails(
|
|||
val title: StringProvider,
|
||||
val tabFocusRequester: FocusRequester = FocusRequester(),
|
||||
val contentFocusRequester: FocusRequester = FocusRequester(),
|
||||
val bringIntoViewRequester: BringIntoViewRequester = BringIntoViewRequester(),
|
||||
) {
|
||||
constructor(
|
||||
@StringRes stringResId: Int,
|
||||
|
|
@ -118,7 +131,36 @@ fun TabbedPage(
|
|||
tabContent: @Composable (Int, TabDetails) -> Unit,
|
||||
) {
|
||||
val selectedTabIndex by viewModel.state.collectAsState()
|
||||
val isShowClock by viewModel.isShowClock.collectAsState(true)
|
||||
TabbedPage(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
tabs = tabs,
|
||||
updateSelectedTabIndex = viewModel::updateSelectedTabIndex,
|
||||
isShowClock = isShowClock,
|
||||
modifier = modifier,
|
||||
showTabs = showTabs,
|
||||
tabContent = tabContent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabbedPage(
|
||||
selectedTabIndex: Int,
|
||||
tabs: List<TabDetails>,
|
||||
updateSelectedTabIndex: (Int) -> Unit,
|
||||
isShowClock: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
showTabs: Boolean = true,
|
||||
tabContent: @Composable (Int, TabDetails) -> Unit,
|
||||
) {
|
||||
val endPadding =
|
||||
remember(isShowClock) {
|
||||
if (isShowClock) {
|
||||
184.dp
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
|
|
@ -131,13 +173,13 @@ fun TabbedPage(
|
|||
selectedTabIndex = selectedTabIndex,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(vertical = 16.dp),
|
||||
.padding(top = 16.dp, bottom = 16.dp, end = endPadding),
|
||||
tabs = tabs,
|
||||
onClick = viewModel::updateSelectedTabIndex,
|
||||
onClick = updateSelectedTabIndex,
|
||||
)
|
||||
}
|
||||
selectedTabIndex.let { tabIndex ->
|
||||
if (tabIndex >= 0) {
|
||||
if (tabIndex in tabs.indices) {
|
||||
tabContent.invoke(tabIndex, tabs[tabIndex])
|
||||
} else {
|
||||
DelayedLoadingPage(focusEnabled = false)
|
||||
|
|
@ -145,3 +187,55 @@ fun TabbedPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> KeyedTabbedPage(
|
||||
selectedTabKey: T,
|
||||
tabs: SortedMap<T, TabDetails>,
|
||||
updateSelectedTabKey: (T) -> Unit,
|
||||
isShowClock: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
showTabs: Boolean = true,
|
||||
tabContent: @Composable (T, TabDetails) -> Unit,
|
||||
) {
|
||||
val endPadding =
|
||||
remember(isShowClock) {
|
||||
if (isShowClock) {
|
||||
184.dp
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
showTabs,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
KeyedTabRow(
|
||||
selectedTabKey = selectedTabKey,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 16.dp, bottom = 16.dp, end = endPadding),
|
||||
tabs = tabs,
|
||||
onClick = updateSelectedTabKey,
|
||||
)
|
||||
}
|
||||
AnimatedContent(
|
||||
targetState = selectedTabKey,
|
||||
transitionSpec = {
|
||||
(fadeIn(animationSpec = tween(300, delayMillis = 100)))
|
||||
.togetherWith(fadeOut(animationSpec = tween(200)))
|
||||
},
|
||||
) { tabKey ->
|
||||
val tab = tabs[tabKey]
|
||||
if (tab != null) {
|
||||
tabContent.invoke(tabKey, tab)
|
||||
} else {
|
||||
DelayedLoadingPage(focusEnabled = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,774 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.filter.FilterValueOption
|
||||
import com.github.damontecres.wholphin.data.filter.ItemFilterBy
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.FilterOptionCache
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.RememberedTabService
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderState
|
||||
import com.github.damontecres.wholphin.ui.components.CollectionFolderViewActions
|
||||
import com.github.damontecres.wholphin.ui.components.ContextMenuProvider
|
||||
import com.github.damontecres.wholphin.ui.components.TabDetails
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
import com.github.damontecres.wholphin.ui.components.baseItemKinds
|
||||
import com.github.damontecres.wholphin.ui.components.defaultViewOptions
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.GetArtistsHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetPersonsHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.SortedMap
|
||||
import java.util.TreeMap
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class FavoritesViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val navDrawerService: NavDrawerService,
|
||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
val streamChoiceService: StreamChoiceService,
|
||||
val mediaReportService: MediaReportService,
|
||||
private val filterOptionCache: FilterOptionCache,
|
||||
private val rememberedTabService: RememberedTabService,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(FavoritesPageState())
|
||||
val state: StateFlow<FavoritesPageState> = _state
|
||||
|
||||
fun init() {
|
||||
viewModelScope.launchIO {
|
||||
val rememberTabs =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.interfacePreferences.rememberSelectedTab
|
||||
|
||||
setupPossibleTypes()
|
||||
|
||||
val favoriteTypeOptions = state.value.favoriteTypeOptions
|
||||
if (favoriteTypeOptions.isNotEmpty()) {
|
||||
val tabKey =
|
||||
if (rememberTabs) {
|
||||
val rememberedTab =
|
||||
rememberedTabService
|
||||
.getRememberedTab(NavDrawerItem.Favorites.id)
|
||||
?.let {
|
||||
BaseItemKind.entries[it]
|
||||
}
|
||||
if (rememberedTab != null && rememberedTab in favoriteTypeOptions) {
|
||||
rememberedTab
|
||||
} else {
|
||||
favoriteTypeOptions.first()
|
||||
}
|
||||
} else {
|
||||
favoriteTypeOptions.first()
|
||||
}
|
||||
val jobs =
|
||||
favoriteTypeOptions.associateWith { type -> initialFetchForType(type) }
|
||||
initialFetchAndWait(tabKey, jobs)
|
||||
} else {
|
||||
Timber.w("No available types to check for favorites!")
|
||||
_state.update {
|
||||
it.copy(loadingState = FavoritesLoadingState.NoFavorites)
|
||||
}
|
||||
}
|
||||
|
||||
userPreferencesService.flow
|
||||
.map { it.appPreferences.interfacePreferences.showClock }
|
||||
.collectLatestIn(viewModelScope) { isShowClock ->
|
||||
_state.update { it.copy(isShowClock = isShowClock) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun setupPossibleTypes() {
|
||||
val availableTypes =
|
||||
navDrawerService.state.value.allLibraries
|
||||
.possibleFavoriteTypes(false)
|
||||
.toSet()
|
||||
val favoriteTypeOptions = favoriteOptions.filter { it in availableTypes }
|
||||
_state.update {
|
||||
it.copy(
|
||||
favoriteTypeOptions = favoriteTypeOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts queries for favorites, waiting for [firstTabKey] to complete
|
||||
*
|
||||
* Exposed for testing
|
||||
*/
|
||||
internal suspend fun initialFetchAndWait(
|
||||
firstTabKey: BaseItemKind,
|
||||
jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>,
|
||||
) {
|
||||
val completed =
|
||||
jobs[firstTabKey]?.let {
|
||||
when (val result = it.await()) {
|
||||
DataLoadingState.Loading,
|
||||
DataLoadingState.Pending,
|
||||
-> false
|
||||
|
||||
is DataLoadingState.Error,
|
||||
-> true
|
||||
|
||||
is DataLoadingState.Success<List<BaseItem?>> -> result.data.isNotEmpty()
|
||||
}
|
||||
} == true
|
||||
if (completed) {
|
||||
Timber.v("Got completed for %s", firstTabKey)
|
||||
_state.update {
|
||||
it.copy(
|
||||
loadingState = FavoritesLoadingState.Success,
|
||||
tabKey = firstTabKey,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// If the first key wasn't successful, wait in order until first successful & non-empty
|
||||
val favoriteTypeOptions = state.value.favoriteTypeOptions
|
||||
val firstCompleted =
|
||||
favoriteTypeOptions.firstOrNull { type ->
|
||||
val job = jobs[type] ?: return@firstOrNull false
|
||||
val result = job.await()
|
||||
result is DataLoadingState.Error ||
|
||||
(result is DataLoadingState.Success<List<BaseItem?>> && result.data.isNotEmpty())
|
||||
}
|
||||
_state.update {
|
||||
if (firstCompleted != null) {
|
||||
it.copy(
|
||||
loadingState = FavoritesLoadingState.Success,
|
||||
tabKey = firstCompleted,
|
||||
)
|
||||
} else {
|
||||
// None were error or successful & non-empty
|
||||
// Check if they were all empty which means the user has no favorites at all
|
||||
val allEmpty =
|
||||
jobs.all { (_, job) ->
|
||||
val result = job.await()
|
||||
result is DataLoadingState.Success<List<BaseItem?>> && result.data.isEmpty()
|
||||
}
|
||||
if (allEmpty) {
|
||||
it.copy(
|
||||
loadingState = FavoritesLoadingState.NoFavorites,
|
||||
)
|
||||
} else {
|
||||
// This means a least one failed
|
||||
val firstError =
|
||||
jobs.entries.firstOrNull { (_, job) ->
|
||||
job.await() is DataLoadingState.Error
|
||||
}
|
||||
if (firstError != null) {
|
||||
it.copy(
|
||||
loadingState = FavoritesLoadingState.Success,
|
||||
tabKey = firstError.key,
|
||||
)
|
||||
} else {
|
||||
// This shouldn't happen
|
||||
it.copy(
|
||||
loadingState = FavoritesLoadingState.Error("An unknown error occurred"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the initial fetch for a [BaseItemKind] which will look up any existing saved sort and filter
|
||||
*/
|
||||
private fun initialFetchForType(type: BaseItemKind): Deferred<DataLoadingState<List<BaseItem?>>> =
|
||||
viewModelScope.async(WholphinDispatchers.IO) {
|
||||
try {
|
||||
val collectionState =
|
||||
CollectionFolderState(
|
||||
item = DataLoadingState.Loading,
|
||||
items = DataLoadingState.Loading,
|
||||
backgroundLoading = LoadingState.Loading,
|
||||
viewOptions = ViewOptions(),
|
||||
)
|
||||
_state.value.favorites[type] = collectionState
|
||||
|
||||
val libraryDisplayInfo =
|
||||
serverRepository.currentUser?.let { user ->
|
||||
libraryDisplayInfoDao.getItem(user, libraryDisplayItemId(type))
|
||||
}
|
||||
val sortAndDirection =
|
||||
libraryDisplayInfo?.sortAndDirection ?: SortAndDirection.DEFAULT
|
||||
val filter = libraryDisplayInfo?.filter ?: GetItemsFilter(favorite = true)
|
||||
val viewOptions = libraryDisplayInfo?.viewOptions ?: type.defaultViewOptions
|
||||
fetchType(type, sortAndDirection, filter, viewOptions)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching favorites for %s", type)
|
||||
updateForException(type, ex)
|
||||
DataLoadingState.Error(ex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query for favorites of the specified [BaseItemKind] using the specified sort and filter
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal suspend fun fetchType(
|
||||
type: BaseItemKind,
|
||||
sortAndDirection: SortAndDirection,
|
||||
filter: GetItemsFilter,
|
||||
viewOptions: ViewOptions,
|
||||
): DataLoadingState<List<BaseItem?>> {
|
||||
val collectionState =
|
||||
CollectionFolderState(
|
||||
item = DataLoadingState.Loading,
|
||||
items = DataLoadingState.Loading,
|
||||
backgroundLoading = LoadingState.Loading,
|
||||
viewOptions = ViewOptions(),
|
||||
)
|
||||
_state.value.favorites[type] = collectionState
|
||||
try {
|
||||
val pager = createPager(type, filter, sortAndDirection)
|
||||
_state.value.favorites[type] =
|
||||
collectionState.copy(
|
||||
item = DataLoadingState.Success(null),
|
||||
items = DataLoadingState.Success(pager),
|
||||
backgroundLoading = LoadingState.Success,
|
||||
sortAndDirection = sortAndDirection,
|
||||
filter = filter,
|
||||
viewOptions = viewOptions,
|
||||
)
|
||||
Timber.v("Got %s favorites for %s", pager.size, type)
|
||||
_state.update {
|
||||
val newTabs =
|
||||
it.createMap {
|
||||
if (pager.isNotEmpty()) {
|
||||
put(type, TabDetails(formatTypeName(type)))
|
||||
} else {
|
||||
remove(type)
|
||||
}
|
||||
}
|
||||
it.copy(
|
||||
tabs = newTabs,
|
||||
)
|
||||
}
|
||||
return DataLoadingState.Success(pager)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
updateForException(type, ex)
|
||||
return DataLoadingState.Error(ex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If fetching data for a type fails, this should be called to update the state with the error
|
||||
*/
|
||||
private fun updateForException(
|
||||
type: BaseItemKind,
|
||||
ex: Exception,
|
||||
) {
|
||||
_state.update {
|
||||
// Want to store the error for the tab
|
||||
_state.value.favorites[type] =
|
||||
CollectionFolderState(
|
||||
item = DataLoadingState.Success(null),
|
||||
items = DataLoadingState.Error(ex),
|
||||
backgroundLoading = LoadingState.Success,
|
||||
viewOptions = type.defaultViewOptions,
|
||||
)
|
||||
// And make sure the tab is included
|
||||
val newTabs =
|
||||
it.createMap {
|
||||
put(type, TabDetails(formatTypeName(type)))
|
||||
}
|
||||
it.copy(tabs = newTabs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createGetItemsRequest(
|
||||
type: BaseItemKind,
|
||||
filter: GetItemsFilter,
|
||||
sortAndDirection: SortAndDirection,
|
||||
): GetItemsRequest =
|
||||
filter.applyTo(
|
||||
GetItemsRequest(
|
||||
isFavorite = true,
|
||||
recursive = true,
|
||||
includeItemTypes = listOf(type),
|
||||
fields = SlimItemFields,
|
||||
sortBy = listOf(sortAndDirection.sort),
|
||||
sortOrder = listOf(sortAndDirection.direction),
|
||||
),
|
||||
overwriteIncludeTypes = false,
|
||||
)
|
||||
|
||||
private fun createGetArtistsRequest(
|
||||
filter: GetItemsFilter,
|
||||
sortAndDirection: SortAndDirection,
|
||||
): GetArtistsRequest =
|
||||
filter.applyTo(
|
||||
GetArtistsRequest(
|
||||
isFavorite = true,
|
||||
fields = SlimItemFields,
|
||||
sortBy = listOf(sortAndDirection.sort),
|
||||
sortOrder = listOf(sortAndDirection.direction),
|
||||
),
|
||||
)
|
||||
|
||||
private fun createGetPersonsRequest(filter: GetItemsFilter): GetPersonsRequest =
|
||||
filter.applyTo(
|
||||
GetPersonsRequest(
|
||||
isFavorite = true,
|
||||
fields = SlimItemFields,
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun createPager(
|
||||
type: BaseItemKind,
|
||||
filter: GetItemsFilter,
|
||||
sortAndDirection: SortAndDirection,
|
||||
): ApiRequestPager<*> =
|
||||
when (type) {
|
||||
BaseItemKind.MUSIC_ARTIST -> {
|
||||
val request = createGetArtistsRequest(filter, sortAndDirection)
|
||||
ApiRequestPager(api, request, GetArtistsHandler, viewModelScope, pageSize = 50)
|
||||
}
|
||||
|
||||
BaseItemKind.PERSON -> {
|
||||
val request = createGetPersonsRequest(filter)
|
||||
ApiRequestPager(api, request, GetPersonsHandler, viewModelScope, pageSize = 50)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val request = createGetItemsRequest(type, filter, sortAndDirection)
|
||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope, pageSize = 50)
|
||||
}
|
||||
}.init()
|
||||
|
||||
fun libraryDisplayItemId(type: BaseItemKind): String {
|
||||
// Previous version used hardcoded keys before more types were supported
|
||||
// So override those type names so that user's settings are maintained
|
||||
val typeKey =
|
||||
when (type) {
|
||||
BaseItemKind.MOVIE -> "movies"
|
||||
BaseItemKind.SERIES -> "series"
|
||||
BaseItemKind.EPISODE -> "episodes"
|
||||
BaseItemKind.VIDEO -> "videos"
|
||||
BaseItemKind.PLAYLIST -> "playlists"
|
||||
BaseItemKind.PERSON -> "people"
|
||||
else -> type.serialName
|
||||
}
|
||||
return "${NavDrawerItem.Favorites.id}_$typeKey"
|
||||
}
|
||||
|
||||
private fun collectionStateFor(type: BaseItemKind): CollectionFolderState? = state.value.favorites[type]
|
||||
|
||||
fun updateSelectedTabKey(newKey: BaseItemKind) {
|
||||
viewModelScope.launchDefault { backdropService.clearBackdrop() }
|
||||
viewModelScope.launchIO {
|
||||
val rememberTabs =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.interfacePreferences.rememberSelectedTab
|
||||
if (rememberTabs) {
|
||||
rememberedTabService.saveRememberedTab(
|
||||
NavDrawerItem.Favorites.id,
|
||||
newKey.ordinal,
|
||||
)
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(tabKey = newKey) }
|
||||
}
|
||||
|
||||
private suspend fun refreshAfterMutate(
|
||||
type: BaseItemKind,
|
||||
position: Int,
|
||||
itemId: UUID,
|
||||
) {
|
||||
try {
|
||||
val pager =
|
||||
collectionStateFor(type)?.let {
|
||||
((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
||||
}
|
||||
pager?.refreshItem(position, itemId)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error refreshing after mutate %s", itemId)
|
||||
showToast(context, "Error refreshing after item mutate")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshAfterDelete(
|
||||
type: BaseItemKind,
|
||||
position: Int,
|
||||
deletedItem: BaseItem,
|
||||
) {
|
||||
try {
|
||||
val pager =
|
||||
collectionStateFor(type)?.let {
|
||||
((it.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
||||
}
|
||||
|
||||
position.let {
|
||||
Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.id)
|
||||
val item = pager?.get(it)
|
||||
// Exact item deleted (eg a movie) or deleted item was within the series
|
||||
if (item?.id == deletedItem.id ||
|
||||
equalsNotNull(item?.data?.id, deletedItem.data.seriesId)
|
||||
) {
|
||||
pager?.refreshPagesAfter(position)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error refreshing after deleted item %s", deletedItem.id)
|
||||
showToast(context, "Error refreshing after item deleted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLibraryDisplayInfo(
|
||||
type: BaseItemKind,
|
||||
newFilter: GetItemsFilter,
|
||||
newSort: SortAndDirection,
|
||||
viewOptions: ViewOptions?,
|
||||
) {
|
||||
serverRepository.currentUser?.let { user ->
|
||||
viewModelScope.launchIO {
|
||||
val libraryDisplayInfo =
|
||||
LibraryDisplayInfo(
|
||||
userId = user.rowId,
|
||||
itemId = libraryDisplayItemId(type),
|
||||
sort = newSort.sort,
|
||||
direction = newSort.direction,
|
||||
filter = newFilter,
|
||||
viewOptions = viewOptions,
|
||||
)
|
||||
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveViewOptions(
|
||||
type: BaseItemKind,
|
||||
viewOptions: ViewOptions,
|
||||
) {
|
||||
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
|
||||
collectionStateFor(type)?.let { collectionState ->
|
||||
saveLibraryDisplayInfo(
|
||||
type = type,
|
||||
newFilter = collectionState.filter,
|
||||
newSort = collectionState.sortAndDirection,
|
||||
viewOptions = viewOptions,
|
||||
)
|
||||
state.value.favorites[type] =
|
||||
collectionState.copy(
|
||||
viewOptions = viewOptions,
|
||||
)
|
||||
if (!viewOptions.showBackdrop) {
|
||||
backdropService.clearBackdrop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createTypedProvider(type: BaseItemKind) = TypedProvider(type)
|
||||
|
||||
/**
|
||||
* This provides context menu actions and other actions on a per [BaseItemKind] basis
|
||||
*/
|
||||
@Immutable
|
||||
inner class TypedProvider(
|
||||
val type: BaseItemKind,
|
||||
) : ContextMenuProvider,
|
||||
CollectionFolderViewActions {
|
||||
override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true
|
||||
|
||||
override fun navigateTo(destination: Destination) = navigationManager.navigateTo(destination)
|
||||
|
||||
override fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
|
||||
override fun deleteItem(
|
||||
index: Int,
|
||||
item: BaseItem,
|
||||
) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
viewModelScope.launchDefault {
|
||||
refreshAfterDelete(type, index, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setWatched(
|
||||
position: Int,
|
||||
itemId: UUID,
|
||||
played: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
|
||||
favoriteWatchManager.setWatched(itemId, played)
|
||||
refreshAfterMutate(type, position, itemId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setFavorite(
|
||||
position: Int,
|
||||
itemId: UUID,
|
||||
favorite: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
|
||||
favoriteWatchManager.setWatched(itemId, favorite)
|
||||
collectionStateFor(type)?.let { collectionState ->
|
||||
// TODO use pager.refreshPagesAfter ??
|
||||
fetchType(
|
||||
type,
|
||||
collectionState.sortAndDirection,
|
||||
collectionState.filter,
|
||||
collectionState.viewOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId)
|
||||
|
||||
override fun updateBackdrop(item: BaseItem) {
|
||||
viewModelScope.launchIO {
|
||||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSortChange(
|
||||
sortAndDirection: SortAndDirection,
|
||||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
) {
|
||||
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
|
||||
Timber.v("onSortChange: type=%s, sortAndDirection=%s", type, sortAndDirection)
|
||||
collectionStateFor(type)?.let { collectionState ->
|
||||
saveLibraryDisplayInfo(
|
||||
type = type,
|
||||
newFilter = filter,
|
||||
newSort = sortAndDirection,
|
||||
viewOptions = collectionState.viewOptions,
|
||||
)
|
||||
state.value.favorites[type] =
|
||||
collectionState.copy(
|
||||
filter = filter,
|
||||
sortAndDirection = sortAndDirection,
|
||||
)
|
||||
fetchType(type, sortAndDirection, filter, collectionState.viewOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFilterChange(
|
||||
newFilter: GetItemsFilter,
|
||||
recursive: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) {
|
||||
Timber.v("onFilterChange: type=%s, newFilter=%s", type, newFilter)
|
||||
collectionStateFor(type)?.let { collectionState ->
|
||||
saveLibraryDisplayInfo(
|
||||
type = type,
|
||||
newFilter = newFilter,
|
||||
newSort = collectionState.sortAndDirection,
|
||||
viewOptions = collectionState.viewOptions,
|
||||
)
|
||||
state.value.favorites[type] =
|
||||
collectionState.copy(
|
||||
filter = newFilter,
|
||||
)
|
||||
fetchType(
|
||||
type,
|
||||
collectionState.sortAndDirection,
|
||||
newFilter,
|
||||
collectionState.viewOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List<FilterValueOption> =
|
||||
// TODO use includeItemTypes?
|
||||
filterOptionCache.getFilterOptionValues(
|
||||
null,
|
||||
filterOption,
|
||||
)
|
||||
|
||||
override suspend fun positionOfLetter(letter: Char): Int? =
|
||||
collectionStateFor(type)?.let { collectionState ->
|
||||
when (type) {
|
||||
BaseItemKind.MUSIC_ARTIST -> {
|
||||
val request =
|
||||
createGetArtistsRequest(
|
||||
collectionState.filter,
|
||||
collectionState.sortAndDirection,
|
||||
).copy(
|
||||
enableUserData = false,
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
)
|
||||
GetArtistsHandler.execute(api, request).content.totalRecordCount
|
||||
}
|
||||
|
||||
BaseItemKind.PERSON -> {
|
||||
null
|
||||
}
|
||||
|
||||
else -> {
|
||||
val request =
|
||||
createGetItemsRequest(
|
||||
type,
|
||||
collectionState.filter,
|
||||
collectionState.sortAndDirection,
|
||||
).copy(
|
||||
enableUserData = false,
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
)
|
||||
GetItemsRequestHandler.execute(api, request).content.totalRecordCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveViewOptions(viewOptions: ViewOptions) {
|
||||
saveViewOptions(type, viewOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class FavoritesPageState(
|
||||
val loadingState: FavoritesLoadingState = FavoritesLoadingState.Pending,
|
||||
val favoriteTypeOptions: List<BaseItemKind> = emptyList(),
|
||||
val favorites: SnapshotStateMap<BaseItemKind, CollectionFolderState> = SnapshotStateMap(),
|
||||
val tabs: SortedMap<BaseItemKind, TabDetails> = TreeMap(compareBy { favoriteOptions.indexOf(it) }),
|
||||
val tabKey: BaseItemKind = favoriteOptions.first(),
|
||||
val isShowClock: Boolean = true,
|
||||
)
|
||||
|
||||
sealed interface FavoritesLoadingState {
|
||||
data object Pending : FavoritesLoadingState
|
||||
|
||||
data object Loading : FavoritesLoadingState
|
||||
|
||||
data object Success : FavoritesLoadingState
|
||||
|
||||
data object NoFavorites : FavoritesLoadingState
|
||||
|
||||
data class Error(
|
||||
val message: String? = null,
|
||||
val exception: Throwable? = null,
|
||||
) : FavoritesLoadingState {
|
||||
constructor(exception: Throwable) : this(null, exception)
|
||||
|
||||
val localizedMessage: String =
|
||||
listOfNotNull(message, exception?.localizedMessage).joinToString(" - ")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List of supported favorite types
|
||||
*
|
||||
* This is also the order they will appear in the UI
|
||||
*/
|
||||
val favoriteOptions =
|
||||
listOf(
|
||||
BaseItemKind.MOVIE,
|
||||
BaseItemKind.SERIES,
|
||||
BaseItemKind.EPISODE,
|
||||
BaseItemKind.BOX_SET,
|
||||
BaseItemKind.PERSON,
|
||||
BaseItemKind.TV_CHANNEL,
|
||||
BaseItemKind.MUSIC_ALBUM,
|
||||
BaseItemKind.MUSIC_ARTIST,
|
||||
BaseItemKind.AUDIO,
|
||||
BaseItemKind.MUSIC_VIDEO,
|
||||
BaseItemKind.PLAYLIST,
|
||||
BaseItemKind.VIDEO,
|
||||
BaseItemKind.PHOTO,
|
||||
BaseItemKind.PHOTO_ALBUM,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a new tab map from the current state applying the supplied block which can contain mutations
|
||||
*/
|
||||
private inline fun FavoritesPageState.createMap(block: TreeMap<BaseItemKind, TabDetails>.() -> Unit) =
|
||||
TreeMap<BaseItemKind, TabDetails>(compareBy { favoriteOptions.indexOf(it) })
|
||||
.apply {
|
||||
putAll(this@createMap.tabs)
|
||||
block.invoke(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the list of libraries, return the possible favorite types
|
||||
*/
|
||||
fun List<Library>.possibleFavoriteTypes(sort: Boolean) =
|
||||
flatMap { it.collectionType.baseItemKinds }
|
||||
.distinct()
|
||||
.filter { it in favoriteOptions }
|
||||
.let { list ->
|
||||
if (sort) {
|
||||
list.sortedWith(compareBy { favoriteOptions.indexOf(it) })
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ import androidx.compose.ui.focus.focusRequester
|
|||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.formatTypeName
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
||||
@Composable
|
||||
fun HomeSettingsFavoriteList(
|
||||
favoriteTypeOptions: List<BaseItemKind>,
|
||||
onClick: (BaseItemKind) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
firstFocus: FocusRequester = remember { FocusRequester() },
|
||||
|
|
@ -33,10 +35,10 @@ fun HomeSettingsFavoriteList(
|
|||
.fillMaxHeight()
|
||||
.focusRestorer(firstFocus),
|
||||
) {
|
||||
itemsIndexed(favoriteOptionsList) { index, type ->
|
||||
itemsIndexed(favoriteTypeOptions) { index, type ->
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = stringResource(favoriteOptions[type]!!),
|
||||
headlineText = stringResource(formatTypeName(type)),
|
||||
onClick = { onClick.invoke(type) },
|
||||
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
|
||||
)
|
||||
|
|
@ -44,17 +46,3 @@ fun HomeSettingsFavoriteList(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
val favoriteOptions by lazy {
|
||||
mapOf(
|
||||
BaseItemKind.MOVIE to R.string.movies_title,
|
||||
BaseItemKind.SERIES to R.string.tv_shows_title,
|
||||
BaseItemKind.EPISODE to R.string.episodes,
|
||||
BaseItemKind.VIDEO to R.string.videos,
|
||||
BaseItemKind.PLAYLIST to R.string.playlists,
|
||||
BaseItemKind.PERSON to R.string.people_title,
|
||||
BaseItemKind.MUSIC_ARTIST to R.string.artists,
|
||||
BaseItemKind.MUSIC_ALBUM to R.string.albums,
|
||||
)
|
||||
}
|
||||
val favoriteOptionsList by lazy { favoriteOptions.keys.toList() }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -914,4 +914,5 @@
|
|||
<string name="dim_percentage">Dim percentage</string>
|
||||
<string name="dim_screen">Dim screen</string>
|
||||
<string name="photo_albums">Photo albums</string>
|
||||
<string name="no_favorites_found">No favorites found!</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -80,6 +80,23 @@ fun playlist(
|
|||
genreItems = genres,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create a simple [BaseItemDto] of the specified type
|
||||
*/
|
||||
fun item(
|
||||
type: BaseItemKind,
|
||||
id: UUID = UUID.randomUUID(),
|
||||
name: String = "Test $type",
|
||||
genres: List<NameGuidPair>? = null,
|
||||
): BaseItemDto =
|
||||
BaseItemDto(
|
||||
id = id,
|
||||
type = type,
|
||||
name = name,
|
||||
seriesId = null,
|
||||
genreItems = genres,
|
||||
)
|
||||
|
||||
fun server(serverId: UUID = UUID.randomUUID()) = JellyfinServer(serverId, "test server", "http://localhost:8096", "10.11.11")
|
||||
|
||||
fun user(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,427 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.FilterOptionCache
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerItemState
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.RememberedTabService
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.test.item
|
||||
import com.github.damontecres.wholphin.test.movie
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.successQueryResult
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.artistsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.personsApi
|
||||
import org.jellyfin.sdk.api.operations.ArtistsApi
|
||||
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||
import org.jellyfin.sdk.api.operations.PersonsApi
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.request.GetArtistsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class TestFavoritesViewModel {
|
||||
private val context: Context = mockk()
|
||||
private val api: ApiClient = mockk()
|
||||
private val navigationManager: NavigationManager = mockk()
|
||||
private val serverRepository: ServerRepository = mockk()
|
||||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao = mockk()
|
||||
private val favoriteWatchManager: FavoriteWatchManager = mockk()
|
||||
private val backdropService: BackdropService = mockk()
|
||||
private val userPreferencesService: UserPreferencesService = mockk()
|
||||
private val mediaManagementService: MediaManagementService = mockk()
|
||||
private val streamChoiceService: StreamChoiceService = mockk()
|
||||
private val mediaReportService: MediaReportService = mockk()
|
||||
private val filterOptionCache: FilterOptionCache = mockk()
|
||||
private val rememberedTabService: RememberedTabService = mockk()
|
||||
private val navDrawerService: NavDrawerService = mockk()
|
||||
|
||||
private val itemsApi: ItemsApi = mockk()
|
||||
private val artistsApi: ArtistsApi = mockk()
|
||||
private val personsApi: PersonsApi = mockk()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
every { api.itemsApi } returns itemsApi
|
||||
every { api.artistsApi } returns artistsApi
|
||||
every { api.personsApi } returns personsApi
|
||||
every { navDrawerService.state } returns
|
||||
MutableStateFlow(
|
||||
NavDrawerItemState(
|
||||
allLibraries =
|
||||
listOf(
|
||||
library(CollectionType.MOVIES),
|
||||
library(CollectionType.TVSHOWS),
|
||||
library(CollectionType.BOXSETS),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createViewModel(): FavoritesViewModel =
|
||||
FavoritesViewModel(
|
||||
context = context,
|
||||
api = api,
|
||||
navigationManager = navigationManager,
|
||||
serverRepository = serverRepository,
|
||||
libraryDisplayInfoDao = libraryDisplayInfoDao,
|
||||
favoriteWatchManager = favoriteWatchManager,
|
||||
backdropService = backdropService,
|
||||
userPreferencesService = userPreferencesService,
|
||||
mediaManagementService = mediaManagementService,
|
||||
streamChoiceService = streamChoiceService,
|
||||
mediaReportService = mediaReportService,
|
||||
filterOptionCache = filterOptionCache,
|
||||
rememberedTabService = rememberedTabService,
|
||||
navDrawerService = navDrawerService,
|
||||
).apply {
|
||||
setupPossibleTypes()
|
||||
}
|
||||
|
||||
private val items = listOf(BaseItem(movie()))
|
||||
|
||||
private val empty = emptyList<BaseItem>()
|
||||
|
||||
private fun successJob(items: List<BaseItem>) = CompletableDeferred(DataLoadingState.Success(items))
|
||||
|
||||
private fun errorJob() = CompletableDeferred(DataLoadingState.Error())
|
||||
|
||||
private suspend fun testInitialFetchAndWait(
|
||||
firstTabKey: BaseItemKind,
|
||||
jobs: Map<BaseItemKind, Deferred<DataLoadingState<List<BaseItem?>>>>,
|
||||
asserts: (FavoritesPageState) -> Unit,
|
||||
) {
|
||||
createViewModel().let { viewModel ->
|
||||
viewModel.initialFetchAndWait(
|
||||
firstTabKey = firstTabKey,
|
||||
jobs = jobs,
|
||||
)
|
||||
viewModel.state.value.let { state ->
|
||||
asserts.invoke(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test tabKey for first success`() =
|
||||
runTest {
|
||||
val jobs =
|
||||
mapOf(
|
||||
BaseItemKind.MOVIE to successJob(items),
|
||||
BaseItemKind.SERIES to successJob(items),
|
||||
)
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.MOVIE,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.MOVIE, state.tabKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test tabKey for second success`() =
|
||||
runTest {
|
||||
val jobs =
|
||||
mapOf(
|
||||
BaseItemKind.MOVIE to successJob(empty),
|
||||
BaseItemKind.SERIES to successJob(items),
|
||||
)
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.MOVIE,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.SERIES, state.tabKey)
|
||||
}
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.SERIES,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.SERIES, state.tabKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test tabKey for first failure`() =
|
||||
runTest {
|
||||
val jobs =
|
||||
mapOf(
|
||||
BaseItemKind.MOVIE to errorJob(),
|
||||
BaseItemKind.SERIES to successJob(items),
|
||||
)
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.MOVIE,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.MOVIE, state.tabKey)
|
||||
}
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.SERIES,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.SERIES, state.tabKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test tabKey for multiple empty`() =
|
||||
runTest {
|
||||
val jobs =
|
||||
mapOf(
|
||||
BaseItemKind.MOVIE to successJob(empty),
|
||||
BaseItemKind.SERIES to successJob(items),
|
||||
BaseItemKind.EPISODE to successJob(empty),
|
||||
BaseItemKind.BOX_SET to successJob(items),
|
||||
)
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.MOVIE,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.SERIES, state.tabKey)
|
||||
}
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.SERIES,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.SERIES, state.tabKey)
|
||||
}
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.EPISODE,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.SERIES, state.tabKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test tabKey for multiple empty with first error`() =
|
||||
runTest {
|
||||
val jobs =
|
||||
mapOf(
|
||||
BaseItemKind.MOVIE to errorJob(),
|
||||
BaseItemKind.SERIES to successJob(empty),
|
||||
BaseItemKind.EPISODE to successJob(items),
|
||||
BaseItemKind.BOX_SET to successJob(items),
|
||||
)
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.SERIES,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.MOVIE, state.tabKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test tabKey for multiple empty with first empty`() =
|
||||
runTest {
|
||||
val jobs =
|
||||
mapOf(
|
||||
BaseItemKind.MOVIE to successJob(empty),
|
||||
BaseItemKind.SERIES to successJob(empty),
|
||||
BaseItemKind.EPISODE to successJob(empty),
|
||||
BaseItemKind.BOX_SET to successJob(items),
|
||||
)
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.EPISODE,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.BOX_SET, state.tabKey)
|
||||
}
|
||||
|
||||
testInitialFetchAndWait(
|
||||
firstTabKey = BaseItemKind.BOX_SET,
|
||||
jobs = jobs,
|
||||
) { state ->
|
||||
assertEquals(BaseItemKind.BOX_SET, state.tabKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test fetchType basic`() =
|
||||
runTest {
|
||||
val movie = movie()
|
||||
coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult(listOf(movie))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val data =
|
||||
viewModel.fetchType(
|
||||
type = BaseItemKind.MOVIE,
|
||||
sortAndDirection = SortAndDirection.DEFAULT,
|
||||
filter = GetItemsFilter(),
|
||||
viewOptions = ViewOptions(),
|
||||
)
|
||||
assertTrue(data is DataLoadingState.Success)
|
||||
assertEquals(BaseItem(movie), (data as DataLoadingState.Success).data.first())
|
||||
viewModel.state.value.let { state ->
|
||||
assertTrue(BaseItemKind.MOVIE in state.tabs)
|
||||
assertTrue(BaseItemKind.MOVIE in state.favorites)
|
||||
state.favorites[BaseItemKind.MOVIE]!!.let { st ->
|
||||
assertTrue(st.item is DataLoadingState.Success)
|
||||
assertTrue(st.items is DataLoadingState.Success)
|
||||
}
|
||||
}
|
||||
coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test fetchType with error`() =
|
||||
runTest {
|
||||
val ex = Exception()
|
||||
coEvery { itemsApi.getItems(any<GetItemsRequest>()) } throws ex
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val data =
|
||||
viewModel.fetchType(
|
||||
type = BaseItemKind.MOVIE,
|
||||
sortAndDirection = SortAndDirection.DEFAULT,
|
||||
filter = GetItemsFilter(),
|
||||
viewOptions = ViewOptions(),
|
||||
)
|
||||
assertTrue(data is DataLoadingState.Error)
|
||||
assertEquals(ex, (data as DataLoadingState.Error).exception)
|
||||
viewModel.state.value.let { state ->
|
||||
assertTrue(BaseItemKind.MOVIE in state.tabs)
|
||||
assertTrue(BaseItemKind.MOVIE in state.favorites)
|
||||
state.favorites[BaseItemKind.MOVIE]!!.let { st ->
|
||||
assertTrue(st.item is DataLoadingState.Success)
|
||||
assertTrue(st.items is DataLoadingState.Error)
|
||||
assertEquals(ex, (st.items as DataLoadingState.Error).exception)
|
||||
}
|
||||
}
|
||||
coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test fetchType with empty result does not add tab`() =
|
||||
runTest {
|
||||
coEvery { itemsApi.getItems(any<GetItemsRequest>()) } returns successQueryResult()
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val data =
|
||||
viewModel.fetchType(
|
||||
type = BaseItemKind.MOVIE,
|
||||
sortAndDirection = SortAndDirection.DEFAULT,
|
||||
filter = GetItemsFilter(),
|
||||
viewOptions = ViewOptions(),
|
||||
)
|
||||
assertTrue(data is DataLoadingState.Success)
|
||||
assertEquals(0, (data as DataLoadingState.Success).data.size)
|
||||
viewModel.state.value.let { state ->
|
||||
assertFalse(BaseItemKind.MOVIE in state.tabs)
|
||||
|
||||
assertTrue(BaseItemKind.MOVIE in state.favorites)
|
||||
state.favorites[BaseItemKind.MOVIE]!!.let { st ->
|
||||
assertTrue(st.item is DataLoadingState.Success)
|
||||
assertTrue(st.items is DataLoadingState.Success)
|
||||
assertEquals(0, (st.items as DataLoadingState.Success).data.size)
|
||||
}
|
||||
}
|
||||
coVerify(exactly = 1) { itemsApi.getItems(any<GetItemsRequest>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test fetchType for artists`() =
|
||||
runTest {
|
||||
val item = item(BaseItemKind.MUSIC_ARTIST)
|
||||
coEvery { artistsApi.getArtists(any<GetArtistsRequest>()) } returns successQueryResult(listOf(item))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val data =
|
||||
viewModel.fetchType(
|
||||
type = BaseItemKind.MUSIC_ARTIST,
|
||||
sortAndDirection = SortAndDirection.DEFAULT,
|
||||
filter = GetItemsFilter(),
|
||||
viewOptions = ViewOptions(),
|
||||
)
|
||||
assertTrue(data is DataLoadingState.Success)
|
||||
assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first())
|
||||
viewModel.state.value.let { state ->
|
||||
assertTrue(BaseItemKind.MUSIC_ARTIST in state.tabs)
|
||||
assertTrue(BaseItemKind.MUSIC_ARTIST in state.favorites)
|
||||
state.favorites[BaseItemKind.MUSIC_ARTIST]!!.let { st ->
|
||||
assertTrue(st.item is DataLoadingState.Success)
|
||||
assertTrue(st.items is DataLoadingState.Success)
|
||||
}
|
||||
}
|
||||
coVerify(exactly = 1) { artistsApi.getArtists(any<GetArtistsRequest>()) }
|
||||
coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) }
|
||||
coVerify(exactly = 0) { personsApi.getPersons(any<GetPersonsRequest>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test fetchType for persons`() =
|
||||
runTest {
|
||||
val item = item(BaseItemKind.PERSON)
|
||||
coEvery { personsApi.getPersons(any<GetPersonsRequest>()) } returns successQueryResult(listOf(item))
|
||||
|
||||
val viewModel = createViewModel()
|
||||
val data =
|
||||
viewModel.fetchType(
|
||||
type = BaseItemKind.PERSON,
|
||||
sortAndDirection = SortAndDirection.DEFAULT,
|
||||
filter = GetItemsFilter(),
|
||||
viewOptions = ViewOptions(),
|
||||
)
|
||||
assertTrue(data is DataLoadingState.Success)
|
||||
assertEquals(BaseItem(item), (data as DataLoadingState.Success).data.first())
|
||||
viewModel.state.value.let { state ->
|
||||
assertTrue(BaseItemKind.PERSON in state.tabs)
|
||||
assertTrue(BaseItemKind.PERSON in state.favorites)
|
||||
state.favorites[BaseItemKind.PERSON]!!.let { st ->
|
||||
assertTrue(st.item is DataLoadingState.Success)
|
||||
assertTrue(st.items is DataLoadingState.Success)
|
||||
}
|
||||
}
|
||||
coVerify(exactly = 1) { personsApi.getPersons(any<GetPersonsRequest>()) }
|
||||
coVerify(exactly = 0) { itemsApi.getItems(any<GetItemsRequest>()) }
|
||||
coVerify(exactly = 0) { artistsApi.getArtists(any<GetArtistsRequest>()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun library(type: CollectionType) =
|
||||
Library(
|
||||
itemId = UUID.randomUUID(),
|
||||
name = "name-${type.serialName}",
|
||||
type = BaseItemKind.COLLECTION_FOLDER,
|
||||
collectionType = type,
|
||||
isRecordingFolder = false,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue