diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index bd65d17c..09acd57e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -4,6 +4,7 @@ package com.github.damontecres.wholphin.data.model import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID @@ -17,9 +18,11 @@ data class GetItemsFilter( val played: Boolean? = null, val studios: List? = null, val tags: List? = null, + val includeItemTypes: List? = null, ) { fun applyTo(req: GetItemsRequest) = req.copy( + includeItemTypes = includeItemTypes, isFavorite = favorite, genreIds = genres, minCriticRating = minCriticRating, 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 394ed3ca..0a7e1ff6 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 @@ -69,7 +69,7 @@ class CollectionFolderViewModel val filter = MutableLiveData(GetItemsFilter()) fun init( - itemId: UUID, + itemId: UUID?, potential: BaseItem?, sortAndDirection: SortAndDirection, recursive: Boolean, @@ -81,7 +81,9 @@ class CollectionFolderViewModel "Error loading collection $itemId", ) + Dispatchers.IO, ) { - fetchItem(itemId, potential) + if (itemId != null) { + fetchItem(itemId, potential) + } loadResults(sortAndDirection, recursive, filter) } @@ -90,68 +92,67 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, ) { - item.value?.let { item -> - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - pager.value = listOf() - loading.value = LoadingState.Loading - this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection - this@CollectionFolderViewModel.filter.value = filter + val item = item.value + viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + pager.value = listOf() + loading.value = LoadingState.Loading + this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection + this@CollectionFolderViewModel.filter.value = filter + } + val includeItemTypes = + when (item?.data?.collectionType) { + CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) + CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) + CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) + CollectionType.MUSIC -> + listOf( + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.MUSIC_ALBUM, + ) + + CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET) + CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST) + + else -> listOf() } - val includeItemTypes = - when (item.data.collectionType) { - CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) - CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) - CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) - CollectionType.MUSIC -> + val request = + filter.applyTo( + GetItemsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + includeItemTypes = includeItemTypes, + recursive = recursive, + excludeItemIds = item?.let { listOf(item.id) }, + sortBy = listOf( - BaseItemKind.AUDIO, - BaseItemKind.MUSIC_ARTIST, - BaseItemKind.MUSIC_ALBUM, - ) - - CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET) - CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST) - - else -> listOf() - } - val request = - filter.applyTo( - GetItemsRequest( - parentId = item.id, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - includeItemTypes = includeItemTypes, - recursive = recursive, - excludeItemIds = listOf(item.id), - sortBy = - listOf( - sortAndDirection.sort, - ItemSortBy.SORT_NAME, - ItemSortBy.PRODUCTION_YEAR, - ), - sortOrder = - listOf( - sortAndDirection.direction, - SortOrder.ASCENDING, - SortOrder.ASCENDING, - ), - fields = SlimItemFields, - ), - ) - val newPager = - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - useSeriesForPrimary = true, - ) - newPager.init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - pager.value = newPager - loading.value = LoadingState.Success - } + sortAndDirection.sort, + ItemSortBy.SORT_NAME, + ItemSortBy.PRODUCTION_YEAR, + ), + sortOrder = + listOf( + sortAndDirection.direction, + SortOrder.ASCENDING, + SortOrder.ASCENDING, + ), + fields = SlimItemFields, + ), + ) + val newPager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + useSeriesForPrimary = true, + ) + newPager.init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + withContext(Dispatchers.Main) { + pager.value = newPager + loading.value = LoadingState.Success } } } @@ -188,7 +189,7 @@ class CollectionFolderViewModel @Composable fun CollectionFolderGrid( preferences: UserPreferences, - itemId: UUID, + itemId: UUID?, item: BaseItem?, initialFilter: GetItemsFilter, recursive: Boolean, @@ -221,7 +222,7 @@ fun CollectionFolderGrid( pager?.let { pager -> CollectionFolderGridContent( preferences, - item!!, + item, pager, sortAndDirection = sortAndDirection, modifier = modifier, @@ -241,7 +242,7 @@ fun CollectionFolderGrid( @Composable fun CollectionFolderGridContent( preferences: UserPreferences, - item: BaseItem, + item: BaseItem?, pager: List, sortAndDirection: SortAndDirection, onClickItem: (BaseItem) -> Unit, @@ -251,9 +252,9 @@ fun CollectionFolderGridContent( showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, ) { - val title = item.name ?: item.data.collectionType?.name ?: "Collection" + val title = item?.name ?: item?.data?.collectionType?.name ?: "Collection" val sortOptions = - when (item.data.collectionType) { + when (item?.data?.collectionType) { CollectionType.MOVIES -> MovieSortOptions CollectionType.TVSHOWS -> SeriesSortOptions CollectionType.HOMEVIDEOS -> VideoSortOptions diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButton.kt index 38a4169c..2c7e0c4e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButton.kt @@ -6,10 +6,12 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource @@ -103,6 +105,7 @@ fun ExpandableFaButton( onClick: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + iconColor: Color = Color.Unspecified, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( @@ -114,16 +117,17 @@ fun ExpandableFaButton( Text( text = stringResource(iconStringRes), style = MaterialTheme.typography.titleSmall, + color = iconColor, fontSize = 16.sp, fontFamily = FontAwesome, textAlign = TextAlign.Center, modifier = Modifier, ) AnimatedVisibility(isFocused) { - Spacer(Modifier.size(8.dp)) Text( text = stringResource(title), style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 4.dp), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 0a2c2e02..402eae1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.focus.FocusState 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.res.stringResource import androidx.compose.ui.unit.dp import androidx.tv.material3.Button @@ -124,8 +125,10 @@ fun PlayButtons( fun ExpandablePlayButtons( resumePosition: Duration, watched: Boolean, + favorite: Boolean, playOnClick: (position: Duration) -> Unit, watchOnClick: () -> Unit, + favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, @@ -185,6 +188,17 @@ fun ExpandablePlayButtons( ) } + // Favorite button + item("favorite") { + ExpandableFaButton( + title = if (favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + onClick = favoriteOnClick, + iconColor = if (favorite) Color.Red else Color.Unspecified, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + // More button item("more") { ExpandablePlayButton( @@ -205,8 +219,10 @@ private fun ExpandablePlayButtonsPreview() { ExpandablePlayButtons( resumePosition = 10.seconds, watched = false, + favorite = false, playOnClick = {}, watchOnClick = {}, + favoriteOnClick = {}, moreOnClick = {}, buttonOnFocusChanged = {}, modifier = Modifier, 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 5de7a852..e063add9 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 @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.BaseItemKind @Composable fun CollectionFolderMovie( @@ -161,7 +162,10 @@ fun CollectionFolderMovie( onClickItem = onClickItem, itemId = destination.itemId, item = destination.item, - initialFilter = GetItemsFilter(), + initialFilter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.MOVIE), + ), showTitle = false, recursive = true, modifier = 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 7cd0ddd0..9e2e1447 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 @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.BaseItemKind @Composable fun CollectionFolderTv( @@ -160,7 +161,10 @@ fun CollectionFolderTv( preferences = preferences, itemId = destination.itemId, item = destination.item, - initialFilter = GetItemsFilter(), + initialFilter = + GetItemsFilter( + includeItemTypes = listOf(BaseItemKind.SERIES), + ), showTitle = false, recursive = true, modifier = 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 new file mode 100644 index 00000000..c7d7b140 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -0,0 +1,212 @@ +package com.github.damontecres.wholphin.ui.detail + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.Modifier +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.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Tab +import androidx.tv.material3.TabDefaults +import androidx.tv.material3.TabRow +import androidx.tv.material3.TabRowDefaults +import androidx.tv.material3.Text +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.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun FavoritesPage( + preferences: UserPreferences, + modifier: Modifier = Modifier, + preferencesViewModel: PreferencesViewModel = hiltViewModel(), +) { + val uiPrefs = preferences.appPreferences.interfacePreferences + val rememberedTabIndex = 0 + // TODO remember tab +// if (uiPrefs.rememberSelectedTab) { +// uiPrefs.getRememberedTabsOrDefault(destination.itemId.toString(), 0) +// } else { +// 0 +// } + + val tabs = listOf("Movies", "TV Shows", "Episodes") + var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + val focusRequester = remember { FocusRequester() } + + val firstTabFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } + +// if (uiPrefs.rememberSelectedTab) { +// LaunchedEffect(selectedTabIndex) { +// preferencesViewModel.preferenceDataStore.updateData { +// preferences.appPreferences.rememberTab(destination.itemId, selectedTabIndex) +// } +// } +// } + var showHeader by rememberSaveable { mutableStateOf(true) } + + val onClickItem = { item: BaseItem -> + preferencesViewModel.navigationManager.navigateTo(item.destination()) + } + + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = focusTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRestorer(firstTabFocusRequester) + .onFocusChanged { + if (!it.isFocused) { + focusTabIndex = selectedTabIndex + } + }, + indicator = + @Composable { tabPositions, doesTabRowHaveFocus -> + tabPositions.getOrNull(focusTabIndex)?.let { currentTabPosition -> +// TabRowDefaults.PillIndicator( +// currentTabPosition = currentTabPosition, +// doesTabRowHaveFocus = doesTabRowHaveFocus, +// ) + TabRowDefaults.UnderlinedIndicator( + currentTabPosition = currentTabPosition, + doesTabRowHaveFocus = doesTabRowHaveFocus, + activeColor = MaterialTheme.colorScheme.border, + ) + } + }, + tabs = { + tabs.forEachIndexed { index, title -> + Tab( + selected = focusTabIndex == index, + onClick = { selectedTabIndex = index }, + onFocus = { focusTabIndex = index }, + colors = + TabDefaults.pillIndicatorTabColors( + focusedContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier + .padding(8.dp) + .ifElse( + index == selectedTabIndex, + Modifier.focusRequester(firstTabFocusRequester), + ), + ) + } + } + }, + ) + } + when (selectedTabIndex) { + 0 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = onClickItem, + itemId = null, + item = null, + initialFilter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.MOVIE), + ), + showTitle = false, + recursive = true, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + ) + } + 1 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = onClickItem, + itemId = null, + item = null, + initialFilter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.SERIES), + ), + showTitle = false, + recursive = true, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + ) + } + 2 -> { + CollectionFolderGrid( + preferences = preferences, + onClickItem = onClickItem, + itemId = null, + item = null, + initialFilter = + GetItemsFilter( + favorite = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + ), + showTitle = false, + recursive = true, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxSize() + .focusRequester(focusRequester), + positionCallback = { columns, position -> + showHeader = position < columns + }, + ) + } + else -> ErrorMessage("Invalid tab index $selectedTabIndex", null) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesDetails.kt index 0b74148f..e27a6080 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesDetails.kt @@ -122,6 +122,7 @@ fun SeriesDetails( people = people, similar = similar, played = played, + favorite = item.data.userData?.isFavorite ?: false, modifier = modifier, onClickItem = { viewModel.navigateTo(it.destination()) }, onLongClickItem = { season -> @@ -144,6 +145,10 @@ fun SeriesDetails( }, playOnClick = { viewModel.playNextUp() }, watchOnClick = { showWatchConfirmation = true }, + favoriteOnClick = { + val favorite = item.data.userData?.isFavorite ?: false + viewModel.setFavorite(item.id, !favorite, null) + }, ) if (showWatchConfirmation) { ConfirmDialog( @@ -186,11 +191,13 @@ fun SeriesDetailsContent( similar: List, people: List, played: Boolean, + favorite: Boolean, onClickItem: (BaseItem) -> Unit, onLongClickItem: (BaseItem) -> Unit, overviewOnClick: () -> Unit, playOnClick: () -> Unit, watchOnClick: () -> Unit, + favoriteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val bringIntoViewRequester = remember { BringIntoViewRequester() } @@ -247,9 +254,11 @@ fun SeriesDetailsContent( SeriesDetailsHeader( series = series, played = played, + favorite = favorite, overviewOnClick = overviewOnClick, playOnClick = playOnClick, watchOnClick = watchOnClick, + favoriteOnClick = favoriteOnClick, bringIntoViewRequester = bringIntoViewRequester, modifier = Modifier @@ -337,10 +346,12 @@ fun SeriesDetailsContent( fun SeriesDetailsHeader( series: BaseItem, played: Boolean, + favorite: Boolean, bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, playOnClick: () -> Unit, watchOnClick: () -> Unit, + favoriteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -429,6 +440,20 @@ fun SeriesDetailsHeader( } }, ) + ExpandableFaButton( + title = if (favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + onClick = favoriteOnClick, + iconColor = if (favorite) Color.Red else Color.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt index 04df03b4..b981229e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt @@ -248,6 +248,23 @@ class SeriesViewModel } } + fun setFavorite( + itemId: UUID, + favorite: Boolean, + listIndex: Int?, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + if (favorite) { + api.userLibraryApi.markFavoriteItem(itemId) + } else { + api.userLibraryApi.unmarkFavoriteItem(itemId) + } + if (listIndex != null) { + refreshEpisode(itemId, listIndex) + } else { + fetchItem(seriesId, null) + } + } + fun setSeasonWatched( seasonId: UUID, played: Boolean, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 486d98cf..980c91d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -192,6 +192,16 @@ class MovieViewModel fetchAndSetItem(itemId) } + fun setFavorite(favorite: Boolean) = + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + if (favorite) { + api.userLibraryApi.markFavoriteItem(itemId) + } else { + api.userLibraryApi.unmarkFavoriteItem(itemId) + } + fetchAndSetItem(itemId) + } + fun savePlayVersion( item: BaseItem, sourceId: UUID, @@ -344,6 +354,9 @@ fun MovieDetails( watchOnClick = { viewModel.setWatched((movie.data.userData?.played ?: false).not()) }, + favoriteOnClick = { + viewModel.setFavorite((movie.data.userData?.isFavorite ?: false).not()) + }, trailerOnClick = { trailer -> when (trailer) { is LocalTrailer -> @@ -407,6 +420,7 @@ fun MovieDetailsContent( trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, watchOnClick: () -> Unit, + favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, onClickItem: (BaseItem) -> Unit, modifier: Modifier = Modifier, @@ -478,9 +492,11 @@ fun MovieDetailsContent( ExpandablePlayButtons( resumePosition = resumePosition, watched = dto.userData?.played ?: false, + favorite = dto.userData?.isFavorite ?: false, playOnClick = playOnClick, moreOnClick = moreOnClick, watchOnClick = watchOnClick, + favoriteOnClick = favoriteOnClick, buttonOnFocusChanged = { if (it.isFocused) { scope.launch(ExceptionHandler()) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index 999f9913..927a08e8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -32,6 +32,7 @@ fun FocusedEpisodeFooter( playOnClick: (Duration) -> Unit, moreOnClick: () -> Unit, watchOnClick: () -> Unit, + favoriteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val dto = ep.data @@ -45,9 +46,11 @@ fun FocusedEpisodeFooter( ExpandablePlayButtons( resumePosition = resumePosition, watched = dto.userData?.played ?: false, + favorite = dto.userData?.isFavorite ?: false, playOnClick = playOnClick, moreOnClick = moreOnClick, watchOnClick = watchOnClick, + favoriteOnClick = favoriteOnClick, buttonOnFocusChanged = {}, modifier = Modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index f1620b6f..b6a52515 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -266,6 +266,12 @@ fun SeriesOverview( viewModel.setWatched(it.id, !played, position.episodeRowIndex) } }, + favoriteOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + val favorite = it.data.userData?.isFavorite ?: false + viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex) + } + }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> moreDialog = buildMoreForEpisode(ep, false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 8ee78bfe..48b21607 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -73,6 +73,7 @@ fun SeriesOverviewContent( onLongClick: (BaseItem) -> Unit, playOnClick: (Duration) -> Unit, watchOnClick: () -> Unit, + favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, overviewOnClick: () -> Unit, modifier: Modifier = Modifier, @@ -293,6 +294,7 @@ fun SeriesOverviewContent( watchOnClick.invoke() episodeRowFocusRequester.tryRequestFocus() }, + favoriteOnClick = favoriteOnClick, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 89f2e203..d62ca443 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -85,6 +85,9 @@ sealed class Destination( val recursive: Boolean, ) : Destination(false) + @Serializable + data object Favorites : Destination(false) + @Serializable data object UpdateApp : Destination(true) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 56bb5e8f..2093ff14 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv import com.github.damontecres.wholphin.ui.detail.DebugPage +import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails import com.github.damontecres.wholphin.ui.detail.SeriesDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails @@ -188,6 +189,12 @@ fun DestinationContent( modifier = modifier, ) + Destination.Favorites -> + FavoritesPage( + preferences = preferences, + modifier = modifier, + ) + Destination.UpdateApp -> InstallUpdatePage(preferences, modifier) Destination.License -> LicenseInfo(modifier) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 14559c34..fb84fc17 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin.ui.nav +import android.content.Context import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource @@ -66,6 +68,7 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -74,17 +77,19 @@ import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.DeviceProfile import timber.log.Timber +import java.util.UUID import javax.inject.Inject @HiltViewModel class NavDrawerViewModel @Inject constructor( + @param:ApplicationContext val context: Context, val serverRepository: ServerRepository, val api: ApiClient, val navigationManager: NavigationManager, ) : ViewModel() { - val libraries = MutableLiveData>(listOf()) + val libraries = MutableLiveData>(listOf()) val selectedIndex = MutableLiveData(-1) init { @@ -96,10 +101,28 @@ class NavDrawerViewModel val libraries = userViews .filter { it.collectionType in supportedCollectionTypes } - .map { BaseItem.from(it, api) } + .map { + NavDrawerItem( + id = it.id, + name = it.name ?: it.id.toString(), + destination = BaseItem.from(it, api).destination(), + type = it.collectionType ?: CollectionType.UNKNOWN, + iconStringRes = null, + ) + } + val extra = + listOf( + NavDrawerItem( + id = UUID.randomUUID(), + name = context.getString(R.string.favorites), + destination = Destination.Favorites, + type = CollectionType.UNKNOWN, + iconStringRes = R.string.fa_heart, + ), + ) Timber.d("Got ${userViews.size} user views filtered to ${libraries.size}") withContext(Dispatchers.Main) { - this@NavDrawerViewModel.libraries.value = libraries + this@NavDrawerViewModel.libraries.value = extra + libraries } } } @@ -109,6 +132,14 @@ class NavDrawerViewModel } } +data class NavDrawerItem( + val id: UUID, + val name: String, + val destination: Destination, + val type: CollectionType, + @param:StringRes val iconStringRes: Int?, +) + /** * Display the left side navigation drawer with [DestinationContent] on the right */ @@ -213,12 +244,12 @@ fun NavDrawer( ) } itemsIndexed(libraries) { index, it -> - LibraryNavItem( + NavItem( library = it, selected = selectedIndex == index, onClick = { viewModel.setIndex(index) - viewModel.navigationManager.navigateToFromDrawer(it.destination()) + viewModel.navigationManager.navigateToFromDrawer(it.destination) }, modifier = Modifier.ifElse( @@ -304,15 +335,15 @@ fun NavigationDrawerScope.IconNavItem( } @Composable -fun NavigationDrawerScope.LibraryNavItem( - library: BaseItem, +fun NavigationDrawerScope.NavItem( + library: NavDrawerItem, onClick: () -> Unit, selected: Boolean, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val icon = - when (library.data.collectionType) { + when (library.type) { CollectionType.MOVIES -> R.string.fa_film CollectionType.TVSHOWS -> R.string.fa_tv CollectionType.HOMEVIDEOS -> R.string.fa_video @@ -320,8 +351,8 @@ fun NavigationDrawerScope.LibraryNavItem( CollectionType.MUSIC -> R.string.fa_music CollectionType.BOXSETS -> R.string.fa_open_folder CollectionType.PLAYLISTS -> R.string.fa_list_ul - else -> R.string.fa_film - } + else -> library.iconStringRes + } ?: R.string.fa_film val isFocused = interactionSource.collectIsFocusedAsState().value val color = when { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d5f8dac5..8c74b09f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,5 +85,8 @@ Trailers Passout Protection More like this + Unfavorite + Favorite + Favorites