Add favorites page (#55)

Closes #38 

Add a nav drawer link to a new favorites page which shows favorited
Movies, TV Shows, and Episodes.

Also adds a button to movies & tv shows to set/unset favorite status.
This commit is contained in:
damontecres 2025-10-22 16:49:27 -04:00 committed by GitHub
parent 298016b131
commit b8202c06ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 437 additions and 80 deletions

View file

@ -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<UUID>? = null,
val tags: List<String>? = null,
val includeItemTypes: List<BaseItemKind>? = null,
) {
fun applyTo(req: GetItemsRequest) =
req.copy(
includeItemTypes = includeItemTypes,
isFavorite = favorite,
genreIds = genres,
minCriticRating = minCriticRating,

View file

@ -69,7 +69,7 @@ class CollectionFolderViewModel
val filter = MutableLiveData<GetItemsFilter>(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<BaseItem?>,
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

View file

@ -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),
)
}
}

View file

@ -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,

View file

@ -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 =

View file

@ -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 =

View file

@ -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)
}
}
}

View file

@ -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<BaseItem>,
people: List<Person>,
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()
}
}
},
)
}
}
}

View file

@ -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,

View file

@ -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()) {

View file

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

View file

@ -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)

View file

@ -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()

View file

@ -85,6 +85,9 @@ sealed class Destination(
val recursive: Boolean,
) : Destination(false)
@Serializable
data object Favorites : Destination(false)
@Serializable
data object UpdateApp : Destination(true)

View file

@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
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
@ -166,6 +167,12 @@ fun DestinationContent(
modifier = modifier,
)
Destination.Favorites ->
FavoritesPage(
preferences = preferences,
modifier = modifier,
)
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
Destination.License -> LicenseInfo(modifier)

View file

@ -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<List<BaseItem>>(listOf())
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
val selectedIndex = MutableLiveData<Int>(-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 {

View file

@ -85,5 +85,8 @@
<string name="trailers">Trailers</string>
<string name="pass_out_protection">Passout Protection</string>
<string name="more_like_this">More like this</string>
<string name="remove_favorite">Unfavorite</string>
<string name="add_favorite">Favorite</string>
<string name="favorites">Favorites</string>
</resources>