More favorite tabs & add people to favorites (#210)

Adds Video, Playlists, & People tabs to the favorites page

Also adds ability to add people to favorites via context menu or on
their page

Closes #199
This commit is contained in:
damontecres 2025-11-13 16:12:42 -05:00 committed by GitHub
parent 655720fc05
commit 19b712d54b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 356 additions and 102 deletions

View file

@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
@ -19,6 +20,7 @@ data class GetItemsFilter(
val studios: List<UUID>? = null, val studios: List<UUID>? = null,
val tags: List<String>? = null, val tags: List<String>? = null,
val includeItemTypes: List<BaseItemKind>? = null, val includeItemTypes: List<BaseItemKind>? = null,
val override: GetItemsFilterOverride = GetItemsFilterOverride.NONE,
) { ) {
fun applyTo(req: GetItemsRequest) = fun applyTo(req: GetItemsRequest) =
req.copy( req.copy(
@ -31,4 +33,14 @@ data class GetItemsFilter(
studioIds = studios, studioIds = studios,
tags = tags, tags = tags,
) )
fun applyTo(req: GetPersonsRequest) =
req.copy(
isFavorite = favorite,
)
}
enum class GetItemsFilterOverride {
NONE,
PERSON,
} }

View file

@ -13,6 +13,7 @@ data class Person(
val role: String?, val role: String?,
val type: PersonKind, val type: PersonKind,
val imageUrl: String?, val imageUrl: String?,
val favorite: Boolean,
) { ) {
companion object { companion object {
fun fromDto( fun fromDto(
@ -25,6 +26,21 @@ data class Person(
role = dto.role, role = dto.role,
type = dto.type, type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = false,
)
fun fromDto(
dto: BaseItemPerson,
favorite: Boolean,
api: ApiClient,
): Person =
Person(
id = dto.id,
name = dto.name,
role = dto.role,
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = favorite,
) )
} }
} }

View file

@ -72,8 +72,8 @@ fun PersonCard(
ItemCardImage( ItemCardImage(
imageUrl = item.imageUrl, imageUrl = item.imageUrl,
name = item.name, name = item.name,
showOverlay = false, showOverlay = true,
favorite = false, favorite = item.favorite,
watched = false, watched = false,
unwatchedCount = -1, unwatchedCount = -1,
watchedPercent = null, watchedPercent = null,

View file

@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -53,6 +54,7 @@ import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -69,6 +71,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -107,13 +110,12 @@ class CollectionFolderViewModel
} }
val sortAndDirection = val sortAndDirection =
if (initialSortAndDirection == null) { initialSortAndDirection
serverRepository.currentUser.value?.let { user -> ?: (
libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection serverRepository.currentUser.value?.let { user ->
} ?: SortAndDirection.DEFAULT libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection
} else { } ?: SortAndDirection.DEFAULT
SortAndDirection.DEFAULT )
}
loadResults(sortAndDirection, recursive, filter) loadResults(sortAndDirection, recursive, filter)
} }
@ -143,7 +145,6 @@ class CollectionFolderViewModel
recursive: Boolean, recursive: Boolean,
filter: GetItemsFilter, filter: GetItemsFilter,
) { ) {
val item = item.value
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
pager.value = listOf() pager.value = listOf()
@ -151,62 +152,7 @@ class CollectionFolderViewModel
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
this@CollectionFolderViewModel.filter.value = filter this@CollectionFolderViewModel.filter.value = filter
} }
val includeItemTypes = val newPager = createPager(sortAndDirection, recursive, filter)
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 request =
filter.applyTo(
GetItemsRequest(
parentId = item?.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = item?.let { listOf(item.id) },
sortBy =
buildList {
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 {
add(sortAndDirection.direction)
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING)
}
if (item?.data?.collectionType == CollectionType.MOVIES) {
add(SortOrder.ASCENDING)
}
},
fields = SlimItemFields,
),
)
val newPager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
)
newPager.init() newPager.init()
if (newPager.isNotEmpty()) newPager.getBlocking(0) if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -216,6 +162,93 @@ class CollectionFolderViewModel
} }
} }
private fun createPager(
sortAndDirection: SortAndDirection,
recursive: Boolean,
filter: GetItemsFilter,
): ApiRequestPager<out Any> {
val item = item.value
return when (filter.override) {
GetItemsFilterOverride.NONE -> {
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 request =
filter.applyTo(
GetItemsRequest(
parentId = item?.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = item?.let { listOf(item.id) },
sortBy =
buildList {
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 {
add(sortAndDirection.direction)
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING)
}
if (item?.data?.collectionType == CollectionType.MOVIES) {
add(SortOrder.ASCENDING)
}
},
fields = SlimItemFields,
),
)
val newPager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
)
newPager
}
GetItemsFilterOverride.PERSON -> {
val request =
filter.applyTo(
GetPersonsRequest(
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
),
)
val newPager =
ApiRequestPager(
api,
request,
GetPersonsHandler,
viewModelScope,
useSeriesForPrimary = true,
)
newPager
}
}
}
suspend fun positionOfLetter(letter: Char): Int? = suspend fun positionOfLetter(letter: Char): Int? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
item.value?.let { item -> item.value?.let { item ->
@ -437,12 +470,14 @@ fun CollectionFolderGridContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
SortByButton( if (sortOptions.isNotEmpty()) {
sortOptions = sortOptions, SortByButton(
current = sortAndDirection, sortOptions = sortOptions,
onSortChange = onSortChange, current = sortAndDirection,
modifier = Modifier, onSortChange = onSortChange,
) modifier = Modifier,
)
}
} }
CardGrid( CardGrid(
pager = pager, pager = pager,

View file

@ -79,5 +79,6 @@ fun getStringRes(sort: ItemSortBy): Int =
ItemSortBy.CRITIC_RATING -> R.string.critic_rating ItemSortBy.CRITIC_RATING -> R.string.critic_rating
ItemSortBy.PLAY_COUNT -> R.string.play_count ItemSortBy.PLAY_COUNT -> R.string.play_count
ItemSortBy.AIRED_EPISODE_ORDER -> R.string.aired_episode_order ItemSortBy.AIRED_EPISODE_ORDER -> R.string.aired_episode_order
ItemSortBy.DEFAULT -> R.string.default_track
else -> throw IllegalArgumentException("Unsupported sort option: $sort") else -> throw IllegalArgumentException("Unsupported sort option: $sort")
} }

View file

@ -288,7 +288,6 @@ fun buildMoreDialogItemsForHome(
fun buildMoreDialogItemsForPerson( fun buildMoreDialogItemsForPerson(
context: Context, context: Context,
person: Person, person: Person,
// favorite: Boolean,
actions: MoreDialogActions, actions: MoreDialogActions,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
@ -301,14 +300,13 @@ fun buildMoreDialogItemsForPerson(
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON)) actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON))
}, },
) )
// TODO need way to get person's favorite status add(
// add( DialogItem(
// DialogItem( text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite,
// text = if (favorite) R.string.remove_favorite else R.string.add_favorite, iconStringRes = R.string.fa_heart,
// iconStringRes = R.string.fa_heart, iconColor = if (person.favorite) Color.Red else Color.Unspecified,
// iconColor = if (favorite) Color.Red else Color.Unspecified, ) {
// ) { actions.onClickFavorite.invoke(itemId, !person.favorite)
// actions.onClickFavorite.invoke(itemId, !favorite) },
// }, )
// )
} }

View file

@ -25,6 +25,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -32,11 +33,15 @@ import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions
import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
@Composable @Composable
fun FavoritesPage( fun FavoritesPage(
@ -59,6 +64,9 @@ fun FavoritesPage(
stringResource(R.string.movies), stringResource(R.string.movies),
stringResource(R.string.tv_shows), stringResource(R.string.tv_shows),
stringResource(R.string.episodes), stringResource(R.string.episodes),
stringResource(R.string.videos),
stringResource(R.string.playlists),
stringResource(R.string.people),
) )
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -165,6 +173,81 @@ fun FavoritesPage(
}, },
) )
} }
3 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_videos",
initialFilter =
GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.VIDEO),
),
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
)
}
4 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_playlists",
initialFilter =
GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
),
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
)
}
5 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_people",
initialFilter =
GetItemsFilter(
favorite = true,
override = GetItemsFilterOverride.PERSON,
),
initialSortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
showTitle = false,
recursive = true,
sortOptions = listOf(),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
)
}
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null) else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
} }
} }

View file

@ -26,6 +26,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -43,11 +45,13 @@ import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.LoadingRow import com.github.damontecres.wholphin.ui.components.LoadingRow
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
@ -56,6 +60,7 @@ import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -78,6 +83,7 @@ class PersonViewModel
constructor( constructor(
api: ApiClient, api: ApiClient,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager,
) : LoadingItemViewModel(api) { ) : LoadingItemViewModel(api) {
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending) val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
@ -144,6 +150,13 @@ class PersonViewModel
} }
} }
} }
fun setFavorite(favorite: Boolean) {
viewModelScope.launchIO {
favoriteWatchManager.setFavorite(itemUuid, favorite)
fetchAndSetItem(itemUuid)
}
}
} }
@Composable @Composable
@ -180,6 +193,7 @@ fun PersonPage(
birthdate = person.data.premiereDate?.toLocalDate(), birthdate = person.data.premiereDate?.toLocalDate(),
deathdate = person.data.endDate?.toLocalDate(), deathdate = person.data.endDate?.toLocalDate(),
birthPlace = person.data.productionLocations?.firstOrNull(), birthPlace = person.data.productionLocations?.firstOrNull(),
favorite = person.favorite,
movies = movies, movies = movies,
series = series, series = series,
episodes = episodes, episodes = episodes,
@ -187,6 +201,9 @@ fun PersonPage(
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
overviewOnClick = { showOverviewDialog = true }, overviewOnClick = { showOverviewDialog = true },
favoriteOnClick = {
viewModel.setFavorite(!person.favorite)
},
modifier = modifier, modifier = modifier,
) )
AnimatedVisibility(showOverviewDialog) { AnimatedVisibility(showOverviewDialog) {
@ -220,11 +237,13 @@ fun PersonPageContent(
birthdate: LocalDate?, birthdate: LocalDate?,
deathdate: LocalDate?, deathdate: LocalDate?,
birthPlace: String?, birthPlace: String?,
favorite: Boolean,
movies: RowLoadingState, movies: RowLoadingState,
series: RowLoadingState, series: RowLoadingState,
episodes: RowLoadingState, episodes: RowLoadingState,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -237,8 +256,10 @@ fun PersonPageContent(
headerFocusRequester.tryRequestFocus() headerFocusRequester.tryRequestFocus()
} }
} }
var focusedOnHeader by remember { mutableStateOf(true) }
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
userScrollEnabled = !focusedOnHeader,
modifier = modifier =
modifier modifier
.padding(start = 16.dp), .padding(start = 16.dp),
@ -251,13 +272,18 @@ fun PersonPageContent(
birthdate = birthdate, birthdate = birthdate,
birthPlace = birthPlace, birthPlace = birthPlace,
deathdate = deathdate, deathdate = deathdate,
favorite = favorite,
overviewOnClick = overviewOnClick, overviewOnClick = overviewOnClick,
favoriteOnClick = favoriteOnClick,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 480.dp) .heightIn(max = 480.dp)
.padding(top = 16.dp, bottom = 40.dp) .padding(top = 16.dp, bottom = 40.dp)
.focusRequester(headerFocusRequester), .focusRequester(headerFocusRequester)
.onFocusChanged {
focusedOnHeader = it.hasFocus
},
) )
} }
item { item {
@ -321,7 +347,9 @@ fun PersonHeader(
birthdate: LocalDate?, birthdate: LocalDate?,
deathdate: LocalDate?, deathdate: LocalDate?,
birthPlace: String?, birthPlace: String?,
favorite: Boolean,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -380,9 +408,9 @@ fun PersonHeader(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
birthPlace?.let { if (birthPlace.isNotNullOrBlank()) {
Text( Text(
text = stringResource(R.string.birthplace) + ": $it", text = stringResource(R.string.birthplace) + ": $birthPlace",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
@ -411,9 +439,12 @@ fun PersonHeader(
) )
} }
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState() val buttonInteractionSource = remember { MutableInteractionSource() }
if (focused) { val focused =
LaunchedEffect(Unit) { interactionSource.collectIsFocusedAsState().value ||
buttonInteractionSource.collectIsFocusedAsState().value
LaunchedEffect(focused) {
if (focused) {
bringIntoViewRequester.bringIntoView() bringIntoViewRequester.bringIntoView()
} }
} }
@ -424,6 +455,14 @@ fun PersonHeader(
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
) )
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,
interactionSource = buttonInteractionSource,
modifier = Modifier,
)
} }
} }
} }
@ -440,6 +479,8 @@ private fun PersonPreview() {
birthPlace = "Phoenix, Arizona, USA", birthPlace = "Phoenix, Arizona, USA",
deathdate = LocalDate.of(2025, 2, 1), deathdate = LocalDate.of(2025, 2, 1),
overviewOnClick = {}, overviewOnClick = {},
favorite = true,
favoriteOnClick = {},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }

View file

@ -18,7 +18,6 @@ import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -26,6 +25,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PeopleFavorites
import com.github.damontecres.wholphin.util.ThemeSongPlayer import com.github.damontecres.wholphin.util.ThemeSongPlayer
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
@ -56,6 +56,7 @@ class MovieViewModel
val itemPlaybackRepository: ItemPlaybackRepository, val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -134,12 +135,11 @@ class MovieViewModel
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
} }
} }
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@MovieViewModel.people.setValueOnMain(people)
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
chapters.value = Chapter.fromDto(item.data, api) chapters.value = Chapter.fromDto(item.data, api)
} }
if (!similar.isInitialized) { if (!similar.isInitialized) {
@ -171,7 +171,14 @@ class MovieViewModel
favorite: Boolean, favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite) favoriteWatchManager.setFavorite(itemId, favorite)
val item = item.value
fetchAndSetItem() fetchAndSetItem()
if (item != null && itemId != item.id) {
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@MovieViewModel.people.setValueOnMain(people)
}
}
} }
fun savePlayVersion( fun savePlayVersion(

View file

@ -16,7 +16,6 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -28,6 +27,7 @@ import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PeopleFavorites
import com.github.damontecres.wholphin.util.ThemeSongPlayer import com.github.damontecres.wholphin.util.ThemeSongPlayer
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -61,6 +61,7 @@ class SeriesViewModel
private val itemPlaybackRepository: ItemPlaybackRepository, private val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
@ -109,11 +110,10 @@ class SeriesViewModel
this@SeriesViewModel.seasons.value = seasons this@SeriesViewModel.seasons.value = seasons
episodes.value = episodeInfo episodes.value = episodeInfo
loading.value = LoadingState.Success loading.value = LoadingState.Success
people.value = }
item.data.people viewModelScope.launchIO {
?.letNotEmpty { people -> val people = peopleFavorites.getPeopleFor(item)
people.map { Person.fromDto(it, api) } this@SeriesViewModel.people.setValueOnMain(people)
}.orEmpty()
} }
if (!similar.isInitialized) { if (!similar.isInitialized) {
viewModelScope.launchIO { viewModelScope.launchIO {
@ -255,7 +255,11 @@ class SeriesViewModel
if (listIndex != null) { if (listIndex != null) {
refreshEpisode(itemId, listIndex) refreshEpisode(itemId, listIndex)
} else { } else {
fetchItem(seriesId) val item = fetchItem(seriesId)
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@SeriesViewModel.people.setValueOnMain(people)
}
} }
} }

View file

@ -18,6 +18,7 @@ import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.personsApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
@ -28,6 +29,7 @@ import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
@ -365,3 +367,22 @@ val GetProgramsDtoHandler =
request: GetProgramsDto, request: GetProgramsDto,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request) ): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
} }
val GetPersonsHandler =
object : RequestHandler<GetPersonsRequest> {
override fun prepare(
request: GetPersonsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetPersonsRequest =
request.copy(
// startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetPersonsRequest,
): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request))
}

View file

@ -0,0 +1,34 @@
package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.letNotEmpty
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PeopleFavorites
@Inject
constructor(
private val api: ApiClient,
) {
suspend fun getPeopleFor(item: BaseItem): List<Person> =
item.data.people.orEmpty().map { it.id }.chunked(50).let { chunks ->
val favorites =
chunks
.map {
api.itemsApi
.getItems(ids = it)
.content.items
}.flatten()
.associateBy({ it.id }, { it.userData?.isFavorite ?: false })
val people =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
}.orEmpty()
people
}
}

View file

@ -82,6 +82,7 @@
<string name="playback_speed">Playback Speed</string> <string name="playback_speed">Playback Speed</string>
<string name="playback">Playback</string> <string name="playback">Playback</string>
<string name="playlist">Playlist</string> <string name="playlist">Playlist</string>
<string name="playlists">Playlists</string>
<string name="preview">Preview</string> <string name="preview">Preview</string>
<string name="profile_specific_settings">User Profile Settings</string> <string name="profile_specific_settings">User Profile Settings</string>
<string name="queue">Queue</string> <string name="queue">Queue</string>
@ -135,6 +136,7 @@
<string name="version">Version</string> <string name="version">Version</string>
<string name="video_scale">Video Scale</string> <string name="video_scale">Video Scale</string>
<string name="video">Video</string> <string name="video">Video</string>
<string name="videos">Videos</string>
<string name="watch_live">Watch live</string> <string name="watch_live">Watch live</string>
<string name="years_old">%1$d years old</string> <string name="years_old">%1$d years old</string>
<string name="play_with_transcoding">Play with transcoding</string> <string name="play_with_transcoding">Play with transcoding</string>