diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 624f01c1..63f6bdd8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme @@ -227,13 +228,7 @@ fun ItemCardImageOverlay( } } if (favorite) { - Text( - color = colorResource(android.R.color.holo_red_light), - text = stringResource(R.string.fa_heart), - fontSize = 20.sp, - fontFamily = FontAwesome, - modifier = Modifier, - ) + FavoriteIndicator() } } Row( @@ -281,3 +276,15 @@ fun ItemCardImageOverlay( } } } + +@Composable +fun FavoriteIndicator( + modifier: Modifier = Modifier, + fontSize: TextUnit = 20.sp, +) = Text( + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = fontSize, + fontFamily = FontAwesome, + modifier = modifier, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt index cbec7d53..0a92199b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme @@ -19,7 +20,10 @@ import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme @Composable -fun WatchedIcon(modifier: Modifier = Modifier) { +fun WatchedIcon( + modifier: Modifier = Modifier, + padding: Dp = 2.dp, +) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -28,7 +32,7 @@ fun WatchedIcon(modifier: Modifier = Modifier) { modifier .background(WatchedIconBackground(), shape = CircleShape) .border(.5.dp, Color.Black, CircleShape) - .padding(2.dp), + .padding(padding), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index ea052c99..f6fde818 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -1,11 +1,9 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,15 +13,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -40,847 +35,34 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.FilterValueOption import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.BaseItem -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.data.model.LibraryDisplayInfo -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService -import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.StreamChoiceService -import com.github.damontecres.wholphin.services.ThemeSongPlayer -import com.github.damontecres.wholphin.services.UserPreferencesService -import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus -import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GridCard -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog -import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.GridItemDetails -import com.github.damontecres.wholphin.ui.detail.ItemViewModel -import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState -import com.github.damontecres.wholphin.ui.detail.music.addToQueue -import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.launchDefault -import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageHeader -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec -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.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.GetPersonsHandler -import com.github.damontecres.wholphin.util.LoadingState -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.MediaType -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetPersonsRequest -import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber -import java.util.UUID import kotlin.time.Duration -@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class) -class CollectionFolderViewModel - @AssistedInject - constructor( - private val savedStateHandle: SavedStateHandle, - api: ApiClient, - @param:ApplicationContext private val context: Context, - val serverRepository: ServerRepository, - private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - private val favoriteWatchManager: FavoriteWatchManager, - private val backdropService: BackdropService, - private val navigationManager: NavigationManager, - private val themeSongPlayer: ThemeSongPlayer, - private val userPreferencesService: UserPreferencesService, - private val mediaManagementService: MediaManagementService, - private val musicService: MusicService, - val streamChoiceService: StreamChoiceService, - val mediaReportService: MediaReportService, - @Assisted itemId: String, - @Assisted initialSortAndDirection: SortAndDirection?, - @Assisted("recursive") private val recursive: Boolean, - @Assisted private val collectionFilter: CollectionFolderFilter, - @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, - @Assisted defaultViewOptions: ViewOptions, - ) : ItemViewModel(api) { - @AssistedFactory - interface Factory { - fun create( - itemId: String, - initialSortAndDirection: SortAndDirection?, - @Assisted("recursive") recursive: Boolean, - collectionFilter: CollectionFolderFilter, - @Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean, - defaultViewOptions: ViewOptions, - ): CollectionFolderViewModel - } - - val loading = MutableLiveData>>(DataLoadingState.Loading) - val backgroundLoading = MutableLiveData(LoadingState.Loading) - val sortAndDirection = MutableLiveData() - val filter = MutableLiveData(GetItemsFilter()) - val viewOptions = MutableLiveData() - - var position: Int - get() = savedStateHandle.get("position") ?: 0 - set(value) { - savedStateHandle["position"] = value - } - - init { - viewModelScope.launchIO { - super.itemId = itemId - try { - val item = - itemId.toUUIDOrNull()?.let { - fetchItem(it) - } - - val libraryDisplayInfo = - serverRepository.currentUser.value?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) - } - this@CollectionFolderViewModel.viewOptions.setValueOnMain( - libraryDisplayInfo?.viewOptions ?: defaultViewOptions, - ) - - val sortAndDirection = - if (collectionFilter.useSavedLibraryDisplayInfo) { - libraryDisplayInfo?.sortAndDirection - } else { - null - } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT - - val filterToUse = - if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { - collectionFilter.filter.merge(libraryDisplayInfo.filter) - } else { - collectionFilter.filter - } - - loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) - .join() -// onResumePage() - } catch (ex: Exception) { - Timber.e(ex, "Error during init") - loading.setValueOnMain(DataLoadingState.Error(ex)) - } - } - mediaManagementService.deletedItemFlow - .onEach { deletedItem -> - refreshAfterDelete(position, deletedItem.item) - }.catch { ex -> - Timber.e(ex, "Error refreshing after deleted item") - }.launchIn(viewModelScope) - } - - private suspend fun refreshAfterDelete( - position: Int, - deletedItem: BaseItem, - ) { - try { - val pager = - ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) - position.let { - Timber.v("Item deleted: position=%s, id=%s", it, itemId) - 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", itemId) - showToast(context, "Error refreshing after item deleted") - } - } - - private fun saveLibraryDisplayInfo( - newFilter: GetItemsFilter = this.filter.value!!, - newSort: SortAndDirection = this.sortAndDirection.value!!, - viewOptions: ViewOptions? = this.viewOptions.value, - ) { - if (collectionFilter.useSavedLibraryDisplayInfo) { - serverRepository.currentUser.value?.let { user -> - viewModelScope.launchIO { - val libraryDisplayInfo = - LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId, - sort = newSort.sort, - direction = newSort.direction, - filter = newFilter, - viewOptions = viewOptions, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } - } - } - } - - fun saveViewOptions(viewOptions: ViewOptions) { - this.viewOptions.value = viewOptions - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - saveLibraryDisplayInfo(viewOptions = viewOptions) - if (!viewOptions.showDetails) { - backdropService.clearBackdrop() - } - } - } - - fun onFilterChange( - newFilter: GetItemsFilter, - recursive: Boolean, - ) { - Timber.v("onFilterChange: filter=%s", newFilter) - saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!) - loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary) - } - - fun onSortChange( - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - ) { - Timber.v( - "onSortChange: sort=%s, recursive=%s, filter=%s", - sortAndDirection, - recursive, - filter, - ) - saveLibraryDisplayInfo(filter, sortAndDirection) - loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary) - } - - private fun loadResults( - resetState: Boolean, - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - useSeriesForPrimary: Boolean, - ) = viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - if (resetState) { - loading.value = DataLoadingState.Loading - } - backgroundLoading.value = LoadingState.Loading - this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection - this@CollectionFolderViewModel.filter.value = filter - } - try { - val newPager = - createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Success(newPager) - backgroundLoading.value = LoadingState.Success - } - } catch (ex: Exception) { - Timber.e( - ex, - "Exception while loading data: sort=%s, filter=%s", - sortAndDirection, - filter, - ) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Error(ex) - } - } - } - - private fun createPager( - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - useSeriesForPrimary: Boolean, - ): ApiRequestPager = - when (filter.override) { - GetItemsFilterOverride.NONE -> { - val request = - createGetItemsRequest( - sortAndDirection = sortAndDirection, - recursive = recursive, - filter = filter, - ) - val newPager = - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - newPager - } - - GetItemsFilterOverride.PERSON -> { - val request = - filter.applyTo( - GetPersonsRequest( - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - ), - ) - val newPager = - ApiRequestPager( - api, - request, - GetPersonsHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) - newPager - } - } - - private fun createGetItemsRequest( - sortAndDirection: SortAndDirection, - recursive: Boolean, - filter: GetItemsFilter, - ): GetItemsRequest { - val item = item.value - val includeItemTypes = - item - ?.data - ?.collectionType - ?.baseItemKinds - .orEmpty() - val request = - filter.applyTo( - GetItemsRequest( - parentId = item?.id, - enableImageTypes = - listOf( - ImageType.PRIMARY, - ImageType.THUMB, - ImageType.BACKDROP, - ImageType.LOGO, - ), - includeItemTypes = includeItemTypes, - recursive = recursive, - excludeItemIds = item?.let { listOf(item.id) }, - sortBy = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.sort) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(ItemSortBy.SORT_NAME) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(ItemSortBy.PRODUCTION_YEAR) - } - } - }, - sortOrder = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.direction) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(SortOrder.ASCENDING) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(SortOrder.ASCENDING) - } - } - }, - fields = SlimItemFields, - ), - ) - return request - } - - suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - FilterUtils.getFilterOptionValues( - api, - serverRepository.currentUser.value?.id, - itemUuid, - filterOption, - ) - - suspend fun positionOfLetter(letter: Char): Int? = - withContext(Dispatchers.IO) { - val sort = sortAndDirection.value - val filter = filter.value - if (sort == null || filter == null) { - return@withContext null - } - val request = - createGetItemsRequest( - sortAndDirection = sort, - recursive = recursive, - filter = filter, - ).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - ) - val result by GetItemsRequestHandler.execute(api, request) - result.totalRecordCount - } - - fun setWatched( - position: Int, - itemId: UUID, - played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - (loading.value as? DataLoadingState.Success)?.let { - (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) - } - } - - fun setFavorite( - position: Int, - itemId: UUID, - favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - (loading.value as? DataLoadingState.Success)?.let { - (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) - } - } - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - fun navigateTo(destination: Destination) { - release() - navigationManager.navigateTo(destination) - } - - fun release() { - themeSongPlayer.stop() - } - - fun onResumePage() { - viewModelScope.launchDefault { - item.value?.let { - Timber.v("onResumePage: %s", loading.value!!::class) - if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { - themeSongPlayer.playThemeFor(it.id) - } - } - } - } - - fun deleteItem( - index: Int, - item: BaseItem, - ) { - deleteItem(context, mediaManagementService, item) { - viewModelScope.launchDefault { - refreshAfterDelete(index, item) - } - } - } - - fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) - - fun addToQueue( - item: BaseItem, - index: Int, - ) = addToQueue(api, musicService, item, index) - } - -/** - * Shows a collection folder as a grid - * - * This is the "Library" tab for Movies or TV shows - */ -@Composable -fun CollectionFolderGrid( - preferences: UserPreferences, - itemId: UUID, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - onClickItem: (Int, BaseItem) -> Unit, - sortOptions: List, - playEnabled: Boolean, - defaultViewOptions: ViewOptions, - modifier: Modifier = Modifier, - viewModelKey: String? = itemId.toServerString(), - initialSortAndDirection: SortAndDirection? = null, - showTitle: Boolean = true, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - useSeriesForPrimary: Boolean = true, - filterOptions: List> = DefaultFilterOptions, - focusRequesterOnEmpty: FocusRequester? = null, -) = CollectionFolderGrid( - preferences, - itemId.toServerString(), - initialFilter, - recursive, - GridClickActions(onClickItem = onClickItem), - sortOptions, - playEnabled, - viewModelKey = viewModelKey, - defaultViewOptions = defaultViewOptions, - modifier = modifier, - initialSortAndDirection = initialSortAndDirection, - showTitle = showTitle, - positionCallback = positionCallback, - useSeriesForPrimary = useSeriesForPrimary, - filterOptions = filterOptions, - focusRequesterOnEmpty = focusRequesterOnEmpty, -) - -@Composable -fun CollectionFolderGrid( - preferences: UserPreferences, - itemId: UUID, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - actions: GridClickActions, - sortOptions: List, - playEnabled: Boolean, - defaultViewOptions: ViewOptions, - modifier: Modifier = Modifier, - viewModelKey: String? = itemId.toServerString(), - initialSortAndDirection: SortAndDirection? = null, - showTitle: Boolean = true, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - useSeriesForPrimary: Boolean = true, - filterOptions: List> = DefaultFilterOptions, - focusRequesterOnEmpty: FocusRequester? = null, -) = CollectionFolderGrid( - preferences, - itemId.toServerString(), - initialFilter, - recursive, - actions, - sortOptions, - playEnabled, - viewModelKey = viewModelKey, - defaultViewOptions = defaultViewOptions, - modifier = modifier, - initialSortAndDirection = initialSortAndDirection, - showTitle = showTitle, - positionCallback = positionCallback, - useSeriesForPrimary = useSeriesForPrimary, - filterOptions = filterOptions, - focusRequesterOnEmpty = focusRequesterOnEmpty, -) - -@Composable -fun CollectionFolderGrid( - preferences: UserPreferences, - itemId: String, - initialFilter: CollectionFolderFilter, - recursive: Boolean, - actions: GridClickActions, - sortOptions: List, - playEnabled: Boolean, - defaultViewOptions: ViewOptions, - modifier: Modifier = Modifier, - viewModelKey: String? = itemId, - initialSortAndDirection: SortAndDirection? = null, - showTitle: Boolean = true, - positionCallback: ((columns: Int, position: Int) -> Unit)? = null, - useSeriesForPrimary: Boolean = true, - filterOptions: List> = DefaultFilterOptions, - focusRequesterOnEmpty: FocusRequester? = null, - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), - viewModel: CollectionFolderViewModel = - hiltViewModel( - key = viewModelKey, - ) { - it.create( - itemId = itemId, - initialSortAndDirection = initialSortAndDirection, - recursive = recursive, - collectionFilter = initialFilter, - useSeriesForPrimary = useSeriesForPrimary, - defaultViewOptions = defaultViewOptions, - ) - }, -) { - val context = LocalContext.current - val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) - val filter by viewModel.filter.observeAsState(initialFilter.filter) - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) - val item by viewModel.item.observeAsState() - val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions) - - var showContextMenu by remember { mutableStateOf(null) } - var overviewDialog by remember { mutableStateOf(null) } - var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) - - val contextActions = - remember { - ContextMenuActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> - viewModel.setWatched(viewModel.position, itemId, watched) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(viewModel.position, itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel.mediaReportService::sendReportFor, - onDeleteItem = { viewModel.deleteItem(viewModel.position, it) }, - onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, - onChooseVersion = { _, _ -> - // Not supported on this page - }, - onChooseTracks = { result -> - // Not supported on this page - }, - onClearChosenStreams = { - // Not supported on this page - }, - ) - } - - val gridActions = - remember(actions) { - GridClickActions( - onClickItem = actions.onClickItem, - onLongClickItem = - actions.onLongClickItem ?: { position, item -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = item, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - }, - onClickPlayAll = - actions.onClickPlayAll ?: { shuffle -> - itemId.toUUIDOrNull()?.let { - val destination = - if (item?.type == BaseItemKind.PHOTO_ALBUM) { - Destination.Slideshow( - parentId = it, - index = 0, - filter = CollectionFolderFilter(filter = filter), - sortAndDirection = sortAndDirection, - recursive = true, - startSlideshow = true, - ) - } else { - Destination.PlaybackList( - itemId = it, - startIndex = 0, - shuffle = shuffle, - recursive = recursive, - sortAndDirection = sortAndDirection, - filter = filter, - ) - } - viewModel.navigateTo(destination) - } - Unit - }, - onClickPlayRemoteButton = - actions.onClickPlayRemoteButton ?: { index, item -> - val destination = - if (item.type == BaseItemKind.PHOTO_ALBUM) { - Destination.Slideshow( - parentId = item.id, - index = index, - filter = CollectionFolderFilter(filter = filter), - sortAndDirection = sortAndDirection, - recursive = true, - startSlideshow = true, - ) - } else { - Destination.Playback(item) - } - viewModel.navigateTo(destination) - }, - ) - } - - when (val state = loading) { - DataLoadingState.Loading, - DataLoadingState.Pending, - -> { - LoadingPage(modifier) - } - - is DataLoadingState.Error, - is DataLoadingState.Success<*>, - -> { - val title = - initialFilter.nameOverride - ?: item?.name - ?: item?.data?.collectionType?.name - ?: stringResource(R.string.collection) - Box(modifier = modifier) { - LifecycleResumeEffect(itemId) { - viewModel.onResumePage() - - onPauseOrDispose { - viewModel.release() - } - } - CollectionFolderGridContent( - preferences = preferences, - initialPosition = viewModel.position, - item = item, - title = title, - loadingState = state as DataLoadingState>, - sortAndDirection = sortAndDirection!!, - modifier = Modifier.fillMaxSize(), - focusRequesterOnEmpty = focusRequesterOnEmpty, - onClickItem = gridActions.onClickItem, - onLongClickItem = gridActions.onLongClickItem!!, - onSortChange = { - viewModel.onSortChange(it, recursive, filter) - }, - filterOptions = filterOptions, - currentFilter = filter, - onFilterChange = { - viewModel.onFilterChange(it, recursive) - }, - getPossibleFilterValues = { - viewModel.getFilterOptionValues(it) - }, - showTitle = showTitle, - sortOptions = sortOptions, - positionCallback = { columns, position -> - viewModel.position = position - positionCallback?.invoke(columns, position) - }, - letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, - viewOptions = viewOptions, - defaultViewOptions = defaultViewOptions, - onSaveViewOptions = { viewModel.saveViewOptions(it) }, - onChangeBackdrop = viewModel::updateBackdrop, - playEnabled = playEnabled, - onClickPlay = gridActions.onClickPlayRemoteButton!!, - onClickPlayAll = gridActions.onClickPlayAll!!, - ) - - AnimatedVisibility( - backgroundLoading == LoadingState.Loading, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - ) { - CircularProgress( - Modifier - .background( - MaterialTheme.colorScheme.background.copy(alpha = .25f), - shape = CircleShape, - ).size(64.dp) - .padding(4.dp), - ) - } - } - } - } - overviewDialog?.let { info -> - ItemDetailsDialog( - info = info, - showFilePath = - viewModel.serverRepository.currentUserDto.value - ?.policy - ?.isAdministrator == true, - onDismissRequest = { overviewDialog = null }, - ) - } - showContextMenu?.let { contextMenu -> - ContextMenuDialog( - onDismissRequest = { showContextMenu = null }, - getMediaSource = null, - contextMenu = contextMenu, - preferredSubtitleLanguage = null, - ) - } - showPlaylistDialog.compose { itemId -> - PlaylistDialog( - title = stringResource(R.string.add_to_playlist), - state = playlistState, - onDismissRequest = { showPlaylistDialog.makeAbsent() }, - onClick = { - playlistViewModel.addToPlaylist(it.id, itemId) - showPlaylistDialog.makeAbsent() - }, - createEnabled = true, - onCreatePlaylist = { - playlistViewModel.createPlaylistAndAddItem(it, itemId) - showPlaylistDialog.makeAbsent() - }, - elevation = 3.dp, - ) - } -} - @OptIn(ExperimentalFoundationApi::class) @Composable -fun CollectionFolderGridContent( +fun CollectionFolderGrid( preferences: UserPreferences, item: BaseItem?, title: String, @@ -893,12 +75,11 @@ fun CollectionFolderGridContent( sortOptions: List, playEnabled: Boolean, getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, - defaultViewOptions: ViewOptions, - onSaveViewOptions: (ViewOptions) -> Unit, viewOptions: ViewOptions, onClickPlayAll: (shuffle: Boolean) -> Unit, onClickPlay: (Int, BaseItem) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + onClickShowViewOptions: () -> Unit, initialPosition: Int, modifier: Modifier = Modifier, showTitle: Boolean = true, @@ -912,8 +93,6 @@ fun CollectionFolderGridContent( val pager = (loadingState as? DataLoadingState.Success)?.data var showHeader by rememberSaveable { mutableStateOf(true) } - var showViewOptions by rememberSaveable { mutableStateOf(false) } - var viewOptions by remember { mutableStateOf(viewOptions) } val headerRowFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() } @@ -928,7 +107,7 @@ fun CollectionFolderGridContent( var position by rememberInt(initialPosition) val focusedItem = pager?.getOrNull(position) - if (viewOptions.showDetails) { + if (viewOptions.showBackdrop) { LaunchedEffect(focusedItem) { focusedItem?.let(onChangeBackdrop) } @@ -939,82 +118,22 @@ fun CollectionFolderGridContent( verticalArrangement = Arrangement.spacedBy(0.dp), modifier = Modifier.fillMaxSize(), ) { - AnimatedVisibility( - showHeader || loadingState !is DataLoadingState.Success, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - if (showTitle) { - GridTitle(title) - } - val endPadding = - 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(start = 16.dp, end = endPadding) - .fillMaxWidth() - .focusRequester(headerRowFocusRequester), - ) { - if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, - ) { - if (sortOptions.isNotEmpty()) { - SortByButton( - sortOptions = sortOptions, - current = sortAndDirection, - onSortChange = onSortChange, - modifier = Modifier, - ) - } - if (filterOptions.isNotEmpty()) { - FilterByButton( - filterOptions = filterOptions, - current = currentFilter, - onFilterChange = onFilterChange, - getPossibleValues = getPossibleFilterValues, - modifier = Modifier, - ) - } - ExpandableFaButton( - title = R.string.view_options, - iconStringRes = R.string.fa_sliders, - onClick = { showViewOptions = true }, - modifier = Modifier, - ) - } - } - if (playEnabled && pager?.isNotEmpty() == true) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier, - ) { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = { onClickPlayAll.invoke(false) }, - ) - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { onClickPlayAll.invoke(true) }, - ) - } - } - } - } - } + CollectionFolderHeader( + showHeader = showHeader || loadingState !is DataLoadingState.Success, + showTitle = showTitle, + playEnabled = playEnabled && pager?.isNotEmpty() == true, + title = title, + sortAndDirection = sortAndDirection, + onSortChange = onSortChange, + sortOptions = sortOptions, + getPossibleFilterValues = getPossibleFilterValues, + onClickShowViewOptions = onClickShowViewOptions, + onClickPlayAll = onClickPlayAll, + currentFilter = currentFilter, + filterOptions = filterOptions, + onFilterChange = onFilterChange, + modifier = Modifier.focusRequester(headerRowFocusRequester), + ) val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current val density = LocalDensity.current AnimatedVisibility(viewOptions.showDetails) { @@ -1082,17 +201,6 @@ fun CollectionFolderGridContent( } }, ) - AnimatedVisibility(showViewOptions) { - ViewOptionsDialog( - viewOptions = viewOptions, - defaultViewOptions = defaultViewOptions, - onDismissRequest = { - showViewOptions = false - onSaveViewOptions.invoke(viewOptions) - }, - onViewOptionsChange = { viewOptions = it }, - ) - } } } } @@ -1227,3 +335,98 @@ data class GridClickActions( val onClickPlayAll: ((shuffle: Boolean) -> Unit)? = null, val onClickPlayRemoteButton: ((Int, BaseItem) -> Unit)? = null, ) + +@Composable +fun CollectionFolderHeader( + showHeader: Boolean, + showTitle: Boolean, + playEnabled: Boolean, + title: String, + sortAndDirection: SortAndDirection, + onSortChange: (SortAndDirection) -> Unit, + sortOptions: List, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + onClickShowViewOptions: () -> Unit, + onClickPlayAll: (Boolean) -> Unit, + modifier: Modifier = Modifier, + currentFilter: GetItemsFilter = GetItemsFilter(), + filterOptions: List> = listOf(), + onFilterChange: (GetItemsFilter) -> Unit = {}, +) { + AnimatedVisibility( + showHeader, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (showTitle) { + GridTitle(title) + } + val endPadding = + 16.dp + if (sortAndDirection.sort == ItemSortBy.SORT_NAME) 24.dp else 0.dp + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = endPadding) + .fillMaxWidth(), + ) { + if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + if (sortOptions.isNotEmpty()) { + SortByButton( + sortOptions = sortOptions, + current = sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + if (filterOptions.isNotEmpty()) { + FilterByButton( + filterOptions = filterOptions, + current = currentFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier, + ) + } + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = onClickShowViewOptions, + modifier = Modifier, + ) + } + } + if (playEnabled) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier, + ) { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlayAll.invoke(false) }, + ) + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlayAll.invoke(true) }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt new file mode 100644 index 00000000..0543646f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -0,0 +1,681 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.useExistingImageAsPlaceholder +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator +import com.github.damontecres.wholphin.ui.cards.WatchedIcon +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.AlphabetButtons +import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.extensions.ticks + +@Composable +fun CollectionFolderList( + preferences: UserPreferences, + item: BaseItem?, + title: String, + loadingState: DataLoadingState>, + sortAndDirection: SortAndDirection, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onSortChange: (SortAndDirection) -> Unit, + letterPosition: suspend (Char) -> Int, + sortOptions: List, + playEnabled: Boolean, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + viewOptions: ViewOptions, + onClickPlayAll: (shuffle: Boolean) -> Unit, + onClickPlay: (Int, BaseItem) -> Unit, + onChangeBackdrop: (BaseItem) -> Unit, + onClickShowViewOptions: () -> Unit, + initialPosition: Int, + modifier: Modifier = Modifier, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + currentFilter: GetItemsFilter = GetItemsFilter(), + filterOptions: List> = listOf(), + onFilterChange: (GetItemsFilter) -> Unit = {}, + focusRequesterOnEmpty: FocusRequester? = null, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val pager = (loadingState as? DataLoadingState.Success)?.data + var showHeader by rememberSaveable { mutableStateOf(true) } + val headerRowFocusRequester = remember { FocusRequester() } + val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME + + val gridFocusRequester = remember { FocusRequester() } + if (pager?.isNotEmpty() == true) { + RequestOrRestoreFocus(gridFocusRequester) + } else { + LaunchedEffect(Unit) { + (focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus() + } + } + val listState = rememberLazyListState() + + var contentHashFocus by remember { mutableStateOf(false) } + var position by rememberInt(initialPosition) + val positionFocusRequester = remember { FocusRequester() } + val focusedItem = pager?.getOrNull(position) + if (viewOptions.showBackdrop) { + LaunchedEffect(focusedItem) { + focusedItem?.let(onChangeBackdrop) + } + } + + fun jumpTo(newPosition: Int) { + scope.launch { + position = newPosition + listState.animateScrollToItem(newPosition) + positionFocusRequester.tryRequestFocus() + } + } + + BackHandler(contentHashFocus && position > 0) { + jumpTo(0) + } + + Column(modifier = modifier) { + CollectionFolderHeader( + showHeader = showHeader || loadingState !is DataLoadingState.Success, + showTitle = showTitle, + playEnabled = playEnabled && pager?.isNotEmpty() == true, + title = title, + sortAndDirection = sortAndDirection, + onSortChange = onSortChange, + sortOptions = sortOptions, + getPossibleFilterValues = getPossibleFilterValues, + onClickShowViewOptions = onClickShowViewOptions, + onClickPlayAll = onClickPlayAll, + currentFilter = currentFilter, + filterOptions = filterOptions, + onFilterChange = onFilterChange, + modifier = Modifier.focusRequester(headerRowFocusRequester), + ) + when (val state = loadingState) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + // This shouldn't happen, so just show placeholder + Text(stringResource(R.string.loading)) + } + + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception) + } + + is DataLoadingState.Success> -> { + val pager = state.data + val topPadding by animateDpAsState( + targetValue = + when { + showHeader -> 8.dp + + // showClock-> TODO() + else -> 48.dp + }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(top = topPadding, bottom = 16.dp) + .onFocusChanged { + contentHashFocus = it.hasFocus + }, + ) { + AnimatedVisibility( + visible = viewOptions.showDetails, + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.Start), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.Start), + ) { + CollectionFolderListDetails( + item = focusedItem, + showLogo = true, + modifier = + Modifier + .fillMaxWidth(.33f) + .fillMaxHeight() + .background( + color = Color.Transparent, // MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ).padding(8.dp), + ) + } + CollectionFolderContent( + items = state.data, + viewOptions = viewOptions, + collectionType = item?.data?.collectionType, + listState = listState, + position = position, + positionFocusRequester = positionFocusRequester, + onPositionChange = { + showHeader = it < 1 + positionCallback?.invoke(1, it) + position = it + }, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + modifier = + Modifier + .weight(1f) + .focusRequester(gridFocusRequester), + ) + val letters = stringResource(R.string.jump_letters) + // Letters + val currentLetter = + remember(focusedItem) { + focusedItem + ?.sortName + ?.firstOrNull() + ?.uppercaseChar() + ?.let { + when (it) { + in '0'..'9' -> '#' + in 'A'..'Z' -> it + else -> null + } + } + ?: letters[0] + } + if (showLetterButtons && pager.isNotEmpty()) { + AlphabetButtons( + letters = letters, + currentLetter = currentLetter, + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(start = 16.dp), + letterClicked = { + scope.launchIO { + val position = letterPosition.invoke(it) + jumpTo(position) + } + }, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalCoilApi::class) +@Composable +fun CollectionFolderListDetails( + item: BaseItem?, + showLogo: Boolean, + modifier: Modifier, +) { + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember(item) { + item?.imageUrlOverride ?: imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = true) + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + SimpleTitleOrLogo( + item, + showLogo, + Modifier + .height(HeaderUtils.logoHeight) + .fillMaxWidth(), + ) + + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageUrl) + .crossfade(300) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + modifier = + Modifier + .clip(shape = RoundedCornerShape(8.dp)) + .weight(1f), + ) + + item?.let { + QuickDetails(item.ui.quickDetails, null) + } + + OverviewText( + overview = item?.data?.overview ?: "", + maxLines = 5, + onClick = {}, + enabled = false, + modifier = Modifier, + ) + + // index? + } +} + +@Composable +fun CollectionFolderContent( + items: List, + viewOptions: ViewOptions, + collectionType: CollectionType?, + listState: LazyListState, + position: Int, + positionFocusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onPositionChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp), + modifier = modifier.focusRestorer(positionFocusRequester), + ) { + itemsIndexed(items) { index, item -> + val onClick: () -> Unit = + remember(index, item) { + { item?.let { onClickItem.invoke(index, item) } } + } + val onLongClick: () -> Unit = + remember(index, item) { + { item?.let { onLongClickItem.invoke(index, item) } } + } + CollectionFolderListItem( + item = item, + dense = viewOptions.type == ViewOptionsType.DENSE_LIST, + collectionType = collectionType, + onClick = onClick, + onLongClick = onLongClick, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) onPositionChange.invoke(index) + }.ifElse(index == position, Modifier.focusRequester(positionFocusRequester)), + ) + } + } +} + +@Composable +fun CollectionFolderListItem( + item: BaseItem?, + dense: Boolean, + collectionType: CollectionType?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (collectionType) { + CollectionType.MOVIES -> ListItemMovie(dense, item, onClick, onLongClick, modifier) + CollectionType.TVSHOWS -> ListItemSeries(dense, item, onClick, onLongClick, modifier) + CollectionType.MUSIC -> ListItemAlbum(dense, item, onClick, onLongClick, modifier) + else -> ListItemGeneric(dense, item, onClick, onLongClick, modifier) + } +} + +@Composable +fun ListItemGeneric( + dense: Boolean, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + ListItemWrapper( + dense = dense, + selected = false, + enabled = true, + onClick = onClick, + onLongClick = onLongClick, + leadingContent = { + if (item?.favorite == true) { + FavoriteIndicator() + } + }, + headlineContent = { TitleContent(item?.title, interactionSource) }, + supportingContent = + item?.subtitle?.let { subtitle -> + { + Text( + text = subtitle, + ) + } + }, + trailingContent = { + Text( + text = + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + ?.toString() ?: "", + ) + }, + scale = scale, + colors = listItemColors(), + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@Composable +fun ListItemMovie( + dense: Boolean, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val title = + remember(item, dense) { + if (dense && item != null && item.title != null && item.data.productionYear != null) { + "${item.title} (${ item.data.productionYear})" + } else { + item?.title ?: "" + } + } + val supportingContent = + remember(item, dense) { + if (!dense) { + @Composable { + Text(item?.subtitle ?: "") + } + } else { + null + } + } + ListItemWrapper( + dense = dense, + selected = false, + enabled = true, + onClick = onClick, + onLongClick = onLongClick, + leadingContent = { + if (item?.favorite == true) { + FavoriteIndicator() + } + }, + headlineContent = { TitleContent(title, interactionSource) }, + supportingContent = supportingContent, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + ?.toString() ?: "", + ) + if (item?.played == true) { + WatchedIcon(padding = 0.dp) + } + } + }, + scale = scale, + colors = listItemColors(), + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@Composable +fun ListItemSeries( + dense: Boolean, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val title = + remember(item, dense) { + if (item != null) { + if (dense) { + "${item.title} (${item.subtitle})" + } else { + item.title ?: "" + } + } else { + "" + } + } + val supportingContent = + remember(item, dense) { + if (!dense) { + @Composable { + Text(item?.subtitle ?: "") + } + } else { + null + } + } + val trailingContent = + remember(item) { + val unplayedItemCount = item?.data?.userData?.unplayedItemCount + if (unplayedItemCount != null && unplayedItemCount > 0) { + @Composable { Text(unplayedItemCount.toString()) } + } else if (item?.played == true) { + @Composable { WatchedIcon(padding = 0.dp) } + } else { + null + } + } + ListItemWrapper( + dense = dense, + selected = false, + enabled = true, + onClick = onClick, + onLongClick = onLongClick, + leadingContent = { + if (item?.favorite == true) { + FavoriteIndicator() + } + }, + headlineContent = { + TitleContent(title, interactionSource) + }, + supportingContent = supportingContent, + trailingContent = trailingContent, + scale = scale, + colors = listItemColors(), + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@Composable +fun ListItemAlbum( + dense: Boolean, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val title = + remember(item, dense) { + if (dense && item != null && item.title != null && item.data.productionYear != null) { + "${item.title} (${item.data.productionYear})" + } else { + item?.title ?: "" + } + } + val supportingContent = + remember(item, dense) { + if (!dense && item?.subtitle != null) { + @Composable { + Text(item.subtitle ?: "") + } + } else { + null + } + } + val overlineContent = + remember(item, dense) { + if (!dense && item?.data?.albumArtist != null) { + @Composable { + Text(item.data.albumArtist ?: "") + } + } else { + null + } + } + val trailingContent = + remember(item) { + if (item?.type == BaseItemKind.MUSIC_ALBUM) { + @Composable { + Text( + text = + item + .data + .runTimeTicks + ?.ticks + ?.roundMinutes + ?.toString() ?: "", + ) + } + } else { + null + } + } + ListItemWrapper( + dense = dense, + selected = false, + enabled = true, + onClick = onClick, + onLongClick = onLongClick, + leadingContent = { + if (item?.favorite == true) { + FavoriteIndicator() + } + }, + headlineContent = { TitleContent(title, interactionSource) }, + supportingContent = supportingContent, + trailingContent = trailingContent, + overlineContent = overlineContent, + scale = scale, + colors = listItemColors(), + interactionSource = interactionSource, + modifier = modifier, + ) +} + +@Composable +private fun TitleContent( + title: String?, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, +) { + val focused by interactionSource.collectIsFocusedAsState() + var focusedAfterDelay by remember { mutableStateOf(false) } + + val hideOverlayDelay = 500L + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + Text( + text = title ?: "", + modifier = modifier.enableMarquee(focusedAfterDelay), + ) +} + +@Composable +fun listItemColors() = + ListItemDefaults.colors( + containerColor = + MaterialTheme.colorScheme + .surfaceColorAtElevation(1.dp) + .copy(alpha = .5f), + ) + +private val scale = ListItemDefaults.scale(focusedScale = 1f, pressedScale = .95f) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt new file mode 100644 index 00000000..7a96d93e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -0,0 +1,899 @@ +package com.github.damontecres.wholphin.ui.components + +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.BaseItem +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.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.MusicService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.ItemViewModel +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.music.addToQueue +import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.util.FilterUtils +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID + +@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class) +class CollectionFolderViewModel + @AssistedInject + constructor( + private val savedStateHandle: SavedStateHandle, + api: ApiClient, + @param:ApplicationContext private val context: Context, + val serverRepository: ServerRepository, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val navigationManager: NavigationManager, + private val themeSongPlayer: ThemeSongPlayer, + private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, + private val musicService: MusicService, + val streamChoiceService: StreamChoiceService, + val mediaReportService: MediaReportService, + @Assisted itemId: String, + @Assisted initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") private val recursive: Boolean, + @Assisted private val collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, + @Assisted defaultViewOptions: ViewOptions, + ) : ItemViewModel(api) { + @AssistedFactory + interface Factory { + fun create( + itemId: String, + initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") recursive: Boolean, + collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean, + defaultViewOptions: ViewOptions, + ): CollectionFolderViewModel + } + + val loading = MutableLiveData>>(DataLoadingState.Loading) + val backgroundLoading = MutableLiveData(LoadingState.Loading) + val sortAndDirection = MutableLiveData() + val filter = MutableLiveData(GetItemsFilter()) + val viewOptions = MutableStateFlow(defaultViewOptions) + + var position: Int + get() = savedStateHandle.get("position") ?: 0 + set(value) { + savedStateHandle["position"] = value + } + + init { + viewModelScope.launchIO { + super.itemId = itemId + try { + val item = + itemId.toUUIDOrNull()?.let { + fetchItem(it) + } + + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + this@CollectionFolderViewModel.viewOptions.value = + libraryDisplayInfo?.viewOptions ?: defaultViewOptions + + val sortAndDirection = + if (collectionFilter.useSavedLibraryDisplayInfo) { + libraryDisplayInfo?.sortAndDirection + } else { + null + } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT + + val filterToUse = + if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { + collectionFilter.filter.merge(libraryDisplayInfo.filter) + } else { + collectionFilter.filter + } + + loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + .join() +// onResumePage() + } catch (ex: Exception) { + Timber.e(ex, "Error during init") + loading.setValueOnMain(DataLoadingState.Error(ex)) + } + } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + refreshAfterDelete(position, deletedItem.item) + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) + } + + private suspend fun refreshAfterDelete( + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, itemId) + 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", itemId) + showToast(context, "Error refreshing after item deleted") + } + } + + private fun saveLibraryDisplayInfo( + newFilter: GetItemsFilter = this.filter.value!!, + newSort: SortAndDirection = this.sortAndDirection.value!!, + viewOptions: ViewOptions? = this.viewOptions.value, + ) { + if (collectionFilter.useSavedLibraryDisplayInfo) { + serverRepository.currentUser.value?.let { user -> + viewModelScope.launchIO { + val libraryDisplayInfo = + LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = newSort.sort, + direction = newSort.direction, + filter = newFilter, + viewOptions = viewOptions, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + } + } + } + + fun saveViewOptions(viewOptions: ViewOptions) { + this.viewOptions.value = viewOptions + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + saveLibraryDisplayInfo(viewOptions = viewOptions) + if (!viewOptions.showBackdrop) { + backdropService.clearBackdrop() + } + } + } + + fun onFilterChange( + newFilter: GetItemsFilter, + recursive: Boolean, + ) { + Timber.v("onFilterChange: filter=%s", newFilter) + saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!) + loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary) + } + + fun onSortChange( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ) { + Timber.v( + "onSortChange: sort=%s, recursive=%s, filter=%s", + sortAndDirection, + recursive, + filter, + ) + saveLibraryDisplayInfo(filter, sortAndDirection) + loadResults(true, sortAndDirection, recursive, filter, useSeriesForPrimary) + } + + private fun loadResults( + resetState: Boolean, + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + useSeriesForPrimary: Boolean, + ) = viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + if (resetState) { + loading.value = DataLoadingState.Loading + } + backgroundLoading.value = LoadingState.Loading + this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection + this@CollectionFolderViewModel.filter.value = filter + } + try { + val newPager = + createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Success(newPager) + backgroundLoading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e( + ex, + "Exception while loading data: sort=%s, filter=%s", + sortAndDirection, + filter, + ) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Error(ex) + } + } + } + + private fun createPager( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + useSeriesForPrimary: Boolean, + ): ApiRequestPager = + when (filter.override) { + GetItemsFilterOverride.NONE -> { + val request = + createGetItemsRequest( + sortAndDirection = sortAndDirection, + recursive = recursive, + filter = filter, + ) + val newPager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + newPager + } + + GetItemsFilterOverride.PERSON -> { + val request = + filter.applyTo( + GetPersonsRequest( + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + ), + ) + val newPager = + ApiRequestPager( + api, + request, + GetPersonsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + newPager + } + } + + private fun createGetItemsRequest( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ): GetItemsRequest { + val item = item.value + val includeItemTypes = + item + ?.data + ?.collectionType + ?.baseItemKinds + .orEmpty() + val request = + filter.applyTo( + GetItemsRequest( + parentId = item?.id, + enableImageTypes = + listOf( + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.BACKDROP, + ImageType.LOGO, + ), + includeItemTypes = includeItemTypes, + recursive = recursive, + excludeItemIds = item?.let { listOf(item.id) }, + sortBy = + buildList { + if (sortAndDirection.sort != ItemSortBy.DEFAULT) { + add(sortAndDirection.sort) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(ItemSortBy.PRODUCTION_YEAR) + } + } + }, + sortOrder = + buildList { + if (sortAndDirection.sort != ItemSortBy.DEFAULT) { + add(sortAndDirection.direction) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(SortOrder.ASCENDING) + } + } + }, + fields = SlimItemFields, + ), + ) + return request + } + + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemUuid, + filterOption, + ) + + suspend fun positionOfLetter(letter: Char): Int? = + withContext(Dispatchers.IO) { + val sort = sortAndDirection.value + val filter = filter.value + if (sort == null || filter == null) { + return@withContext null + } + val request = + createGetItemsRequest( + sortAndDirection = sort, + recursive = recursive, + filter = filter, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ) + val result by GetItemsRequestHandler.execute(api, request) + result.totalRecordCount + } + + fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (loading.value as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + + fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (loading.value as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + + fun release() { + themeSongPlayer.stop() + } + + fun onResumePage() { + viewModelScope.launchIO { + item.value?.let { + Timber.v("onResumePage: %s", loading.value!!::class) + if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { + themeSongPlayer.playThemeFor(it.id) + } + } + } + } + + fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(index, item) + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun addToQueue( + item: BaseItem, + index: Int, + ) = addToQueue(api, musicService, item, index) + } + +/** + * Shows a collection folder as a grid + * + * This is the "Library" tab for Movies or TV shows + */ +@Composable +fun CollectionFolderView( + preferences: UserPreferences, + itemId: UUID, + initialFilter: CollectionFolderFilter, + recursive: Boolean, + onClickItem: (Int, BaseItem) -> Unit, + sortOptions: List, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + modifier: Modifier = Modifier, + viewModelKey: String? = itemId.toServerString(), + initialSortAndDirection: SortAndDirection? = null, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + useSeriesForPrimary: Boolean = true, + filterOptions: List> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, +) = CollectionFolderView( + preferences, + itemId.toServerString(), + initialFilter, + recursive, + GridClickActions(onClickItem = onClickItem), + sortOptions, + playEnabled, + viewModelKey = viewModelKey, + defaultViewOptions = defaultViewOptions, + modifier = modifier, + initialSortAndDirection = initialSortAndDirection, + showTitle = showTitle, + positionCallback = positionCallback, + useSeriesForPrimary = useSeriesForPrimary, + filterOptions = filterOptions, + focusRequesterOnEmpty = focusRequesterOnEmpty, +) + +@Composable +fun CollectionFolderView( + preferences: UserPreferences, + itemId: UUID, + initialFilter: CollectionFolderFilter, + recursive: Boolean, + actions: GridClickActions, + sortOptions: List, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + modifier: Modifier = Modifier, + viewModelKey: String? = itemId.toServerString(), + initialSortAndDirection: SortAndDirection? = null, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + useSeriesForPrimary: Boolean = true, + filterOptions: List> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, +) = CollectionFolderView( + preferences, + itemId.toServerString(), + initialFilter, + recursive, + actions, + sortOptions, + playEnabled, + viewModelKey = viewModelKey, + defaultViewOptions = defaultViewOptions, + modifier = modifier, + initialSortAndDirection = initialSortAndDirection, + showTitle = showTitle, + positionCallback = positionCallback, + useSeriesForPrimary = useSeriesForPrimary, + filterOptions = filterOptions, + focusRequesterOnEmpty = focusRequesterOnEmpty, +) + +@Composable +fun CollectionFolderView( + preferences: UserPreferences, + itemId: String, + initialFilter: CollectionFolderFilter, + recursive: Boolean, + actions: GridClickActions, + sortOptions: List, + playEnabled: Boolean, + defaultViewOptions: ViewOptions, + modifier: Modifier = Modifier, + viewModelKey: String? = itemId, + initialSortAndDirection: SortAndDirection? = null, + showTitle: Boolean = true, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, + useSeriesForPrimary: Boolean = true, + filterOptions: List> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), + viewModel: CollectionFolderViewModel = + hiltViewModel( + key = viewModelKey, + ) { + it.create( + itemId = itemId, + initialSortAndDirection = initialSortAndDirection, + recursive = recursive, + collectionFilter = initialFilter, + useSeriesForPrimary = useSeriesForPrimary, + defaultViewOptions = defaultViewOptions, + ) + }, +) { + val context = LocalContext.current + val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) + val filter by viewModel.filter.observeAsState(initialFilter.filter) + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) + val item by viewModel.item.observeAsState() + val viewOptions by viewModel.viewOptions.collectAsState() + + var showContextMenu by remember { mutableStateOf(null) } + var overviewDialog by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showViewOptions by rememberSaveable { mutableStateOf(false) } + + val contextActions = + remember { + ContextMenuActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> + viewModel.setWatched(viewModel.position, itemId, watched) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(viewModel.position, itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onDeleteItem = { viewModel.deleteItem(viewModel.position, it) }, + onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, + onChooseVersion = { _, _ -> + // Not supported on this page + }, + onChooseTracks = { result -> + // Not supported on this page + }, + onClearChosenStreams = { + // Not supported on this page + }, + ) + } + + val gridActions = + remember(actions) { + GridClickActions( + onClickItem = actions.onClickItem, + onLongClickItem = + actions.onLongClickItem ?: { position, item -> + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = item, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + }, + onClickPlayAll = + actions.onClickPlayAll ?: { shuffle -> + itemId.toUUIDOrNull()?.let { + val destination = + if (item?.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = it, + index = 0, + filter = CollectionFolderFilter(filter = filter), + sortAndDirection = sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = sortAndDirection, + filter = filter, + ) + } + viewModel.navigateTo(destination) + } + Unit + }, + onClickPlayRemoteButton = + actions.onClickPlayRemoteButton ?: { index, item -> + val destination = + if (item.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = item.id, + index = index, + filter = CollectionFolderFilter(filter = filter), + sortAndDirection = sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.Playback(item) + } + viewModel.navigateTo(destination) + }, + ) + } + + when (val state = loading) { + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier) + } + + is DataLoadingState.Error, + is DataLoadingState.Success<*>, + -> { + val title = + initialFilter.nameOverride + ?: item?.name + ?: item?.data?.collectionType?.name + ?: stringResource(R.string.collection) + Box(modifier = modifier) { + LifecycleResumeEffect(itemId) { + viewModel.onResumePage() + + onPauseOrDispose { + viewModel.release() + } + } + if (viewOptions.type == ViewOptionsType.GRID) { + CollectionFolderGrid( + preferences = preferences, + initialPosition = viewModel.position, + item = item, + title = title, + loadingState = state as DataLoadingState>, + sortAndDirection = sortAndDirection!!, + modifier = Modifier.fillMaxSize(), + focusRequesterOnEmpty = focusRequesterOnEmpty, + onClickItem = gridActions.onClickItem, + onLongClickItem = gridActions.onLongClickItem!!, + onSortChange = { + viewModel.onSortChange(it, recursive, filter) + }, + filterOptions = filterOptions, + currentFilter = filter, + onFilterChange = { + viewModel.onFilterChange(it, recursive) + }, + getPossibleFilterValues = { + viewModel.getFilterOptionValues(it) + }, + showTitle = showTitle, + sortOptions = sortOptions, + positionCallback = { columns, position -> + viewModel.position = position + positionCallback?.invoke(columns, position) + }, + letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + viewOptions = viewOptions, + onChangeBackdrop = viewModel::updateBackdrop, + playEnabled = playEnabled, + onClickPlay = gridActions.onClickPlayRemoteButton!!, + onClickPlayAll = gridActions.onClickPlayAll!!, + onClickShowViewOptions = { showViewOptions = true }, + ) + } else { + CollectionFolderList( + preferences = preferences, + initialPosition = viewModel.position, + item = item, + title = title, + loadingState = state as DataLoadingState>, + sortAndDirection = sortAndDirection!!, + modifier = Modifier.fillMaxSize(), + focusRequesterOnEmpty = focusRequesterOnEmpty, + onClickItem = gridActions.onClickItem, + onLongClickItem = gridActions.onLongClickItem!!, + onSortChange = { + viewModel.onSortChange(it, recursive, filter) + }, + filterOptions = filterOptions, + currentFilter = filter, + onFilterChange = { + viewModel.onFilterChange(it, recursive) + }, + getPossibleFilterValues = { + viewModel.getFilterOptionValues(it) + }, + showTitle = showTitle, + sortOptions = sortOptions, + positionCallback = { columns, position -> + viewModel.position = position + positionCallback?.invoke(columns, position) + }, + letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + viewOptions = viewOptions, + onChangeBackdrop = viewModel::updateBackdrop, + playEnabled = playEnabled, + onClickPlay = gridActions.onClickPlayRemoteButton!!, + onClickPlayAll = gridActions.onClickPlayAll!!, + onClickShowViewOptions = { showViewOptions = true }, + ) + } + + AnimatedVisibility( + backgroundLoading == LoadingState.Loading, + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + ) { + CircularProgress( + Modifier + .background( + MaterialTheme.colorScheme.background.copy(alpha = .25f), + shape = CircleShape, + ).size(64.dp) + .padding(4.dp), + ) + } + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto.value + ?.policy + ?.isAdministrator == true, + onDismissRequest = { overviewDialog = null }, + ) + } + showContextMenu?.let { contextMenu -> + ContextMenuDialog( + onDismissRequest = { showContextMenu = null }, + getMediaSource = null, + contextMenu = contextMenu, + preferredSubtitleLanguage = null, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } + AnimatedVisibility(showViewOptions) { + ViewOptionsDialog( + viewOptions = viewOptions, + defaultViewOptions = defaultViewOptions, + onDismissRequest = { + showViewOptions = false + viewModel.saveViewOptions(viewOptions) + }, + onViewOptionsChange = viewModel::saveViewOptions, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ListItemWrapper.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ListItemWrapper.kt new file mode 100644 index 00000000..bdfddb9e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ListItemWrapper.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.tv.material3.DenseListItem +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemBorder +import androidx.tv.material3.ListItemColors +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.ListItemGlow +import androidx.tv.material3.ListItemScale +import androidx.tv.material3.ListItemShape + +/** + * Displays either a [ListItem] or [DenseListItem] based on the dense parameter + */ +@Composable +@NonRestartableComposable +fun ListItemWrapper( + dense: Boolean, + selected: Boolean, + onClick: () -> Unit, + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, + overlineContent: (@Composable () -> Unit)? = null, + supportingContent: (@Composable () -> Unit)? = null, + leadingContent: (@Composable BoxScope.() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + tonalElevation: Dp = ListItemDefaults.TonalElevation, + shape: ListItemShape = ListItemDefaults.shape(), + colors: ListItemColors = ListItemDefaults.colors(), + scale: ListItemScale = ListItemDefaults.scale(), + border: ListItemBorder = ListItemDefaults.border(), + glow: ListItemGlow = ListItemDefaults.glow(), + interactionSource: MutableInteractionSource? = null, +) = if (dense) { + DenseListItem( + selected = selected, + onClick = onClick, + headlineContent = headlineContent, + modifier = modifier, + enabled = enabled, + onLongClick = onLongClick, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + tonalElevation = tonalElevation, + shape = shape, + colors = colors, + scale = scale, + border = border, + glow = glow, + interactionSource = interactionSource, + ) +} else { + ListItem( + selected = selected, + onClick = onClick, + headlineContent = headlineContent, + modifier = modifier, + enabled = enabled, + onLongClick = onLongClick, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + tonalElevation = tonalElevation, + shape = shape, + colors = colors, + scale = scale, + border = border, + glow = glow, + interactionSource = interactionSource, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt index 5af10b8b..ff001d0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.widthIn @@ -9,6 +10,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight @@ -93,3 +95,28 @@ fun rememberLogoUrl(item: BaseItem?): String? { } } } + +@Composable +fun SimpleTitleOrLogo( + item: BaseItem?, + showLogo: Boolean, + modifier: Modifier = Modifier, +) { + val logoImageUrl = rememberLogoUrl(item) + var imageError by remember { mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + if (showLogo && logoImageUrl != null && !imageError) { + AsyncImage( + model = logoImageUrl, + contentDescription = item?.title, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + } else { + Title(item?.title, Modifier) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index 74c00328..4f4d7588 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -42,7 +42,7 @@ import org.jellyfin.sdk.model.api.ImageType /** * A dialog that shows the controls for making changes to a [ViewOptions] object. The caller must manage the state of the [ViewOptions]. * - * It displays the [AppPreference] objects from [ViewOptions.OPTIONS] + * It displays the [AppPreference] objects from [ViewOptions.GRID_OPTIONS] */ @Composable fun ViewOptionsDialog( @@ -66,6 +66,14 @@ fun ViewOptionsDialog( window.setGravity(Gravity.END) window.setDimAmount(0f) } + val options = + remember(viewOptions.type) { + when (viewOptions.type) { + ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS + ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS + ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS + } + } LazyColumn( state = columnState, contentPadding = PaddingValues(16.dp), @@ -86,7 +94,7 @@ fun ViewOptionsDialog( style = MaterialTheme.typography.titleMedium, ) } - items(ViewOptions.OPTIONS) { pref -> + items(options, key = { it.title }) { pref -> pref as AppPreference val interactionSource = remember { MutableInteractionSource() } val value = pref.getter.invoke(viewOptions) @@ -122,8 +130,10 @@ data class ViewOptions( val contentScale: PrefContentScale = PrefContentScale.FIT, val aspectRatio: AspectRatio = AspectRatio.TALL, val showDetails: Boolean = false, + val showBackdrop: Boolean = false, val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, val showTitles: Boolean = true, + val type: ViewOptionsType = ViewOptionsType.GRID, ) { companion object { val ViewOptionsColumns = @@ -176,6 +186,13 @@ data class ViewOptions( getter = { it.showDetails }, setter = { vo, value -> vo.copy(showDetails = value) }, ) + val ViewOptionsBackdrop = + AppSwitchPreference( + title = R.string.show_backdrop, + defaultValue = false, + getter = { it.showBackdrop }, + setter = { vo, value -> vo.copy(showBackdrop = value) }, + ) val ViewOptionsShowTitles = AppSwitchPreference( title = R.string.show_titles, @@ -202,22 +219,52 @@ data class ViewOptions( valueToIndex = { it.ordinal }, ) + val ViewOptionsTypePref = + AppChoicePreference( + title = R.string.layout, + defaultValue = ViewOptionsType.GRID, + displayValues = R.array.view_options_types, + getter = { it.type }, + setter = { viewOptions, value -> + val spacing = + when (value) { + ViewOptionsType.GRID -> 16 + ViewOptionsType.LIST -> 4 + ViewOptionsType.DENSE_LIST -> 2 + } + viewOptions.copy(type = value, spacing = spacing) + }, + indexToValue = { ViewOptionsType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + val ViewOptionsReset = AppClickablePreference( title = R.string.reset, ) - val OPTIONS = + val GRID_OPTIONS = listOf( + ViewOptionsTypePref, ViewOptionsImageType, ViewOptionsAspectRatio, ViewOptionsDetailHeader, + ViewOptionsBackdrop, ViewOptionsShowTitles, ViewOptionsColumns, ViewOptionsSpacing, ViewOptionsContentScale, ViewOptionsReset, ) + + val LIST_OPTIONS = + listOf( + ViewOptionsTypePref, + ViewOptionsDetailHeader, + ViewOptionsBackdrop, + ViewOptionsSpacing, + ViewOptionsReset, + ) } } @@ -249,3 +296,9 @@ enum class ViewOptionImageType( THUMB(ImageType.THUMB), // BANNER(ImageType.BANNER), } + +enum class ViewOptionsType { + GRID, + LIST, + DENSE_LIST, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index e0c15c26..7d5c262c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -11,7 +11,7 @@ import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsWide import com.github.damontecres.wholphin.ui.data.VideoSortOptions @@ -41,7 +41,7 @@ fun CollectionFolderGeneric( ViewOptionsWide } } - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, onClickItem = { index, item -> preferencesViewModel.navigationManager.navigateTo(item.destination(index)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 906040fa..04adf7eb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -31,7 +31,7 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.ViewOptions @@ -165,7 +165,7 @@ fun CollectionFolderLiveTv( else -> { val folderIndex = selectedTabIndex - 2 if (folderIndex in folders.indices) { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, onClickItem = onClickItem, itemId = folders[folderIndex].id, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 6f67b011..564ac66a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedMovie @@ -106,7 +106,7 @@ fun CollectionFolderMovie( // Library 1 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) @@ -138,7 +138,7 @@ fun CollectionFolderMovie( // Collections 2 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index 1911fc7a..cbea7dde 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -31,7 +31,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GridClickActions @@ -204,7 +204,7 @@ fun CollectionFolderMusic( // Albums 1 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = destination.itemId, @@ -234,7 +234,7 @@ fun CollectionFolderMusic( // Artists 2 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = destination.itemId, @@ -276,7 +276,7 @@ fun CollectionFolderMusic( // Songs 4 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = destination.itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index 62186f55..1235fc03 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -12,7 +12,7 @@ import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.ViewOptionsWide @@ -50,7 +50,7 @@ fun CollectionFolderPhotoAlbum( }, ) { var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = remember { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index e7e19066..30a40fcd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel @@ -25,7 +25,7 @@ fun CollectionFolderPlaylist( preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index a58db1b9..0279bd72 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel @@ -25,7 +25,7 @@ fun CollectionFolderRecordings( preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index cbc87bae..af75b638 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -26,7 +26,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedTvShow @@ -113,7 +113,7 @@ fun CollectionFolderTv( // Library 1 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, itemId = destination.itemId, initialFilter = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 9b03aa0c..b0124313 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -27,7 +27,7 @@ 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.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.TabRow @@ -121,7 +121,7 @@ fun FavoritesPage( when (selectedTabIndex) { // Movies 0 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = "${NavDrawerItem.Favorites.id}_movies", @@ -153,7 +153,7 @@ fun FavoritesPage( // TV 1 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = "${NavDrawerItem.Favorites.id}_series", @@ -185,7 +185,7 @@ fun FavoritesPage( // Episodes 2 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = "${NavDrawerItem.Favorites.id}_episodes", @@ -218,7 +218,7 @@ fun FavoritesPage( // Videos 3 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = "${NavDrawerItem.Favorites.id}_videos", @@ -250,7 +250,7 @@ fun FavoritesPage( // Playlists 4 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = "${NavDrawerItem.Favorites.id}_playlists", @@ -282,7 +282,7 @@ fun FavoritesPage( // People 5 -> { - CollectionFolderGrid( + CollectionFolderView( preferences = preferences, actions = actions, itemId = "${NavDrawerItem.Favorites.id}_people", diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c18fa1b..119a4a84 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -703,6 +703,14 @@ @string/image_type_thumb + Grid + List + Compact List + + @string/view_options_type_grid + @string/view_options_type_list + @string/view_options_type_list_compact + Image with dynamic color Image only