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( fun from(
dto: BaseItemDto, dto: BaseItemDto,
api: ApiClient, api: ApiClient,
useSeriesForPrimary: Boolean = false,
): BaseItem { ): BaseItem {
val backdropImageUrl = val backdropImageUrl =
if (dto.type == BaseItemKind.EPISODE) { if (dto.type == BaseItemKind.EPISODE) {
@ -73,9 +74,20 @@ data class BaseItem(
} else { } else {
api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP) 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( return BaseItem(
dto, dto,
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), primaryImageUrl,
backdropImageUrl, backdropImageUrl,
) )
} }

View file

@ -31,6 +31,19 @@ fun ItemRow(
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit, onLongClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier, 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, focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
) { ) {
@ -72,13 +85,47 @@ fun ItemRow(
} }
cardOnFocus?.invoke(it.isFocused, index) cardOnFocus?.invoke(it.isFocused, index)
} }
ItemCard( cardContent.invoke(
item = item, item,
onClick = { if (item != null) onClickItem.invoke(item) }, cardModifier,
onLongClick = { if (item != null) onLongClickItem.invoke(item) }, { if (item != null) onClickItem.invoke(item) },
modifier = cardModifier, { 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences 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.cards.ItemRow
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
import com.github.damontecres.dolphin.ui.isNotNullOrBlank 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.roundMinutes
import com.github.damontecres.dolphin.ui.timeRemaining import com.github.damontecres.dolphin.ui.timeRemaining
import com.github.damontecres.dolphin.util.formatDateTime 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.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 org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber
import javax.inject.Inject
data class HomeRow( data class HomeRow(
val section: HomeSection, val section: HomeSection,
val items: List<BaseItem>, 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 @Composable
fun MainPage( fun MainPage(
preferences: UserPreferences, preferences: UserPreferences,
@ -241,7 +116,7 @@ fun MainPage(
) { ) {
items(homeRows) { row -> items(homeRows) { row ->
ItemRow( ItemRow(
title = stringResource(row.section.nameRes), title = row.title ?: stringResource(row.section.nameRes),
items = row.items, items = row.items,
onClickItem = { onClickItem = {
navigationManager.navigateTo(it.destination()) navigationManager.navigateTo(it.destination())
@ -251,6 +126,21 @@ fun MainPage(
}, },
onLongClickItem = {}, onLongClickItem = {},
modifier = Modifier.fillMaxWidth(), 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
}
}
}
}