diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 7abdc890..e5cd53bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.data.model +import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seriesProductionYears @@ -18,8 +20,14 @@ import kotlin.time.Duration data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, -) { - val id get() = data.id +) : CardGridItem { + override val id get() = data.id + + override val playable: Boolean + get() = type.playable + + override val sortName: String + get() = data.sortName ?: data.name ?: "" val type get() = data.type diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 821794e3..164035e7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -31,7 +31,7 @@ class ImageUrlService imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, - ): String? = + ): String = when (imageType) { ImageType.BACKDROP, ImageType.LOGO, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt new file mode 100644 index 00000000..43d160e3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -0,0 +1,127 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Genre +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.setup.rememberIdColor +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import timber.log.Timber +import java.util.UUID + +@Composable +fun GenreCard( + genre: Genre?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val background = rememberIdColor(genre?.id).copy(alpha = .6f) + Card( + modifier = + modifier, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = Color.Transparent, + ), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + ) { + Timber.v("genre image=${genre?.imageUrl}") + if (genre?.imageUrl.isNotNullOrBlank()) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(genre.imageUrl) + .crossfade(true) + .build(), + contentScale = ContentScale.FillBounds, + contentDescription = null, + modifier = + Modifier + .alpha(.6f) + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize(), + ) + } + Box( + modifier = + Modifier + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize() + .background(background), + ) { + Text( + text = genre?.name ?: "", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(16.dp) + .align(Alignment.Center), + ) + } + } + } +} + +@PreviewTvSpec +@Composable +private fun GenreCardPreview() { + WholphinTheme { + val genre = + Genre( + UUID.randomUUID(), + "Adventure", + null, + Color.Black, + ) + GenreCard( + genre = genre, + onClick = {}, + onLongClick = {}, + modifier = Modifier.width(180.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 33900e2f..f4fea70c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -10,30 +10,43 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetGenresRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.request.GetGenresRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest import java.util.UUID +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @HiltViewModel @@ -41,11 +54,12 @@ class GenreViewModel @Inject constructor( private val api: ApiClient, + private val imageUrlService: ImageUrlService, val navigationManager: NavigationManager, ) : ViewModel() { private lateinit var itemId: UUID val loading = MutableLiveData(LoadingState.Pending) - val genres = MutableLiveData>(listOf()) + val genres = MutableLiveData>(listOf()) fun init(itemId: UUID) { loading.value = LoadingState.Loading @@ -56,11 +70,69 @@ class GenreViewModel parentId = itemId, fields = SlimItemFields, ) - val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init() + val genres = + GetGenresRequestHandler + .execute(api, request) + .content.items + .map { + Genre(it.id, it.name ?: "", null, Color.Black) + } +// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init() withContext(Dispatchers.Main) { - genres.value = pager + this@GenreViewModel.genres.value = genres loading.value = LoadingState.Success } +// val excludeItemIds = mutableSetOf() + val genreToUrl = ConcurrentHashMap() + val semaphore = Semaphore(4) + genres + .map { genre -> + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + val item = + GetItemsRequestHandler + .execute( + api, + GetItemsRequest( +// excludeItemIds = excludeItemIds, + parentId = itemId, + recursive = true, + limit = 1, + sortBy = listOf(ItemSortBy.RANDOM), + fields = listOf(ItemFields.GENRES), + imageTypes = listOf(ImageType.THUMB), + imageTypeLimit = 1, + includeItemTypes = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + ), + genreIds = listOf(genre.id), + enableTotalRecordCount = false, + ), + ).content.items + .firstOrNull() + if (item != null) { +// excludeItemIds.add(item.id) + genreToUrl[genre.id] = + imageUrlService.getItemImageUrl( + item.id, + item.type, + null, + false, + ImageType.THUMB, + ) + } + } + } + }.awaitAll() + val genresWithImages = + genres.map { + it.copy( + imageUrl = genreToUrl[it.id], + ) + } + this@GenreViewModel.genres.setValueOnMain(genresWithImages) } } @@ -78,6 +150,16 @@ class GenreViewModel } } +data class Genre( + override val id: UUID, + val name: String, + val imageUrl: String?, + val color: Color, +) : CardGridItem { + override val playable: Boolean = false + override val sortName: String get() = name +} + @Composable fun GenreCardGrid( itemId: UUID, @@ -126,13 +208,13 @@ fun GenreCardGrid( initialPosition = 0, positionCallback = { columns, position -> }, - cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> - GridCard( - item = item, + columns = 4, + cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> + GenreCard( + genre = item, onClick = onClick, onLongClick = onLongClick, modifier = mod, - imageAspectRatio = 1f, ) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index eeb16031..ba74641a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -67,23 +67,29 @@ import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.playback.isBackwardButton import com.github.damontecres.wholphin.ui.playback.isForwardButton import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp -import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber +import java.util.UUID private const val DEBUG = false +interface CardGridItem { + val id: UUID + val playable: Boolean + val sortName: String +} + @OptIn(ExperimentalFoundationApi::class) @Composable -fun CardGrid( - pager: List, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - onClickPlay: (Int, BaseItem) -> Unit, +fun CardGrid( + pager: List, + onClickItem: (Int, T) -> Unit, + onLongClickItem: (Int, T) -> Unit, + onClickPlay: (Int, T) -> Unit, letterPosition: suspend (Char) -> Int, gridFocusRequester: FocusRequester, showJumpButtons: Boolean, @@ -92,13 +98,13 @@ fun CardGrid( initialPosition: Int = 0, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, cardContent: @Composable ( - item: BaseItem?, + item: T?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier, ) -> Unit = { item, onClick, onLongClick, mod -> GridCard( - item = item, + item = item as BaseItem?, onClick = onClick, onLongClick = onLongClick, imageContentScale = ContentScale.FillBounds, @@ -220,7 +226,7 @@ fun CardGrid( return@onKeyEvent true } else if (isPlayKeyUp(it)) { val item = pager.getOrNull(focusedIndex) - if (item?.type?.playable == true) { + if (item?.playable == true) { Timber.v("Clicked play on ${item.id}") onClickPlay.invoke(focusedIndex, item) } @@ -351,7 +357,6 @@ fun CardGrid( remember(focusedIndex) { pager .getOrNull(focusedIndex) - ?.data ?.sortName ?.first() ?.uppercaseChar() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 4ab990de..0d7d3c6e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -179,8 +179,14 @@ fun ServerList( * Generate a consistent color for a UUID */ @Composable -fun rememberIdColor(id: UUID): Color = +fun rememberIdColor( + id: UUID?, + nullColor: Color = MaterialTheme.colorScheme.surfaceVariant, +): Color = remember(id) { + if (id == null) { + return@remember nullColor + } // Generate a color based on the server ID hash, fallback to URL hash val hash = id.hashCode() val hue = (hash % 360).toFloat()