Update main page

This commit is contained in:
Damontecres 2025-10-02 15:08:36 -04:00
parent 933dc4fd5a
commit 805f63cb0e
No known key found for this signature in database
4 changed files with 234 additions and 134 deletions

View file

@ -61,6 +61,7 @@ data class BaseItem(
fun from(
dto: BaseItemDto,
api: ApiClient,
useSeriesForPrimary: Boolean = false,
): BaseItem {
val backdropImageUrl =
if (dto.type == BaseItemKind.EPISODE) {
@ -73,9 +74,20 @@ data class BaseItem(
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP)
}
val primaryImageUrl =
if (useSeriesForPrimary && dto.type == BaseItemKind.EPISODE) {
val seriesId = dto.seriesId
if (seriesId != null) {
api.imageApi.getItemImageUrl(seriesId, ImageType.PRIMARY)
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)
}
} else {
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)
}
return BaseItem(
dto,
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
primaryImageUrl,
backdropImageUrl,
)
}

View file

@ -31,6 +31,19 @@ fun ItemRow(
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
cardContent: @Composable (
item: BaseItem?,
modifier: Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
) -> Unit = { item, modifier, onClick, onLongClick ->
ItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
)
},
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
) {
@ -72,13 +85,47 @@ fun ItemRow(
}
cardOnFocus?.invoke(it.isFocused, index)
}
ItemCard(
item = item,
onClick = { if (item != null) onClickItem.invoke(item) },
onLongClick = { if (item != null) onLongClickItem.invoke(item) },
modifier = cardModifier,
cardContent.invoke(
item,
cardModifier,
{ if (item != null) onClickItem.invoke(item) },
{ if (item != null) onLongClickItem.invoke(item) },
)
}
}
}
}
@Composable
fun BannerItemRow(
title: String,
items: List<BaseItem?>,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
aspectRatioOverride: Float? = null,
) = ItemRow(
title = title,
items = items,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
modifier = modifier,
cardContent = { item, modifier, onClick, onLongClick ->
BannerCard(
imageUrl = item?.imageUrl,
aspectRatio =
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
cornerText = item?.data?.indexNumber?.let { "E$it" },
played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = null,
)
},
focusPair = focusPair,
cardOnFocus = cardOnFocus,
)

View file

@ -30,15 +30,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.cards.BannerCard
import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
@ -46,137 +43,15 @@ import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.roundMinutes
import com.github.damontecres.dolphin.ui.timeRemaining
import com.github.damontecres.dolphin.util.formatDateTime
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import javax.inject.Inject
data class HomeRow(
val section: HomeSection,
val items: List<BaseItem>,
val title: String? = null,
)
@HiltViewModel
class MainViewModel
@Inject
constructor(
val api: ApiClient,
) : ViewModel() {
val homeRows = MutableLiveData<List<HomeRow>>()
init {
viewModelScope.launch(Dispatchers.IO) {
val user = api.userApi.getCurrentUser().content
val displayPrefs =
api.displayPreferencesApi
.getDisplayPreferences(
displayPreferencesId = "usersettings",
client = "emby",
).content
val homeSections =
displayPrefs.customPrefs.entries
.filter { it.key.startsWith("homesection") && it.value.isNotNullOrBlank() }
.sortedBy { it.key }
.map { HomeSection.fromKey(it.value ?: "") }
.filterNot { it == HomeSection.NONE }
val homeRows =
homeSections
.mapNotNull { section ->
Timber.v("Loading section: %s", section.name)
when (section) {
HomeSection.LATEST_MEDIA -> {
user.configuration?.orderedViews?.firstOrNull()?.let { viewId ->
val request =
GetLatestMediaRequest(
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = viewId,
groupItems = true,
limit = 25,
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.from(it, api) }
HomeRow(
section = section,
items = latest,
)
}
}
// TODO
HomeSection.LIBRARY_TILES_SMALL -> null
HomeSection.RESUME -> {
val request =
GetResumeItemsRequest(
userId = user.id,
fields = DefaultItemFields,
// TODO, more params?
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.from(it, api) }
HomeRow(
section = section,
items = items,
)
}
HomeSection.ACTIVE_RECORDINGS -> null
HomeSection.NEXT_UP -> {
val request =
GetNextUpRequest(
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = null,
limit = 25,
enableResumable = false,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
.map { BaseItem.from(it, api) }
HomeRow(
section = section,
items = nextUp,
)
}
HomeSection.LIVE_TV -> null
// TODO Not supported?
HomeSection.LIBRARY_BUTTONS -> null
HomeSection.RESUME_AUDIO -> null
HomeSection.RESUME_BOOK -> null
HomeSection.NONE -> null
}
}.filter { it.items.isNotEmpty() }
withContext(Dispatchers.Main) {
this@MainViewModel.homeRows.value = homeRows
}
}
}
}
@Composable
fun MainPage(
preferences: UserPreferences,
@ -241,7 +116,7 @@ fun MainPage(
) {
items(homeRows) { row ->
ItemRow(
title = stringResource(row.section.nameRes),
title = row.title ?: stringResource(row.section.nameRes),
items = row.items,
onClickItem = {
navigationManager.navigateTo(it.destination())
@ -251,6 +126,21 @@ fun MainPage(
},
onLongClickItem = {},
modifier = Modifier.fillMaxWidth(),
cardContent = { item, modifier, onClick, onLongClick ->
// TODO better aspect ration handling?
BannerCard(
imageUrl = item?.imageUrl,
aspectRatio = (2f / 3f),
cornerText = item?.data?.indexNumber?.let { "E$it" },
played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = null,
cardHeight = 200.dp,
)
},
)
}
}

View file

@ -0,0 +1,151 @@
package com.github.damontecres.dolphin.ui.main
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class MainViewModel
@Inject
constructor(
val api: ApiClient,
) : ViewModel() {
val homeRows = MutableLiveData<List<HomeRow>>()
init {
viewModelScope.launch(Dispatchers.IO) {
val user = api.userApi.getCurrentUser().content
val displayPrefs =
api.displayPreferencesApi
.getDisplayPreferences(
displayPreferencesId = "usersettings",
client = "emby",
).content
val homeSections =
displayPrefs.customPrefs.entries
.filter { it.key.startsWith("homesection") && it.value.isNotNullOrBlank() }
.sortedBy { it.key }
.map { HomeSection.fromKey(it.value ?: "") }
.filterNot { it == HomeSection.NONE }
val latestMediaIncludes =
user.configuration?.orderedViews.orEmpty().toMutableList().apply {
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
}
val views by api.userViewsApi.getUserViews()
val homeRows =
homeSections
.mapNotNull { section ->
Timber.Forest.v("Loading section: %s", section.name)
when (section) {
HomeSection.LATEST_MEDIA -> {
latestMediaIncludes.map { viewId ->
val title =
views.items.firstOrNull { it.id == viewId }?.name?.let {
"Recently Added in $it"
}
val request =
GetLatestMediaRequest(
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = viewId,
groupItems = true,
limit = 25,
)
val latest =
api.userLibraryApi
.getLatestMedia(request)
.content
.map { BaseItem.Companion.from(it, api, true) }
HomeRow(
section = section,
items = latest,
title = title,
)
}
}
HomeSection.RESUME -> {
val request =
GetResumeItemsRequest(
userId = user.id,
fields = DefaultItemFields,
// TODO, more params?
)
val items =
api.itemsApi
.getResumeItems(request)
.content
.items
.map { BaseItem.Companion.from(it, api, true) }
listOf(
HomeRow(
section = section,
items = items,
),
)
}
HomeSection.NEXT_UP -> {
val request =
GetNextUpRequest(
fields = DefaultItemFields,
imageTypeLimit = 1,
parentId = null,
limit = 25,
enableResumable = false,
)
val nextUp =
api.tvShowsApi
.getNextUp(request)
.content
.items
.map { BaseItem.Companion.from(it, api) }
listOf(
HomeRow(
section = section,
items = nextUp,
),
)
}
// TODO
HomeSection.LIVE_TV -> null
HomeSection.ACTIVE_RECORDINGS -> null
// TODO Not supported?
HomeSection.LIBRARY_TILES_SMALL -> null
HomeSection.LIBRARY_BUTTONS -> null
HomeSection.RESUME_AUDIO -> null
HomeSection.RESUME_BOOK -> null
HomeSection.NONE -> null
}
}.flatten()
.filter { it.items.isNotEmpty() }
withContext(Dispatchers.Main) {
this@MainViewModel.homeRows.value = homeRows
}
}
}
}