diff --git a/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt index 2942d8b0..f0ccad63 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt @@ -1,6 +1,8 @@ package com.github.damontecres.dolphin import android.view.KeyEvent +import androidx.compose.foundation.MarqueeAnimationMode +import androidx.compose.foundation.basicMarquee import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -10,6 +12,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import timber.log.Timber import kotlin.contracts.ExperimentalContracts @@ -108,3 +111,14 @@ fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) { } } } + +fun Modifier.enableMarquee(focused: Boolean) = + if (focused) { + basicMarquee( + initialDelayMillis = 250, + animationMode = MarqueeAnimationMode.Immediately, + velocity = 40.dp, + ) + } else { + basicMarquee(animationMode = MarqueeAnimationMode.WhileFocused) + } diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt new file mode 100644 index 00000000..10fadceb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt @@ -0,0 +1,30 @@ +package com.github.damontecres.dolphin.data.model + +import com.github.damontecres.dolphin.ui.nav.Destination +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.ImageType + +@Serializable +data class BaseItem( + val data: BaseItemDto, + val imageUrl: String?, +) { + @Transient val id = data.id + + @Transient val type = data.type + + @Transient val name = data.name + + fun destination() = Destination.MediaItem(id, type, this) + + companion object { + fun from( + dto: BaseItemDto, + api: ApiClient, + ) = BaseItem(dto, api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt index 0c30c8a4..53b61a49 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import com.github.damontecres.dolphin.R +import org.jellyfin.sdk.model.api.ItemFields val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900)) @@ -14,3 +15,12 @@ sealed class AppColors private constructor() { val TransparentBlack75 = Color(0xBF000000) } } + +const val DEFAULT_PAGE_SIZE = 50 + +val DefaultItemFields = + listOf( + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.SEASON_USER_DATA, + ItemFields.CHILD_COUNT, + ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/CardRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/CardRow.kt new file mode 100644 index 00000000..2aef10bc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/CardRow.kt @@ -0,0 +1,49 @@ +package com.github.damontecres.dolphin.ui.cards + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.data.model.BaseItem + +@Composable +fun CardRow( + title: String, + items: List, + onClickItem: (BaseItem) -> Unit, + onLongClickItem: (BaseItem) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = title, + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxWidth(), + ) { + items(items) { item -> + ItemCard( + item = item, + onClick = { if (item != null) onClickItem.invoke(item) }, + onLongClick = { if (item != null) onLongClickItem.invoke(item) }, + modifier = Modifier, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt index cba389d1..cfd695ee 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt @@ -1,55 +1,209 @@ package com.github.damontecres.dolphin.ui.cards +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.tv.material3.Card +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage -import org.jellyfin.sdk.model.api.BaseItemDto -import java.util.UUID +import com.github.damontecres.dolphin.R +import com.github.damontecres.dolphin.data.model.BaseItem +import com.github.damontecres.dolphin.enableMarquee +import com.github.damontecres.dolphin.ui.FontAwesome +import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.BaseItemKind @Composable fun ItemCard( - item: BaseItemDto?, - imageUrlBuilder: (UUID) -> String?, + item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, cardWidth: Dp = 150.dp, cardHeight: Dp = 200.dp, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { + val hideOverlayDelay = 1_000L + + val focused = interactionSource.collectIsFocusedAsState().value + var focusedAfterDelay by remember { mutableStateOf(false) } + + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + if (item == null) { NullCard(modifier, cardWidth, cardHeight) } else { + val dto = item.data + // TODO better aspect ratio handling + val height = + if (dto.primaryImageAspectRatio != null && dto.primaryImageAspectRatio!! > 1) cardWidth else cardHeight Card( modifier = modifier, onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, ) { Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.width(cardWidth), ) { - AsyncImage( - model = imageUrlBuilder.invoke(item.id), - contentDescription = item.name, - contentScale = ContentScale.Fit, + ItemCardImage( + imageUrl = item.imageUrl, + name = item.name, + showOverlay = !focusedAfterDelay, + favorite = dto.userData?.isFavorite ?: false, + watched = dto.userData?.played ?: false, + unwatchedCount = dto.userData?.unplayedItemCount ?: -1, + watchedPercent = dto.userData?.playedPercentage, modifier = Modifier .fillMaxWidth() - .height(cardHeight), + .height(height), ) Text( text = item.name ?: "", + maxLines = 1, + modifier = + Modifier + .enableMarquee(focusedAfterDelay), ) - Text(text = item.primaryImageAspectRatio.toString()) + if (dto.type == BaseItemKind.EPISODE) { + if (dto.parentIndexNumber != null && dto.indexNumber != null) { + Text( + text = "S${dto.parentIndexNumber} E${dto.indexNumber}", + ) + } + } + } + } + } +} + +@Composable +fun ItemCardImage( + imageUrl: String?, + name: String?, + showOverlay: Boolean, + favorite: Boolean, + watched: Boolean, + unwatchedCount: Int, + watchedPercent: Double?, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + AsyncImage( + model = imageUrl, + contentDescription = name, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + AnimatedVisibility( + visible = showOverlay, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (favorite) { + Text( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(8.dp), + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = 20.sp, + fontFamily = FontAwesome, + ) + } + if (watched) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.border.copy(alpha = 1f), + modifier = + Modifier + .align(Alignment.TopEnd) + .size(48.dp) + .padding(8.dp), + ) + } + if (unwatchedCount > 0) { + Box( + modifier = + Modifier + .padding(8.dp) + .align(Alignment.TopEnd), + ) { + Text( + modifier = + Modifier + .background( + MaterialTheme.colorScheme.border, + shape = CircleShape, + ), + color = MaterialTheme.colorScheme.onSurface, + text = unwatchedCount.toString(), + fontSize = 20.sp, + ) + } + } + + watchedPercent?.let { percent -> + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .background( + MaterialTheme.colorScheme.tertiary, + ).clip(RectangleShape) + .height(4.dp) + .fillMaxWidth((percent / 100.0).toFloat()), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt index 5760c986..0c50bb3c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt @@ -41,6 +41,7 @@ import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.dolphin.R +import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.ifElse import com.github.damontecres.dolphin.tryRequestFocus import com.github.damontecres.dolphin.ui.AppColors @@ -52,21 +53,16 @@ import com.github.damontecres.dolphin.ui.playback.isForwardButton import com.github.damontecres.dolphin.ui.playback.isPlayKeyUp import com.github.damontecres.dolphin.util.DolphinPager import kotlinx.coroutines.launch -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import kotlin.math.max -private val DEBUG = false +private const val DEBUG = false @Composable fun CardGrid( - api: ApiClient, pager: DolphinPager, - itemOnClick: (BaseItemDto) -> Unit, - longClicker: (BaseItemDto) -> Unit, + itemOnClick: (BaseItem) -> Unit, + longClicker: (BaseItem) -> Unit, letterPosition: suspend (Char) -> Int, requestFocus: Boolean, gridFocusRequester: FocusRequester, @@ -329,7 +325,6 @@ fun CardGrid( item = item, onClick = { if (item != null) itemOnClick.invoke(item) }, onLongClick = { if (item != null) longClicker.invoke(item) }, - imageUrlBuilder = { api.imageApi.getItemImageUrl(it, ImageType.PRIMARY) }, ) } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt index 44dc1957..601bfba1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.tv.material3.Text import com.github.damontecres.dolphin.OneTimeLaunchedEffect +import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.nav.Destination @@ -20,7 +21,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType @@ -41,7 +41,7 @@ class CollectionFolderViewModel override fun init( itemId: UUID, - potential: BaseItemDto?, + potential: BaseItem?, ): Job = viewModelScope.launch { super.init(itemId, potential)?.join() @@ -93,7 +93,14 @@ fun CollectionFolderDetails( CollectionType.UNKNOWN -> TODO() CollectionType.MOVIES -> TODO() CollectionType.TVSHOWS -> { - TVShowCollectionDetails(viewModel.api, preferences, navigationManager, library!!, item!!, pager, modifier) + TVShowCollectionDetails( + preferences, + navigationManager, + library!!, + item!!, + pager, + modifier, + ) } CollectionType.MUSIC -> TODO() @@ -113,17 +120,15 @@ fun CollectionFolderDetails( @Composable fun TVShowCollectionDetails( - api: ApiClient, preferences: UserPreferences, navigationManager: NavigationManager, library: Library, - item: BaseItemDto, + item: BaseItem, pager: DolphinPager, modifier: Modifier = Modifier, ) { val gridFocusRequester = remember { FocusRequester() } CardGrid( - api = api, pager = pager, itemOnClick = { navigationManager.navigateTo(Destination.MediaItem(it.id, it.type, it)) }, longClicker = {}, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt index d4e585c4..3cd50d86 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt @@ -1,11 +1,14 @@ package com.github.damontecres.dolphin.ui.detail +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.Button import androidx.tv.material3.Text @@ -40,10 +43,27 @@ fun EpisodeDetails( Text(text = "Loading...") } else { item?.let { item -> - LazyColumn(modifier = modifier) { + val dto = item.data + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(32.dp), + modifier = modifier, + ) { item { Text(text = item.name ?: "Unknown") } + item { + if (dto.parentIndexNumber != null && dto.indexNumber != null) { + Text( + text = "S${dto.parentIndexNumber} E${dto.indexNumber}", + ) + } + } + dto.overview?.let { + item { + Text(text = it) + } + } item { Button( onClick = { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt index 389b2545..5714ff73 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt @@ -3,25 +3,25 @@ package com.github.damontecres.dolphin.ui.detail 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.data.model.DolphinModel import com.github.damontecres.dolphin.data.model.convertModel import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemDto import timber.log.Timber import java.util.UUID abstract class ItemViewModel( val api: ApiClient, ) : ViewModel() { - val item = MutableLiveData(null) + val item = MutableLiveData(null) val model = MutableLiveData(null) open fun init( itemId: UUID, - potential: BaseItemDto?, + potential: BaseItem?, ): Job? { if (item.value == null && potential?.id == itemId) { item.value = potential @@ -34,7 +34,7 @@ abstract class ItemViewModel( try { val fetchedItem = api.userLibraryApi.getItem(itemId).content val modelInstance = convertModel(fetchedItem, api) - item.value = fetchedItem + item.value = BaseItem.from(fetchedItem, api) model.value = modelInstance as T } catch (e: Exception) { Timber.e(e, "Failed to load item $itemId") diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt index fd9f39a2..925e541f 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt @@ -16,8 +16,8 @@ fun MediaItemContent( modifier: Modifier = Modifier, ) { when (destination.type) { - BaseItemKind.SERIES -> TODO() - BaseItemKind.SEASON -> TODO() + BaseItemKind.SERIES -> SeriesDetails(preferences, navigationManager, destination, modifier) + BaseItemKind.SEASON -> SeasonDetails(preferences, navigationManager, destination, modifier) BaseItemKind.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier) BaseItemKind.MOVIE -> TODO() BaseItemKind.VIDEO -> TODO() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt new file mode 100644 index 00000000..fca418db --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt @@ -0,0 +1,97 @@ +package com.github.damontecres.dolphin.ui.detail + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.data.model.BaseItem +import com.github.damontecres.dolphin.data.model.Video +import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.cards.CardRow +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.util.DolphinPager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class SeasonViewModel + @Inject + constructor( + api: ApiClient, + ) : ItemViewModel